-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.