-1-
1 Introduzione
1.1 Budino di nocciole
Ingredienti:
-
700 ml. di latte;
-
6 uova;
-
200 gr di nocciole sgusciate;
-
180 gr di zucchero;
-
150 gr di savoiardi;
-
20 gr di burro;
-
Odore di vaniglia.
Preparazione:
Sbucciate le nocciole nell'acqua calda e asciugatele bene al sole o al fuoco; indi
pestatele finissime nel mortaio con lo zucchero versato poco per volta. Mettete il latte al
fuoco e quando sarà entrato in bollore sminuzzateci dentro i savoiardi e fateli bollire per
cinque minuti, aggiungendovi il burro. Passate il composto dallo staccio e rimettetelo al
fuoco con le nocciole pestate per sciogliervi dentro lo zucchero. Lasciatelo poi ghiacciare
per aggiungervi le uova, prima i rossi, dopo le chiare montate; versatelo in uno stampo
unto di burro e spolverizzato di pan grattato, che non venga tutto pieno, cuocetelo in
forno o nel fornello e servitelo freddo. Questa dose potrà bastare per nove o dieci
persone.
(Artusi)
Immaginate una cucina, una certa quantità di ingredienti, utensili per cucinare, fornelli,
forno, un cuoco (possibilmente umano). Preparare un budino di nocciole è un processo che
parte dagli ingredienti, viene portato avanti dal cuoco, con l'aiuto di un forno e di altri
strumenti e, cosa significativa, è in accordo con le istruzioni di una ricetta. Gli ingredienti
corrispondono, nella terminologia che useremo nel proseguimento di questo corso, all'input del
processo. Il budino è, abbastanza ovviamente, l'output. La ricetta è l'algoritmo. In altre parole,
l'algoritmo prescrive le attività che costituiscono il processo attraverso cui a partire dall'input
(ingredienti) si arriva all'output (budino). La ricetta, o l'algoritmo, se scritta in maniera
formale, come all'inizio di questo capitolo, corrisponde a ciò che viene chiamato software, o
programma, mentre gli utensili, il forno e, in questo caso, lo stesso cuoco, vanno sotto il nome
di hardware.
Nella prima parte di questo corso ci occuperemo di algoritmi, ossia di come utilizzare gli
ingredienti per ottenere un budino. Come vedremo in seguito, l'analogia tra un algoritmo
computazionale e una ricetta può essere spinta solo fino a un certo punto, oltre il quale cessa
-2di essere illuminante e diventa un mero gioco di parole. Per esempio, sia un computer che
l'hardware-cucina sono in grado di compiere solo operazioni elementari: il computer, come
vedremo più in dettaglio nel seguito di questo corso, può operare direttamente solo sui bit,
ossia su interruttori che possono essere o accesi o spenti, mentre l'hardware-cucina può
sbucciare, pestare, mescolare, cuocere e misurare quantità, ma non può, direttamente, creare
un budino dal nulla.
Un primo (importantissimo) problema che si presenta, sia nel caso del computer che in
quello dell'hardware-cucina, è quello del livello di dettagli al quale dobbiamo scendere perché
la serie di istruzioni che costituiscono l'algoritmo abbia un senso, e ci permetta di arrivare al
risultato, sia che si tratti di un budino o del calcolo molto complesso per la costruzione della
struttura portante di un ponte. Prendiamo per esempio l'istruzione "pestare le nocciole
finissime nel mortaio". Perché l'algoritmo non dice "ridurre le nocciole a particelle grandi al
massimo un decimo di millimetro"? Semplicemente perché questo livello di dettaglio è
eccessivo per lo scopo che ci prefiggiamo, che è quello di ottenere una pasta omogenea di
nocciole. In altre parole possiamo dire che in questo caso l'hardware già sa cosa significa
"pestare le nocciole finissime", e non ha bisogno di ulteriori dettagli.
Consideriamo un altro esempio, più vicino al resto di quel che studieremo durante il
corso: la moltiplicazione tra due numeri interi. Supponiamo che ci chiedano di moltiplicare 528
per 46. Sappiamo esattamente (o almeno lo spero) cosa fare. Moltiplichiamo 6 per 8, che dà
48, Scriviamo 8 e riportiamo 4; quindi moltiplichiamo 6 per 2 e aggiungiamo il 4 del riporto, e
questo ci dà 16. Scriviamo 6 a sinistra dell'8 e riportiamo 1, eccetera. Qui possiamo porci la
stessa domanda di prima, relativa al grado di sbriciolamento delle nocciole. Perché
moltiplichiamo 6 per 8 e non invece aggiungiamo 8 volte 6 a sé stesso? La risposta,
abbastanza ovvia, è che già sappiamo come moltiplicare 6 per 8, e non abbiamo bisogno di
ricorrere alla definizione elementare di moltiplicazione. Al contrario, ma allo stesso modo,
potremmo chiederci perché non moltiplichiamo direttamente 528 per 46, senza ricorrere a un
algoritmo. Alcune persone riescono a farlo: queste persone corrispondono a dei cuochi che
sanno preparare un perfetto budino di nocciole senza leggere la ricetta. In altre parole, usando
l'algoritmo della moltiplicazione stiamo dicendo che l'hardware (in questo caso noi stessi) è in
grado di compiere certe operazioni elementari (moltiplicare 6 per 8, riportare 4, eccetera) ma
non è capace di moltiplicare 528 per 46 "al volo". Questo esempio mostra la necessità di
mettersi subito d'accordo sulle azioni basilari che un algoritmo deve essere in grado di
prescrivere. Senza questa specificazione è inutile cercare di stabilire un algoritmo per un
qualsiasi dato problema. Naturalmente, problemi diversi sono associati a diversi tipi di azioni
basilari. Nel caso della cucina, le azioni basilari sono mescolare, pestare, cuocere, pesare,
eccetera. Nel caso della moltiplicazione di due numeri grandi le azioni basilari si riducono a
moltiplicazioni di numeri minori di 10, riporti, somme, eccetera.
Nel caso degli algoritmi di cui ci occuperemo in seguito, e qui arriviamo al limite
dell'analogia con le ricette di cui parlavamo prima, le azioni basilari di cui stiamo parlando
dovranno essere specificate con chiarezza e precisione. Non saremo in grado di accettare
istruzioni tipo "montare le chiare a neve". L'idea che un certo cuoco ha di chiare montate a
neve può essere decisamente diversa da quella di un altro cuoco. Le istruzioni dovranno essere
chiaramente distinte dalle non-istruzioni. "Questa dose potrà bastare per nove o dieci persone"
non è, per esempio, un'istruzione che serve a preparare il budino. Frasi ambigue tipo "che non
venga tutto pieno" (metà? tre quarti? nove decimi?) non trovano posto in algoritmi che vanno
poi realmente eseguiti sui calcolatori. Le ricette, per dirla tutta, rispetto agli algoritmi per
calcolatore dànno troppe cose per scontate, la più notevole delle quali è che un essere umano
-3(il cuoco) fa parte dell'hardware. Nel disegnare algoritmi per calcolatori non potremo
permetterci questo lusso, e dovremo cercare di essere molto più stringenti e precisi.
Nel seguito ci occuperemo principalmente di problemi per i quali è possibile una precisa
formalizzazione, quasi sempre matematica: se un dato problema è così chiaramente
comprensibile ed esprimibile, sarà in generale possibile definire una strategia di soluzione
basata sull’applicazione sistematica di ben precise regole operative che consentirà di ottenere il
risultato atteso a partire dai dati disponibili. In molti casi sarà possibile affidare l’applicazione
delle regole di soluzione a un esecutore altamente specializzato (un calcolatore), in grado di
svolgere il compito con estrema rapidità.
1.2 Un po' di storia
Tra il 400 e il 300 a.C. il matematico greco Euclide inventò un algoritmo per trovare il
Massimo Comun Divisore (MCD) di due numeri interi positivi. A scanso equivoci ricordiamo che
il MCD di X e Y è il più grande numero intero che divide esattamente (e cioè senza resto) sia X
che Y. Per esempio, il MCD di 32 e 12 è 4.
Vediamo quali possibili algoritmi possiamo utilizzare per trovare il MCD di due numeri
interi assegnati X e Y. Una prima possibilità è quella di applicare direttamente la definizione
matematica di MCD. In questo caso l’algoritmo da seguire è (Algoritmo A):
1. calcolo gli insiemi D(X) e D(Y) dei divisori di X e Y;
2. costruisco l’insieme intersezione di D(X) e D(Y);
3. determino il valore massimo dell’insieme intersezione.
Vediamo questo algoritmo in azione nel caso dell’esempio di prima, ossia il MCD di
X=32 e Y=12. Abbiamo che D(X) = [1, 2, 4, 8, 16, 32], mentre D(Y) = [1, 2, 4, 6, 12].
L’insieme intersezione è dato da [1, 2, 4], quindi MCD(32,12) = 4.
Un’altra possibilità è la seguente (Algoritmo B):
1. individuo il valore minimo (M) tra X e Y;
2. se M divide esattamente sia X che Y allora MCD(X,Y) = M [fine
algoritmo];
3. altrimenti decremento M di 1 e ripeto il passo precedente, al più
fino al valore M = 1.
Nel caso del nostro esempio abbiamo che M=12. 32/12 non dà un numero intero come
risultato, quindi passo a M=11. Ma 11 non va ancora bene, e continuo a decrementare M
finché arrivo a M=4, per il quale ho 32/4 = 8, 12/4 = 3, quindi MCD(32,12) = 4.
L'algoritmo di Euclide per il MCD è il primo algoritmo non banale, in ordine di tempo, di
cui ci sia giunta notizia. Questo algoritmo parte da una proprietà precisa del MCD di due
numeri interi. La proprietà è la seguente:
-4-
se X, Y, Q, e R sono numeri interi, con X ≥ Y e X = Q * Y + R,
allora l’insieme intersezione di D(X) e D(Y) è uguale all’insieme
intersezione di D(Y) e D(R).
Quindi il problema di trovare il valore massimo nell’insieme intersezione di D(X) e D(Y)
può essere ridotto al problema più semplice di trovare il massimo nell’insieme intersezione di
D(Y) e D(R). Sulla base di questa proprietà è possibile scrivere l’algoritmo di Euclide (Algoritmo
E) per il MCD:
1. R = MOD(X,Y);
[MOD è il resto della divisione intera]
2. Se R = 0 allora Y è il MCD(X,Y), altrimenti calcola MCD(Y,R).
Vediamo questo algoritmo all’opera sul nostro esempio favorito. Abbiamo
MOD(32,12)=8 (perché 32 = 12 * 2 + 8), quindi calcoliamo MOD(12,8)=4 (perché 12 = 8 * 1
+ 4), e infine MOD(8,4)=0 (perché 8 = 4 * 2), quindi MCD(32,12)=4.
La parola algoritmo deriva dal nome di un matematico arabo, Mohammed alKhowarizmi, che visse nel nono secolo della nostra era e trovò alcuni procedimenti sequenziali
per la moltiplicazione e la divisione di numeri interi. Il suo nome fu latinizzato in Algorismus, e
da qui ad algoritmo il passo è breve.
1.3 Algoritmi corti, processi lunghi
Supponiamo che ci venga fornita la lista del personale di una compagnia. In questa
tabella troviamo, per ogni riga, il nome dell'impiegato, un campo di dettagli personali
(indirizzo, codice fiscale, eccetera) e il salario mensile. Siamo interessati a conoscere la somma
totale dei salari mensili degli impiegati. Ecco l'algoritmo che potremmo usare:
1. annotiamo da qualche parte il numero 0;
2. procedendo lungo la lista, sommiamo il salario di ciascun
impiegato al numero annotato;
3. quando la lista finisce, produciamo in output il numero
annotato.
E' abbastanza facile rendersi conto che questo algoritmo "funziona", ossia è un
algoritmo corretto per il problema che ci siamo posti. Il numero "annotato" (per esempio su un
pezzo di carta) all'inizio contiene zero. Dopo il primo impiegato conterrà, quindi, il salario
mensile del primo impiegato, dopo il secondo la somma dei salari del primo e del secondo,
eccetera. E' interessante notare che il testo di questo algoritmo è corto (e per di più di
lunghezza fissata), mentre il processo che detto algoritmo descrive e controlla può essere
arbitrariamente lungo: basti pensare a un'azienda con un milione di impiegati.
Ancor più interessante notare che l'algoritmo funziona sia per aziende con pochi
impiegati che per aziende con moltissimi impiegati: basta fornire all'algoritmo la giusta lista
(input), per quanto lunga essa sia. Non solo: indipendentemente dalla quantità di impiegati,
per ogni azienda c'è bisogno di un solo "oggetto" (il numero annotato, che corrisponde alla
somma progressiva dei salari) per compiere il lavoro assegnato. Naturalmente il valore di
-5questo numero potrà essere piccolo o grande, a seconda delle dimensioni dell'azienda, del
numero degli impiegati e della consistenza del loro stipendio.
1.4 Problemi algoritmici
Siamo arrivati al punto di aver fissato un determinato algoritmo che "funziona" in molti
casi diversi, con processi che possono essere corti o lunghi a seconda dell'input che l'algoritmo
riceve in pasto. Anche il semplicissimo algoritmo che abbiamo esposto nella sezione
precedente può avere un numero molto alto di possibili input: ditte individuali (una sola
persona), aziende con milioni di dipendenti, aziende in cui alcuni dei salari sono nulli, altre in
cui sono uguali, altre ancora in cui i dipendenti ricevono uno stipendio negativo (cioè pagano
per il piacere di poter lavorare).
L'algoritmo dello "stipendio totale" funziona in realtà per un numero infinito di possibili
input diversi. C'è infatti un numero infinito di possibili liste di impiegati perfettamente
accettabili, e l'algoritmo dovrebbe essere in grado di sommare gli stipendi in ciascuna di
queste. Ci stiamo scontrando con un altro limite dell'analogia tra algoritmi e ricette: nel caso
della ricetta gli ingredienti sono fissati una volta per tutte, e sebbene la stessa ricetta possa
essere utilizzata infinite volte (colesterolo permettendo), l'output della ricetta è sempre lo
stesso budino, per nove o dieci amanti del budino (e delle nocciole). Tuttavia in questo caso
potremmo generalizzare la ricetta, cioè potremmo specificare, invece di 200 gr. di nocciole e
20 di burro, X gr di nocciole e X/10 gr di burro (e di conseguenza per gli altri ingredienti), con
il risultato finale che la quantità di budino in output basterà per X/20 persone. In questo caso
la ricetta si riavvicinerebbe allo spirito dell'algoritmo. Un'altra problematica legata all'input di
un algoritmo riguarda la sua "legalità", o correttezza. Questo per esempio significa che una
lista delle piante ospitate nel giardino botanico di Ginevra non va bene come input per il nostro
algoritmo dello "stipendio totale", così come le aringhe affumicate non costituiscono un
ingrediente accettabile di un qualsiasi budino alle nocciole degno di questo nome.
In termini più generali, le ricette, o gli algoritmi, sono soluzioni a certi tipi di problema,
chiamati problemi computazionali, o algoritmici. Nel caso degli stipendi, per esempio, il
problema può essere completamente specificato dal fatto di chiedere che un certo numero (la
somma degli stipendi) sia prodotto a partire da una lista, legale e di lunghezza qualsiasi, di
impiegati: tale lista, per poter essere accettata in input dall'algoritmo, deve possedere una
serie di caratteristiche (la prima colonna contiene i nomi degli impiegati, la seconda lo
stipendio). Questo problema può essere visto come la ricerca di una scatola nera (black box):
la scatola "mangia" l'input (la lista degli impiegati) e produce un output (la somma di tutti gli
stipendi). Ciò che definisce la scatola nera è la serie di operazioni elementari che dobbiamo
compiere sulla lista per ottenere il risultato, o in altre parole il modo in cui il risultato dipende
dagli elementi in input.
Possiamo dire, forse un po' tautologicamente, che un problema algoritmico è risolto
quando abbiamo trovato un algoritmo che produce il risultato voluto a partire da un input dato.
In questo caso la scatola nera è stata riempita da un contenuto (l'algoritmo A): detta scatola
"funziona" secondo l'algoritmo A. Dato A, la scatola nera produce l'appropriato output a partire
da qualsiasi input legale, eseguendo i processi che sono prescritti e governati da A.
La parola "qualsiasi", nella frase precedente ("qualsiasi input legale") è di
un'importanza fondamentale. Non siamo interessati a soluzioni che per alcuni tipi di input
-6(legale) non funzionano. Come esempio estremo, immaginiamo per il problema dello "stipendio
totale" il seguente algoritmo:
1. produci zero come output.
Questo algoritmo funziona solo per una ristrettissima classe di aziende.
Un altro aspetto importante da non sottovalutare riguarda il tempo di esecuzione, da
parte dell'hardware di turno, di ciascuna azione basilare, o operazione, prescritta
dall'algoritmo. Risulta infatti necessario, anche se apparentemente ovvio, richiedere che
ciascun passo elementare dell'algoritmo possa essere portato a termine in un tempo finito. Nel
caso contrario l'algoritmo non terminerebbe mai, risultando quindi di scarsa se non nulla utilità
pratica.
1.5 Un tentativo di riassunto
Per riassumere, un problema algoritmico consiste in:

una caratterizzazione di un insieme legale, anche se infinito, di possibile input;

la specifica dell'output desiderato in funzione dell'input.
Si assume che sia data a priori una descrizione dei passi elementari possibili, o
equivalentemente una configurazione hardware e la specifica delle azioni elementari che
l'hardware stesso può eseguire. La soluzione a un problema algoritmico (o computazionale)
consiste nell'algoritmo stesso, composto da istruzioni elementari che prescrivono le azioni da
compiere, scelte tra le azioni possibili e legali. L'algoritmo, quando viene eseguito in seguito
all'immissione di un qualsiasi input legale, risolve il problema, producendo l'output richiesto.
Va notato che le regole di un qualsiasi algoritmo sono in genere applicate su
rappresentazioni degli oggetti fondamentali che vivono nello spazio in cui l’algoritmo opera. Nel
caso del MCD, l’algoritmo di Euclide opera su X, Y eccetera, che sono rappresentazioni di
particolari numeri interi (32 e 12, nel nostro caso).
Il messaggio fondamentale di questo capitolo riguarda la natura e la definizione di
algoritmo e di problema algoritmico: un algoritmo è sostanzialmente un insieme di regole che,
eseguite ordinatamente, permettono di risolvere un problema a partire dai dati a disposizione
(input). Perché questo insieme di regole possa considerarsi un algoritmo a tutti gli effetti deve
rispettare alcune proprietà:

Non ambiguità: le istruzioni devono essere univocamente interpretabili dall’esecutore
dell’algoritmo, che sia un calcolatore (come più spesso accade) o un essere umano;

Eseguibilità: l’esecutore deve essere in grado, con le risorse a disposizione, di eseguire
ogni istruzione in un tempo finito;

Finitezza: l’esecuzione di un algoritmo deve terminare in un tempo finito per ogni
insieme di valori in input.
E' importante capire che gli esempi che abbiamo sviluppato in questo capitolo
introduttivo (la ricetta e la moltiplicazione di due interi) non rendono giustizia alla
considerevole complessità del problema generale di trovare algoritmi soddisfacenti per un dato
problema. Non bisogna pensare che le cose siano così semplici come sono state presentate. E
-7se sono state presentate in maniera così semplice è solo a scopo esemplificativo. I problemi
algoritmici di interesse pratico possono risultare incredibilmente complessi, e possono
richiedere anni di lavoro da parte di un equipe di specialisti per poter essere risolti in maniera
soddisfacente. Addirittura alcuni problemi non possono essere assolutamente risolti in maniera
soddisfacente, mentre altri non ammettono nessuna soluzione (è possibile stabilire con un
algoritmo quale sarà il cambio Euro/Dollaro il primo gennaio del 2100?). E ciò che è peggio,
per certi problemi non sappiamo neppure se possano essere risolti algoritmicamente o meno.
-8-
2 Algoritmi e dati
Sappiamo già che gli algoritmi contengono istruzioni elementari
selezionate con cura che prescrivono le azioni basilari che devono
essere eseguite al fine di ottenere un certo risultato in output a
partire da un certo input. Non abbiamo parlato del modo in cui
queste istruzioni sono arrangiate (???) nell’algoritmo, in modo tale
che chi poi si incaricherà di eseguire materialmente l’algoritmo
(probabilmente un calcolatore) possa immaginare l’ordine preciso
nel quale le azioni elementari devono essere eseguite. Non
abbiamo neanche discusso gli oggetti manipolati da queste azioni
elementari.
L’esecuzione di un algoritmo può essere pensato come portato
avanti da un piccolo robot, o un processore, che chiameremo
Corrintorno. Il processore riceve istruzione di correre qui e là
facendo questo e quello, dove “questo e quello” sono proprio le
azioni basilari dell’algoritmo. Nell’algoritmo dello “stipendio totale”
del capitolo precedente al piccolo Corrintorno è stato ordinato di
prendere nota del numero 0 e poi di cominciare a lavorare sulla
lista di impiegati, trovando gli stipendi e aggiungendoli, uno a uno,
al numero annotato all’inizio.
Dovrebbe risultare chiaro che l’ordine in cui le azioni elementari
sono eseguite è cruciale. E’ di un’importanza fondamentale non
solo che le azioni elementari siano chiare e non ambigue, ma
anche che lo stesso criterio di chiarezza e non ambiguità sia
applicato al meccanismo che controlla la sequenza in cui le
istruzioni elementari sono eseguite. L’algoritmo deve quindi
contenere istruzioni di controllo per spingere il processore (il
nostro Corrintorno) in questa o quella direzione, a seconda dei
casi, dicendogli chiaramente cosa fare passo per passo.
2.1 Strutture di controllo
Il controllo sulla sequenza delle operazioni in genere è svolto con l’aiuto di un insieme di
istruzioni chiamate strutture di controllo di flusso, o più semplicemente strutture di controllo.
Avvertenza: poiché tutti i linguaggi di programmazione utilizzano ampiamente l’inglese per le
loro parole-chiave, nel seguito le strutture di controllo saranno specificate in inglese (la prima
volta che sono presentate anche in italiano, per facilitare la traduzione). Anche la ricetta del
budino alle nocciole contiene diverse istruzioni o strutture di controllo, come le seguenti:

Sequenza diretta: sono della forma “fai A e poi B” (do A followed by B). Nella
ricetta: “inserire le chiare montate a neve dopo aver amalgamato i rossi d’uovo”);
-9
Salto condizionale: sono della forma “se succede Q allora fai A altrimenti fai B” (if Q
then do A else do B), o semplicemente “se succede Q allora fai A” (if Q then do A), in
cui Q è qualche tipo di condizione. Nella ricetta: “sminuzzare i savoiardi se il latte bolle,
altrimenti continuare a scaldare il latte”).
Queste due strutture di controllo, sequenza diretta e salto, non spiegano come un
algoritmo di lunghezza prefissata possa eseguire processi arbitrariamente lunghi, a seconda
dell’input. Un algoritmo che contenga solo sequenze dirette e salti condizionali può solo
prescrivere processi di lunghezza prefissata, poiché nessuna parte dell’algoritmo può essere
eseguita più di una volta. Strutture di controllo che permettono all’algoritmo di eseguire
processi arbitrariamente lunghi sono nascoste anche nella ricetta del budino, ma sono di gran
lunga più esplicite nell’algoritmo di “stipendio totale”. Queste strutture sono genericamente
chiamate iterazioni, o costrutti di loop (loop significa cappio, ossia una cosa che torna su sé
stessa come la corda di un cappio), e possono presentarsi in diverse maniere. Qui ne
descriviamo due:

Iterazioni limitate: sono della forma “fai A esattamente N volte” (do A exactly N
times), in cui N è un numero;

Iterazioni condizionali: sono della forma “fai A fino a che non si verifica la condizione
Q” (do A until Q), oppure “finché la condizione Q è vera fai A” (while Q do A). Nella
ricetta, implicitamente: “battere le chiare d’uovo finché non sono montate a neve”).
Quando abbiamo descritto l’algoritmo dello “stipendio totale” siamo rimasti sul vago
relativamente a come la parte principale dell’algoritmo dovesse essere svolta: abbiamo scritto
qualcosa del tipo “scorri tutta la lista, aggiungendo lo stipendio dell’impiegato corrente al
numero annotato”. In realtà, per descrivere con precisione l’algoritmo (e ogni algoritmo va
descritto con estrema precisione) avremmo dovuto utilizzare un costrutto iterativo,
specificando esattamente in questo modo a Corrintorno il modo in cui scorrere la lista degli
impiegati. Assumiamo che insieme alla lista sia data in input anche la sua lunghezza, ovvero il
numero degli impiegati, N. In questo caso è possibile utilizzare un costrutto del tipo “iterazione
limitata”, che porta al seguente algoritmo:
1. annota 0;
2. punta al primo stipendio della lista;
3. fai le cose che seguono N-1 volte;
3.1. somma lo stipendio a cui stai puntando al numero annotato;
3.2. punta al prossimo stipendio;
4. somma lo stipendio a cui stai puntando al numero annotato;
5. produci il numero annotato come output.
Le “cose che seguono” al punto 3. si riferiscono naturalmente
notato subito il livello di indentazione di questi punti, che sono scritti
Quello dell’indentazione è un trucco che useremo spesso, anche
concretamente a scrivere programmi, per connotare i cicli iterativi, o
codice.
ai punti 3.1. e 3.2. Va
più a destra degli altri.
quando ci troveremo
in generale i blocchi di
Gli studenti sono incoraggiati a cercare di capire come mai al punto 2. stiamo usando
N-1 invece di N, e perché stiamo sommando separatamente l’ultimo stipendio. E’ da notare che
- 10 l’algoritmo fallisce se la lista è vuota (cioè se N = 0) perché in questo caso la seconda parte del
punto 1. non ha alcun significato.
Se l’input non include il numero N di impiegati, dobbiamo ricorrere a un’iterazione
condizionale, del tipo “while (la lista non è finita) somma stipendio”. In questo caso però
dobbiamo specificare qual è il segnale che ci dice che la lista è finita.
2.2 Diagrammi di flusso
Abbiamo già detto (ma non lo ripeteremo mai abbastanza) che i vari passi di un
algoritmo devono essere specificati nel modo più chiaro possibile, soprattutto se a eseguire
l’algoritmo sarà poi in concreto un calcolatore. Tutti ci siamo trovati nella penosa situazione di
dover montare un mobile seguendo le poche istruzioni scritte in svedese su un foglietto mal
disegnato: in quel momento, anche se non lo sapevate, avete ardentemente desiderato, al
posto del foglietto in svedese, un algoritmo ben congegnato e ben descritto, che spiegasse
nella maniera più semplice possibile i vari passi da eseguire per ottenere, a partire da una
serie di tavole di legno e di viti generalmente della misura sbagliata, una libreria stabile con
minimi rischi di crollo.
Sembra quindi necessario, nella costruzione di un algoritmo, salire a un livello di
formalizzazione superiore a quello impiegato nella descrizione dell’algoritmo dello “stipendio
totale” nella sezione precedente (da questo punto di vista analogo al famigerato foglietto
scritto in svedese). Alla fine del processo vedremo che il grado di formalizzazione massima, dal
punto di vista risolutivo, consiste nello scrivere un programma, in un certo linguaggio di
programmazione, che implementi l’algoritmo. Prima di arrivare a quel punto dobbiamo
superare un paio di stadi intermedi. Il primo di questi livelli consiste in una visualizzazione
grafica.
Esistono molti metodi per visualizzare in maniera grafica un algoritmo. Il più usato e
certamente il più efficace è quello dei diagrammi di flusso (in inglese flow charts). In un
diagramma di flusso i vari passi di un algoritmo sono rappresentati da diversi elementi grafici,
la cui forma rende visivamente immediato il tipo di operazione che si sta compiendo in quel
particolare punto dell’algoritmo. Gli elementi grafici sono poi uniti da linee direzionate (cioè
dotate di frecce): l’algoritmo scorre nella direzione delle frecce (per questo sono chiamati
diagrammi di flusso). Gli elementi grafici più comunemente usati nei diagrammi di flusso sono:
start
un ovale, per rappresentare l’inizio (start) o la fine (stop) dell’algoritmo;
azione
un rettangolo, per rappresentare un’azione vera e propria (per esempio
l’assegnazione di un valore a una variabile);
?
una losanga, per rappresentare una condizione.
- 11 In Figura 1 è disegnato, a mo di esempio, il diagramma di flusso dell’algoritmo dello stipendio
totale, mentre in Figura 2 e 3 sono rappresentati, rispettivamente, i diagrammi di flusso
dell’algoritmo del “diretto superiore” (vedi sezione ???) e dell’algoritmo di Euclide per il
Massimo Comun Divisore.
start
annota 0
punta al primo
elemento della lista
aggiungi lo stipendio
corrente al numero
annotato
sì
output somma
la lista
è
finita?
no
punta al prossimo
elemento della
lista
stop
FIGURA 1 Diagramma di flusso dell’algoritmo dello “stipendio totale”.
Notiamo che osservando un diagramma di flusso ci accorgiamo subito della presenza
nell’algoritmo di un ciclo: se seguendo le frecce troviamo un percorso che ci riporta in un punto
del programma che abbiamo già attraversato, siamo in presenza di un ciclo.
2.3 Linguaggio di programmazione naturale
Il secondo passo di formalizzazione che dobbiamo compiere, prima di arrivare a un
linguaggio di programmazione vero e proprio che possa essere usato congiuntamente a un
calcolatore per implementare un algoritmo, consiste nell’introduzione di un linguaggio di
programmazione che chiameremo naturale. Questo linguaggio di programmazione generico
(LP) se da un lato è, come vedremo presto, molto vicino a un vero e proprio linguaggio di
programmazione (almeno per quel che riguarda l’uso delle variabili e delle strutture di
controllo), dall’altro se ne discosta drasticamente per quel che riguarda il livello di dettaglio in
- 12 cui si scende per descrivere certe operazioni di alto livello, e da questo punto di vista il
risultato netto assomiglia più a un diagramma di flusso che a un programma vero e proprio.
Per fare un esempio concreto, l’operazione di input non sarà descritta, a livello di LP, con lo
stesso dettaglio necessario in un linguaggio di programmazione vero e proprio affinché l’input
avvenga poi concretamente e in maniera corretta, ma sarà genericamente descritta
dall’istruzione:
Input( x, y, z );
dove x, y e z sono le variabili che si stanno leggendo in input.
Per descrivere in un minimo didettaglio il LP è conveniente iniziare con degli esempi. Il
primo esempio che vedremo è l’oramai famoso l’algoritmo dello “stipendio totale”:
Input( lista );
somma := 0;
while ( not lista.finita ) {
p := lista.prossimo_elemento;
somma := somma + p.stipendio;
}
Output( somma );
Notiamo subito le istruzioni Input e Output, che come abbiamo detto restano molto
generiche (il LP non ci dice come effettivamente avvenga l’inserimento dei dati, né come il
risultato finale sia mostrato in output all’utente). Notiamo anche l’uso della struttura di
controllo while ( condizione ), seguita da un cosiddetto blocco di codice, incluso tra
parentesi graffe.
In generale possiamo dire che per scrivere un algoritmo in LP possiamo usare i seguenti
elementi:

variabili, ossia nomi logici associati agli oggetti sui quali il nostro algoritmo
deve operare (nell’esempio sopra: lista, somma e p);

istruzione Input( lista variabili );

istruzione Output( lista variabili );

ciclo condizionale while ( condizione ) { blocco di codice }; l’istruzione
while fa sì che il blocco di codice contenuto tra le parentesi graffe sia ripetuto di
seguito finché non si avvera la condizione nelle parentesi tonde;

ciclo limitato for i = 1 to N { blocco di codice }; questo ciclo viene usato
per eseguire il blocco di codice esattamente N volte. A ogni iterazione la variabile
i viene incrementata di uno;

istruzione if ( A ) { blocco 1 } else { blocco 2 }; questa istruzione
comporta l’esecuzione del blocco 1 se la condizione A è vera: altrimenti sarà
eseguito il blocco 2;
- 13 
istruzione di assegnazione nome_variabile := valore; l’uso del simbolo :=
è per differenziare l’assegnazione dalla condizione di uguaglianza;

operatori logici: not, and, or, =, <= >= >

metodi,
della
forma
nome_variabile.nome_metodo:
nell’esempio
sopra
riportato lista.finita è un metodo che ci dice se la lista è finita oppure no. Va
notato che il LP non specifica in nessun modo il concreto funzionamento di un
metodo (ossia come questo metodo viene implementato in pratica). Per
esempio, invocando il metodo p.stipendio otteniamo lo stipendio dell’impiegato
il cui profilo è contenuto nella variabile p, ma non sappiamo come abbiamo fatto
per ottenerlo (in altre parole non siamo scesi fino al livello di dettagli necessario
per capire come ottenere questo valore).

funzioni, della forma nome_funzione( lista parametri ). La funzione è
esattamente come un metodo, solo che un metodo in qualche senso appartiene
a una variabile (o più propriamente a una classe di variabili) mentre la funzione
opera sulla lista dei parametri che le passiamo e restituisce un certo risultato:
possiamo pensare a una funzione come a un sottoalgoritmo, di cui al momento
della stesura dell’algoritmo vero e proprio non ci interessa conoscere i dettagli di
funzionamento. Va senza dire che quando si scriveranno veri e propri programmi
occorrerà specificare in dettaglio anche le istruzioni che definiscono i metodi e le
funzioni.
2.4 Variabili e array
Nelle sezioni precedenti abbiamo spesso usato il concetto di “variabile”, a volte anche
implicitamente, ma finora non abbiamo mai detto cosa sia effettivamente una variabile. Una
variabile è un po’ come una stanza d’albergo: non va confusa con chi la occupa. In altre parole
possiamo pensare a una variabile come a un contenitore (astratto) dentro il quale poter
mettere i dati sui quali l’algoritmo deve operare. Nell’esempio in LP sopra riportato, abbiamo
usato l’istruzione di assegnazione
somma := 0;
e anche l’istruzione
somma := somma + p.stipendio;
Qest’ultima istruzione è particolarmente importante, perché spiega bene il diverso
significato da attribuire a una variabile a seconda che la variabile stessa si trovi a destra o a
sinistra dell’operatore di assegnazione: se la variabile è a sinistra la dobbiamo intendere come
contenitore, mentre se è a destra stiamo usando il valore in essa contenuto. Nell’istruzione
somma := somma + p.stipendio;
stiamo prima sommando il valore contenuto nella variabile somma al valore restituito dal
metodo p.stipendio, e poi stiamo assegnando alla variabile somma il valore così ottenuto.
Un modo particolarmente furbo di raggruppare variabili è quello di metterle in un array,
o vettore. Per continuare con la metafora del contenitore, possiamo dire che un array è un
contenitore di contenitori. Vediamo gli array all’opera in una versione modificata dell’algoritmo
dello “stipendio totale”:
- 14 -
Input( lista );
N := lista.lunghezza;
somma := 0;
for i = 1 to N {
p := lista[i];
somma := somma + p.stipendio;
}
Output( somma );
In questo caso la variabile lista è pensata come un vettore (array) ordinato: gli
elementi dell’array, che sono le variabili vere e proprie, sono ottenute appendendo il loro
numero progressivo, racchiuso da parentesi quadre, al nome dell’array, e in questo modo
lista[1] contiene il primo elemento della lista, lista[2] il secondo e così via, fino all’ultimo
elemento, che sarà lista[N].
2.5 Cicli annidati
Non è inusuale, nella risoluzione di problemi algoritmici, trovarsi a dover considerare la
costruzione di cicli annidati, ovvero di un ciclo che si svolge interamente dentro un altro ciclo.
Per costruire un esempio, modifichiamo la domanda del problema dello “stipendio totale”:
richiediamo un algoritmo che trovi la somma degli stipendi di tutti i dipendenti il cui stipendio
sia maggiore dello stipendio del diretto superiore. Immaginiamo che in input ci sia la tabella
seguente:
Nome
Superiore
Stipendio (Euro)
Annalisa
-
2.000
Emanuele
Annalisa
3.000
Giorgio
Annalisa
1.000
Marco
Emanuele
4.000
Vediamo che il diretto superiore sia di Emanuele che di Giorgio è Annalisa, ma solo
Emanuele guadagna più di Annalisa. Inoltre Marco guadagna più di Emanuele, che è il suo
diretto superiore. L’algoritmo deve fornire come risposta, in questo caso, 7.000 Euro (cioè la
somma degli stipendi di Emanuele e Marco). In figura 2 abbiamo il flow chart dell’algoritmo che
ci serve. Vediamo immediatamente che i cicli annidati si mostrano visivamente come due
percorsi chiusi uno dentro l’altro.
- 15 -
start
somma := 0
p := primo
elemento della lista
q := primo
elemento della lista
sì
p.stip >
q.stip?
sì
no
qè
sup. di
p?
no
somma :=
somma +
p.stip
qè
ultimo?
sì
output somma
pè
ultimo?
no
no
stop
FIGURA 2 Flow chart dell'algoritmo dello "stipendio totale modificato"
q := prossimo
p := prossimo
- 16 -
3 Cenni sull’architettura dei calcolatori
Alla domanda “che cos’è un calcolatore?” è possibile rispondere in
molte maniere diverse, ciascuna relativa al diverso punto di vista
dal quale si guarda all’oggetto in questione. In questo corso il
punto di vista prevalente sarà quello che consente di rispondere
alla domanda in questa maniera: “un calcolatore è una macchina
programmabile, per tramite della quale è possibile scrivere ed
eseguire programmi”. Potremmo anche dire che un calcolatore è
una macchina che tramite i programmi permette di risolvere
problemi algoritmici. L’adozione di questo punto di vista non ci
impedirà di considerare di tanto in tanto, e soprattutto a scopo
esemplificativo, altri punti di vista: per esempio quello di un
semplice utente, per il quale “un calcolatore è una macchina che
esegue programmi”. D’altronde, dal punto di vista tecnologico, “un
calcolatore è un sistema elettronico molto complicato”.
3.1
Programmi, applicazioni
Nel senso in cui li useremo durante questo corso, i termini “programma” e
“applicazione” sono sinonimi. Un programma, in generale, oltre a svolgere determinati compiti
(e cioè risolvere ad esempio un problema algoritmico), costituirà anche l’interfaccia tra l’utente
e il calcolatore.
In un programma è possibile identificare, in tutta generalità, i seguenti elementi:


informazioni gestite dal programma (acquisizione, memorizzazione, visualizzazione);
operazioni che possono essere eseguite per manipolare le informazioni.
In generale le caratteristiche di un programma consistono in questi pochi fatti
fondamentali:




un programma permette ai suoi utenti di perseguire un particolare scopo;
gestisce un insieme di dati e di informazioni;
consente di elaborare le informazioni attraverso operazioni;
una particolare operazione può essere eseguita solo se sono soddisfatte le condizioni
che ne abilitano l’esecuzione;
 ciascuna operazione va richiesta con la sua modalità;
 l’utente interagisce con l’applicazione richiedendo l’esecuzione di una sequenza di
operazioni, una operazione alla volta.
La possibilità di eseguire su un medesimo calcolatore applicazioni diverse rende il
calcolatore una macchina che può essere utullizzata da un utente per la risoluzione di problemi
anche molto diversi tra loro. Per poter risolvere un particolare problema usando un
programma, l’utente deve essere in grado di fornire al programma stesso le istruzioni
dettagliate su come il problema debba essere risolto. In alternativa può sempre scriversi un
programma tutto suo. Dal punto di vista dell’utente, con riferimento all’esecuzione di un
- 17 applicazione, le istruzioni che è possibile richiedere al calcolatore di eseguire sono quelle
corrispondenti alle richieste di esecuzione delle operazioni fornite dall’applicazione. Inoltre
ciascun programma può essere caratterizzato dall’insieme di operazioni che permette di
eseguire (e dalle regole per usarle) e dalla tipologia di informazioni che permette di gestire.
La capacità di comprendere e usare un programma è largamente indipendente dalla
comprensione del funzionamento del calcolatore, esattamente come si riesce a guidare la
macchina senza sapere nulla sul funzionamento del motore. Ma allo stesso modo in cui è
consigliabile conoscere almeno le basi del funzionamento del motore per guidare una
macchina, così sarebbe preferibile avere almeno una vaga idea di come funziona un
calcolatore, prima di provare a utilizzare o addirittura a scrivere programmi.
3.2
Macchine virtuali
Una Macchina Virtuale è una macchina che non c’è, ma che si comporta come se
esistesse. Quest’affermazione un po’ paradossale ha bisogno di essere spiegata. Quando
diciamo che la Macchina Virtuale “non c’è” intendiamo dire che non esiste allo stato materiale:
non possiede leve, interruttori, circuiti, eccetera. Ma al tempo stesso, dal punto di vista
dell’utilizzatore, è come se esistesse: l’utilizzatore può impartire comandi a una Macchina
Virtuale, può girare un programma eccetera. In altre parole una Macchina Virtuale è un
sistema astratto che si comporta a tutti gli effetti come un vero sistema. Ad esempio, il
sistema operativo di un calcolatore è una Macchina Virtuale, che usa come base l’Hardware
della Macchina per funzionare. I software applicativi, invece, sono Macchine Virtuali che
utilizzano come base sia l’Hardware che il sistema operativo. Possiamo pensare a un
calcolatore come a un sistema gerarchico di Macchine Virtuali: ogni Macchina Virtuale, per
funzionare, ha bisogno della Macchina Virtuale di livello precedente. Alla base di questa catena
troveremo ovviamente l’hardware della Macchina, ossia una Macchina Reale, che alla fine è
l’unico vero motore di qualsiasi programma o applicazione.
Ciascuna Macchina Virtuale, ovvero ciascun livello, fornisce una serie di operazioni e di
metodi che sono più semplici, o più facilmente apprendibili, dei metodi e delle operazioni
disponibili al livello inferiore, anche se ovviamente questi metodi semplici sono implementati in
termini dei metodi del livello sottostante. Possiamo dire che una Macchina Virtuale ha due
“interfacce”: una che guarda verso l’interno (il livello inferiore, più complicato, più vicino alla
Macchina Reale), e l’altra che guarda verso l’esterno (il livello superiore – se esiste – più
semplice, più intuitivo, più vicino all’utente finale). Per esempio il sistema operativo di un
calcolatore è una Macchina Virtuale che avvolge le complicazioni dell’hardware e presenta
all’utente un’interfaccia molto probabilmente grafica e facilmente utilizzabile. L’utente sposta il
mouse, clicca su un’icona e si apre una finestra: cioè l’utente e il sistema operativo si “parlano”
a un livello molto astratto, molto comprensibile da parte dell’utente. D’altro canto, perché
all’operazione “doppio click su un’icona” corrisponda la reazione “apertura di una finestra”, il
sistema operativo è costretto anche a dialogare con la Macchina Reale, nel linguaggio proprio
della Macchina stessa (che è appunto il Linguaggio Macchina: istruzioni codificate sotto forma
di una serie di 0 e di 1).
Come vedremo in seguito, il linguaggio di programmazione Java definisce una sua
propria Macchina Virtuale (JVM, ossia Java Virtual Machine) che permette allo stesso
programma Java di essere eseguito su piattaforme (hardware) diverse.
- 18 -
3.3
Macchina di Von Neumann
Per comprendere l’architettura generale di un calcolatore faremo uso di un modello,
chiamato Macchina di Von Neumann. Ogni calcolatore realmente esistente è, dal punto di vista
astratto, una macchina di Von Neumann: la differenza consiste nel dettaglio di
implementazione delle singole componenti dei calcolatori reali. In altre parole la macchina di
Von Neumann modellizza in maniera generale lo schema che ogni calcolatore deve seguire per
essere appunto considerato un calcolatore.
In una macchina di Von Neumann distinguiamo quattro grandi componenti, che
andiamo ad elencare:




CPU, ovvero Central Processing Unit, ovvero Unità Centrale di Calcolo: è il “cuore” della
macchina, ossia il luogo in cui vengono prese le decisioni sul flusso del programma,
vengono coordinate le azioni delle altre componenti del calcolatore e vengono compiute
le operazioni.
Memoria centrale: è il luogo in cui risiedono i programmi in esecuzione e i dati su cui i
programmi operano.
Bus: è la “navetta” che trasporta le informazioni da una parte all’altra dal calcolatore.
Dispositivi di Input/Output: tutto ciò che permette l’interazione tra l’utente e il
calcolatore: tastiera, mouse, schermo, stampante, eccetera.
tastiera
mouse
schermo
BUS
Memoria
Centrale
CPU
FIGURA 3 Schema di una macchina di Von Neumann.
3.4
Logica e Aritmetica
Come abbiamo già detto, la CPU costituisce il cuore di un calcolatore. In figura 2
possiamo vedere lo schema generico di una CPU. In esso troviamo:

Controller: è l’unità che controlla il flusso del programma, e che istruisce l’ALU sulle
operazioni da compiere. Nel controller distinguiamo:
- 19 registro PC (Program Counter): tiene il conto del numero di operazioni
effettuate;
o registro IR (Instruction Register): contiene, in forma binaria, il codice della
prossima operazione da effettuare;
o registro PSW (Processor State Word): contiene lo stato della macchina (se c’è
stato un errore in un’operazione, per esempio);
ALU (Arithmetic-Logic Unit, ovvero Unità Aritmetico-Logica): il luogo in cui
effettivamente avvengono le operazioni;
Register Stack (pila dei registri): i registri contengono i dati su cui l’ALU opera;
MAR (Memory Address Register): contiene l’indirizzo di memoria da cui estrarre il
prossimo dato su cui si deve operare o in cui salvare il risultato di un’operazione già
effettuata;
MDR (Memory Data Register): registro di transito per il valore prelevato dalla memoria,
prima che venga scaricato nel Register Stack, o sulla strada contraria (dal Register
Stack alla memoria).
o




MAR
MDR
register
register
register
register
register
PC
Controller
IR
PSW
ALU
FIGURA 4 Schema di una CPU
Una CPU dei nostri giorni contiene una quarantina di milioni di transistor. I transistor
sono assemblati a gruppi per formare le cosiddette porte logiche, che implementano le
operazioni logiche come AND, OR, NOT e così via.
Quel che vogliamo vedere adesso è l’idea del funzionamento di una porta logica. Non
scenderemo in dettaglio nel modo di funzionamento di un transistor, ma ci accontenteremo
- 20 dell’idea di base, e useremo un modello formato da un circuito elettrico, due interruttori e una
lampadina, e quello che ci domanderemo è: possiamo costruire il circuito in modo che si
comporti come un operatore AND, oppure come un operatore OR?
Ricordiamo brevemente la definizione degli operatori AND e OR. Abbiamo a che fare
con quantità (o proposizioni) che possono avere solo due valori: Vero (V) e Falso (F).
Chiamando P e Q due di queste quantità, ci chiediamo quale sia il valore di P AND Q e di P OR
Q.
La proposizione (P AND Q) sarà vera solo se sono vere entrambe le quantità P e Q,
mentre la proposizione (P OR Q) sarà vera se anche una sola delle due quantità è vera. In
altre parole otteniamo la seguente tabellina:
P
Q
P AND Q
P OR Q
V
V
V
V
V
F
F
V
F
V
F
V
F
F
F
F
Vogliamo ora vedere come costruire dei circuiti che implementino le porte logiche AND
e OR. Consideriamo il circuito in figura 3. Notiamo che i due interruttori sono in serie: come
risultato avremo che la corrente nel circuito scorrerà solo se gli interruttori saranno entrambi
chiusi. Se interpretiamo (interruttore chiuso) = Vero e (interruttore aperto) = Falso, abbiamo
che la lampadina si accende (cioè il risultato è Vero) in corrispondenza di un AND logico tra gli
interruttori.
-
+
FIGURA 5 Circuito elettrico che implementa una porta logica AND
- 21 Nel caso della figura 4, invece, gli interruttori sono in parallelo. Questo significa che per
far accendere la lampadina sarà necessario chiudere solo uno dei due interruttori, e ciò
comporta che il circuito implementa la porta logica OR.
FIGURA 6 Circuito elettrico che implementa una porta logica OR
Nella CPU di un calcolatore non ci sono circuiti come quelli che abbiamo appena
presentato: ci sono però dei circuiti integrati basati sui transistor, che effettuano lo stesso tipo
di operazioni. Esistono altri tipi di porte logiche oltre AND e OR: per gli scopi che ci
prefiggiamo vogliamo ricordare solo l’operatore logico NOT, che applicato a una proposizione
ne cambia il valore di verità (ossia NOT Vero = Falso e viceversa), e l’OR Esclusivo, o XOR,
che a differenza dell’OR inclusivo è vero solo se le proposizioni P e Q sono una vera e l’altra
falsa.
Il motivo per il quale ci stiamo interessando ai circuiti logici è che le operazioni che
l’ALU può compiere sui bit sono implementate con circuiti logici. Più tardi daremo un semplice
esempio di circuito logico che permette di sommare due numeri scritti in notazione binaria.
Prima di arrivare a tanto dobbiamo spendere due parole sul modo in cui il calcolatore gestisce i
numeri.
Noi esseri umani siamo abituati fin dalla tenera infanzia a contare in base 10. I
personaggi dei cartoni animati probabilmente usano una base 8, perché hanno generalmente 4
dita per mano. Ma che significa in realtà “contare in una certa base B”? Significa che per
rappresentare un numero qualsiasi abbiamo a disposizione esattamente B simboli (cifre), la
posizione delle quali all’interno del numero assume un significato particolare (notazione
posizionale): una cifra nella k-esima posizione (a partire da destra) significa che nello sviluppo
del numero che stiamo considerando quella cifra moltiplica la (k-1)-ma potenza della base B.
In base 10 abbiamo a disposizione 10 simboli, che sono: 0, 1, 2, 3, 4, 5, 6, 7, 8 e 9.
Se scriviamo il numero 1234, in realtà stiamo intendendo:
1234 = 1 * 103 + 2 * 102 + 3 * 101 + 4 * 100
- 22 -
Nel seguito, per non confonderci, quando scriveremo un numero in una certa base B lo
scriveremo in questo modo: numeroB , e quindi 1234 in base 10 lo scriveremo 1234 10 .
Ora, in un calcolatore la quantità minima di informazione è chiamata bit, che è una
contrazione di binary digit, ossia cifra binaria. Un bit è una quantità che può avere solo due
valori: spento o acceso, falso o vero, e quindi in definitiva 0 o 1. Avendo a disposizione solo 2
simboli, un calcolatore è costretto a “contare” in base 2. Vediamo come conterebbe un
calcolatore, e partiamo da 02. Il prossimo numero è 12. E dopo già sorgono i problemi, perché
non abbiamo a disposizione il simbolo 22. Per analogia, cosa succede in base 10 quando
arriviamo al limite dei simboli, cioè quando arriviamo a 9? Subito dopo abbiamo 10, ossia
riportiamo a 0 il contatore delle unità e incrementiamo quello delle “decine” (ossia passiamo
alla potenza di 10 superiore). In base 2 dobbiamo fare esattamente la stessa cosa, quindi
210 = 102
Continuando a contare, dobbiamo incrementare il contatore delle unità, quindi
arriviamo a 310 = 112, e poi abbiamo di nuovo esaurito i simboli, e dobbiamo scalare verso
sinistra di un’ulteriore potenza della base, ottenendo 4 10 = 1002.
Rivediamo ancora il tutto con un esempio: abbiamo il numero in base 2
100101112
e vogliamo sapere quant’è questo numero in base 10. Dobbiamo partire da destra,
esattamento come abbiamo fatto per il caso 1234 in base 10, e contare le potenze di 2 (ossia
di 102):
100101112 = 1 * 1002 +
1 * 1012 +
1 * 1022 +
0 * 1032 +
1 * 1042 +
0 * 1052 +
0 * 1062 +
1 * 1072,
Occorre stare attenti a considerare che 1 * 1072 in realtà significa, in decimale, 1 * 2710.
Quindi in definitiva
- 23 100101112 = 15110.
Possiamo anche porci la domanda al contrario: dato un numero in base 10, come
possiamo esprimerlo in base 2? Vediamolo con un esempio: abbiamo il numero 104, e
vogliamo esprimerlo in base 2. Dobbiamo prima di tutto chiederci qual è la più grande potenza
di 2 minore o uguale a 104: la risposta è ovviamente 64, cioè 2 6. Possiamo quindi scrivere
104 = 64 + 50.
A questo punto operiamo ricorsivamente, e ci chiediamo qual è la più grande potenza di
2 minore o uguale a 50. La risposta è 32 (25). Scriviamo quindi
104 = 64 + 32 + 18.
Siamo arrivati al 18, che possiamo scomporre, in potenze di 2, come 16 + 2. In
definitiva abbiamo
104 = 64 + 32 + 16 + 2 = 26 + 25 + 24 + 21
Per scrivere il nostro numero in binario partiamo da destra: notiamo che nello sviluppo
non c’è la potenza 20, quindi la prima cifra a destra sarà 0. Poi c’è un 1, perché troviamo 2 1
nello sviluppo: quindi vediamo che mancano sia 2 2 che 23. In definitiva otteniamo
10410 = 11100102.
Dal punto di vista del calcolatore, un’altra base importante è la base 16, o esadecimale.
Per contare in base 16 abbiamo bisogno di 16 simboli (ovvero 16 cifre) per costruire un
qualsiasi numero: occorre quindi introdurre nuovi simboli rispetto alle consuete cifre da 0 a 9. I
simboli necessari sono fornite dalle prime lettere dell’alfabeto latino. Abbiamo quindi la
seguente corrispondenza:
A16 = 1010
B16 = 1110
C16 = 1210
D16 = 1310
E16 = 1410
F16 = 1510
- 24 Perché è importante la base 16? Perché il contenuto di un byte (ossia di una serie
consecutiva di 8 bit) può essere scritto come una coppia di numeri esadecimali, nel modo che
andiamo adesso a vedere: prendiamo 8 bit (ossia, come abbiamo detto, un byte) e dividiamolo
in una parte inferiore (i quattro bit a destra) e in una parte superiore (i quattro bit a sinistra).
1
1
0
1
-
1
0
1
1
Notiamo che il numero più grande che possiamo inserire in quattro bit è 1111 2, che
corrisponde a 1510 e quindi in definitiva a F16. Ossia abbiamo che il contenuto di 4 bit può
essere espresso come una singola cifra esadecimale. Vediamo quindi che possiamo scrivere i
quattro bit più a destra come
10112 = 1110 = B16,
e i quattro bit più a sinistra come
11012 = 1310 = D16.
Abbiamo quindi che l’intero byte può essere scritto come DB 16. A questo punto
potremmo domandarci se quel che abbiamo fatto è pienamente legale, ossia se dal punto di
vista del numero completo è vero che 1101101116 = DB16. La risposta è sì, e la dimostrazione
è lasciata come esercizio. Si noti che tutto questo è reso possibile dal fatto che 16 è una
potenza esatta di 2.
Possiamo ora discutere come l’ALU riesca per esempio a sommare 2 numeri interi,
scritti in forma binaria, utilizzando sostanzialmente solo le porte logiche che operano sui bit
che compongono i numeri. Ripetiamo che nella CPU sono presenti microcircuiti che sebbene
molto diversi in pratica dai circuiti elettrici con la lampadina che abbiamo presentato prima,
operano però con la stessa idea di base. Introduciamo ora i seguenti simboli grafici per
rappresentare le porte logiche:
Porta AND:
p
q
Porta OR:
p AND q
- 25 -
p
p OR q
q
Porta XOR:
p
p XOR q
q
Vediamo ora tutte le possibilità che ci si presentano quando dobbiamo sommare due bit
P e Q:
P
Q
P+Q
0
0
0
1
0
1
0
1
1
1
1
10
Notiamo subito che nel quarto caso otteniamo come risultato 2 bit (10), un po’ come
quando sommiamo due numeri a una cifra in base 10 e otteniamo un risultato maggiore di 10.
Per motivi di simmetria aggiungiamo uno zero (ininfluente) ai primi tre risultati, ottenendo la
tabella
P
Q
P+Q
0
0
00
1
0
01
0
1
01
1
1
10
Ora, possiamo chiamare il bit più a destra SOMMA e il bit più a sinistra RIPORTO,
ottenendo l’ulteriore tabella:
P
Q
SOMMA
RIPORTO
0
0
0
0
1
0
1
0
0
1
1
0
1
1
0
1
Notiamo subito che la SOMMA si comporta come se fosse stata ottenuta da una porta
XOR, mentre il RIPORTO si comporta come se fosse stato ottenuto da una porta AND.
Possiamo quindi disegnare il seguente circuito, che avrà come effetto (output) quello di
sommare (con riporto) i due bit in ingresso (input):
- 26 -
p
SOMMA
q
RIPORTO
FIGURA 7 Schema dell'addizionatore Incompleto
A partire dai due elementi XOR e AND, che ottengono rispettivamente la SOMMA e il
RIPORTO, possiamo costruire un primo circuito “integrato” che chiameremo “Addizionatore
Incompleto” (o AI):
AI
L’addizionatore è “incompleto” perché ci permette sì di sommare due bit, ma non ci
permette di sommare numeri binari formati da un numero arbitrario di cifre. Infatti, dopo aver
sommato le due cifre a destra, e aver ottenuto un eventuale riporto, ci troviamo nella
condizione di dover sommare tre cifre binarie: il circuito che permette di sommare tre cifre
binarie è quello raffigurato in Figura 6: la linea R(in) porta il riporto di una precendente
operazione di somma tra due bit. L’AI in basso a sinistra somma le due cifre successive del
numero binario, p2 e q2. La somma di questa operazione va a sommarsi, nell’AI al centro, con
R(in), producendo S(out) (la somma dei due bit più il riporto della somma precedente) e un
riporto che dobbiamo ancora sommare col riporto della somma dei secondi due bit. E’ facile
vedere, facendo una tabellina di tutti i casi possibili, che la linea che esce in basso dall’AI
centrale e la linea che esce in basso dall’AI di sinistra non possono essere tutte e due uguali a
1: quindi per sommarle basterà farle entrare in un circuito OR, che in questo caso specifico si
comporta come uno XOR, cioè come un addizionatore.
- 27 -
R(in)
S(out)
AI
p2
q2
AI
R(out)
FIGURA 8 Schema dell'Addizionatore Completo
Dovrebbe essere chiaro a questo punto che assembrando di seguito diversi circuiti come
quelli mostrati in figura 6 è possibile, tramite solo operazioni logiche sui bit, sommare numeri
interi di grandezza qualsiasi.
- 28 -
- 29 -
4 Introduzione a Java
4.1
Linguaggi di programmazione
I primi calcolatori, negli anni 50 del secolo scorso, potevano venir programmati solo in
Linguaggio Macchina, ossia inserendo ogni istruzione direttamente come quel particolare
codice numerico che il calcolatore è in grado di interpretare come l’istruzione stessa.
Il primo linguaggio di programmazione degno di questo nome è stato l’Assembler:
l’Assembler è molto vicino al Linguaggio Macchina, ma ha il vantaggio che le istruzioni sono
rappresentate da brevi sigle (generalmente di tre o quattro lettere) mnemoniche. Per esempio,
l’istruzione di somma viene rappresentata dalla sigla ADD, l’istruzione di salto (l’equivalente
del GOTO) dalla sigla JMP. L’Assembler, oltre a non essere di facile interpretazione, non è un
linguaggio strutturato, e anche se i programmi scritti in Assembler risultano di solito molto
efficienti (le istruzioni hanno un rapporto uno a uno con le operazioni che la CPU è in grado di
compiere, ed è per questo motivo che i sistemi operativi dei calcolatori - ossia quel programma
che rappresenta l’interfaccia primaria tra l’hardware della macchina e il mondo esterno - sono
stati per molto tempo scritti in Assembler) sono però anche difficilmente decodificabili e
modificabili. Ben presto la necessità di avere linguaggi ad alto livello, ossia più simili ai
linguaggi umani che al linguaggio binario della macchina, e che quindi lasciassero ai
programmatori la possibilità di scrivere programmi più leggibili e più facilmente modificabili, ha
portato alla creazione di linguaggi specializzati: il FORTRAN per i calcoli scientifici, il COBOL per
i programmi finanziari, il PASCAL (molto valido dal punto di vista didattico), e molti altri.
Una prima rivoluzione nel campo della programmazione si è avuta con l’avvento del
linguaggio C: un linguaggio di alto livello, strutturato ed efficiente, in grado di sostituire
l’Assembler per la creazione e la scrittura di sistemi operativi. La potenza del C è anche il
fattore che lo rende un linguaggio difficile da maneggiare con disinvoltura: è abbastanza facile,
per il programmatore non particolarmente esperto, commettere errori devastanti. Con il C++
poi, un’evoluzione del C, entriamo finalmente nel campo dei linguaggi orientati agli oggetti, che
rappresentano la seconda rivoluzione informatica degli scorso decennio.
4.2
Java
Uno dei problemi incontrati da chi scrive software è quello della portabilità: idealmente
si vorrebbe che un determinato programma, scritto in un determinato linguaggio di
programmazione, possa girare su qualsiasi piattaforma, ossia su qualsiasi tipo di hardware con
qualsiasi sistema operativo. Di fatto questo obiettivo è molto difficile da realizzare: occorre
tenere presente che un programma scritto, poniamo, in C, prima di poter essere “capito” e
quindi eseguito dalla macchina ha bisogno di essere compilato: ossia occorre trasformare le
istruzioni ad alto livello (scritte in un linguaggio molto simile all’inglese) in istruzioni di basso
livello (codice binario, ossia linguaggio macchina) che la CPU possa comprendere ed eseguire. I
dettagli della compilazione naturalmente dipendono dal tipo di macchina su cui si sta
- 30 lavorando, e ogni hardware diverso richiede in generale istruzioni in linguaggio macchina
diverse. Inoltre, cosa da non trascurare data l’importanza oramai acquisita dalle interfacce
user-friendly dei programmi, l’aspetto visivo del programma dipende dall’interfaccia grafica
utilizzata dal sistema operativo.
Java risolve il problema della portabilità eliminando, in qualche senso, la compilazione,
o almeno riducendola a una semi-compilazione. Un programma scritto in Java, prima di poter
essere eseguito, deve venir trasformato in bytecode, ossia in un formato binario che però non
viene direttamente compreso dalla CPU del calcolatore su cui si sta lavorando. Il bytecode, per
poter essere eseguito, va dato in pasto alla Java Virtual Machine, che lo traduce “al volo” in
istruzioni che la macchina è in grado di interpretare. In altre parole Java introduce una nuova
interfaccia (ossia una Macchina Virtuale, vedi capitolo precedente) tra il programma scritto ad
alto livello e il Linguaggio Macchina.
4.3
Prolegomena
Per arrivare a eseguire un programma in Java è prima di tutto necessario che sul
calcolatore siano installati il compilatore Java e la Java Runtime Environment (ossia l’Ambiente
Java di Esecuzione Programmi). Poi, occorrerà scrivere il programma in un file. Per far questo
ci si può servire di un qualsiasi editor di testo (tipo il Notepad). E’ importante sottolineare che
un file Java deve necessariamente avere un’estensione .java (così come i file di Word hanno
un’estensione .doc). Una volta scritto il programma occorre compilarlo: il compilatore legge il
file Java inserito e genera un nuovo file per ogni classe presente nel programma: i nuovi file
avranno il nome della classe corrispondente e l’estensione .class (questi file di tipo class
contengono il bytecode). Dopo aver compilato il programma occorre invocare la Macchina
Virtuale di Java per eseguirlo.
Per esemplificare il tutto, poniamoci l’obiettivo di scrivere un programma che stampi
sullo schermo un breve messaggio, per esempio “Questo e’ il mio primo programma in
Java”. Mettiamoci nel caso concreto e tutt’altro che improbabile in cui il calcolatore su cui
stiamo lavorando abbia un qualche tipo di Windows (95, 98, 2000, etc) come sistema
operativo. Come prima cosa, per essere ordinati, creiamo una Nuova Cartella sul disco C:
chiamandola Corso. Apriamo il Notepad e inseriamo le righe seguenti:
class Esempio {
public static void main( String args[] ) {
System.out.println( “Questo e’ il mio primo programma in Java” );
}
}
Abbiamo appena scritto un programma Java. Adesso salviamolo con il nome
Esempio.java dentro la cartella Corso appena creata. A questo punto occorre aprire una
finestra di comandi del DOS, cambiare directory in modo da posizionarsi nella stessa cartella in
cui abbiamo salvato il file Java e dare il comando
javac Esempio.java
- 31 -
Se non compaiono messaggi d’errore significa che il nostro programma non presenta
errori di sintassi, e quindi il compilatore ha potuto produrre il file Esempio.class. A questo
punto possiamo invocare la Macchina Virtuale di Java per eseguire il programma:
java Esempio
e nella riga immediatamente inferiore comparirà la scritta “Questo e’ il mio primo
programma in Java” (vedi figura).
Può essere utile, per scrivere programmi in Java (ma anche in altri linguaggi di
programmazione), usare i cosiddetti IDE, ossia Integrated Development Environment
(Ambienti Integrati di Sviluppo), cioè delle applicazioni che mettono a disposizione tutti gli
strumenti che sono necessari per scrivere il programma stesso (quindi un editor di testi), per
compilarlo e per eseguirlo. Nel caso del presente corso abbiamo scelto un’applicazione
Freeware (ossia gratuita), Jcreator.
4.4
Un’occhiata più attenta al primo programma
Riguardiamo con una certa attenzione il programma che abbiamo appena scritto: per
comodità inserirò nel listato del programma i numeri di linea, che non devono essere presenti
nel codice sorgente che va poi compilato.
- 32 -
1. class Esempio {
2.
public static void main( String args[] ) {
3.
System.out.println( “Questo e’ il mio primo programma in Java” );
4.
}
5. }
Nella prima riga utilizziamo la parola chiave class per dichiarare che tutto quello che
segue, dall’apertura delle parentesi graffe fino alla chiusura alla riga 5, è la definizione di una
nuova classe. Esempio è un identificatore, che costituisce il nome della classe.
La riga 2 contiene l’inizio del metodo main. Notiamo che il metodo è definito come
public static void: tutte queste parole chiave verranno definite in seguito. Per il momento
non vogliamo occuparci dei particolari, ma solo della struttura generale, che verrà poi indagata
più a fondo. Dentro le parentesi tonde, subito dopo main, sono contenuti i parametri che
possiamo passare al programma dalla linea di comando. Questi parametri sono sempre
contenuti in un vettore (args[]) di tipo String (una stringa, come vedremo meglio in seguito,
è una sequenza di caratteri racchiusa da doppi apici).
Tutti i programmi Java devono avere almeno una classe che contenga un metodo main:
il metodo main è il punto di ingresso nel programma, e cioè quando eseguiamo un programma
Java il sistema operativo passa il controllo al programma, che inizia la sua esecuzione
eseguendo, una per una, le istruzioni contenute nel metodo main.
Come si può facilmente notare dal listato del programma, il corpo del metodo main è,
come il corpo della classe che lo contiene, delimitato da parentesi graffe. In Java un gruppo di
linee di codice racchiuse tra parentesi graffe rappresentano un blocco di programma.
In realtà in questo semplicissimo programma il metodo main è costituito da una sola
istruzione, quella contenuta nella riga 3. In questa istruzione utilizziamo la classe System,
tramite un suo oggetto, System.out, un oggetto predefinito della libreria di sistema di Java
che permette di inviare un output allo schermo. In questo caso usiamo il metodo println
dell’oggetto System.out: questo metodo accetta come parametro una stringa (la serie di
caratteri racchiusa tra doppi apici), e la sua azione consiste nello stamparla sullo schermo.
Notiamo subito due cose importanti: la prima, è che per invocare un metodo di un certo
oggetto si scrive il metodo dell’oggetto seguito da un punto e poi dal nome del metodo
(System.out . println – qui gli spazi sono stati inseriti per chiarezza). La seconda è che per
terminare un’istruzione, in Java come in molti altri linguaggi, si usa il punto e virgola: in altre
parole un’istruzione può essere scritta su più righe diverse, con la convenzione che l’istruzione
stessa deve necessariamente terminare con un simbolo di punto e virgola.
In questa sezione, analizzando un semplice programma Java, sono stati introdotti
velocemente e alla buona una quantità di concetti la cui spiegazione costituirà il succo del
seguito del corso.
4.5
Tipi di dati, variabili, array
Un linguaggio di programmazione non sarebbe tale se non permettesse di definire delle
variabili. Abbiamo già incontrato, nelle sezioni precedenti, il concetto di variabile in ambito
- 33 informatico. Una variabile può essere vista come un contenitore di qualcosa, e quindi deve
possedere un nome (per poterci riferire a essa) e un valore (il contenuto). Dovrebbe risultare
chiaro che un contenitore adatto a ospitare una variabile intera (un numero senza cifre dopo la
virgola) può non essere adatto a ospitare una variabile con cifre decimali (o come diremo
spesso in seguito, una variabile di tipo floating point, ossia in virgola mobile). È quindi
opportuno introdurre il concetto di tipo di variabile: variabili di un certo tipo possono ospitare
solo un certo tipo di oggetti. Esisteranno quindi, dal punto di vista numerico, tipi interi e tipi in
virgola mobile.
Java definisce otto diversi tipi semplici (o elementari): byte, short, int, long,
char, float, double e boolean. Questi tipi possono essere classificati in quattro grandi
gruppi:




Numeri Interi: byte, short, int e long;
Numeri in virgola mobile: float e double;
Caratteri: char;
Valori logici: boolean.
4.5.1 Tipi interi
Dovrebbe essere chiaro a tutti cosa sia un numero intero. I quattro tipi che Java
definisce per contenere numeri interi differiscono tra loro per la quantità di bit impiegata per
rappresentare il numero stesso, e quindi in ultima analisi da quanto grande può essere un
numero contenuto in una variabile di un certo tipo.
Il tipo byte, il più piccolo, utilizza un byte1, appunto, per rappresentare un certo numero
intero. Poiché un byte è composto da otto bit, sembrerebbe che il numero più grande
rappresentabile con un byte sia 255 (ossia, in rappresentazione binaria, 11111111). In realtà
le cose non stanno così, perché in Java tutti i tipi hanno un segno, e quindi uno degli otto bit è
usato per decidere se il numero rappresentato dal byte è negativo o positivo. Quindi un byte
può contenere numeri interi che vanno da –128 a 127 (in tutto, come è ovvio, 256 possibili
valori). Un tipo short, invece, utilizza due byte, ossia 16 bit, e può quindi contenere numeri
interi che vanno da –32 768 a 32 767 (perché? E’ un utile esercizio per lo studente calcolare
quanti possibili valori diversi possono assumere 16 bit). Il tipo int, quello più frequentemente
usato per rappresentare numeri interi, può contenere numeri che vanno da –2 147 483 648 a
2 147 483 647, e a questo scopo utilizza 4 byte, ossia 32 bit. Se c’è bisogno di rappresentare
numeri ancora più grandi si può usare il tipo long, che utilizza 64 bit ed è in grado di
rappresentare numeri interi nell’intervallo da –9 223 372 036 854 775 808 a 9 223 372 036
854 775 807.
4.5.2 Tipi in virgola mobile
Esistono due tipi per rappresentare i numeri in virgola mobile, e si differenziano,
analogamente al caso degli interi, per la grandezza dei numeri che possono contenere. Il tipo
float utilizza quattro byte e può contenere numeri fino a circa 10 38, mentre il tipo double
utilizza otto byte e il numero massimo che può contenere equivale a circa 10 308. Ovviamente i
due tipi si differenziano anche per la precisione dei calcoli: se bisogna affrontare un problema
1
In Java questo non è strettamente vero (l’ambiente run-time di Java è libero di usare la
quantità di bit che preferisce per definire un tipo byte), ma in ogni caso un tipo byte può contenere solo
numeri da –128 a 127, ossia a tutti gli effetti si comporta come se fosse composto da otto bit. Lo stesso è
vero per gli altri tipi.
- 34 matematico che richiede una grande accuratezza numerica è certamente consigliabile usare il
tipo double per rappresentare numeri in virgola mobile.
4.5.3 Il tipo carattere
Il tipo char, in Java, è stato studiato al fine di contenere la maggior parte dei caratteri
alfabetici, compresi quelli del greco, del cirillico, dell’arabo, dell’ebraico e di alcune lingue nonideogrammatiche dell’estremo oriente, come il katakana. Per questo, a differenza del C, in cui
il tipo char utilizza un solo byte, in Java il char utilizza due byte.
4.5.4 Tipo logico
Capita molto di frequente, in un programma, di dover stabilire se una condizione sia
vera o meno. Il tipo di variabile in grado di contenere il valore di verità di un enunciato deve
poter contenere solamente due valori, vero (true) o falso (false). Questo tipo di variabile in
Java si chiama boolean: a una variabile di tipo boolean possono essere assegnati solamente i
valori true o false.
4.6
Variabili
All’interno di un programma Java, come per qualsiasi linguaggio di programmazione, la
variabile è l’unita base di memorizzazione di un dato. Una variabile è definita dalla
combinazione di un identificatore (nome della variabile), un tipo e un inizializzatore opzionale.
Inoltre tutte le variabili hanno un campo d’azione, che ne definisce la visibilità, e una durata.
4.6.1 Dichiarazione di una variabile
Prima di utilizzare una variabile è necessario dichiararla. La forma base per la
dichiarazione di una variabile è la seguente:
tipo
identificatore
[= valore];
dove le [] indicano una parte opzionale della dichiarazione. Vediamo degli esempi:
int a;
int b = 5;
float r;
double piGreco = 3.141592;
E’ possibile dichiarare più variabili di uno stesso tipo nella stessa riga, separandone i
nomi con una virgola:
int a, b=5, c=123;
- 35 Una variabile può anche essere definita dinamicamente, come nell’esempio che segue:
class Dinamica {
public static void main( String args[] ) {
double a = 3.0, b = 4.0;
double c = Math.sqrt( a*a + b*b );
System.out.println( “Ipotenusa = “ + c );
}
}
In questo semplice programma vengono dichiarate tre variabili locali, a, b e c. a e b
vengono inizializzate ai valori rispettivamente 3.0 e 4.0 durante la dichiarazione, mentre c è
inizializzata dinamicamente (Math.sqrt(x) è una funzione matematica che ritorna la radice
quadrata dell’argomento x).
4.6.2 Ambito di visibilità e durata delle variabili
Java consente di dichiarare una nuova variabile in qualsiasi punto del programma, con
l’avvertenza che l’ambito di visibilità di una variabile (cioè il contesto all’interno del quale la
variabile è visibile e utilizzabile) corrisponde al blocco di programma nel quale la variabile è
dichiarata (un blocco comprende anche eventuali sottoblocchi, ed è delimitato da parentesi
graffe. Ogni gruppo di linee di codice delimitato da parentesi graffe aperte e chiuse è un blocco
di programma, quindi possiamo dire che la variabile “vive” finché non viene chiuso il blocco in
cui è stata dichiarata e definita.
4.6.3 Array, o vettori
Un array (in italiano vettore) è un gruppo di variabili dello stesso tipo a cui viene fatto
riferimento usando un nome comune, e offre un sistema comodo per raggruppare informazioni
correlate. E’ possibile accedere a un particolare elemento dell’array medianto il relativo indice.
Nel corso ci occupiamo esclusivamente di array monodimensionali, anche se in generale è
possibile definire array con due indici (matrici) o più. La forma generale per la dichiarazione di
un array monodimensionale è la seguente:
tipo nome-array[];
in cui tipo definisce il tipo base di ciascun elemento dell’array. Per esempio, se vogliamo
dichiarare un array di interi che andrà a contenere i giorni in ciascun mese, possiamo scrivere
int giorniNelMese[];
Occorre fare attenzione al fatto che dopo la dichiarazione di un array in realtà ancora
non esiste nessun array. Per creare un array infatti occorre dire esplicitamente al compilatore
quanti elementi vogliamo raggruppare nell’array, con l’istruzione:
- 36 giorniNelMese = new int[12];
che utilizza la parola chiave int. Dopo questa istruzione esisterà un array che contiene
dodici elementi, ma occorre tenere presente che l’indice va da 0 (primo elemento dell’array) a
11 (ultimo elemento dell’array). Quindi se vogliamo dire che i giorni nel mese di gennaio sono
31 dobbiamo scrivere
giorniNelMese[0] = 31;
mentre se vogliamo inizializzare il valore relativo a giugno occorre scrivere
giorniNelMese[5] = 30;
Un metodo alternativo per dichiarare
all’allocazione di memoria, è il seguente:
un
array,
che
unisce
la
dichiarazione
int giorniNelMese[] = new int[12];
4.7
Stringhe
In Java le stringhe, ossia sequenze di caratteri racchiuse tra doppi apici, non sono tipi
semplici, ma sono implementate come una classe. Tuttavia l’importanza delle stringhe nella
programmazione in generale è così grande che sembra necessario fornire una breve
introduzione alle stringhe prima di aver introdotto il concetto di classe.
Per dichiarare una variabile di tipo stringa si ricorre all’istruzione
String str = “Questa e’ una stringa.”;
Da notare che l’istruzione
System.out.println( “Stringa str = ” + str );
produrrebbe sullo schermo, in questo caso, il seguente output:
Stringa str = Questa e’ una stringa.
- 37 -
4.8
Istruzioni di controllo
Le istruzioni di controllo che presenteremo in queste dispense sono, per semplicità di
esposizione, solo 3 (if, while e for), anche se Java dispone di altre tipologie di istruzioni di
controllo. Con le tre istruzioni che stiamo per presentare è però possibile scrivere una gran
quantità di programmi.
4.8.1 Istruzione if
La forma più semplice dell’istruzione if è la seguente:
if ( condizione ) {
righe di codice;
...
...
}
In cui condizione è un’espressione booleana, che può essere solo vera o falsa. Quando
durante l’esecuzione del programma si arriva all’istruzione if vista sopra, se condizione è vera
allora verranno eseguite le righe di codice racchiuse tra la parentesi graffe, mentre se è falsa
l’esecuzione continuerà dalla riga seguente la chiusura delle parentesi graffe.
Vediamo un semplicissimo esempio di utilizzo di istruzione if:
class EsempioIf {
public static void main( String args[] ) {
int x = 10;
int y = 20;
if ( x < y ) {
System.out.println( “x e’ minore di y” );
}
if ( x > y ) {
System.out.println( “x e’ maggiore di y” );
}
}
}
Poiché 10 è minore di 20, la prima condizione è vera, e la stringa “x e’ minore di y”
verrà stampata sullo schermo, mentre la seconda condizione è falsa, e quindi la stringa “x e’
maggiore di y” non verrà stampata.
Una variante dell’istruzione if è la seguente
- 38 -
if ( condizione ) {
codice1;
...
...
} else {
codice2;
...
...
}
In questo caso se condizione è vera vengono eseguite le righe di codice1, mentre se è
falsa vengono eseguite le righe di codice2.
Una terza variante è:
if ( condizione1 ) {
codice1;
...
...
} else if ( condizione2 ) {
codice2;
...
...
} else if ( condizione3 ) {
codice3;
...
} ...
...
...
} else {
codiceAlternativo;
...
...
}
Da notare che gli else if possono essere tanti a piacere. Il significato dovrebbe essere
chiaro: se la condizione1 è vera viene eseguito codice1, altrimenti si passa al prossimo else
if, e quindi se la condizione2 è vera viene eseguito codice2, e così via: se nessuna delle
condizioni è vera verrà eseguito il blocco codiceAlternativo relativo a else.
4.8.2 Istruzione for
Con l’istruzione for in Java vengono implementati i cicli finiti. La forma dell’istruzione
for è la seguente:
- 39 -
for ( istruzioneA; condizione; istruzioneB ) {
righe di codice;
...
...
}
e funziona in questa maniera. Quando il flusso del programma arriva alla riga
contenente l’istruzione for, la prima cosa che viene fatta è l’esecuzione dell’istruzioneA. Dopo
di che viene valutata la condizione: se condizione è vera, vengono eseguite le righe di codice
racchiuse dalle parentesi graffe. Alla chiusura delle parentesi, il flusso del programma ritorna
alla riga che contiene il for, e viene eseguita l’istruzioneB; dopo di che viene nuovamente
valutata la condizione, e se la si trova ancora vera le righe di codice verranno eseguite
nuovamente, e così via. Il ciclo termina quando condizione cessa di essere vera, e l’esecuzione
del programma continua dalla riga immediatamente seguente la chiusura delle parentesi
graffe. Facciamo un esempio:
class EsempioFor {
public static void main( String args[] ) {
int i;
int n = 5;
for ( i = 0; i < n; i = i + 1 ) {
System.out.println( “Il valore di i e’: “+ i );
}
}
}
Se eseguito, questo programma darà il seguente output:
Il valore di i e’ 0;
Il valore di i e’ 1;
Il valore di i e’ 2;
Il valore di i e’ 3;
Il valore di i e’ 4;
Chiaramente, quando i diventa uguale a 5 la condizione (i < n) non è più vera, e il
ciclo si interrompe. Notiamo che l’istruzione i = i + 1 può essere scritta in forma più concisa
come i++. L’operatore ++ ha l’effetto di incrementare di uno la variabile intera a cui è
applicato.
4.8.3 Istruzione while
Con l’istruzione while in Java vengono implementati i cicli condizionali. La forma di
questa istruzione è la seguente:
- 40 -
while ( condizione ) {
righe di codice;
...
...
}
e funziona in questa maniera: quando si incontra l’istruzione while, viene valutata la
condizione, e se la si trova vera vengono eseguite le righe di codice. Alla chiusura delle
parentesi graffe il flusso del programma torna all’istruzione while e valuta di nuovo la
condizione, eseguento nuovamente le righe di codice se condizione è ancora vera, e così via
fino a che la condizione diventa falsa. Quando la condizione diventa falsa il programma salta
alla riga immediatamente successiva alla chiusura delle parentesi graffe. Come esempio,
riscriviamo il programma EsempioFor con un while:
class EsempioWhile {
public static void main( String args[] ) {
int i = 0;
int n = 5;
while ( i < n ) {
System.out.println( “Il valore di I e’: “+ I );
i++;
}
}
}
L’output è lo stesso del programma EsempioFor.
4.9
Operatori
Java offre un ambiente molto ricco per quel che riguarda gli operatori. In generale gli
operatori possono essere suddivisi in quattro grandi gruppi: aritmetici, binari, relazionali e
logici. Nel seguito descriveremo tutti i gruppi elencati a eccezione degli operatori binari.
4.9.1 Operatori aritmetici
Gli operatori aritmetici vengono utilizzati nelle espressioni matematiche con le stesse
modalità con cui vengono impiegati nell’algebra ordinaria. Gli operandi devono essere di tipo
numerico (interi, floating point e anche caratteri, dato che il tipo char è sostanzialmente un
sottoinsieme degli interi). La tabella seguente elenca gli operatori aritmetici disponibili in Java:
OPERATORE
RISULTATO
DESCRIZIONE
+
Addizione
c = a + b;
-
Sottrazione
c = a – b;
- 41 *
Moltiplicazione
c = a * b;
/
Divisione
c = a / b;
%
Modulo
c = a % b; // c è il resto della divisione tra a e b
++
Incremento
c++; // c viene incrementato di 1
+=
Assegnazione addizione
c += a; // c viene incrementato di a
-=
Assegnazione sottrazione
c -= a; // c viene decrementato di a
*=
Assegnazione moltiplicazione
c *= a; // c viene moltiplicato per a
/=
Assegnazione divisione
c /= a; // c viene diviso per a
Assegnazione modulo
c %= a; // c = resto di c/a
Decremento
c--; // c viene decrementato di 1
%=
--
Tutte le operazioni aritmetiche di base si comportano come ci si potrebbe
ragionevolmente attendere. Da ricordare che l’operatore Divisione tra interi comporta un
risultato intero (senza parte frazionaria).
4.9.2 Operatori di relazione
Gli operatori di relazione determinano, come dice il nome, la relazione che un operando
ha con l’altro. Più specificatamente servono a determinare l’uguaglianza o l’ordinamento tra
due operandi, come illustrato nella seguente tabella:
OPERATORE
RISULTATO
DESCRIZIONE
==
Uguale a
a == b; // true se a è uguale a b
!=
Diverso da
a != b; // true se a è diverso da b
>
Maggiore di
a > b; // true se a è maggiore di b
Maggiore di o uguale a
a >= b; // true se a è maggiore o uguale a b
Minore di
a < b; // true se a è minore di b
Minore di o uguale a
a <= b; // true se a è minore o uguale a b
>=
<
<=
Da notare che tutti i risultati di queste operazioni sono valori di tipo boolean, e come
tali possono e devono essere utilizzati nelle espressioni che controllano le istruzioni if e i cicli
while. E’ da notare che l’operatore di uguaglianza è formato da due segni = posti accanto:
questo per distinguerlo dall’operatore di assegnazione (un solo =).
4.9.3 Operatori logici
Gli operatori logici che introdurremo in questa sottosezione (non tutti quelli presenti in
Java) funzionano solo se applicati a tipi boolean:
OPERATORE
RISULTATO
DESCRIZIONE
&&
AND logico
a && b; // true se a AND b sono veri
||
OR logico
a || b; // true se a OR b sono veri
!
NOT
!a; // true se a è false
- 42 -
4.9.4 Precedenza degli operatori
Come nella matematica ordinaria che si fa con carta e penna, anche in Java gli
operatori hanno una precedenza: in particolare, in un’espressione algebrica, la moltiplicazione
e la divisione hanno la precedenza su somma e sottrazione. L’espressione
d = a + b * c;
viene valutata in questo modo: prima c viene moltiplicato per b, il risultato di questa
operazione è sommato ad a e il risultato finale è messo in d. Se l’intenzione è quella di
moltiplicare per c la somma a+b occorre usare le parentesi tonde:
d = (a + b) * c;
Occorre prestare attenzione al mescolamento tra divisione e moltiplicazione: avendo le
due operazioni la stessa precedenza, vengono eseguite nell’ordine in cui sono scritte. Quindi
l’espressione
d = a / b * c;
è molto diversa dall’espressione
d = a / ( b * c );
In caso di dubbio è sempre consigliabile usare le parentesi tonde per definire le
precedenze delle operazioni in un’espressione algebrica complicata.
- 43 -
- 44 -
5 Introduzione alle classi
La classe è il "nucleo" di Java. È il costrutto logico su cui si basa
tutto il linguaggio Java perché definisce la forma e la natura di un
oggetto. In quanto tale, la classe costituisce la base della
programmazione orientata agli oggetti in Java. Qualunque concetto
si desideri implementare in un programma Java deve essere
incapsulato in una classe.
5.1
Concetti fondamentali sulla classe
Le classi sono state utilizzate anche nel capitolo precedente, ma finora è stata
impiegata solo la loro forma più rudimentale. Le classi create nel capitolo 4 esistono
semplicemente per incapsulare il metodo main(), che è stato utilizzato per dimostrare i
concetti fondamentali della sintassi di Java. Come verrà spiegato successivamente, le classi
sono in realtà più potenti di quelle limitate illustrate finora.
Forse, il concetto più importante da comprendere relativamente a una classe è che essa
definisce un nuovo tipo di dati, che successivamente può essere utilizzato per creare oggetti di
quel tipo. Quindi una classe è un modello di un oggetto, mentre l'oggetto è un'istanza di una
classe. Poiché un oggetto è un'istanza di una classe, i due termini oggetto e istanza verranno
spesso utilizzati in modo intercambiabile.
5.1.1 La forma generale di una classe
Quando si definisce una classe, si dichiarano la sua forma e la sua natura esatte,
specificando i dati che essa contiene e il codice che agisce su tali dati. Anche se le classi molto
semplici possono contenere solo codice o solo dati, moltissime classi del mondo reale
contengono entrambi.
Come verrà spiegato, il codice di una classe defmisce l'interfaccia ai propri dati. Una
classe viene dichiarata mediante l'utilizzo della parola chiave class. Le classi utilizzate finora
sono esempi molto limitati della sua forma completa. In realtà le classi possono essere molto
più complesse.
La forma generale di una definizione di class è illustrata di seguito:
class nomeclasse {
tipo variabile-istanza1;
tipo variabile-istanza2;
~
...
tipo variabile-istanzaN;
tipo nome-metodo1( elenco-parametri ) {
- 45 -
Corpo del metodo1;
}
tipo nome-metodo2( elenco-parametri ) {
Corpo del metodo2;
}
...
tipo nome-metodoN( elenco-parametri ) {
Corpo del metodoN;
}
}
I dati, o le variabili, definiti all’interno di una classe sono chiamate variabili d’istanza. Il
codice vero e proprio, ossia le istruzioni per operare sulle variabili, e quindi sui dati, è
contenuto all’interno dei metodi. Nel loro insieme, variabili e metodi di una classe sono
chiamati membri della classe. Nella maggior parte dei casi, solo i metodi di una certa classe
possono agire sui dati della stessa classe, quindi sono i metodi a determinare in che modo
possono essere utilizzati i dati di una classe.
5.2
La classe Persona: i costruttori
Invece di dare nozioni astratte sulle classi, cercheremo di imparare qualcosa sul loro
funzionamento attraverso un esempio concreto. Immaginiamo quindi di voler scrivere un
programma in Java per implementare una rudimentale ma funzionante agendina del telefono.
Per capire di quali classi abbiamo bisogno, dobbiamo considerare il fatto che a ogni “oggetto”
che fa parte del nostro problema dobbiamo assegnare una classe. Occorre dunque prima
capire quali tipi di oggetto servono per definire un’agenda del telefono.
La prima classe che consideriamo verrà chiamata Persona: un oggetto di tipo Persona
dovrà contenere i tipici dati di una persona che finiscono in un’agenda del telefono, ossia
nome, cognome e numero di telefono. Scriviamo quindi la classe Persona 2:
class Persona {
String nome;
String cognome;
String telefono;
Persona( String unNome, String unCognome, String unTelefono ) {
nome = unNome;
cognome = unCognome;
telefono = unTelefono;
2
Nota Bene: durante le prove pratiche di laboratorio la classe Persona è stata chiamata
Elemento.
- 46 -
}
}
E’ importante ricordare che una dichiarazione class ha il solo effetto di definire un
modello, e non di creare un oggetto. Per creare un oggetto concreto di tipo Persona (ossia,
come si dice in gergo java, per instanziare la classe Persona) si dovrà scrivere, in un metodo
di un’altra classe, l’istruzione
Persona p = new Persona( n, c, t );
in cui n, c e t sono variabili (o letterali) di tipo stringa che contengono rispettivamete il
nome, il cognome e il numero di telefono della Persona i dati della quale vanno immessi
nell’agenda. Per fare un esempio concreto, potremmo scrivere
Persona p = new Persona( “Mario”, “Rossi”, “06/1234567” );
oppure
String n = “Mario”;
String c = “Rossi”;
String t = “06/1234567”;
Persona p = new Persona( n, c, t );
In entrambi i casi il risultato netto sarà che la variabile p.nome conterrà la stringa
“Mario”, p.cognome la stringa “Rossi” e p.telefono la stringa “06/1234567”.
Notiamo che nella classe Persona c’è un metodo che ha lo stesso nome della classe, a
cui passiamo tre variabili di tipo stringa: questo metodo si chiama costruttore, e viene usato
appunto per costruire l’oggetto. In altre parole quando scriviamo new Persona( n, c, t )
stiamo chiamando il metodo Persona della classe Persona, ossia il costruttore della classe.
5.3
Utilizzo della classe Persona: la classe Agenda
Un’agenda in sé stessa si configura come una lista (ordinata) di nomi di persone con
accanto il loro numero di telefono. Nel nostro caso quindi dobbiamo costruire una classe
Agenda che contenga una tale lista, ed è ovvio implementare questa lista con un vettore che
contiene un certo numero di oggetti di tipo Persona. Quando usiamo un’agenda possiamo
sostanzialmente fare tre cose:
1. inserire un nuovo numero di telefono;
2. cercare il numero di telefono di qualcuno;
3. sfogliare l’agenda.
- 47 Nello scrivere la classe Agenda dovremo creare tre metodi che implementino nel mondo
di Java le tre azioni che possiamo compiere nel mondo reale.
class Agenda {
private Persona lista[];
final int MAX = 20;
int n;
Agenda() {
lista = new Persona[MAX];
n = 0;
}
public void inserisci( String ilNome,
String ilCognome,
String ilTelefono ) {
if ( n < MAX ) {
lista[n] = new Persona( ilNome, ilCognome, ilTelefono );
n++;
} else {
System.out.println( "*** ERRORE: impossibile aggiungere
numeri di telefono." );
System.out.println( "
L'Agenda e' piena" );
}
}
public void stampa() {
System.out.println( "\n\nLista dei numeri presenti
nell'Agenda:\n\n" );
for ( int i = 0; i < n; i++ ) {
System.out.println( "\t" + lista[i].nome + " " +
lista[i].cognome + "......." + lista[i].telefono );
}
System.out.println( "\n" );
}
public void cerca( String ilCognome ) {
int i;
boolean trovato = false;
for ( i = 0; i < n; i++ ) {
if ( lista[n].cognome.equals( ilCognome ) ) {
System.out.println( "\t" + lista[i].nome + " " +
lista[i].cognome + "......." + lista[i].telefono );
trovato = true;
}
- 48 -
}
if ( ! trovato ) {
System.out.println( ilCognome +
“ non è presente nell’Agenda” );
}
}
}
Notiamo prima di tutto che la classe Agenda contiene tre variabili d’istanza: il vettore
lista, che contiene oggetti di tipo Persona, e che dichiariamo come private perché non
vogliamo che venga usato direttamente all’esterno di questa classe: il numero intero MAX, che
indica il numero massimo di persone che possiamo inserire nell’Agenda, dichiarato come final,
che equivale a dire che il suo valore (20) non potrà essere cambiato: il numero intero n che
indica quante persone abbiamo effettivamente inserito nell’agenda.
Il costruttore Agenda() non riceve nessuna variabile in ingresso, e si limita a:

dire che la quantità di numeri di telefono inseriti è pari a zero (n = 0);

creare il vettore lista.
Il metodo inserisci è dichiarato public, perché deve poter essere chiamato
dall’esterno di questa classe, e void, cioè non restituisce nessun oggetto; riceve in ingresso tre
variabile di tipo stringa (ilNome, ilCognome, ilTelefono). Prima di tutto questo metodo
controlla che l’agenda non sia piena: quindi if ( n < MAX ) crea un nuovo oggetto di tipo
Persona, usando le tre variabili di tipo stringa ricevute dal metodo, e lo inserisce nella n-esima
posizione del vettore lista (la posizione corrente). Dopo queste operazioni incrementa il
valore di n (perché abbiamo aggiunto una persona all’agenda). Se invece l’agenda è già piena
(perché abbiamo già inserito 20 numeri) si limita a segnalare l’errore sullo schermo.
Il metodo stampa è molto semplice. Si limita a eseguire un ciclo for che ha come
effetto quello di stampare sullo schermo i nomi, i cognomi e i numeri di telefono delle persone
presenti nell’agenda.
Il metodo cerca è invece il più complicato dei tre. Notiamo che riceve in ingresso un
oggetto di tipo stringa (ilCognome) che rappresenta il cognome da cercare nell’agenda.
Inizializza una variabile boolean (trovato) al valore false, per indicare che il cognome che
cerchiamo non è stato ancora trovato. Dopo di che inizia un ciclo for per scorrere tutte le
persone presenti nell’agenda, e controlla se il cognome dell’i-esima Persona corrisponde al
cognome (ilCognome) che vogliamo cercare. Per svolgere questo controllo utilizza il metodo
equals( String s ) della classe String. Notiamo infatti che lista[i].cognome è un oggetto
di tipo String. A questo oggetto applichiamo il metodo equals( ilCognome ) che restituisce
un valore true se lista[i].cognome (la stringa oggetto al quale stiamo applicando il metodo
equals) è uguale a ilCognome (la stringa che stiamo passando come parametro al metodo
equals), e restituisce un valore false altrimenti. Quindi if ( lista[n].cognome.equals(
ilCognome ) ) allora scriviamo sullo schermo il nome, il cognome e il numero di telefono della
persona trovata e mettiamo uguale a true la variabile trovato, per segnalare che abbiamo
trovato il cognome che cercavamo.
- 49 -
5.4
Unire le classi in un programma: Rubrica
Finora, nella nostra esplorazione del programma di agenda telefonica, abbiamo visto la
classe Persona e la classe Agenda: è tempo di mettere tutto quanto insieme e di costruire una
classe che contenga un metodo main (punto di inizio del programma) e che usi in maniera
appropriata le classi che abbiamo appena costruito. Presentiamo quindi la classe Rubrica:
import java.io.*;
class Rubrica {
public static void main( String args[] ) throws IOException {
Agenda a = new Agenda();
Reader r = new Reader();
boolean continua = true;
while ( continua ) {
char c = menu( r );
if ( c == 'q' ) {
continua = false;
} else if ( c == 'i' ) {
inserisci( r, a );
} else if ( c == 'c' ) {
//cerca();
} else if ( c == 's' ) {
a.stampa();
}
}
}
public static char menu( Reader lettore ) throws IOException {
System.out.println( "\n\n
System.out.println( "
[i]
telefono; " );
System.out.println( "
[c]
System.out.println( "
[s]
System.out.println( "
[q]
return lettore.readChar();
}
Fai una scelta:\n\n" );
Inserisci nuovo numero di
Cerca un numero di telefono;" );
Stampa l'Agenda;" );
Esci dal programma." );
public static void inserisci( Reader lettore,
- 50 -
Agenda lAgenda ) throws IOException
{
System.out.println( "\n\n" );
System.out.print( "Inserisci il Nome: " );
String nome = lettore.readString();
System.out.print( "Inserisci il Cognome: " );
String cognome = lettore.readString();
System.out.print( "Inserisci il Numero di Telefono: " );
String telefono = lettore.readString();
lAgenda.inserisci( nome, cognome, telefono );
}
}
Notiamo subito che il file Rubrica.java inizia con una dichiarazione di import;
import java.io.*;
Poiché abbiamo bisogno di leggere un input da tastiera, durante il programma, stiamo
importando tutte le librerie di input/output. Notiamo anche che stiamo usando la classe Reader
(che abbiamo scritto durante le lezioni in Laboratorio) e che non sarà descritta in nessun
dettaglio. Ai nostri scopi basterà dire che un oggetto di classe Reader, propriamente usato, ci
permette di leggere numeri e stringhe che inseriamo con la tastiera durante lo svolgimento del
programma. Anche le varie clausole di gestione degli errori (throws IOException) non
saranno discusse. La cosa importante da notare in questa classe è il fatto che serve come un
semplice contenitore del metodo main, che a sua volta utilizza un metodo menu (cioè un
metodo che permette all’utente di scegliere quale operazione compiere, di volta in volta:
inserimento, ricerca o stampa.
5.5
La classe String
Le stringhe sono così importanti, in generale, nella programmazione, che non sembra
inutile guardare con un certo dettaglio come vengono gestite da Java. Java definisce la classe
String che permette di manipolare stringhe, ossia sequenze di caratteri delimitati da doppi
apici. Così se vogliamo assegnare alla variabile s il valore “Mario Rossi” possiamo usare le
seguenti alternative:
String s = “Mario Rossi”;
String s = new String( “Mario Rossi” );
- 51 Esistono altri metodi per inizializzare una stringa, che qui però non prenderemo in
considerazione. Quel che vogliamo ottenere è la conoscenza di alcuni metodi che possiamo
applicare a un oggetto di tipo String per ottenere certi risultati.
5.5.1 Il metodo length()
Il metodo length() permette di conoscere la lunghezza di una stringa, ossia quanti
caratteri contiene. Ad esempio, se abbiamo
String s = “Mario Rossi”;
int lunghezza = s.length();
la variabile lunghezza conterrà il valore 11 (tanti quanti sono i caratteri di “Mario
Rossi”, spazio incluso ovviamente).
5.5.2 Il medodo indexOf( char c );
Questo metodo permette di conoscere in che posizione compare, se compare, il
carattere c in una stringa. Ad esempio, se s è la solita stringa “Mario Rossi”,
int i = s.indexOf( ‘M’ );
darà come risultato che la variabile i contiene il valore 0 (le posizioni dei caratteri
partono da 0 e arrivano a N-1, se N è la lunghezza della stringa), mentre l’istruzione
int j = s.indexOf( ‘r’ );
farà sì che j contenga il valore 2. Da notare che
int k = s.indexOf( ‘s’ );
darà come risultato 8 (cioè viene considerata la prima ‘s’ della stringa) mentre
l’istruzione
int q = s.indexOf( ‘z’ );
darà come risultato –1 (perché il carattere ‘z’ non è contenuto nella stringa).
5.5.3 Il metodo lastIndexOf( char c );
Questo metodo funziona come indexOf, solo che restituisce la posizione in cui il
carattere c compare per l’ultima volta nella stringa. Quindi
- 52 -
int i = s.lastIndexOf( ‘s’ );
restituirà il valore 9;
5.5.4 Il metodo charAt( int i );
Questo metodo restituisce il carattere nella posizione i-esima. Quindi
char c = s.charAt( 1 );
farà sì che la variabile c contenga il valore ‘a’.
5.5.5 Il metodo substring()
Questo metodo può essere usato in più di un modo (ossia in gergo Java si dice che è
sovraccarico) e in ogni caso serve ad estrarre un porzione di stringa da una stringa data.
Un primo modo di usarlo è il seguente:
String cognome = s.substring( 6 );
Se passiamo una sola variabile intera al metodo substring, diciamo k, il metodo
restituisce la sottostringa che parte dal carattere nella posizione k-esima. Nel caso
dell’esempio sopra la variabile cognome conterrà la stringa “Rossi”. Un altro modo di utilizzare
substring è il seguente:
String nome = s.substring( 0, 4 );
I due numeri interi rappresentano l’indice iniziale e l’indice finale della sottostringa da
estrarre. In questo caso nome conterrà “Mario”.
5.5.6 Il metodo trim()
Questo metodo consente di eleminare da una stringa tutti gli spazi vuoti eventualmente
presenti all’inizio e alla fine della stringa stessa. Quindi
String sSpazi = “
Mario Rossi
String s = sSpazi.trim();
farà sì che s contenga solamente “Mario Rossi”.
“;
- 53 -
5.5.7 I metodi toLowerCase(), toUpperCase()
Sono metodi che servono a trasformare una stringa tutta in minuscolo (toLowerCase) o
tutta in MAIUSCOLO (toUpperCase);
String minuscolo = s.toLowerCase(); // restituisce “mario rossi”
String maiuscolo = s.toUpperCase(); // restituisce “MARIO ROSSI”;
5.5.8 Concatenare stringhe
Per concatenare due o più stringhe si può usare l’operatore +.
String nome = “Mario”;
String cognome = “Rossi”;
String s = nome + “ “ + cognome; // restituisce “Mario Rossi”
5.5.9 Il metodo equals();
Come abbiamo già visto il metodo equals serve a comparare due stringhe, e ritorna
true in caso le due stringhe da comparare siano uguali, false in caso contrario. Quindi
boolean b = s.equals( “Mario Rossi” ); // restituisce true
boolean b = s.equals( “MARIO ROSSI” ); // restituisce false
Se vogliamo comparare due stringhe indipendentemente dalle maiuscole e minuscole
dobbiamo usare equalsIgnoreCase:
boolean b = s.equalsIgnoreCase( “MARIO ROSSI” ); // restituisce true
5.5.10
Il metodo compareTo()
Questo metodo serve a capire l’ordine alfabetico di due stringhe. Ad esempio
int i = s.compareTo( “Mario Rossi” );
restituisce valore 0 (le due stringhe sono uguali), mentre l’istruzione
int i = s.compareTo( “Giuseppe Verdi” );
restituisce un valore > 0 (perché s, cioè “Mario Rossi”, è alfabeticamente maggiore di
“Giuseppe Verdi”). Al contrario,
- 54 int i = s.comapreTo( “Sergio Bianchi” );
restituisce un valore < 0.