130 53 27MB
Italian Pages 808 Year 2013
Dal catalogo Apogeo Education Informatica Bolchini, Biandolese, Salice, Sciuto, Reti logiche, seconda edizione Coppola, Mizzaro, Laboratorio di programmazione in java Bruni, Corradini, Gervasi, Programmazione inJava, seconda edizione Deitei, C Corso completo di programmazione, terza edizione Della Mea, Di Gaspero, Scagnetto, Programmazione web lato server, seconda edizione aggiornata Hanly, KofBnan, Problem solving e programmazione in C Hennessy, Patterson, Architettura degli elaboratori Horstmann, Concetti di informatica efondamenti diJava, quinta edizione King, Programmazione in C Laganà, Righi, Romani, Informatica. Concetti e sperimentazioni, seconda edizione Lambert, Programmazione in Python Lombardo,Valle, Audio e multimedia, terza edizione Malik, Programmazione in C++ Mazzanti, Milanese, Programmazione di applicazioni grafiche in Java Peterson, Davie, Reti di calcolatori, terza edizione Pigni, Ravarini, Scinto, Sistemi per la gestione dell*informazione, seconda edizione PoliUo, Facile da usare. Una moderna introduzione all*ingegneria deirusabilità Schneider, Gersting, Informatica
Algoritmi e strutture dati William J. Collins
Edizione italiana a cura di Marcello Dalpasso
APOReQ eaucation
MAGGIOU EDITORE
Algorìtmi e strutture d ad Autore: William J. Collins Titolo originale: D ata Structures and thè Java Collections Framework, third cdition
Copyright 0 2 0 1 1 John Wiley & Sons. All rights reserved.
Traduzione: Marcello Dalpasso Impaginazione elettronica: Grafica editoriale Copertina e progetto grafico: Enrico Marcandalli
O Copyright 2013 by Maggioli S.p.A. Maggioli Editore è un marchio di Maggioli S.p.A. Azienda con sistema qualità cerdEcato ISO 9001: 2000 47822 Santarcangelo di Romagna (RN) • Via del Carpino, 8 Tei 0541/628111 • Fax 0541/622595 www.maggioli.it/servizioclienti e-mail: clientì.editorei^maggioli.it Diritti di traduzione, di memorizzazione elettronica, di riproduzione e di adattamento, totale o parziale con qualsiasi mezzo sono riservati per tutti i Paesi.
Finito di stampare nel mese di novembre 2013 dalla Litografia Titanlito S.p.A. Dogana (Repubblica di San Marino)
Sommario
Presentazione deiredizione italiana................................................................. xv Prefazione........................................................................................... xvii Capitolo 1 - Introduzione a Java....................................................................... 1
Obiettivi del capitolo 1.1 Gli aspetti basilari di Java 1.1.1 I tipi primitivi 1.1.2 II tipo char 1.2 Le classi 1.2.1 La classe String 1.2.2 Usare la notazione javadoc per specificaremetodi 1.2.3 Uguaglianza tra riferimenti e uguaglianza traoggetti 1.2.4 Variabili locali 1.2.5 La classe Scanner 1.3 Array 1.4 Argomenti e parametri 1.5 Impaginazione delle informazioni visualizzate Esercizi di programmazione
1 2 2 3 4 4 6 8 10 13 19 21 24 26
Capitolo 2 - Elementi di programmazione orientata agli oggetti................................... 29
Obiettivi del capitolo 2.1 Astrazione relativa ai dati 2.2 Metodi astratti e interfacce
29 30 31
vi
Sommario
2.2.1 Tipi di dad astratti e strutture dati 2.2.2 Un’interfaccia e una classe che la implementa 2.2.3 Udlizzo della classe FullTimeEmployee 2.3 Ereditarietà 2.3.1 II modificatore di visibilità protected 2.3.2 Ereditarietà e costruttori 2.3.3 Regola per la sosdtuzione da parte di una sottoclasse 2.3.4 Relazioni; “è un” oppure “ha un” 2.4 Protezione delle informazioni 2.5 Polimorfismo 2.6 II linguaggio UML Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 2.1 La classe CalendarDate
34 35 38 41 43 47 47 52 52 53 54 58 58 60 61
Capitolo 3 - Ulteriori caratteristidie di programmazione e di lava______
...63
Obiettivi del capitolo 3.1 Variabili, costanti e metodi statici 3.2 Collaudo di metodi 3.2.1 Ulteriori dettagli sul collaudo di unità 3.3 Gestione delle eccezioni 3.3.1 Propagazione delle eccezioni 3.3.2 Collaudo di unità in caso di eccezioni propagate 3.3.3 Eccezioni a controllo obbligatorio 3.3.4 II blocco finaUy 3.4 Scrittura di file 3.5 Collaudo di sistema 3.6 La macchina virtuale di Java 3.6.1 Pre-inizializzazione dei campi 3.6.2 Garbage collection 3.7 Pacchetti 3.8 Sovrascrivere il metodo equals della classe Object Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 3.1 Un browser web integrato con motore di ricerca, Parte 1
63 64
66 69 73 76 80 83 87 88
98 99 100 100 101
102 105 106 107
no
Capitolo 4 - Analisi di algoritmi................................................. .
,.115
Obiettivi del capitolo 4.1 Stimare l’efficienza dei metodi 4.1.1 Notazione O-grande 4.1.2 Stimare O-grande rapidamente 4.1.3 Omega-grande,Theta-grande e lingua^io naturale 4.1.4 Velocità di crescita 4.1.5 Compromessi
115 116 117
121 127 129 132
S ommario
4.2 Analisi dell^esecuzione 4.2.1 Misura del tempo 4.2.2 Panoramica della classe Random Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 4.1 Let s Make a Deal!
VII
133 134 135 139 139 142 143
Capitolo 5 - Java Collections Framework........................................................... 145
145 Obiettivi del capitolo 146 5.1 Raccolte 147 5.1.1 Classi che rappresentano raccolte 5.1.2 Strutture di memorizzazione per classi che rappresenuno raccplte 148 150 5.2 Alcuni dettagli del Java Collections Framework 150 5.2.1 Classi astratte 153 5.2.2 Tipi parametrici 155 5.2.3 L’interfaccia Collection 161 5.2.4 L’interfaccia List 164 Riepilogo 165 Esercizi di teoria 166 Esercizi di programmazione Progetto di programmazione 5.1 166 Ricoprire il ruolo del progettista, poi quello dell’utilizzatore Capitolo 6 -Ricorsione_____________________
.169
Obiettivi del capitolo 6.1 Introduzione 6.2 Fattoriale 6.2.1 Immagini d’esecuzione 6.3 Da decimale a binario 6.4 Le Torri di Hanoi 6.4.1 Analisi del metodo move 6.5 Cercare in un array 6.6 Backtracking 6.6.1 Uscire da un labirinto 6.7 Ricorsione indiretta 6.8 Costo della ricorsione Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 6.1 Soluzione iterativa delle Torri di Hanoi Progetto di programmazione 6.2 Le otto regine Progetto di programmazione 6.3 Il percorso del cavallo
169 170 170 174 177 182 192 194 208 213 226 227 228 229 232 237 239 240
vili
Sommario
Progetto di programmazione 6.4 Sudoku Progetto di programmazione 6.5 Numbrix
243 245
Capitolo 7 - Liste basate su array................................................................... 251
Obiettivi del capitolo 7.1 L'interfaccia List 7.2 La classe ArrayList 7.2.1 Specifiche dei metodi della classe ArrayList 7.2.2 Un semplice programma che usa un oggetto di tipo ArrayList 7.2.3 Intestazione e campi della classe ArrayList 7.2.4 Definizione del metodo add avente un parametro 7.3 Applicazione: aritmetica a elevata precisione 7.3.1 Specifiche dei metodi e collaudo della classe VeryLonglnt 7.3.2 Campi della classe VeryLonglnt 7.3.3 Definizione dei metodi della classe VeryLonglnt Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 7.1 Espandere la classe VeryLonglnt Progetto di programmazione 7.2 Un browser web integrato con motore di ricerca, Parte 2
252 252 253 253 264 266 268 271 272 274 275 278 279 280 284 285
Capitolo 8 - Liste concatenate____________________________________________ 289
Obiettivi del capitolo 8.1 Cos'è una lista concatenata? 8.2 La classe SinglyLinkedList: una classe-giocattolo! 8.2.1 Definizione di campi e metodi nella classe SinglyLinkedList 8.2.2 Scandire gli elementi di un oggetto di tipo SinglyLinkedList 8.3 Liste doppiamente concatenate 8.3.1 II punto di vista di un utilizzatore della classe LinkedList 8.3.2 Confix>nto tra le classi LinkedList e ArrayList 8.3.3 Gli iteratori di LinkedList 8.3.4 Un semplice programma che usa un oggetto di tipo LinkedList 8.3.5 Campi e intesuzione della classe LinkedList 8.3.6 Creazione e gestione di un oggetto di tipo LinkedList 8.3.7 Definizione del metodo add con due parametri 8.4 Applicazione: un editor di linea 8.4.1 Progetto e collaudo deUa classe Editor 8.4.2 Definizione dei metodi dellaclasse Editor 8.4.3 Analisi dei metodi della classe Editor 8.4.4 Progetto della classe EditorUser 8.4.5 Realizzazione della classe EditorUser Riepilogo Esercizi di teoria Esercizi di programmazione
289 290 290 295 299 304 305 307 309 316 318 321 323 325 330 334 338 338 339 341 342 343
S ommario
Progetto di programmazione 8.1 Espandere la classe SinglyLinkedList Progetto di programmazione 8.2 Realizzare il metodo removeQ in SinglyLinkedListlterator Progetto di programmazione 8.3 Progettare una lisu semplicemente concatenata con struttura circolare Progetto di programmazione 8.4 Realizzare la classe LinkedList in modo diverso Progetto di programmazione 8.5 Espandere le funzionalità dell*editor di testo Progetto di programmazione 8.6 Un Browser web integrato con motore di ricerca. Parte 3
IX
346 347
347 348 348 353
Capitolo 9 -Pile e code...................................................................... ....... 355
Obiettivi del capitolo 9.1 Pila(5tóffe) 9.1.1 La classe Stack 9.1.2 Un errore fatale? 9.1.3 Applicazione di pila: come i compilatori realizzano la ricorsione 9.1.4 Applicazione di pila: conversione da notazione infissa a notazione postfissa 9.1.5 Notazione prefissa 9.2 Coda (queué) 9.2.1 L'interfaccia Queue 9.2.2 Implementazioni dell'interfaccia Queue 9.2.3 Simulazioni al calcolatore 9.2.4 Applicazione di coda: simulazione di un autolavaggio Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 9.1 Rendere più realistica la simulazione dell'autolavaggio Progetto di programmazione 9.2 Progetto, collaudo e realizzazione di un programma che valuta una condizione Progetto di programmazione 9.3 Uscire da un labirinto, una nuova versione Progetto di programmazione 9.4 Correggere la classe Stack
355 356 356 360 361 365 371 375 375 377 378 380 394 395 397 397
399 403 403
Capitolo 10 - Alberi binari............................
.405
Obiettivi del capitolo 10.1 Definizione di albero binario 10.2 Proprietà degli alberi binari 10.3 II Teorema dell'Albero Binario 10.4 Lunghezza dei percorsi verso l'esterno 10.5 Attraversamenti di un albero binario
405 406 406 413 416 417
Sommario
Riepilogo Esercizi di teoria
424 425
Capitolo 11 - Alberi binari di ricerca.................................................................431
Obiettivi del capitolo 11.1 Alberi binari di ricerca 11.1.1 La classe BinarySearchTree come implementazione dell’interfaccia Set 11.1.2 Realizzazione della classe BinarySearchTree 11.2 Alberi binari di ricerca bilanciati • 11.2.1 Alberi AVL 11.2.2 L’altezza di un albero AVL 11.2.3 La classe AVLTree 11.2.4 Stime del tempo di esecuzione Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 11.1 Una realizzazione alternativa del tipo di dato “albero binario di ricerca’’ Progetto di programmazione 11.2 Visualizzare un oggetto di tipo BinarySearchTree Progetto di programmazione 11.3 Il metodo fixAfterlnsertion Progetto di programmazione 11.4 Il metodo fixAfterDeletion
431 432 433 441 462 468 469 471 475 475 476 481 482 483 484 488
Capitolo 12 - Ordinamento.............................................
..489
Obiettivi del capitolo 12.1 Introduzione 12.2 Ordinamenti semplici 12.2.1 Ordinamento per inserimento {Insertion Sari) 12.2.2 Ordinamento per selezione {Selectiort Sort) 12.2.3 Ordinamento a bolle (Bubble Sort) 12.3 L’interfaccia Comparator 12.4 Quanto velocemente si può ordinare? 12.4.1 Ordinamento per fusione {Mer^e Sort) 12.4.2 Lo schema progettuale “dividi e conquista’’ 12.4.3 Quick Sort 12.5 Radix Sort Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 12.1 Ordinare un file in senso crescente
489 490 491 491 495 496 499 502 504 512 512 526 530 531 533
Capitolo 13 - Mappe e insiemi realizzati con alberi.
Obiettivi del capitolo 13.1 Alberi rosso-nero
534 ..539
539 540
Sommario
13.1.1 L'altezza dì un albero rosso-nero 13.2 L’interfaccia Map 13.3 L’implementazione di SortedMap inTreeMap 13.3.1 I campi della classe TreeMap e la sua classe interna Entry 13.3.2 Definizione di metodi della classe TreeMap 13.4 Applicazione di TreeMap: un semplice dizionario dei sinonimi 13.4.1 Progetto, collaudo e implementazione della classe Thesaurus 13.4.2 Progetto e collaudo della classe ThesaurusUser 13.4.3 Realizzazione della classe ThesaurusUser 13.5 La classe TreeSet 13.5.1 Implementazione della classe TreeSet 13.5.2 Applicazione: un semplice verificatore lessicale Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 13.1 Una nuova versione di verificatore lessicale {speli checker) Progetto di programmazione 13.2 Frequenza delle parole Progetto di programmazione 13.3 Costruire una concordanza Progetto di programmazione 13.4 Votazione per approvazione Progetto di programmazione 13.5 Un Browser web integrato con motore di ricerca, Parte 4
XI
542 544 548 552 553 557 558 561 562 565 568 570 576 577 579 580 581 583 584 586
Capitolo 14 - Code prioritarie....................................................................... 591
Obiettivi del capitolo 14.1 Introduzione 14.2 La classe PriorityQueue 14.3 Dettagli realizzativi della classe PriorityQueue 14.3.1 Definizione di campi e metodi della classe PriorityQueue 14.4 II metodo heapSort 14.4.1 Analisi di heapSort 14.5 Applicazione: codici di Huffman 14.5.1 Alberi di Huffinan 14.5.2 Lo schema progettuale per algoritmi greedy 14.5.3 li progetto per la codifica di Huflfman Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 14.1 Decodificare un messaggio compresso mediante la codifica di Hufhnan Progetto di programmazione 14.2 Un Browser web integrato con motore diricerca. Parte 5
591 592 593 594 599 609 614 615 618 622 622 635 635 638 639 641
xii
Sommario
Capitolo 15 - Hashing..................................
645
Obiettivi del capitolo 15.1 Un'analisi degli algoritmi di ricerca 15.2 Una panoramica degli algoritmi di ricerca 15.2.1 Ricerca sequenziale 15.2.2 Ricerca binaria 15.2.3 Ricerca in un albero rosso-nero 15.3 La classe HashMap, implementazione dell’interfaccia Map 15.3.1 Hashing 15.3.2 L’Ipotesi di Hashing Uniforme * 15.3.3 Concatenazione 15.3.4 Implementazione della classe HashMap 15.3.5 Analisi del metodo containsKey 15.3.6 La classe Hashiterator 15.3.7 Creare una tabeUa di simboli usando la tecnica di hashing 15.4 La classe HashSet 15.5 Hashing a indirizzamento aperto (op^forfd/e) 15.5.1 II metodo remove 15.5.2 Clustering primario 15.5.3 Doppio hashing Riepilogo Esercizi di teoria Esercizi di programmazione Progetto di programmazione 15.1 Implementazione della classe HashMap mediante doppio hashing Progetto di programmazione 15.2 Un browser web integrato con motore di ricerca. Parte 6
645 646 646 646 647 649 650 651 657 657 660 663 664 666 674 674 676 679 680 684 685 687 688 689
Capitolo 16 - Grafi, alberi e reti.....................................................
..693
Obiettivi del capitolo 16.1 Grafi non orientati 16.2 Grafi orientati 16.3 Alberi 16.4 Reti 16.5 Algoritmi per grafi 16.5.1 Iteratori 16.5.2 Connessione 16.5.3 Individuare uno sparming tree minimo 16.5.4 Individuare un percorso minimo attraverso una rete 16.5.5 Individuare il percorso più lungo attraverso una rete? 16.6 Una classe Network 16.6.1 Specifiche dei metodi e collaudo della classe Network 16.6.2 Campi della classe Network 16.6.3 Definizione dei metodi della classe Network 16.7 11 backtracking in una rete Riepilogo Esercizi di teoria Esercizi di programmazione
693 694 697 699 700 701 702 710 711 716 720 723 725 734 736 740 743 744 747
Sommario
Progetto di programmazione 16.1 Il Problema del commesso viaggiatore (traveling salesperson problem) Progetto di programmazione 16.2 Backtracking attraverso una rete Progetto di programmazione 16.3 Attività critiche in una rete di progetto Progetto di programmazione 15.4 Un browser web integrato con motore di ricerca, Parte 7
xiìi
748 749 751 752
Appendice Al - Ulteriori caratteristiche del lava Collections Framework.......................... 755
A 1.1 Introduzione A 1.2 Serializzazione A 1.3 Iteratori di tipo
755 755 757
Appendice A2 - Prerequisiti matematici.
.761
A2.1 Introduzione A2.2 Funzioni e sequenze A2.3 Somme e prodotti A2.4 Logaritmi A2.5 Induzione matematica A2.6 Induzione e ricorsione Esercizi di teoria
761 761 762 763 765 777 777
Appendice A3 - Scegliere una struttura dati_______ ___
A3.1 A3.2 A3.3 A3.4 A3.5 A3.6 A3.7
Introduzione Ordinamento cronologico Ordinamento in base a un indice Ordinamento in base a confronti Ordinamento in base a una funzione di hash Osservazioni sull’occupazione di memoria La struttura dati migliore?
..781
781 781 782 783 784 784 785
Bibliografia....
.787
Indice analitico.
.791
1 Introduzione a Java
Questo libro si occupa di programmazione e, più specificatamente, insegna a utilizzare gli algoritmi e le strutture per la memorizzazione dei dati. L’infrastruttura software denominata Java Collections Framework contiene un numero rilevante di strutture dati e di algoritmi: i capitoli successivi a questo avranno come argomento proprio t2Ac framework e il suo utilizzo all’interno dei programmi. Perché tutte queste informazioni abbiano un senso per chi legge, è necessaria una certa familiarità con quelle caratteristiche di Java che vengono illustrate in questo capitolo: tutto questo serve per comprendere l’infrastruttura stessa o per usarla nei propri progetti di programmazione.
Obiettivi del capitolo Apprendere (o rivedere) le caratteristiche fondamentali di Java, tra le quali classi, oggetti e messaggi. Saper usare javadoc per scrivere le specifiche dei metodi. Programmare usando la classe Scanner. Com prendere quanto sia importante sapere che, quando un m etodo viene invocato, è una copia dell'argom ento ad essere memorizzata nel parametro corrispondente. Capire nel dettaglio l'uso degli array e la disposizione delle informazioni visualizzate dai programmi.
CAPITOLO 1
1.1 GII aspetti basilari di Java Ogni programma Java è un insieme di classi. In pratica, una classe è costituita da variabili, dette campi, e da junzioni, dette metodi, che operano su tali campi. Un programma viene eseguito quando un suo specifico metodo, main, viene invocato dall'ambiente di esecuzione {run-time System, altrimenti noto come macchina virtuale Java o Java Virtual Machine). L'intestazione (o firma) di tale metodo è prefissata, come si può vedere nel programma seguente: public class HelloWorld { public static void main(String[ ] args) System.out.printlnC*Hello, world!”); } // metodo main } // classe HelloWorld
Il metodo main di questo programma invoca un altro metodo, println, per visualizzare le seguenti informazioni (dette output del programma) sulla finestra di esecuzione (detta console): Hello^ world!
L'output sulla console (cioè le informazioni prodotte in uscita dal programma e visualizzate sulla finestra di esecuzione presente sul monitor) viene gestito dai metodi System.out.print, System.out.println e System.out.printf,come si vedrà nel Paragrafo 1.5.
1.1.1 I tipi primitivi Un tipo primitivo è un insieme di valori, associato alle operazioni che si possono eseguire su tali valori. Ad esempio, la parola riservata in t indica il tipo primitivo i cui valori sono numeri interi appartenenti all’intervallo che si estende da circa -2 miliardi a circa 2 miliardi e le cui operazioni sono l'addizione, la sottrazione, e così via. Una variabile (detta anche esemplare o istanza) di tipo in t è una zona (o locazione) della memoria del computer che è in grado di contenere un valore di tipo int. Viene usato il termine “variabile” perché il valore che vi è memorizzato può cambiare durante l’esecuzione di un programma. Invece di indicare esplicitamente l'indirizzo deUa locazione di memoria, utilizziamo un identificatore (cioè un nome) ed è il compilatore Java ad associare tale identificatore a un indirizzo. Ad esempio, ecco la dichiarazione di una variabile di tipo in t il cui nome è score: int score; Solitamente si compie un abuso linguistico e si dice che score è una variabile, mentre, più correttamente, score è l’identificatore di una variabile. Ad esempio, l'enunciato score = 0; memorizza il valore 0 nella variabile score. Un'assegnazione successiva può modificare il valore memorizzato: score = 88;
Introduzione
Tabella 1.1
a
Java
I tipi primitivi
Tipo primitivo Intervallo da -2147483648 a 2147483647 int da - 2 ^ a 2^5 - 1 long da -32768 a 32767 short d a -12 8 a 127 byte d a -1.7976931348623157 • 10>* a doublé
Dimansiont 4 byte 8 byte 2 byte 1 byte 1.7976931348623157 ■ 10^*
8 byte
(15 cifre di precisione)
float char boolean
da -3.4028235 • 10» a 3.4028235 • 10» (6 cifre di precisione)
4 byte 2 byte
false, true
1 byte
La parte sinistra di un enunciato di assegnazione deve essere una variabile, mentre la parte destra può essere qualunque espressione, cioè una qualsiasi combinazione valida di simboli che assuma un valore. Possiamo, ad esempio, scrivere: score * (score + 3) / 10; Se, prima delPesecuzione di questo enunciato di assegnazione, score aveva il valore 88, dopo la sua esecuzione avrà il valore 9. Si noti che Toperatore di divisione, /, restituisce il quoziente della divisione intera, perché i due operandi, 91 e 10, sono entrambi numeri interi. Un altro operatore che si può applicare al tipo int è %,Voperatore modulo,che restituisce il resto (intero) calcolato dalla divisione intera. Ad esempio, 91 % 10 restituisce il resto, 1, della divisione tra 91 .e 10. Analogamente, 87 % 2 restituisce 1, (-37) % 5 restituisce -2 e 10 % 91 restituisce 10. Java fornisce supporto a otto tipi primitivi, riassunti nella Tabella 1.1.
1.1.2 lltipochar Il tipo char memorizza caratteri rappresentati in base alla sequenza ordinata {collating sequence) di codifica Unicode, che comprende anche tutti i caratteri ASCII, come *a', 'A', ' ?', e ' ', il carattere di spaziatura {blank). Immaginiamo, ad esempio, di scrivere:
char delimiter = ' La variabile delimiter, quindi, è di tipo char e contiene il valore ' '. La sequenza ordinata Unicode contiene anche altri alfabeti, diversi da quello latino, come l'alfabeto greco, cirillico, arabo ed ebraico; è stata progettata per contenere fino a 65536 (cioè 2*^) caratteri diversi, ma fino ad ora soltanto circa la metà sono stati assegnati. Per inserire in un programma un carattere come © (una **faccina sorridente", detta smiley) bisogna conoscerne la sequenza di escape: una sequenza di simboli che inizia con il carattere di barra rovesciata (backslash, \) e rappresenta un singolo carattere. Ad esempio, la sequenza di escape per il carattere della faccina sorridente, ©, è ' \u263A', per cui, per dichiarare la variabile c di tipo char e assegnarle il carattere della faccina sorridente, possiamo scrivere:
4
Capitolo 1 char c = *\u263A';
C ’è anche un diverso e forse più importante utilizzo delle sequenze di escape, usate per il controllo dell’impaginazione durante la visualizzazione di informazioni. La sequenza ' \n ', ad esempio, rappresenta “il carattere che va a capo” {newline), mentre *\t' rappresenta il carattere di tabulazione {tab). L’esecuzione di Systefn.out.printlnC'We can control\noutput\twith\ttabs");
produce la seguente visualizzazione
,
We can control output with tabs
Serve una sequenza di escape anche per visualizzare un carattere “virgolette” (doublé quote) ^ perché, altrimenti, tale carattere simboleggia la fine della stringa di caratteri da visualizzare. Ad esempio, fornendo come argomento a System.out.println la stringa seguente "His motto was \'‘Don't sweat thè nickels and dimes!\""
si produce la seguente visualizzazione His motto was "Don't sweat thè nickels and dimes!”
1.2 Lodassi Oltre ai tipi primitivi come in t e char,Java consente ai programmatori di creare nuovi e potenti tipi, detti “classi”. Dato un problema, soliumente sviluppiamo classi (o utilizziamo classi esistenti) che corrispondano agli elementi costitutivi (o componenti) del problema stesso. Una classe combina insieme, in un’unica entità, i componenti passivi (detti campi) e i componenti attivi (detti metodi): tale raggruppamento aumenta la modularità del pro gramma, ossia la scomposizione del programma in componenti che siano unità compatte e coerenti. Nello specifico, una classe può essere isolata dal resto del programma e ciò rende il programma stesso più facile da capire e da modificare. Nel prossimo paragrafo approfondiremo il concetto di classe, studiando un esempio specifico, la classe Stringi la più usata tra le classi predefinite di Java.
1.2.1 LaclasseStrìng Per iniziare con un esempio semplice, esaminiamo la classe Strìng, che, in realtà, è piuttosto complicata, con parecchi campi e dozzine di metodi. Noi, però, ci concentreremo sull’Mri7i>^o della classe String, ignorandone i campi e analizzando soltanto alcuni dei suoi metodi. Nel Capitolo 2 presenteremo, invece, una nuova classe e ne analizzeremo i campi, oltre ai metodi. Un oggetto è un esemplare (o istanza) di una classe: in altre parole, un oggetto è una variabile che contiene campi e può invocare metodi. Parlando dell’utilizzo della classe Strìng, si può pensare a un oggetto come a una variabile che contiene una stringa (cioè una sequenza di caratteri) e può invocare i metodi della classe Strìng. A questo proposito, si pongono solitamente due domande:
Introduzione
a
Java
5
1. Come si dichiarano oggetti di tipo Strìng? 2. Come fanno gli oggetti di tipo String a invocare metodi della classe String? La risposta alla prima domanda è in qualche modo sorprendente: infatti, in Java gli oggetti di tipo String, come gli oggetti di qualunque altra classe, non possono essere dichiarati. Si dichiarano, invece, variabili (dette variabili riferimento^) che sono in grado di contenere Tindirizzo di un oggetto. Possiamo, ad esempio, dichiarare: String si; A questo punto, si non è un oggetto di tipo String, bensì una variabile che può contenere Vindirizzo di un oggetto di tipo String. Perché si possa effettivamente contenere un tale riferimento, bisogna che venga effettivamente riservato in memoria (**allocato”) lo spazio per un oggetto di tipo String, poi bisogna assegnare un valore iniziale ai campi dell'oggetto appena creato e, infine, l'indirizzo di tale oggetto deve essere assegnato a si. Queste tre fasi vengono combinate in un singolo enunciato di assegnazione; ad esempio, se vogliamo che si faccia riferimento a un oggetto di tipo String vuoto, scriviamo: si = new Stringo ; La parte destra di questo enunciato di assegnazione assolve a vari compiti. L’operatore new riserva spazio in memoria per un oggetto di tipo String, invoca uno speciale metodo (che prende il nome di “costruttore”) per assegnare un valore iniziale ai campi presenti nell'oggetto e restituisce l'indirizzo di tale oggetto appena creato, indirizzo che, infine, viene memorizzato in si. Un costruttore è un metodo avente lo stesso nome della classe, il cui obiettivo è l’inizializzazione dei campi di un oggetto, esemplare della classe stessa. In questo esempio, i campi vengono inizializzati in modo che l'oggetto di tipo String appena creato rappresenti una stringa vuota, ossia una stringa che non contiene alcun carattere. Il costruttore appena citato non ha parametri e prende il nome di costruttore di default o predefinito, ma la classe String ha anche un costruttore che riceve un parametro di tipo “riferimento a String”, la cui intestazione è qui riportata: public String(String originai) Il parametro originai è di tipo “riferimento a String”. Quando viene invocato questo costruttore alternativo, l’argomento (inserito all’interno delle parentesi tonde) verrà asse gnato al parametro, quindi verrà eseguito il corpo del costruttore (cioè verranno eseguiti gli enunciati che si trovano all'interno delle parentesi graffe che lo definiscono). Come esempio di invocazione di questo costruttore, l'enunciato seguente combina la dichiarazione di una variabile riferimento con l’assegnazione a tale variabile di un riferimento a un oggetto di tipo String appena costruito: String s2 » neM StringC'transparent”); Quando questo enunciato viene eseguito, si riserva spazio in memoria per un nuovo oggetto di tipo String, i campi di questo oggetto appena creato vengono inizializzati in modo che '
N ei linguaggi C e C++, una variabile che può contenere l'indirizzo di un'altra variabile viene detta
variabile puntatore.
Capitolo 1
il nuovo oggetto rappresenti la stringa "transparent" e, infine, Tindirizzo di tale nuovo oggetto viene assegnato al riferimento s2 di tipo Stiing. Ora che si e s2 contengono riferimenti attivi, gli oggetti a cui si e s2 fanno riferimento possono invocare qualsiasi metodo deUa classe Stringi. Ad esempio, il metodo length non necessita di parametri e restituisce il numero di caratteri presenti nell'oggetto invocante, che è l’oggetto che, appunto, invoca il metodo length. Possiamo scrivere: System.out.println(si.length());
Verrà visualizzato uno zero. Se, invece, scriviamo:
^
System.out.println(s2.length());
verrà visualizzato il numero 11, perché l’oggetto invocante contiene la stringa "transparent". Il costruttore di default e il costruttore che riceve un parametro di tipo riferimento a String hanno lo stesso nome, String, ma un diverso elenco di parametri. Java consente il sovraccarico dei metodi {method overloading): una classe può avere metodi con lo stesso nome (o, per meglio dire, con lo stesso identificatore), a patto che abbiano un diverso elenco di parametri. Per chiarire meglio le implicazioni derivanti dal sovraccarico di metodi, defi niamo la firma {signature) di un metodo come l’identificatore del metodo stesso, seguito dall’elenco ordinato dei tipi dei suoi parametri. Ciò detto, il sovraccarico è consentito tra metodi aventi firme diverse. Consideriamo, ad esempio, le seguenti intestazioni di metodi: public String fìndLast(ifit n, String s) public String findLast(String s, int n)
In questo esempio, l’elenco dei parametri del primo metodo inizia con un parametro di tipo int, mentre l’elenco dei parametri del secondo metodo inizia con un parametro di tipo String, per cui i due metodi hanno firme diverse ed è lecito che questi due metodi siano definiti all’interno di una stessa classe, costituendo, così, un esempio di sovraccarico tra metodi. Confrontiamo questo esempio con il seguente: public String findLast(int n, String s) public int findLast(int
String t)
Qui i due metodi hanno la stessa firma (ricordando che il tipo del valore restituito è irrile vante ai fini della definizione della firma di un metodo), per cui non sarebbe lecito definirli all’interno di una stessa classe,.
1.2.2 Usarela notazionejavadocperspecificaremetodi La classe String ha un metodo che restituisce un riferimento a una copia di una specifica sottostringa (che è una porzione di caratteri adiacenti) dell’oggetto di tipo stringa con cui
2 C on Teccezione dei costruttori della classe S tring, che vengono invocati dall'operatore new. Per que sto motivo, oltre al fatto che i costruttori non restituiscono alcun valore, gli sviluppatori del linguaggio Java non hanno catalogato i costruttori tra i metodi (si veda Arnold, 1996). C iò nonostante, per semplicità, noi assimileremo i costruttori ai metodi di una classe.
Introduzione
a
Java
si invoca il metodo. Per agevolare la comprensione di tale metodo, presenteremo qui le sue speciBche: le specifiche di un metodo sono quelle informazioni di cui un utente ha bisogno per scrivere codice che invochi tale metodo. Le specifiche di un metodo comprendono la **notazione javadoc*’: javadoc è un pro gramma che legge il codice sorgente Java e un blocco di commenti impaginato in modo opportuno, generando la corrispondente API {Application Programming Interface^ interfaccia per la programmazione di applicazioni) in codice HTML (Hypertext Markup Language), facilmente visualizzabile con un browser, uno dei programmi comunemente utilizzati per navigare nel Web. Dal momento che javadoc è disponibile in qualsiasi sistema che consenta lo sviluppo di codice Java, il “formato javadoc” è divenuto uno standard per la scrittura delle specifiche dei metodi. Ciascun blocco di commenti inizia con /**, ogni successiva riga inizia con e Tultima riga del blocco è costituita soltanto dai caratteri */. Le specifiche complete sono costituite dai conunenti javadoc e dall*intestazione del metodo: /*♦
* Restituisce una copia della sottostringa di questo oggetto * di tipo String compresa tra i due indici specificati.
*
* é^aram beginindex la posizione iniziale (inclusa) *
della sottostringa.
* fiparam endindex la posizione finale (esclusa) *
della sottostringa.
*
* Return la sottostringa di questo oggetto di tipo String * *
*
compresa tra gli indici beginindex (incluso) e endindex (escluso).
♦ gthrows IndexOutOfBoundsException se beginindex è ♦ negativo, oppure se beginindex è maggiore di ♦ endindex, oppure se endindex è maggiore di ♦ lengthQ. V public String substring(int beginindex, int endindex)
La prima frase di un blocco di commenti javadoc è detta post-condizione ed è l’effetto provo cato da un’invocazione valida del metodo. 11 blocco di conunenti elenca anche i parametri (mediante le clausole 9param), il valore restituito (con ^return) e le eccezioni eventualmen te lanciate (con 9throws). U n a eccezione, come IndexOutOfBoundsException, è un oggetto creato in seguito al verificarsi di una condizione anomala, solitamente un’elaborazione di dati non validi. Il Paragrafo 3.3 si occupa di eccezioni, parlando di come vengano lanciate e catturate. Per non far sorgere dubbi, fino al termine di questo capitolo ometteremo le clausole ^throws dai conunenti. Per illustrare l’effetto di questo metodo, presentiamo qui alcune sue invocazioni con una stringa vuota, a cui fa riferimento si, o con la stringa "transparent”,a cui fa riferimento s2: sl.substring(0, 0) // sl.substring(0, l) // // s2.substring(l, 4) // // s2.substring(5, 10) //
restituisce un riferimento a una stringa vuota errore: il secondo argomento è maggiore della lunghezza dell'oggetto invocante restituisce un riferimento a una copia di "ran", una stringa di 3 caratteri restituisce un riferimento a una copia di
8
C apitolo 1 // "pareri”, una stringa di 5 caratteri s2.substring(5^ il) // restituisce un riferimento a una copia di // "parent", una stringa di 6 caratteri
Ci sono parecchi altri aspetti che vale la pena menzionare relativamente ai blocchi di com menti. Innanzitutto, nella post-condizione e altrove, la locuzione “questo oggetto di tipo Stringi descrive l'oggetto invocante. Poi, ciò che viene restituito è sempre un riferimento a una copia della sottostringa. Infine, Tultimo carattere della sottostringa richiesta si trova nella posizione endindex - 1 e non endlndex. Il blocco di commenti javadoc appena illustrato è uh po’ più semplice di quello ef fettivamente usato per specificare il metodo substring nella classe String, che contiene anche alcuni marcatori (tag) HTML,come , e , e visualizzando la descrizione del metodo con un browser,cioè dopo l’esecuzione del programma javadoc per la classe String, i commenti sarebbero di più facile lettura. Ad esempio, invece di leggere:
* ^return la sottostringa di questo oggetto di tipo String ♦
compresa tra gli indici beginindex (incluso) e endlndex (escluso).
si leggerebbe: Returns: la sottostringa di questo oggetto di tipo String compresa tra gli indici beginindex (incluso) e endlndex (escluso). La documentazione di Java accessibile on-line è stata generata con javadoc e la documenta zione di un metodo appartenente a una classe può contenere un collegamento (hyperlink) a un’altra classe. Per esempio, l’intestazione del metodo next() neUa classe Scanner è questa: public String next() In questo modo, se state consultando la documentazione del metodo next() e volete avere informazioni in merito alla classe String, tutto ciò che dovete fare è cliccare con il mouse suUa parola sottolineata. String. che è un collegamento attivo (link). In tutto il libro useremo javadoc per scrivere la documentazione di un metodo e, analogamente, anche voi dovreste farlo per descrivere i metodi che progettate.
1.2.3 Uguaglianzatra riferimenti euguaglianzatra oggetti Le variabili riferimento rappresentano un miglioramento rispetto al meccanismo dei punta tori utilizzato nei linguaggi C e C + + che hanno preceduto Java. A una variabile puntatore si poteva assegnare qualsiasi indirizzo di memoria, dando spesso luogo a errori di difiicile individuazione. Al contrario, se una variabile riferimento contiene un indirizzo, questo deve necessariamente essere l’indirizzo di un oggetto che è stato creato dall’operatore nen. Per segnalare che una variabile riferimento non contiene alcun indirizzo le si può assegnare un valore speciale, rappresentato daUa parola riservata nuli: String s3 = nuli;
A questo punto, s3 non contiene alcun indirizzo, per cui non sarebbe lecito scrivere:
Introduzione
a
Java
9
s3.1ength()
Nella terminologia classica della programmazione orienuta agli oggetti, quando viene in vocato un metodo, un messaggio viene inviato all’oggetto invocante: la parola ’*messaggio** suggerisce una forma di comunicazione che si instaura tra una porzione del programma e un’altra. Ad esempio, il messaggio seguente restituisce la lunghezza dell’oggetto di tipo String a cui fa riferimento s2: s2.1ength()
Questo messaggio richiede che l’oggetto a cui fa riferimento s2 restituisca la propria lunghezza, e, in effetti, viene restituito il valore 11. Il formato di un messaggio è costi tuito da un riferimento seguito da un punto (detto operatore di selezione di membro), a sua volta seguito dall’identificatore di un metodo e da un elenco di parametri racchiuso tra parentesi tonde. Verificate di avere ben compreso la differenza tra un riferimento nuli (come $3) e un riferimento (come si) a una stringa vuota: si tratta di una differenza essenziale per la comprensione del modeUo di riferimenti a oggetti usato da Java. La differenza tra oggetti e riferimenti diventa imporunte nel confix>nto tra il metodo equals e l’operatore Il metodo equals è specificato in questo modo: Confronta questo oggetto di tipo String con l'oggetto fornito come parametro. Il risultato è true se e solo se l'argomento non è nuli ed è un oggetto di tipo String che rappresenta la stessa sequenza di caratteri di questo oggetto di tipo String.
* 9param anObject l'oggetto da confrontare con questo. * * Return true se i due oggetti di tipo String sono uguali; ♦ ‘ “ altrimenti. ■ false *
*/ public boolean equals(Object anObject) Il tipo specificato per il parametro indica che l’oggetto invocante può essere confix>ntato con un oggetto di qualsiasi tipo, non solunto con un oggetto di tipo String, ma, ovvia mente, se tale oggetto non è di tipo String verrà restituito false. La classe Object verrà tratuu nel Capitolo 2. L’operatore ■■ effettua semplicemente il confix>nto tra due riferimenti, restituendo true se e solo se i due riferimenti contengono il medesimo indirizzo. Di conseguenza, se s tr i e str2 fanno riferimento a due oggetti di tipo String identici, ma che si trovano ad indirizzi diversi, allora strl.equals(str2)
restituirà true, perché gli oggetti sono identici, ma s tr i — str2 restituirà false, perché s tr i e str2 contengono indirizzi diversi.
10
CAPITOLO 1
Infine, ricordiamo che è possibile creare oggetti di tipo Stiing anche senza invocare l’operatore new. Ad esempio, in questo modo: String strO str3
yes , "yes";
Dato che gli ometti da creare sono a tutti gli efifetti identici, viene costruito un unico og getto di tipo String e tanto strO quanto str3 fanno riferimento a tale ometto. La Figura 1.1 mostra alcuni esempi e confionu l’uso del metodo equals della classe String con l’udlizzo dell’operatore » tra riferimenti.
String s4 ss s6 s7 s8 S9
« • = • « =
nai String("restfur), na« String("restfur), n w String("peaceful"), s4, "restful", "restful";
Systein.out.println(s4.equals(s5)); Systein.out.println(s4.equals(s6)); System.out.println(s4 »■ ss); System.out.println(s4 »■ s7); System.out.println(s4 s8); System.out.println(s8 s9); Figura 1.1
// visualizza "true" // visualizza "false" U visualizza "false" // visualizza "true" // visualizza "false" // visualizza "true"
Effetti deirutllizzo del metodo equals e dell'operatore= s.
La prima e la terza invocazione di println nella Figura 1.1 visualizzano due risultati diversi perché il metodo equals confinnta stringhe, mentre l’operatore confionta rife rimenti. Ricordate che ogni invocazione dell’operatore neu crea un nuovo oggetto di tipo String, quindi, come evidenziato nella Figura 1.2, s4 fa riferimento a un oggetto di tipo String il cui valore è "restful",m entre s5 h riferimento a un diverso oggetto di tipo String, il cui valore è di nuovo "restful".
1.2.4 Variabili locali Le variabili definite alI*interno di un metodo, cosi come i parametri del metodo stesso, si chiamano variabili locali. Ad esempio, il metodo seguente ha due variabili locali, n e j: /♦* * Determina se il numero intero specificato, * maggiore di uno, è un numero primo. *
* ^aram n il numero intero di cui si vuole determinare *
la primalità.
* ^return true se e solo se n è un numero primo. * V
public static boolean isPrime(int n) {
Introduzione
a
Java
11
# restful S4
restful S5
♦
peaceful
s6
S7
Figura 1
Posizionamento in memoria dei riferimenti e degli oggetti della Figura 1.1.
if (n « 2) retum trae; if (n % 2 == 0) return false; for (Int j = 3; j ^ j neM Scanner(System.in);
14
CAPITOLO 1
Nel seguito troviamo la dichiarazione di un oggetto'di tipo Scanner che leggerà i dati dal file di nome "myFile.dat": Scanner scanner = new Scanner(neii File("myFile.dat''));
Se, invece, vogliamo scandire il testo contenuto nell*oggetto line di tipo String, dobbiamo usare la seguente dichiarazione: Scanner lineScanner
Scanner(line);
A questo punto possiamo usare Toggetto se di tipo Scarfner appena dichiarato per leggere un valore di tipo iiit che rappresenti la valutazione di una prova d*esame: iiit score * sc.nextIntO; Per comprendere il funzionamento del metodo nextint occorre introdurre un po’ di ter minologia: uno scanner (“scansionatore”) suddivide il testo in token (“simboli”), separati da delimitatori. Nel caso del metodo nextint, i delimitatori sono “gli spazi bianchi” (whitespace), cioè i caratteri di spaziatura, i marcatori di fine riga, i caratteri di tabulazione e cosi via, mentre tutte le altre sequenze di caratteri comprese tra due delimitatori sono i token. 11 processo di scansione procede in questo modo: per prima cosa vengono ignorati tutti gli eventuali spazi bianchi, poi viene letto un token e, se i caratteri del token rappresentano un valore di tipo int, tale valore viene memorizzato nella variabile score (nel Paragrafo 3.3 del Capitolo 3 vedremo cosa succede se il token non rappresenta un valore di tipo int). Possiamo leggere e sommare valutazioni finché non viene letto un valore “sentinella” (ad esempio, -1). Nel programma che segue, utilizziamo la classe java.util.Scanner del pacchetto {package) java.util: dato che vorremo spesso usare parecchie classi del pacchetto java.util, specifichiamo che tutte le classi di tale pacchetto siano disponibili all’interno del programma. Come? Scrivendo java.util.* in una direttiva di import, rendiamo noto al compilatore che l’intero pacchetto java.util deve essere reso disponibile. In questo programma, e in tutti quelli che saranno presentati nel libro, il metodo main è costituito da un’unica riga: viene creato un nuovo esemplare della classe invocandone il costruttore di default (fornito automaticamente dal compilatore) e tale nuovo esemplare invoca il proprio metodo run. Ecco il file sorgente completo: import java.util.*; // per la classe Scanner ptiblic class Sum { public static void main(String[ ] args) new Sum().run(); } // metodo main
public void run() { final in t SENTINEL
-i;
final String INPUT^PROMPT = "\nPlease enter a test score” + •’ (or ” + SENTINE! + ” to quit):
Introduzione
a
Java
15
final String SUM MESSAGE * "\n\nThe sum of thè scores is Scanner se = new Scanner(Systefn.in);
Int score, sum » 0;
Mhile (tnie) {
System.out.print(INPUT^PROMPT); score = sc.nextIntO; if (score « SENTINEL)
break; sum +» score; } // while System.out.println(SUM_MESSACE + sum); } // metodo run } // classe Sum
Una caratteristica di questo programma che vai la pena evidenziare è Tenunciato Mhile: il ciclo continua finché non viene letta la sentinella, perché Tesecuzione dell*enunciato break provoca la terminazione immediata del ciclo e il successivo enunciato che sarà eseguito è il println che visualizza la somma. Il ciclo ha un unico punto di ingresso e un unico punto di uscita, caratteristiche che aiutano a comprenderne il funzionamento. Ancora, c’è un unico punto in cui viene visualizzato il prompt (cioè il messaggio che chiede dati all’utente) e un unico punto in cui i dati in ingresso vengono effettivamente letti. Perché in questo programma abbiamo usato una sentinella invece di consentire all’utente di porre semplicemente termine al ciclo smettendo di inserire dati? Quando viene invocato il metodo sc.nextlnt(),il programma si blocca in attesa che venga inserito un valore che non sia uno spazio bianco (seguito dalla pressione del tasto Invio o Enter), quindi è necessaria una sentinella per porre fine all’acquisizione di dati dalla tastiera. Nella scansione di una stringa o di un file, è raro che ci sia una sentinella, per cui un’invocazione del metodo nextlnt() dovrebbe essere preceduta dall’invocazione del metodo hasNextInt() della classe Scanner, che restituisce true se esiste un altro token da leggere e false in caso contrario. Questo garantisce che l’invocazione di nextlnt() non provocherà una terminazione anomala del programma a causa della mancanza di un ulteriore token. Nella classe Sum, all’utente viene chiesto di fornire un unico valore di tipo in t per ogni riga, ma, in generale, una riga può contenere più valori di tipo int, oppure nessun valore. Ad esempio, potremmo scrivere: Scanner se =
neM Scanner(System.in);
in t scorei = sc.nextIntO, score2 = sc.nextIntO, score3 = sc.nextIntO;
e i dati potrebbero essere forniti in questo modo (con la seconda riga vuota): 85 95 87
16
CAPITOLO 1
A questo punto, le variabili scorei, score2 e score3 avrebbero, rispettivamente, i valori 85, 95 e 87. Il programma seguente, un po’ più complicato, legge i dati da un file. Ciascuna riga del file è costituita dal nome di uno studente e daUa media dei suoi voti. Al termine dell’ese cuzione, il ptogranuna visualizza il nome dello studente che ha la media più elevata. Non sono presenti sentinelle: la lettura dei dati prosegue fintanto che il file di ingresso ha ulte riori token (che, ricordiamo, sono sequenze di caratteri prive di spazi bianchi). Come già detto, il metodo haxNext() restituisce trae se e solo se nel file sono presenti altri token, e il metodo next() restituisce, appunto, il successivo token, sotto forma di stringa. ùiport java.util.*; // per la classe Scanner i^XMTt java.io.*;
‘
// per la classe File
public class HighestCPA { public static veld (nain(String[ ] args) throws FileNotFoundException new HighestGPA().run(); } // metodo main public void run() throws FileNotFoundException { final doublé NECATIVE_GPA - -l.O; final String NO_VALID_INPUT "Error: thè givin file has no valid input.”;
final String BEST_MESSAGE = "\n\nThe student with thè highest grade point average is Scanner fileScanner « new Scanner(new File(”students.dat”)); String name, bestStudent = nuli;
doublé gpa, highestGPA = NEGATIVE_GPA; while (fileScanner.hasNextLineO)
{
Scanner lineScanner » new Scanner(fileScanner.nextLineO); name = lineScanner.next(); gpa • lineScanner.nextOoubleO; if (gpa > highestGPA)
{
highestGPA » gpa; bestStudent » name; } n if } // while if (highestGPA — NEGATIVE_GPA)
Introduzione
a
Java
17
System.out.println(NO VALID INPUT); else
System.out.println(BEST_MESSAGE + bestStudent); } // metodo run } // classe HighestGPA
Il significato della clausola throMS FileNotFoundException è illustrato nel Paragrafo 3.3.3 del Capitolo 3. Come detto nel Paragrafo 1.2.4, la variabile bestStudent viene inizializzata per eviure il messaggio d’errore “might not bave been initialized” nel momento in cui viene visualizzata. Si noti, per esempio, che se il file **students.dat” è vuoto, il ciclo non viene eseguito, per cui a bestStudent non viene assegnato alcun valore all’interno del ciclo stesso. Ecco un esempio del contenuto del file “students.dat”: Larry 3.3 Curly 3.7 Moe 3.2
Corrispondentemente, si ottiene in uscita quanto segue: The student with thè highest grade point average is Curly
Nel programma precedente, il nome del file da cui leggere i dati era‘^codificato” all’interno del programma stesso, cioè era scritto nel codice, ma è certamente più realistico che sia l’utente del programma a fornire, tramite la tastiera, il nome del file di dati. Di conseguenza, abbiamo bisogno di due oggetti di tipo Scanner: uno per leggere il nome del file e un altro per leggere il file stesso. Dal momento che il nome di un file (eventualmente completo di percorso, path) può contenere degli spazi, non possiamo leggerlo invocando il metodo next(),ma dobbiamo utilizzare il metodo nextLine(),che fa procedere la scansione fino al termine della riga attualmente in fase di lettura e restituisce (la parte rimanente di) tale riga, con l’esclusione del marcatore di fine riga. Ecco il codice che, nel programma precedente, sostituisce l’inizializzazione della variabile fileScanner: final String INJILE_PROMPT = "Please'enter thè path for thè input file: Scanner keyboardScanner * new Scanner(System.in); System.out.print(IN_FILE_PR0MPT); String inFileName = keyboardScanner.nextLine(); Scanner fileScanner > new Scanner(new File(inFileName));
Infine, ecco un esempio di dati forniti in ingresso al programma (in grassetto) e di infor mazioni visualizzate in usciu dal programma stesso: Please enter thè path for thè input file: students.dat The student with thè highest grade point average is Curly
Occorre ricordare che il metodo next() legge e ignora eventuali “spazi bianchi” e restitu isce il token successivo, mentre il metodo nextLine() legge dati fino al termine della riga
18
C apitolo 1
attuale e restituisce tutti i caratteri che ha letto, con Tesclusione del marcatore di fine riga. Analogamente, il metodo hasNext() restituisce tn ie se e solo se nel testo è presente un ulteriore token, mentre il metodo hasNextLine() restituisce true se e solo se nel testo è presente almeno un altro carattere, eventualmente anche solo uno spazio bianco. Quindi, se nel testo rimane da leggere una riga di spazi bianchi, o anche soltanto un ultimo marcatore di fine riga, hasNext() restituirà false, mentre hasNextLine() restituirà true. L'esempio precedente presenta una situazione che ritroveremo più e più volte nei prossimi capitoli: uno scanner legge dalla tastiera il nome di un file, un altro scanner legge, una dopo l’altra, le righe di quel file e, infine, un ulteriore Wanner analizza ciascuna singola riga letta dal file. Nel prossimo esempio vedremo uno scanner che legge ciascuna singola parola presente in una riga, per poi convertirla in caratteri minuscoli e visualizzarla. Tale scanner viene dichiarato in un metodo il cui unico parametro è una riga da analizzare e scomporre in parole: public void run() {
split("Mere today gone tomorrow”); } // metodo run
public void split(String line) { Scanner se
Scanner(line);
ubile (sc.hasNextO) System.out.println(sc.next().toLowerCase()); } // metodo split
La visualizzazione prodotta sarà: bere today gone tomorrow
Sfortunatamente, se i dati in ingresso contengono caratteri non alfabetici che non siano spazi bianchi, questi entreranno a far parte dei token. Ad esempio, usando questa invocazione: split("Mere today, gone tomorrow.”);
la visualizzazione prodotta sarà: bere today, gone tomorrow.
Possiamo modificare il delimitatore standard di token, costituito da qualsiasi spazio bianco, usando il metodo useDelimiter(String pattern), che restituisce un (riferimento a un) oggetto di tipo Scanner. Ad esempio, se vogliamo che il delimitatore di token sia qualsiasi numero positivo di caratteri non alfabetici, possiamo definirlo in questo modo:
Introduzione
a
Java
19
Scanner se » nen Scanner(line).useOelimiter("['^a-zA-Z]+");
Nell’argomento del metodo useDelimìter, le parentesi quadre specificano un gruppo di caratteri, il carattere specifica il complemento dei caratteri che lo seguono rispetto all’insieme di caratteri completo e il carattere *+’ è un’abbreviazione per **qualsiasi numero positivo di ripetizioni” del gruppo che lo precede. In altre parole, stiamo in questo modo definendo come delimitatore una qualsiasi sequenza di una o più ripetizioni di caratteri non alfabetici, per cui, avendo aggiunto tale invocazione di useDelimìter, scrivendo: split("Mere today, gone tomorrow.");
la visualizzazione prodotta sarà: here today gone tomorrow
Ipotizziamo, infine, di voler consentire che una parola contenga un apostrofo. Dobbiamo, quindi, includere l’apostrofo nell’insieme di caratteri il cui complemento definisce i deli mitatori: Scanner se > new Scanner(line).use0elimiter("[''a-2A-Z*]+”);
Con questa invocazione: split("You*re 21?? rii need to see some ID!“);
la visualizzazione prodotta sarà: you're i’il need to see some id
Nota, La classe Scanner consente al programmatore di utilizzare una espressione canonica (regular expression), un modo per descrivere stringhe ricorrenti (pattern) da individuare all’interno di un testo. In effetti, gli argomenti forniti fin qui al metodo useDelimìter sono semplici esempi di espressioni canoniche, ma, in generale, le espressioni canoniche sono uno strumento potente (e in qualche modo complicato) per cercare stringhe specifiche all’interno di un testo. Per maggiori informazioni sulle espressioni canoniche in Java, si veda http://www.txt2re.com (Habibi,2004).
1-3 Array Un array è una raccolta di elementi dello stesso tipo che vengono memorizzati in zone di memoria tra loro contigue o adiacenti, in modo, cioè, che i singoli elementi siano memo-
20
Capitolo 1
rizzati uno adiacente all’altro^. Ad esempio, in questo modo possiamo creare un array di cinque oggetti di tipo Strìngi String[ ] names
String[5];
In seguito all’esecuzione di questo enunciato, l’operatore new riserva spazio in memoria per un array di cinque riferimenti a oggetti di tipo String (ciascuno inizializzato a nuli daUa Java Virtual Machine, la maahina virtuale Java) e restituisce un riferimento all’inizio dello spazio cosi riservato: tale riferimento viene memorizzato nella variabile names. Per specificare uno dei singoli elementi di un array si, usa un indice^ che è un’espressio ne di tipo intero racchiusa tra parentesi quadre: il valore dell’espressione determina quale singolo elemento vada selezionato, ricordando che il valore minimo dell’indice è zero. Ad esempio: names[0] - "Cromer”;
memorizzerà un riferimento alla stringa “Cromer” nella posizione di indice zero dell’array names (o, per meglio dire, dell’array a cui names fa riferimento). La dimensione di un array è fissata al momento deUa sua creazione, ma non è necessa rio che tale dimensione sia nota al momento della compilazione del codice. Per esempio, questo è lecito: publlc void processInput(String s) int n « noi Scanner(s).nextlnt(); String[ ] names
String[n];
Quando, durante l’esecuzione del programma, viene eseguito il metodo processinput, a names verrà assegnato un riferimento a un array di n riferimenti a stringhe, tutti inizializzati a nuli. Un array è un oggetto, nonostante non esisto alcuna “classe array’’. Solitamente si usa una terminologia un po’ imprecisa (ma comoda), dicendo che names “è un array’’, anche se sarebbe più corretto dire che “è un riferimento a un array’’. La capacità di un array, cioè il massimo numero di elementi che vi si possono me morizzare, è a sua volto memorizzato nel campo length dell’array stesso. Supponiamo, ad esempio, di inizializzare un array nel momento in cui lo creiamo, in questo modo: double[ ] weights » {107.3, 112.1, 114.4, 119.0, 117.4}; Possiamo visualizzare la capacità di questo array in questo modo: System.out.println(weights.length);
ottenendo:
^ In realtà, ciò che im porta è che, per Futilizzatore di un array, gli elem enti siano memorizzati fossero adiacenti.
com e se
Introduzione
a
Java
21
Per un array, x, il valore dell’indice deve essere compreso tra 0 e x.length - 1, estremi compresi. Se il valore di un indice si trova al di fuori di tale intervallo, viene lanciata un’ec cezione di tipo ArraylndexOutOfBoundsExceptìon (per una discussione relativa alla gestione delle eccezioni, si veda il Paragrafo 3.3 del Capitolo 3), come nell’esempio seguente: final int MAX = 10; double[ ] salaries = nem double[MAX]; for (int i = 0; i lineScanner.next();
int hoursWorked > lineScanner.nextlnt(); doublé payRate ■ lineScanner.nextDouble(); xetum neN HourlyEmployee(name, hoursWorked, payRate); } // metodo getNextEmployee } // classe HourlyCompany
Ricordiamo che il metodo ereditato f indBestPaid contiene la seguente assegnazione: fu ll ■ getNextEmployee(sc);
Elementi
di programmazione orientata agli o ggeiti
49
La parte sinistra di questa assegnazione è (un riferimento a) un oggetto di tipo FullTimeEmployee,ma il valore restituito dall’invocazione del metodo getNextEmployee è un riferimento a un oggetto di tipo MourlyEmployee. Una situazione di questo tipo è perfettamente leciu, perché un oggetto di tipo MourlyEmployee è anche un oggetto di tipo FullTimeEmployee. Si tratta di un’applicazione della Regola per la Sostituzione da parte di una Sottoclasse: Quando nellavalutazione di un'espressioneviene usato un riferimentoa un oggetto ditipo superclasse,losipuò sostituirecon un riferimentoa un oggetto ditipo sottoclasse. In questo caso specifico, la parte sinistra del precedente enunciato di assegnazione è un riferimento a un oggetto di tipo FullTimeEmployee, per cui nella parte destra deU’assegnazione è richiesto, appunto, un riferimento a un oggetto di tipo FullTimeEmployee: è, quindi, lecito che quell’espressione, a destra, sia un riferimento a un oggetto di tipo MourlyEmployee. È importante osservare che, per un oggetto di tipo MourlyEmployee, il metodo toStringO prevede l’aggiunta della stringa “HOURLY”. Il riferimento restituito dal metodo getNextEmployee viene assegnato al riferimento full, di tipo FullTimeEmplo yee, che, a sua volta, viene usato per aggiornare il riferimento bestPaid, di nuovo di tipo FullTimeEmployee. Quando il valore di bestPaid viene restituito al metodo run e viene inviato il messaggio (costituito dall’invocazione di) bestPaid.toStringO, sarà presente anche la scritta “HOURLY” . Perché? Il motivo è piuttosto interessante: Quando viene Inviato un messaggio, laversione del metodo Invocato dipende dal tipo effettivo dell'oggetto presente almomento dell'esecuzione, non dal tipodel riferimentospecificatodurante lacompilazione. A partire dalla costruzione del nuovo oggetto di tipo MourlyEmployee nel metodo get NextEmployee, tutti i riferimenti successivi riguardano un oggetto di tipo MourlyEmployee, per cui b versione del metodo toStringO invocato dal messaggio bestPaid.toStringO è quella della classe MourlyEmployee. Analizziamo meglio la Regola per la Sostituzione da parte di una Sottoclasse e consi deriamo l’esempio seguente: FullTimeEmployee full *
FullTimeEmployeeO;
MourlyEmployee hourly =
MourlyEmployeeO;
full = hourly;
In quest’ultimo enunciato di assegnazione, si richiede un riferimento di tipo FullTimeEmplo yee come risultato della valutazione dell’espressione che si trova nel lato destro, per cui vi fi può sostituire un riferimento di tipo MourlyEmployee: un oggetto di tipo MourlyEmployee è un oggetto di tipo FullTimeEmployee. Ma il caso inverso non è lecito: FullTimeEmployee full = new FullTimeEmployeeO; MourlyEmployee hourly * nai MourlyEmployeeO; hourly = full; // non lecito
Nella parte destra di quest'ultimo enunciato di assegnazione, il compilatore si aspetta di trovare un riferimento di tipo HourlyEmployee, quindi un riferimento di tipo FullTimeEmployee non è accettabile: un oggetto di tipo FullTimeEmployee non è necessariamente un oggetto di tipo HourlyEmployee. Si noti che la parte sinistra di un enunciato di assegnazione deve consistere in una variabile, che è anche un’espressione, ma tale parte sinistra non viene valutata come espressione durante l’esecuzione dell’enunciato di assegnazione, per cui ad essa non si applica la Regola per la Sostituzione da parte di una Sottoclasse. Supponiamo ora di avere questa situazione: FullTimeEmployee fu ll
FullTimeEmployeeO;
HourlyEmployee hourly = new HourlyEmployee();
’
fu ll * hourly; hourly * fu ll; / / ancora non lecito Dopo aver assegnato hourly a fu ll, fu ll contiene un riferimento a un oggetto di tipo HourlyEmployee, ma l’assegnazione: hourly » full; genera ancora un errore durante la fase di compilazione perché il tipo usato nella dichia razione di fu ll è riferimento-a-FullTimeEmployee. In questa situazione possiamo evitare la segnalazione d’errore da parte del compilatore usando un cast (cioè una **forzatura”): la conversione temporanea del tipo di un’espressione in modo che diventi di un tipo diverso. La sintassi con cui si può effettuare un cast è: {nuovo tipo) espressione
In questo caso particolare, forzeremo fu ll a diventare temporaneamente di tipo HourlyEm ployee: FullTimeEmployee fu ll =
FullTimeEmployeeO;
HourlyEmployee hourly =
HourlyEmployeeO;
full = hourly; hourly * (HourlyEmployee) full; Usando un’analogia antropomorfica, stiamo dicendo al compilatore: **Guarda, so bene che fu ll è di tipo riferimento-a-FullTimeEmployee, ma ti garantisco che, al momento dell’ese cuzione del programma, l’oggetto a cui esso farà effettivamente riferimento sarà, in realtà, di tipo HourlyEmployee”. Questa azione di Sforzatura” è sufficiente a tacitare il compilatore, perché ora la parte destra dell’ultimo enunciato di assegnazione è di tipo riferimento-aHourlyEmployee e anche al momento dell’esecuzione non ci sarà alcun problema, perché, per effetto della precedente assegnazione di hourly a fu ll, il valore della parte destra è effettivamente di tipo riferimento-a-HourlyEmployee. La situazione seguente, però, pur essendo accettata dal compilatore, durante l’esecuzione provocherà il lancio di un’eccezione di tipo ClassCastException: FullTimeEmployee fu ll = new FullTimeEmployeeO;
HourlyEmployee hourly = na< HourlyEmployee(); hourly * (HourlyEmployee) fu ll; Il problema riscontrato durante Tesecuzione su nel fatto che fu ll punu effettivamente a un oggetto di tipo FullTimeEmployee e non a un oggetto di tipo HourlyEmployee. Prima di poter fare un ultimo esempio di applicazione della Regola per la Sostitu zione da parte di una Sottoclasse, dobbiamo presenure la classe Object: è la superclasse di tutte le classi, una classe basilare, i cui metodi vengono soliumente sovrascritti dalle sue sottoclassi. Ecco, come esempio, le specifiche del metodo equals e la sua definizione nella classe Object: ♦ Determina se l'oggetto invocante è lo stesso che viene ♦ fornito come argomento. ♦ * 9param obj l'oggetto fornito come argomento, da confrontare * con l'oggetto invocante. * * ftreturn true se i due oggetti sono lo stesso oggetto. « ♦/ public boolean equals(Object obj) { zetuin (this «« obj); } / / metodo equals La definizione di questo metodo confronu nferimenti.non oggetti,per vedere se sono ugua li, quindi verrà restituito il valore true se e solo se il riferimento usato per l’invocazione del metodo contiene lo stesso indirizzo contenuto nel riferimento obj. Consideriamo, ad esempio, il seguente frammento di codice: Object objl » new Object(), obj2 * neM Object(), obj3 = objl; System.out.println(objl.equals(obj2) + " " + objl.equals(obj3)); la cui esecuzione visualizza: false true Proprio per questo motivo, questo metodo viene solitamente sovrascritto dalle sottoclassi della classe Object, come abbiamo visto, ad esempio, quando abbiamo analizzato il metodo equals della classe String, nel Paragrafo 1.2.3 del Capitolo 1. In quel metodo equals il parametro è di tipo Object, per cui, usando la Regola per la Sostituzione da parte di una Sottoclasse, l’argomento può essere di tipo String, che è una sottoclasse di Object. Ad esempio, possiamo scrivere: i f (message.equalsC'nevermore"))
52
CAPITOLO 2
23.4 Relazioni: "'èun" oppure "ha un" Vi troverete spesso di fronte a quesu situazione: state sviluppando la classe B e vi accorgete che i metodi di un*altra classe, A, sarebbero utili. Una soluzione possibile è definire B come sottoclasse di A, in modo che ne erediti i metodi: tutti i metodi protected di Asono dispo nibili in B (oltre, ovviamente, a tutti i metodi public di A, che sono disponibili in Banche se Bnon è una sottoclasse di A). In alternativa, si può definire, nella classe B, un campo di tipo A, in modo che i metodi di A siano invocabili tramite tale campo. È davvero importante, a questo punto, capire bene la diflferenza esistente tra questi due modi di accedere ai metodi deUa classe A. Uereditarietà descrive una relazione di tipo “è un”: uh oggetto della sottoclasse HourlyEmployee è anche un oggetto della superclasse FullTimeEmployee, per cui possiamo aflfermare che un HourlyEmployee è-un FullTimeEmployee. D ’altra parte, i campi di una classe stabiliscono una relazione di tipo “ha un”: ad esem pio, il campo name deUa classe FullTimeEmployee è di tipo (riferimento a) String, per cui possiamo dire che un oggetto di tipo FullTimeEmployee ha-un (riferimento a un) oggetto di tipo String. Solitamente, se una classe B condivide le funzionalità complessive della classe A, è preferibile usare l’ereditarietà tra A e B. Più spesso, però, c’è una sola caratteristica di B che trarrebbe beneficio dai metodi di A: in questo caso, l’alternativa migliore consiste nel definire un oggetto di tipo A come campo nella classe B.Tale oggetto può invocare i metodi della classe A che servono a B. La scelta tra le due alternative non è sempre così netta, per cui dovrete lasciarvi guidare dall’esperienza e nel prossimi capitoli incontreremo nuovamente questo problema più volte. Quando si lavora con un approccio orientato agli oggetti, l’enfasi non va posta tanto sullo sviluppo di un programma nel suo complesso, quanto sulla progettazione di parti mo dulari del programma stesso: le classi. Questo non solunto rende il programma di più facile comprensione e gestione, ma consente il riutilizzo dei componenti in altri programmi. Un altro vanuggio derivante da questo approccio su nel fatto che è facile cambiare il progetto di una classe: per prima cosa decidiamo quali classi ci serviranno, poi, dal momento che ciascuna classe interagisce con le altre solunto attraverso le specifiche dei metodi, possiamo modificare i campi e le definizioni dei metodi delle classi ogni volta che lo desideriamo, fintantoché le specifiche dei metodi non vengono coinvolte. La prossima sezione di questo capitolo si occupa di illustrare quanto un linguaggio possa consentire agli sviluppatori di una classe di costringere gli utilizzatori di tale classe a seguire il Principio dell’Astrazione per i Dati.
2.4 Protezione delltjinfoniia2ioni Il Principio dell’Astrazione per i Dati afferma che il codice di un utilizzatore di una classe non dovrebbe accedere ai dettagli realizzativi della classe utilizzaU. Aderendo a questo principio, il codice deU’utilizzatore è protetto da modifiche che riguardino soltanto quei detugli realizzativi, come, ad esempio, la modifica dei campi. Quesu protezione è ulteriormente migliorata dal fatto che al codice dell’utilizzatore di una classe sia proibito accedere ai dettagli realizzativi della classe utilizzata. Il principio della protezione delle informazioni (information hiding) prevede che i detugli realizzativi di una classe vengano resi inaccessibili al codice che usa tale classe. Questo costringe gli utilizza
Elementi
di programmazione orientata agli oggetti
53
tori a seguire il Principio dell’Astrazione per i Dati, dal momento che b protezione delle informazioni è una caratteristica del linguaggio che consente agli sviluppatori di una classe di impedire ai suoi utilizzatori di violare il principio stesso. Come avete visto nel Paragrafo 2.3.1,Java consente la protezione delle informazioni mediante l’uso del modificatore di visibilità protected per i campi. Mediante l’uso dei modificatori di visibilità private e protected,Java costringe gli utilizzatori di una classe ad accedere soltanto a quei membri (campi e metodi) il cui accesso è stato consentito dagli sviluppatori. In quest’ambito, il termine incapsulamento (encapsulation) si riferisce al fatto che campi e metodi vengano raggruppati all’interno di un’unica entità (la classe), i cui dettagli realizzativi rimangono nascosti ai suoi utilizzatori. Tre sono le caratteristiche principali dei linguaggi orientati agli oggetti: Vincapsulamento di campi e metodi all’interno di un’unica entità che consenta la protezione delle infor mazioni, Vereditarietà dei campi e dei metodi di una classe da parte di una sottoclasse e il polimoifismot di cui parleremo nel Paragrafo 2.5.
2.5 PBlimorfismo Uno degli strumenti più utili per il riutilizzo del codice, tra quelli caratteristici dei linguaggi orientati agli oggetti, è il polimorfismo. Il termine polimorfismo (derivato dalle parole greche che significano '*molte” e “forme”) riguarda la possibilità che un riferimento sia relativo a oggetti diversi all’interno di una gerarchia di classi. Per analizzare un primo semplice esempio di questo concetto, sorprendentemente utile, supponiamo che se sia un riferimento a un oggetto Scanner già costruito. Possiamo, quindi, scrivere quanto segue: FullTiraeEmployee employee; // di tipo riferimento-a-FullTimeEmployee
if (sc.nextLine().equals(''full time’’)) employee » neu FullTimeEmployee(*'Doremus", 485.0); else employee = new HourlyEmployee('*Kokoska”, 45, 20); System.out.printIn(employee.toString());
Dato che la variabile employee è stata dichiarata di tipo riferimento-a-FullTimeEmployee, è lecito scrivere: employee » new FullTimeEmployee(''Doremus", 485.0);
Ma, per la Regola per la Sostituzione da parte di una Sottoclasse, è altretunto lecito scrivere: employee = new HourlyEmployee("Kokoska'*, 45, 20);
Cerchiamo, ora, di comprendere il significato del messaggio seguente: employee.toString()
La versione del metodo toStringO che viene eflfettivamente eseguita dipende dal tipo dell’oggetto a cui fa riferimento employee. Se la riga letta e analizzata contiene la descrizione di un (Spendente a tempo pieno, alla variabile employee viene assegnato il riferimento a un esemplare della classe FullTimeEmployee,per cui verrà poi invocata la versione di toStringO
54
CAPITOLO 2
deUa classe FullTimeEmployee. Se, invece, la riga lecu contiene informazioni diverse, ad employee viene assegnato il riferimento a un esemplare della classe HourlyEmployee, per cui verrà poi invocata la versione di toStringO della classe HourlyEmployee. In questo esempio, employee è un riferimento polimorfico: Toggetto a cui si riferisce può essere un esemplare della classe FullTimeEmployee oppure un esemplare della classe HourlyEmployee e, conseguentemente, il significato del messaggio employee.toStringO dipende da quanto detto nel Paragrafo 2.3.3, nel senso che, quando viene inviato un messaggio, la versione del metodo invocato dipende dal tipo delfoggetto, e non dal tipo del riferimento. Ciò che è importante sottolineare, a, questo punto, è che il polimorfi smo consente il riutilizzo del codice per i metodi, in collaborazione con Tereditarietà: non c'è bisogno che invochiamo in modo esplicito una delle due versioni del metodo toStringO . Il codice appena descritto pone un problema: come può il compilatore Java decidere quale versione di toStringO invocare? Oppure, detto in altro modo: come può l'identifica tore toString essere accoppiato alla definizione corretta (quella presente nella classe FullTi meEmployee o nella classe HourlyEmployee) durante la fase di compilazione, dal momento che le informazioni necessarie a risolvere questo problema saranno note soltanto al momento dell'esecuzione del programma? La risposta è semplice: Taccoppiamento (binding) non viene effettuato dal compilatore e viene ritardato fino al momento dell'esecuzione. Un metodo che viene accoppiato al proprio identificatore (di metodo) al momento dell'esecuzione viene detto metodo virtuale. In Java, quasi tutti i metodi sono virtuali. L'unica eccezione riguarda i metodi static (di cui parleremo nel Paragrafo 3.1) e i metodi final (il modificatore final identifica quei metodi che non possono essere sovrascritti nelle sottoclassi). Questo accoppiamento ritardato (late binding, detto anche aaoppiamento dinamico, dynamic binding) tra identificatore e definizione di metodo è uno dei motivi che rende più lenta l'esecuzione dei programmi Java rispetto a queUi scritti in linguaggi diversi. Il polimorfismo è una caratteristica fondamentale del linguaggio Java e rende possibile la realizzazione di ambienti complessi e potenti, come il Java Collections Framework. Di questo parleremo più difiùsamente nel Capitolo 5, dove vedremo una panoramica del Java Collections Framework. Le specifiche dei metodi sono strumenti di documentazione a livello di metodo; il prossimo paragrafo, invece, parla degli strumenti di documenuzione a livello di classe.
2.6 II linguaggio UML In Ogni progetto, illustreremo le classi e le relazioni esistenti tra loro usando il linguag gio UML (Unijied Modeling Language, linguaggio unificato per descrivere modelli), un linguaggio standard a livello industriale, prevalentemente grafico, che si basa sulle buone pratiche di ingegneria del software per la gestione dei modelli di sistemi. Lo strumento visivo fondamentale in UML è lo schema (o diagramma) di classe: per ogni classe (tranne quelle di utilizzo molto difiuso, come Strìng e Random), Io schema di classe è costituito da un rettangolo che contiene informazioni relative, appunto, alla classe. Queste informa zioni contengono il nome della classe, i suoi attributi e le sue operazioni. Per semplicità, considereremo il termine attributo, tipicamente usato in UML, come sinonimo di campo; analogamente, il termine operazione sarà considerato un sinonimo di metodo. Ad esempio.
Elementi
di programmazione orientata agli oggetti
55
FullTimeEmployee # name: String # grossPay: int + FullTimeEmployeeO + FullTimeEmployee(name: String, grossPay: doublé) + getName(): String + getCrossPayO: doublé toStringO: String
Rgura 2.1
Lo schema UML per laclasse FuUTimeEmployee.
la Figura 2.1 mostra lo schema della classe FullTimeEmployee, descritta nel Paragrafo 2.2.2. Tanto nel caso degli attributi, quando in quello dei parametri delle operazioni, il tipo segue la variabile (invece di precederla, come avviene in Java). In uno schema di classe, Telenco di parametri di un metodo, racchiuso tra parentesi tonde, è seguito dal tipo del valore restituito, se il metodo restituisce un valore. Le infor mazioni di visibilità sono cosi abbreviate:
Figura 2.2 Nel linguaggio grafico UML, l'ereditarietà viene raffigurata mediante una freccia a tratto continuo, orientata dalia sottoclasse verso la superclasse.
56
Capitolo 2
+ significa visibilità pubblica - significa visibilità privata # significa visibilità ‘‘protetta”
.
’
L’ereditarietà è raffigurata mediante una fireccia a tratto continuo, orientata daUa sottoclas se verso la superclasse. Ad esempio, la Figura 2.2 mostra la relazione esistente tra le classi HourlyEmployee e FullTimeEmployee definite nel Paragrafo 2.3. Una freccia tratteggiata, invece, raffigura la relazione esistente tra una classe e un’inter faccia da essa implementata, come nella Figura 2.3, che integra lo schema di classi della Figura 2.2 aggiungendo l’interfaccia Employee.
Figura 2.3 Rappresentazione UML della relazione esistente tra uninterfaccia, una classe che la implementa e una sottoclasse di quest'ultima.
Elementi
di programmazione orientata agli oggéttì
3/
Figura 2.4 Rappresentazione UML di un'associazione tra due classi. Una relazione tra classi che non sia Tereditarietà è detta associazione e viene raffigurata mediante una riga a tratto continuo tra gli schemi che rappresentano le classi stesse. Ad esempio, la Figura 2.4 mostra un’associazione esistente tra le classi Company e FullTimeEmployee quando vengono utilizzate nel progetto di ricerca del dipendente a tempo pieno con il salario maggiore, presentato nel Paragrafo 2.2.3. Nella Figura 2.4, il simbolo **’, presente neUa parte inferiore della linea che raffigura l'associazione, sta a significare che un’azienda può avere un numero qualsiasi di dipendenti, mentre il numero 1 che figura nella parte superiore della stessa linea significa che un di pendente può lavorare per una sola azienda. A volte si vuole segnalare in modo esplicito che un’associazione tra classi è una re lazione di tipo *'ha-un”, cioè che un esemplare di una classe è un campo aU’interno di un’altra classe: in UML, una relazione di tipo **ha-un” viene detta aggregazione e si raffigura mediante una linea a tratto continuo tra le classi coinvolte, con un piccolo rombo vuoto (al posto della punu di fireccia) nell’estremità più vicina alla classe che contiene un esemplare dell’altra. Ad esempio, la Figura 2.5 segnala che la classe FullTimeEmployee ha un campo di tipo Strìng (per semplicità, la figura contiene soltanto il nome della classe all’interno del corrispondente blocco). Gli strumenti grafici, come gli schemi UML, svolgono un ruolo molto importante nelle prime fasi della progettazione. A partire dal Capitolo 6, svilupperemo progetti, come applicazioni di algoritmi e strutture dati: ciascuno di essi verrà presentato mediante schemi UML per le classi.
58
CAPITOLO 2
Figura 2.5 Rappresentazione deH'aggregazione in UML: laclasse FullTImeEmployee ha un campo di tipo String.
Riepiloyo Questo capitolo presenta una panoramica della programmazione orientata agli oggetti. Ci siamo concentrati suU*utilizzo delle classi piuttosto che sulla loro realizzazione e questo è un esempio di astrazione relativa ai dati. Vastrazione relativa ai datiy cioè il fatto di mante nere separate le specifiche dei metodi dalle loro definizioni, è un modo per proteggere il codice scritto dagli utilizzatori di una classe dalle modifiche che possono essere apportate ai dettagli realizzativi delle classi utilizzate. Le tre caratteristiche fondamentali dei linguaggi orientati agli oggetti sono: 1. Vincapsulamento di campi e metodi aU*interno di un’unica entità,la classe,! cui dettagli realizzativi rimangono nascosti ai suoi utilizzatori. 2. Vereditarietà dei campi e dei metodi di una classe da parte di sottoclassi. 3. Il polimorfismo, cioè il fatto che un riferimento possa riferirsi a oggetti di tipi diversi. Il linguaggio UML (Unified Modeling Language) è uno standard industriale: un linguaggio grafico utilizzato nella descrizione dei modelli relativi a un progetto.
EsercM di teoria 2.1 Sapendo che HourlyEmployee e SalariedEmployee sono sottoclassi di FullTimeEmployee,
si abbia: FullTimeEmployee full = new FullTimeEmployee(); HourlyEmployee hourly * new HourlyEmployee(); SalariedEmployee salaried = nen SalariedEmployee(); full = salaried;
Quale dei seguenti enunciati di assegnazione sarebbe lecito tanto durante la fase di compilazione quanto durante l’esecuzione? Realizzare un piccolo progetto che dimostri la correttezza delle risposte fornite. salaried = (SalariedEmployee) full; salaried = full;
Elementi
di programmazione orientata agli oggetti
59
salaried » (FullTimeEmployee) full; hourly = (HourlyEmployee) full;
2.2 Ipotizzando che le classi seguenti siano tutte descritte nel file Polymorphism. java, determinare ciò che viene visualizzato quando si esegue il programma. Cosa ci sarebbe di diverso se l’invocazione di println fosse System.out.println(a.toStringO)?
import java. util. public class Polymorphism { public static void main(String[ ] args) new Polymorphism().run(); } // metodo main
public void run() {
Scanner se =
Scanner(System.in);
A a;
iiit code = sc.nextIntO; if (code == 0) a » nan A(); else // letto numero intero diverso da zero a » new D(); System.out.println(a); } // metodo run } // classe Polymorphism
class A { public String toStringO return "A"; } // metodo toString } // classe A
class D extends A { public String toStringO { return "D"; } // metodo toString } // classe D
2.3 Modificare il metodo toString della classe FullTimeEmployee in modo che il salario lordo venga visualizzato con una virgola a sinistra della cifira delle centinaia. Ad
60
Capitolo 2 esempio, se il nome del dipendente è *"0’Brien,Theresa*’ e il suo salario lordo è 74400.00, il metodo toString restituirà questa stringa: O'BrienJheresa $74,400.00
2.4
Cosa si può dedurre in merito all’identificatore out analizzando questo messaggio? System.out.println("Eureka !"); Qual è la dichiarazione completa dell'identificatore out? Esaminare il file System, java del pacchetto java.lang.
bwci2i di pmframmazione 2.1 Queste sono le specifiche dei metodi di una semplice classe che trova l’età più alta dopo aver letto alcune età:
public class Age protected iirt highestAge; /* *
* Inizializza questo oggetto dì tipo Age.
*/ public Age() /*♦ * Restituisce l'età più alta tra quelle lette da tastiera. * Il valore sentinella è -l.
*
* 9param se lo Scanner usato per leggere le età.
*
* ftreturn l'età più alta tra quelle lette tramite se. ♦ */ public int findHighestAge(Scanner se) } // classe Age
a. Scrivere le definizioni dei metodi della classe Age. b. Collaudare la classe Age sviluppando ed eseguendo un progetto che la usi. 2.2 Seguendo come traccia la classe Age dell’esercizio precedente, sviluppare una classe Salary che legga valori di salario fino alla comparsa della sentinella (-1.00) e che, poi, visualizzi il valore medio dei salari letti, cioè la somma dei salari divisa per il loro numero. 2.3 Questo esercizio illustra un’alternativa rispetto all’utilizzo di campi con visibilità protected. Modificare la classe PullTimeEmployee in questo modo: cambiare, da pro tected a private, la visibilità dei campi name e grossPay; aggiungere metodi public che consentano di ispezionare e modificare i valori di tali campi. Un metodo che modifica un campo di un oggetto è detto modificatore, mentre un metodo che resti
Elementi
di programmazione orientata agli oggetti
61
tuisce una copia di un campo si dice d'accesso. Ecco le specifiche dei metodi relativi al campo natne: /•*
* Restituisce il nome di questo oggetto di tipo FullTimeEinployee.
*
* ftreturn (il riferimento a) una copia del nome di questo *
«
oggetto di tipo FullTimeEmployee.
♦/
public String getName() /** * Assegna al nome di questo oggetto di tipo FullTimeEmlpoyee * la stringa ricevuta.
*
* Jiparam namein l'oggetto di tipo String il cui valore viene * *
*
assegnato al nome di questo oggetto di tipo FullTimeEmployee.
V
public void setName(String namein) 2.4 Progettare e realizzare una classe che determini quale dipendente con paga oraria,
tra quelli letti da un file, ha ricevuto la maggior quota di salario dovuta a lavoro straordinario. Il nome del file contenente i dati deve essere acquisito tramite System, in. 2.5 Nel metodo toStringO della classe FullTimeEmployee è presente un’invocazione del metodo format, la cui intestazione è:
public final String format(doublé number) Qual è la sua definizione?
Progetto dì programmazione 2.1 La classe CalendarDate In questo progetto svilupperete e collauderete la classe CalendarDate,di cui vengono qui l'iencate le responsabilità, cioè i servizi forniti dalla classe ai suoi utilizzatori: 1. inizializzare un oggetto di tipo CalendarDate in modo che rappresenti la data: 1 gennaio 2012; 2. inizializzare un oggetto di tipo CalendarDate a partire dai valori fomiti per il giorno, il mese e l’anno; se la data non è valida (perché, ad esempio, il mese è 6 e il giorno è 31), usa 1 gennaio 2012; 3. restituisce, sotto forma di String, la data successiva a quella di questo oggetto di tipo CalendarDate; se, ad esempio, esso rappresenta la data 31 gennaio 2012, il valore restituito sarà la data 1 febbraio 2012;
62
Capitolo 2
4. restituisce, sotto forma di String, la data precedente a quella di questo oggetto tii tipo CalendarDate; se, ad esempio, esso rappresenta la data 1 gennaio 2013, il valore restituito sarà la data 31 dicembre 2012; 5. restituisce, sotto fornìa di String, il giorno della settimana corrispondente a questo oggetto di tipo CalendarDate; se, ad esempio, esso rappresenta la data 20 dicembre 2012, il valore restituito sarà “ I hursday'*; Parte a:
creare le specifiche dei metodi a partire dalle responsabilità sopra descritte.
Parte b: progettare la classe CalendarDate, cioè individuare quali siano i campi da dichiarare in essa e, poi, definirne i metodi. Parte c: creare un progetto per collaudare la classe CalendarDate, invocando almeno due volte ciascun suo metodo.
3 Ulteriori caratteristiche di programmazione e di Java
Il Capitolo 2 ha avuto lo scopo principale di introdurre i concetti relativi alla pro grammazione orientata agli oggetti, come le interfacce, l’ereditarietà e il polimorfismo, neH’ambito del linguaggio Java. Questo capitolo presenta ulteriori argomenti relativi alla programmazione, sia in generale sia relativamente a Java, evidenziando come questi possano essere d’aiuto. Ad esempio, il meccanismo di gestione delle eccezioni, in Java, consente ai programmatori di esercitare un efficace controllo su tutto quanto accade nel momento in cui si verificano errori.
Obiettivi del capitolo Capire la differenza tra membri statici e membri di esemplare. Saper sviluppare collaudi per i metodi di una classe usando JUnit. Saper progettare blocchi try e blocchi catch per gestire eccezioni.. Confrontare l'attività di input/output tramite file con quella attraverso la console. Apprendere gli elementi basilari del funzionam ento della Java Virtual Machine. Saper sovrascrivere il m etodo equals della classe Object. Comprendere l'interazione possibile tra pacchetti e modificatori di visibilità.
64
CAPITOLO 3
3.1 Variabili, tostanti e metodi stand Come detto nel Paragrafo 2.2.2, un membro* di una classe può essere un campo o un metodo: esaminiamo alcuni diversi membri che possono esistere in Java. In particolare, ci sono due tipi di campi. Una variabile di esemplare o di istanza è un campo associato a un oggetto, cioè a un esemplare di una classe. Ad esempio, nella classe FullTimeEmployee vista nel Capitolo 2, name e grossPay sono variabili di esemplare: ogni oggetto di tipo FullTimeEmployee ha h propria coppia di variabili di esemplare. Supponiamo di aver dichiarato: FullTimeEmployee oldEmployee^ currentEmployee, newEmployee;
runClasses(HourlyEinployeeTest.class); System.out.printlnC'Tests run • " + result.getRunCount() + “\nTests failed » " + result.getFailuresO); } // metodo tnain
public static final doublé DELTA • O.OOOOOOl; protected HourlyEmployee hourly; ?Test
public void testlO { hourly • new HourlyEmployee("andrew", 39> 10.0); assertEquals(390.0, hourly.getRegularPayO, DELTA); assertEquals(o.o, hourly.getOvertimePayO, DELTA); assertEquals(390.0, hourly.getCrossPay(), DELTA); } // metodo testi ^Test
public void testi0 hourly ■ new HourlyEmployee("beth", 40, 20.0); assertEquals(800.0, hourly.getRegularPayO, DELTA); assertEquals(0.0, hourly.getOvertimePay(), DELTA); assertEquals(800.0, hourly.getCrossPayO, DELTA); } // metodo testi pTest
public void test3() {
hourly » ncM HourlyEmployee("terry", 41, 20.0); assertEquals(800.0, hourly.getRegularPayO, DELTA); assertEquals(30.0, hourly.getOvertimePayO* DELTA); assertEquals(830.0, hourly.getCrossPay(), DELTA); } // metodo test3
gTest
public void test4() { hourly = new HourlyEniployee("karen", 50, 10.0); assertEquals(400.0, hourly.getRegularPayO, DELTA); assertEqualsÒSO.O, hourly.getOvertimePayO, DELTA); assertEqualsÒSO.O, hourly.getCrossPayO, DELTA); } // metodo test4
U lteriori
caratteristiche di programmazione e di Java
73
} // classe HourlyEmployeeTest
E il collaudo degli altri metodi? Non esiste una vera e propria regola che indichi quali metodi di una classe devono essere collaudati: la strategia migliore consiste neiripotizzare che un metodo contenga sempre piccoli e subdoli errori, che possono essere rilevati solunto con un collaudo rigoroso. Un buon collaudo necessita di grande scetticismo. Questa regola può costituire una vera sfida per i programmatori, che tendono a vedere con benevolenza il proprio lavoro, spesso esclamando raggianti: '*una gran cosa, una gioia eterna”. Per questo motivo, i programmatori non sono per nulla adatti al collaudo dei propri metodi, perché lo scopo del collaudo è proprio quello di scoprire errori e la persona ideale per progettare collaudi dovrebbe, in effetti, sperare che il metodo fallisca il collaudo. Se un metodo fallisce un collaudo e viene, poi, modificato, tutti i collaudi relativi a quel metodo dovrebbero essere eseguiti nuovamente. Nel prossimo paragrafo presenteremo la gestione delle eccezioni in Java, esaminando le interazioni esistenti tra la gestione delle eccezioni e il collaudo.
Ì.Ì Gestione delle eccezioni Una eccezione (exception) è un oggetto creato in seguito al verificarsi di una condizione anomala, solitamente un tentativo di elaborazione non valida. Quando viene costruito un oggetto che rappresenta un'eccezione, il normale flusso di esecuzione del programma si in terrompe e si dice che l’eccezione è stata lanciata (throum). Il controllo viene immediatamente trasferito al codice che ^'gestisce” l’eccezione, o nel metodo attualmente in esecuzione o in qualche altro metodo. La gestione di un’eccezione è solitamente dipendente dalla specifica eccezione di cui ci si sta occupando e può prevedere la visualizzazione di un messaggio d’errore, la terminazione del programma, l’esecuzione di un’azione o, a volte, anche nulla. Un programma robusto non termina mai in modo inatteso per effetto di dati introdotti dall’utente. Preferiamo sempre programmi che, invece di andare incontro alla propria “di struzione” {crash), consentano il recupero in seguito a un errore come l’inserimento di 7.o al posto di 7.0 nel momento in cui viene richiesto un valore di tipo doublé. Il fatto che Java metta a disposizione strumenti per la gestione delle eccezioni consente ai programmatori di evitare quasi tutte le terminazioni anomale di un programma. Come semplice introduzione alla gestione delle eccezioni, partiamo da un metodo che richiede, come parametro, un (riferimento non nullo a) un oggetto di tipo String,che rappre sento il nome completo di una penona, nella forma “primo-nome secondo-nome cognome” {first-name, middle-name e lasUnamé), Il metodo restituisce il nome nella forma “cognome, primo-nome iniziale-del-secondo-nome.”. Se, per esempio, abbiamo: rearrange("Dohn Quincy Adams") la stringa restituito sarà: Adams^ Dohn Q.
74
C apitolo 3
Ecco le specifiche del metodo e una sua definizione preliminare: /*♦ * Restituisce un nome completo nella forma * "cognome, primo-nome iniziale-del-secondo-nome.".
*
* ^aram fullName un (riferimento non nullo a) un oggetto di tipo * *
String che rappresenti un nome completo, nella forma "primo-nome secondo-nome cognome".
*
* Jireturn il nome nella forma * "cognome, primo-nome iniziale-del-secondo-nome.". « V public String rearrange(String fullName) { Scanner se
Scanner(fullName);
String firstName - sc.next(), middleName « sc.nextQ, lastName * sc.next();
return lastName + ", " + firstName + " " + middleName.charAt(O) + } // metodo rearrange Il problema di questo metodo, così come è attualmente definito, è che la sua esecuzione può terminare in modo anomalo. Perché? Se l’argomento che corrisponde a fullName è (un riferimento a) un oggetto di tipo String che non è costituito da tre componenti separati da spazi, verrà lanciato un oggetto di tipo NoSuchElementException: in tal caso, l’esecuzione del metodo terminerà in modo anomalo. Invece di una terminazione anomala, vogliamo consentire la prosecuzione dell’esecuzione anche se l’argomento corrispondente a fullName non è un riferimento a una stringa che sia costituita da quelle tre componenti. In pratica, “cerchiamo” (try) di suddividere fullName e “catturiamo” (catch) l’eventuale eccezione che viene lanciata. Ecco una revisione delle specifiche e della definizione del metodo: ♦ Restituisce un nome completo nella forma ♦ "cognome, primo-nome iniziale-del-secondo-nome.".
«
* 9param fullName un (riferimento non nullo a) un oggetto di tipo * String che rappresenti un nome completo, nella « forma "primo-nome secondo-nome cognome". ♦ * ftreturn se fullName ha tre componenti, il nome nella forma * "cognome, primo-nome iniziale-del-secondo-nome.". * Altrimenti, restituisce "java.util.NoSuchElementException: * thè name is not of thè form "first-name middle-name * last-name"".
public String rearrange(String fullName) { String result;
try {
U lteriori Scanner se
caratteristiche di programmazione e di Java
75
Scanner(fullName);
String firstName = sc.next(), middleName = sc.nextQ, lastName = sc.next(); result = lastName + " + firstName + " " + middleName.charAt(O) + } // try catch(NoSuchElementException e) result = e.toStringO + thè name is not of thè form\”" + "first-name middle-name last-name\”"; } // catch
retum result; } // metodo rearrange Durante l’esecuzione di questo metodo, il controllo di flusso si comporta nel modo seguen te. All’interno del blocco try, se il contenuto di fullName può essere suddiviso in primo nome, secondo nome e cognome, verranno eseguite le tre invocazioni di sc.next() e la conseguente assegnazione del risultato alla variabile result. L’intero blocco catch verrà ignorato e verrà eseguito l’enunciato return. Se, invece, il contenuto di fullName non può essere correttamente suddiviso, una delle invocazioni di sc.next() provocherà il lancio di un oggetto di tipo NoSuchElementException, il controllo di flusso uscirà dal blocco try e verrà eseguito l’enunciato contenuto nel blocco catch. Infine, come nel caso precedente, verrà eseguito l’enunciato return. Nel blocco catch, il parametro e è un (riferimento a un) oggetto di tipo NoSuchEle* mentException,creato e lanciato durante l’esecuzione di una delle invocazioni di sc.next(); infatti, e.toStringO restituisce la stringa "java.util.NoSuchElementException".Tra breve, nel Paragrafo 3.3.1, vedremo proprio come si possa lanciare un’eccezione in un metodo, per poi catturarla in un altro. Presentiamo qui una classe di collaudo per il metodo rearrange, nell’ipotesi che esso sia definito nella classe NameChange, che può anche contenere soltanto tale metodo, senza null’altro.
import im^rt import im^rt import
org.junit.*; static org.junit.Assert.*; org.junit.runner.Result; static org.junit.runner.DUnitCore.runClasses; java.util.*;
public class NameChangelest {
public static void main(String[ ] args) {
Result result = runClasses(NameChangeTest.class); System.out.println("Tests run = " + result.getRunCount() + "\nTests failed = " + result.getFailuresO); } // metodo main
public static final String EXCEPTION =
"java.util.NoSuchElementException";
76
C apitolo 3
public static final int EXCEPTION_LENGTH . EXCEPTION.length(); protected NameChange change; protected String result; fCefore
public void runBeforeEveryTestO {
change - new NameChangeO; } / / metodo runBeforeEveryTest
gTest
public void rearrangeTestlO {
result • change.rearrange("John Quincy Adams"); assertEquals("Adams, Dohn Q.", result); } / / metodo rearrangeTestl
gTest
public void rearrangeTest2() result » change.rearrangeCJohn Adams"); assertEquals(EXCEPTION, result.substring(0, EXCEPTION_LENCTH)); } / / metodo rearrangeTest2 ?Test
public void rearrangeTestlO {
result » change.rearrange("John"); assertEquals(EXCEPTION, result.substring(0, EXCEPTION_LENCTH)); } / / metodo rearrangeTestJ
gTest
public void rearrangeTest4() result » change.rearrangeC'"); assertEquals(EXCEPTION, result.substring(0, EXCEPTION_LENCTH)); } / / metodo rearrangeTest4 } / / classe NameChangeTest In questo esempio l’eccezione viene gestita nel metodo rearrange, all’interno del blocco catch, ma nel prossimo paragrafo vedrete come gestire eccezioni che non vengono catturate nel metodo in cui vengono lanciate.
3.3.1 Propagazionedelleeccezioni Cosa accade se un'eccezione, come NoSuchElementException, viene lanciata in un m e todo che non la cattura? Il controllo viene trasferito nuovamente al metodo invocan te, cioè al metodo che aveva invocato il metodo che ha lanciato l'eccezione: questo trasferimento di controllo prende il nome di propagazione dell*eccezione. Ad esempio, il metodo seguente determina se un numero intero, acquisito mediante uno scanner, è un
U lteriori
caratteristiche di programmazione e di Java
77
anno bisestile^ oppure no (una delle eccezioni coinvolte viene lanciau in modo esplicito, usando un enunciato throM): /*♦ * Determina se un numero intero, acquisito mediante uno scanner, * è un anno bisestile oppure no. * * Jiparam se un (riferimento a un) oggetto di tipo Scanner da * cui viene letto il numero che indica l’anno. * * Jireturn true se il numero intero è un anno bisestile; * altrimenti restituisce false. * * Jithrows InputMismatchException se la stringa acquisita tramite * se non è vuota ma non è costituita * da un numero intero. * githrows NoSuchElementException se il valore acquisito tramite se * è una stringa vuota. * githrows NullPointerException se se vale nuli. * ^hrows IllegalArgumentException se il valore acquisito tramite se * è un numero intero minore di 1582
V public boolean isLeapYear(Scanner se) {
final int FIRST YEAR = 1582; // inizio del calendario gregoriano int year = sc.nextlnt(); if (year < FIRST^YEAR)
throw new IllegalArgumentException(); if ((year % 4 == 0) && (year % 100 !» 0 || year % 400 »» 0)) return true; return false; } // metodo isLeapYear Quali errori possono accadere durante l’esecuzione di questo metodo? Un possibile er•orc, come segnalato nelle clausole githrows della documentazione javadoc, avviene se la Uringa letta tramite se non è vuota ma non è costituita da un numero intero: in tal caso «trra lanciata un’eccezione di tipo InputMismatchException. Questa eccezione non viene catturata nel metodo isLeapYear, per cui si propaga al metodo che ha invocato isLeapYear. Ad esempio, la classe LeapYear riportata nel seguito ha un metodo run() che legge cinque flfhe da System.in e determina quali di queste contengono anni bisestili e quali non con ino numeri interi. l^wrt java.util.*; // per la classe Scanner
^ l i c class LeapYear
* lìtio che la Terra compie una rotazione completa attorno al sole in un numero di giorni un po’ infet .V>5.25, non tutti gli anni divisibili per 4 sono bisestili. In particolare, un anno bisestile deve essere per 4 e non divisibile per 100, oppure divisibile per 400. Conseguentemente, l’anno 2000 fu , n u l’anno 2100 non lo sarà.
/«
«^PITOLO 3
publlc statlc void main(String[ ] args) neM LeapYear().run(); } // metodo maio
public void run() { final String INPUT_PROMPT - "Please enter thè year: Scanner se - nat Scanner(System.in);
for (int i - o; i < 5; i++) tiy {
System.out.print(INPUT_PROMPT); System.out.println(isLeapYear(sc));
} H try
catch(InputMismatchException e)
{
System.out.println("The input is not an integer."); sc.nextLineO; } // catch InputMismatchException } // metodo run
publlc boolean isLeapYear(Scanner se) final Int FIRST_YEAR = 1582; // inizio del calendario gregoriano Int year = sc.nextlnt(); If (year < FIRST_YEAR) throw n m IllegalArgumentException(); If ((year % 4 »» 0) 8& (year X lOO != 0 || year X 400 ** 0)^ return true; return false;
} // metodo isLeapYear } // classe LeapYear
Leggendo questi dati: 2000
2100 2010 2010 2008
verrà visualizzato quanto segue: true false The input is not an integer. false true
U lteriori
caratteristiche di programmazione e di Java
79
Come si può vedere,il blocco catch contiene l’invocazione sc.nextLine(). Se questa non ci fosse, con i dati precedenti la visualizzazione sarebbe: true false The input is not an integer. The input is not an integer. The input is not an integer.
Perché? Quando la terza invocazione di sc.nextlnt() all’interno di isLeapYear lancia InputMismatchException per aver letto ’*20lo”,lo scanner, invece di avanzare fino alla quarta riga, rimane posizionato sulla terza. Conseguentemente, le due successive invocazioni di sc.nextIntO lanciano nuovamente InputMismatchException perché leggono di nuovo *‘20lo”. Dobbiamo inserire sc.nextLine() nel blocco catch per essere sicuri che lo scanner ignori la r i ^ contenente dati non validi. £ bene notare che nelle specifiche di un metodo vengono elencate nella sezione ^hrows dei commenti javadoc soltanto quelle eccezioni che vengono propagate al metodo invocante, perché ogni altra eccezione che venga catturata all’interno del codice definito per il metodo (come abbiamo fatto nel metodo rearrange presentato nel Paragrafo 3.3) è un suo dettaglio realizzativo e, come tale, è un aspetto che l’utilizzatore del metodo non ha bisogno di conoscere. Si può anche osservare che, senza grande difficoltà, è possibile modificare il metodo run in modo che legga un numero di valori arbitrario. In tal caso, per segnalare che i dati sono terminati, abbiamo bisogno di un valore, detto sentinella, che non sia un valore valido per rappresentare un anno: ad esempio, la stringa *'****\ Quando si digita sulla tastiera tale valore, il metodo isLeapYear lancia l’eccezione InputMismatchException, poi catturata nel metodo run, dove un enunciato break mette fine all’esecuzione del ciclo di lettura. Ecco la nuova versione del metodo run:
public ¥oid run() * { final String SENTINEL = final String INPUT_PROMPT = "Please enter thè year (or Scanner se
+ SENTINEL + " to quit):
Scanner(System.in);
while (tnie) { try {
System.out.print (INPUT__PROMPT) ; System.out.printIn (is LeapYear(se )); } // try catch(InputMismatchException e)
{
if (sc.nextLine().equals(SENTINEL)) break;
System.out.printlnC'The input is not an integer."); } // catch InputMismatchException } // while
80
Capitolo 3 } // metodo run
Se un’eccezione non viene catturata nel metodo in cui viene lanciata, essa si propaga al metodo invocante; se anche questo non la gestisce, la propagazione dell’eccezione conti nua verso il successivo metodo invocante, risalendo la catena delle invocazioni; infine, se l’eccezione giunge al metodo main e nemmeno qui viene catturata, il programma termina in modo anomalo e viene visualizzato un messaggio che descrive l’eccezione. Il vantaggio della propagazione delle eccezioni è che, in questo modo, le si può gestire a un livello più elevato all’interno del programma: è così possibile prendere le decisioni relative aUa ge stione delle eccezioni in unico contesto, piuttosto che'disperderle qua e là aU’interno del programma. Inoltre, i livelli più elevati possono avere accesso a strumenti non disponibili ai livelli più bassi, come la possibilità di visualizzare informazioni all’interno di una fine stra di una GUI (Graphical User Interface), cioè dell’ambiente grafico con cui interagisce l’utente del computer.
3.3.2 Collaudodi unità incasodi eccezioni propagate Come si collauda un metodo che propaga un’eccezione? Subito dopo l’annotazione 9Test, specifichiamo l’eccezione che ci attendiamo. Ad esempio, questo potrebbe essere un collaudo per il metodo ìsLeapYear: gTest (expected = InputMismatchException.class) public void isLeapYeaiTestO { leap.ÌsLeapYear(new Scanner(”20lo")); ) // metodo isLeapYearTest
Per collaudare completamente il metodo ÌsLeapYear, non possiamo leggere i dati da System, in, perché un tale collaudo richiederebbe un intervento umano. In alternativa, si possono leggere i dati, mediante uno scanner, da una sequenza di righe contenenti i valori da sot toporre a collaudo, ma, usando JUnit, i singoli metodi di collaudo vengono invocati in un ordine non specificato e il risultato di un collaudo non deve avere effetto sugli altri. Quindi, per garantire che le invocazioni dello scanner inizino sulle righe opportune, dovremmo inserire tutti i collaudi all’interno di un unico metodo: una strategia leciu ma non adeguata, perché i collaudi devono essere tra loro indipendenti. La seguente test suite per il metodo ÌsLeapYear presenta ciascun collaudo in un metodo distinto e contiene verifiche per InputMismatchException, NoSuchElementException, NullPointerException e IllegalArgumentException. Ecco la classe:
iifx>rt org.junit.*; import static org.junit.Assert.*; ii^wrt org.junit.runner.Result; iiijwrt static org.junit.runner.DUnitCore.runClasses; import java.util.*;
public class LeapYearTest { public static void main(String[ ] args) Result result = runClasses(LeapYearTest.class); System.out.printlnC'Tests run = ” + result.getRunCount() +
U lteriori
caratteristiche di programmazione e di Java
"\nTests failed * " + result.getFailuresO); } // metodo main
protected LeapYear leap; protected boolean answer; gBefore
public void lunBeforeEverylestO leap = new LeapYear(); } // metodo runBeforeEveryTest pTest
public void leapYearTestlO {
answer = leap.isLeapYear(na< Scanner (”2000")); assertEquals(true, answer); } // metodo leapYearTestl
gTest
public void leapYearIest2() {
answer * leap.isLeapYear(new Scanner("2l00”)); assertEquals(false, answer); } // metodo leapYearTest2
gTest
public void leapYearlestsO {
answer = leap.isLeapYear(new Scanner("l582”)); assertEquals(false, answer); } // metodo leàpYearTest3
gTest (expected = InputMismatchException.class) public void leapYearTest4()
{
leap.isLeapYear(new Scanner("20lo")); } // metodo leapYearTestA
J)Test (expected = NoSuchElementException.class) public void leapYearlestsO {
leap.isLeapYear(new Scanner(""));
} // metodo leapYearTestS pTest (expected = NullPointerException.class) public void leapYearTestóO { leap.isLeapYear(null); } // metodo leapYearTestó J)Test (expected = IllegalArgumentException.class) public void leapYearIest7()
{
leap.isLeapYear(new Scanner("l58l")); } // metodo leapYearTestó
81
82
Capitolo 3 } // classe LeapYearTest
Cosa succede se Teccezione che viene propagata dal metodo sottoposto a collaudo non è quella prevista dal metodo di collaudo? Quest*ultimo genererà un messaggio d*errore, per segnalare che l’eccezione lanciata non è quella prevista. Infine, cosa succede se il metodo sottoposto a collaudo propaga un’eccezione senza che ne fosse prevista alcuna? Ad esempio, all’inizio del metodo leapYearTestS, supponiamo di sostituire l’annotazione gTest (expected = NoSuchElementException.class) *
con gTest
In questo caso il collaudo genererà il seguente messaggio d’errore: Tests failed = [leapYearTest5(LeapYearTest): nuli]
La presenza della parola chiave nuli in questo contesto significa che è stata lanciata un’ec cezione senza che fosse prevista. Durante l’esecuzione di un metodo di collaudo in JUnit, il lancio di un’eccezione imprevista o il mancato lancio di un’eccezione che, invece, era attesa costituiscono errori, cosi come è un errore il lancio di un’eccezione nel momento in cui ne era prevista una diversa. Gli errori vengono inseriti nella stringa restituita dal me todo getPailuresO della classe runClasses, anche se spesso si usa il termine guasto {failure) soltanto in quelle situazioni in cui fallisce la verifica di un’asserzione. Dato che il collaudo di unità ha come obiettivo principale la verifica di asserzioni, prima di iniziare il vero e proprio collaudo è necessario eliminare tutti gli eventuali errori. Abbiamo definito il metodo isLeapYear prima di presentare il meccanismo di propa gazione delle eccezioni, necessario per collaudare tale metodo. Come potremmo proce dere se volessimo, come spesso avviene, coUaudare un metodo prima della sua definizione completa? Espresso in altri termini, come possiamo progettare uno stub che generi un messaggio d’errore in tutti i casi previsti dai collaudi sopra elencati? Se lo stub restituisce true, il metodo leapYearIestl() avrà successo, mentre, se lo stub restituisce false, sarà il metodo leapYeaiTest2() ad avere successo: ovviamente, lo stub non può restituire sia trite sia false, ma lancerà un’eccezione diversa da quelle previste dalle specifiche del metodo. Ad esempio, in questo modo:
public boolean isLeapYear(Scanner se) {
throM new UnsupportedOperationException(); } // metodo isLeapYear
Quando l’apparato di collaudo progettato nella classe LeapYearTest viene eseguito su questo stub, ogni singolo collaudo genera un messaggio d’errore (e quesu è una buona notizia...), come qui visualizzato (dopo aver impaginato il tutto per migliorarne la leggibilità): Tests run = 7 Tests failed * [ leapYearTestl(LeapYearTest): nuli, leapYearTest2(LeapYearTest): nuli, leapYearTest3(LeapYearTest): nuli.
U lteriori
caratteristiche di programmazione e di Java
83
leapYearTest4(LeapYearTest): Unexpected exception, expected but was, leapYearTest5(LeapYearTest): Unexpected exception, expected but was, leapYearTest6(LeapYearTest): Unexpected exception, expected but was, leapYearTest7(LeapYearTest): Unexpected exception, expected but was
]
3.3.3 Eccezioni acontroiioobbiigatorìo Le eccezioni relative alle attività di input/output, come quelle lanciate nel caso in cui non si trovi un file oppure venga raggiunto il segnale di 6ne del 61e durante una fase di lettu ra, rappresentano gli esempi più diffusi di eccezioni a controllo obbligatorio. Nel caso di un'eccezione a controllo obbligatorio {checked exception), il compilatore verifica che Teccezione venga catturata dal metodo in cui viene lanciata oppure, per consentire la sua propagazione, che all’intestazione del metodo stesso sia stata aggiunta una corrispondente clausola throMS. public void
sampleO throMS lOExceptìon
{ Questa clausola segnala che il metodo sample potrebbe lanciare un oggetto di tipo lOExcep tìon e, in tal caso, tale eccezione verrà propagata al metodo che ha invocato sample. Il metodo invocante, così chiamato in causa, deve catturare lOExceptìon oppure avere la medesima clausola throMS neUa propria intestazione, e cosi via. Le eccezioni a controllo obbligatorio vengono propagate per gli stessi motivi che giustificano la propagazione delle altre eccezioni: può darsi che sia preferibile gestire tali eccezioni a un livello più elevato, per omogeneità oppure perché a quel livello potrebbero essere disponibili degli strumenti più adeguati (come una finestra aU’interno di una GUI). Per illustrare un esempio di come si possa gestire un’eccezione a controllo obbligatorio all’interno di un metodo, possiamo modificare il metodo run() visto nel Paragrafo 3.3.1, in modo che legga righe da un file e determini quali di esse contengono numeri interi corrispondenti ad anni bisestili. Il nome del file verrà letto da tastiera all’interno di un ciclo, che continua ad essere eseguito fino a quando il nome fornito non corrisponde a un file effettivamente esistente. Ecco, quindi, il metodo run() modificato in tal senso: public void run() { final Strìng INPUT_PROMPT = "Please enter thè file name: Scanner keyboardScanner = new Scanner(System.ìn); Strìng fìleName;
while (true) {
System.out .prìnt(INPUTPROMPT) ;
fileName * keyboardScanner.next(); try { . ’ Scanner se > new Scanner(na« File(fileName)); Mhile (sc.hasNextO) try { System.out.printIn(isLeapYear(se));
} / / cerca di leggere un anno catch(InputMismatchException e) { System.out.printlnC'The input is not an integer."); sc.nextLineO; } / / catch InputMismatchException * break; } / / cerca di leggere i l nome di un f ile esistente catch(FileNotFoundException e) System.out.println(e); } / / catch FileNotFoundException } / / while true } / / metodo run L’enunciato break, usato per uscire dal ciclo esterno, viene eseguito quando il nome del file letto da tastiera corrisponde a un file esistente e dopo che quel file è stato letto per visualiz zare gli anni bisestili in esso contenuti. La condizione usata nel ciclo interno, sc.hasNext(), è leggermente preferibile rispetto a sc.hasNextLine() perché,in particolare,se l’ultima riga del file è vuota, sc.hasNext() restituirà false e l’esecuzione del ciclo interno terminerà, come desiderato, mentre sc.hasNextLine() restituirebbe true e la successiva invocazione di sc.nextIntO all’interno del metodo isLeapYear lancerebbe NoSuchElementException.Sc, ovviamente tale eccezione venisse catturata dal metodo run(), l’uso di sc.hasNextLine() non sarebbe un problema. Un’eccezione a controUo obbligatorio deve essere catturata oppure specificata in una clausola throns,e il compilatore “verifica” (in inglese,‘Vfceffe”,da cui il nome checked exception) questo, per accertarsi che tutto sia fatto come richiesto. Quali eccezioni sono a controllo obbligatorio, e quali non lo sono? La risposta è semplice: le eccezioni che sono sottoclassi di RuntimeException non sono a controllo obbligatorio, mentre tutte le altre lo sono. La Figura 3.1 mostra la gerarchia delle eccezioni di Java, compresa l’eccezione lOException e le sue sottoclassi (come FileNotFoundException) e l’eccezione RuntimeException e le sue sottoclassi (come NullPointerException). Perché alcune eccezioni sono a controUo non obbligatorio? La motivazione risiede nel fatto che un’eccezione come NullPointerException o NumberFormatException può avvenire in quasi tutti i metodi, per cui l’aggiunta obbligatoria di una corrispondente clausola throws neU’intestazione sarebbe un onere per lo sviluppatore dei metodi, senza fornire informazioni utili a chi legge il codice del metodo o a chi lo utilizza. Quando viene lanciata un’eccezione, le classi che figurano come parametro deUe clau sole catch presenti sono verificate una dopo l’altra, secondo l’ordine in cui appaiono, fino a quando se ne trova (eventualmente) una di cui l’eccezione lanciata sia un esemplare. Quindi, se si vuole avere l’assoluta certezza che qualunque eccezione a controllo non obbligatorio sia catturata all’interno di un metodo, basta inserire questa clausola come ultimo blocco
catch:
u ib r\iw r« i
I b i ^ i ^ I 1^1 i k
v i
■ ■ « w < w . . .
Exception
A lOException
NoSuchElementException
RuntimeException
IndexOutOfBoundsException
t
NullPointerException
A
InputMismatchException ...
ArraylndexOutOfBoundsException
StringIndexOutOfBoundsException
FIgHra 3.1 La gerarchia di eccezioni in Java; in UML, l'ereditarietà è rappresentata mediante una freccia che punta da una sottociasse alla sua superclasse.
catch(RuntimeException e)
{
// codice che gestisce l'eccezione } // catch RuntimeException
Dopo aver inserito un blocco catch che catturi RuntimeException, bisogna accertarsi che non sia seguito da un blocco catch dedicato a una sottoclasse di RuntimeException. Dato che, ad esempio, NullPointerException è una sottoclasse di RuntimeException, la sequenza di blocchi catch qui descritta genererà un errore durante la compilazione: catch(RuntimeException e) // codice che gestisce l'eccezione } // catch RuntimeException catch(NullPointerException e) // errore! // codice che gestisce l'eccezione } // catch NullPointerException
Il messaggio d'errore metterà in evidenza che il secondo blocco catch costituisce una sezione di codice irraggiungibile. U n’eccezione può anche essere lanciata esplicitamente dal programmatore, che si trova a dover decidere di quale classe di eccezione creare un esemplare e in quali circostanze lanciare tale oggetto. Ad esempio, supponiamo di voler progettare un metodo che restituisca il più piccolo di due valori di tipo doublé, che rappresentino prezzi rilevati dal confronto di due negozi. Se i prezzi sono troppo diversi (diciamo, se la loro differenza è maggiore
86
C apitolo 3
del prezzo inferiore), invece di restituire il prezzo più piccolo, lanciamo un*eccezione. Il meccanismo per il lancio esplicito di un’eccezione prevede l’utilizzo dell’enunciato throM, che può essere inserito in qualunque punto in cui sia consentita la presenza di un enunciato. Il codice corrispondente alla situazione appena descritta potrebbe essere simile a quello del seguente metodo smaller (tenendo presente che il metodo statico abs della classe Math restituisce il valore assoluto del suo argomento):
public class Compare { public staile void main(String[ ] args)
,
new Compare().run(); } // metodo main
public void run() System.out.println(smaller(5.0, 4.0)); System.out.printIn(smaller(5.0, 20.0)); } // metodo run
public doublé smaller(double pricei, doublé price2) {
If (Math.abs(pricel - price2) > Math.min(pricel, price2))
throw new ArithmeticException("difference too large"); return Math.min(pricel, price2); } // metodo smaller } // classe Compare
Se il confronto specificato è vero, viene eseguito l’enunciato throw, che crea un nuovo esemplare della classe di eccezioni ArithmeticException, invocando, tra i suoi costruttori, quello che richiede un argomento di tipo String. L’eccezione verrà propagau al metodo che ha invocato smaller e l’esecuzione del metodo smaller terminerà immediatamente: nell’esempio precedente tale eccezione non viene catturata, quindi il programma termina, visualizzando: 4.0
java.lang.ArithmeticException: difference too large
La scelta di ArithmeticException come classe dì eccezioni di cui creare un esemplare da lanciare è stata in qualche modo arbitraria. Un programmatore, poi, può anche creare nuove classi di eccezioni, come in questo esempio:
public class UnreasonablenessException extends RuntimeException public UnreasonablenessException(String s) super(s); } // costruttore con un parametro di tipo String } // classe UnreasonablenessException
Possiamo riscrivere il metodo smaller in modo che lanci questa eccezione:
U lteriori
caratteristiche di programmazione e di Java
87
public doublé smaller(double pricel, doublé price2) if (Math.abs(pricel - price2) > Math.min(pricel, price2)) throM new UnreasonablenessException(”difference too large"); retum Math.min(pricel, price2); } // metodo smallar
Questo codice crea un nuovo esemplare della classe UnreasonablenessException e il pro gramma precedente, con questa nuova versione del metodo smallar, terminerebbe con il messaggio seguente: UnreasonablenessException: difference too large
La classe UnreasonablenessException è una sottoclasse di RuntimeException, la quale si occupa^ di alcuni dei dettagli di basso livello relativi alla gestione delle eccezioni, come la memorizzazione del metodo in cui è avvenuto il lancio dell’eccezione, del metodo che lo aveva invocato, del metodo che aveva invocato quest’ultimo, e così via. Una tale sequenza, detta '"cali stock" (cioè pila di invocazioni), può essere di grande aiuto al programmatore per individuare alla radice la causa di un errore. Un’alternativa al lancio esplicito di un’eccezione è l’esecuzione di un’azione che con senta di recuperare la corretta operatività dopo che si sia verificata una situazione d’errore. Ecco, ad esempio, una versione del costruttore della classe FullTìmeEmployee dotato di due parametri, che usa il valore 0.0 come salario lordo nel caso in cui venga fornito un valore negativo:
public FullTimeEmployee(String name, doublé grossPay) this.name = name; th is.grossPay = Math.max(grossPay, 0.0); } // costruttore che richiede due parametri
3.3.4 II bloccofinally In condizioni normali, il codice che si trova dopo l’ultimo blocco catch sarà eseguito indipendentemente dal fatto che nel blocco try venga lanciata un’eccezione oppure no, per cui in tal punto si può inserire codice che ‘Taccia pulizia”, ad esempio chiudendo i file utilizzati. Questo approccio ha, però, due svantaggi. Innanzitutto, nel blocco try possono verificarsi eccezioni che non vengono catturate da alcun blocco catch. Poi, un ulteriore pericolo deriva dal fatto che qualcuno dei blocchi catch possa lanciare un’eccezione che non viene catturata. Di conseguenza, può accadere che il codice di “pulizia” non venga eseguito. Per evitare questi problemi,Java consente l’inserimento di un blocco finally dopo l’ultimo blocco catch. Possiamo, cioè, scrivere:
try { ' In realtà, RuntimeException ha diverii costruttori, ciascuno dei quali invoca semplicemen te il corrispondente costruttore di Exception, la superclasse di RuntimeException. Questa, a sua volta, passa il testimone alla propria superclasse, Throwable, che gestisce effettivamente i dettagli di basso livello.
G vpitolo 3 ... // codice che può lanciare un'eccezione } // try catch(NumberFormatException e) ... // codice che gestisce NumberFormatException } // catch NumberFormatException catch(IOException e) ... // codice che gestisce lOException } // catch lOException
finaUy {
'
... // codice per "fare pulizia"; sarà eseguito anche se nel // blocco try o nei blocchi catch vengono lanciate // eccezioni che non vengono catturate } // finally
Se un blocco try o un blocco catch può lanciare eccezioni a controllo non obbligatorio, è buona pratica aggiungere un blocco finally: in caso contrario, la sezione di codice posta dopo Tultimo blocco catch potrebbe non essere eseguiu. Inoltre, il linguaggio Java richiede necessariamente le presenza di un blocco finally quando viene usato un blocco try senza alcun blocco catch. Nel prossimo paragrafo viene discussa la gestione delle eccezioni di input/output, una delle caratteristiche fondamentali dell'elaborazione di file.
3.4 Saitturadifile La scrittura di informazioni in un file è poco diversa dalla visualizzazione sulla console (che normalmente è la finestra in cui viene eseguito un programma). Per prima cosa si associa un riferimento di tipo PrintWrìter al nome di un file; ad esempio, per associare printWriter a “scores.out”, si scrive: PrintWriter printWriter * nen PrintWriterCnen BufferedWriter (neM FileWriterC'scores.out"))); L'oggetto di tipo PrintWriter a cui fa riferimento printWriter può ora invocare i metodi print e println. Ad esempio: printW riter.println(line); Le informazioni così scritte non vengono immediatamente memorizzate nel file "scores. out", ma in un buffer, cioè un’area temporanea in memoria. Dopo aver usato l'oggetto a cui fa riferimento printWriter per invocare print e println tutte le volte che serve, occorre invocare il metodo d o se di quell'oggetto: printWriter.close(); Il metodo dose svuota il buflfer e scrive il suo contenuto nel file "scores.out", poi chiude il file stesso. Il programma che svilupperemo in questo paragrafo per elaborare file è basato su un programma visto nel Paragrafo 1.2.5 del Capitolo 1. Quel programma calcolava la somma
U lteriori
caratteristiche di programmazione e di Java
89
di voti (5tonr) Ietti tramite la tastiera; questa versione, le^erm ente modificata, usa un metodo distinto per leggere i dati e sommarli:
inport java.util.*; // per la classe Scanner publlc class Scoresl { pubUc final int SENTINEL - -i; publlc static void niain(String[ ] args) nat Scoresl().run(); } // metodo main
publlc void run() { final String INPUT_PROMPT » "\nOn each line, enter a test score (or " + SENTINEL + " to quit):
final String RESULT « "\n\nThe sum of thè scores is Scanner se ° neu Scanner(System.in); System.out.print(INPUT_PROMPT);
int sum > addScores(sc); System, out. println(RESULT + sum); } // metodo run
/** * Restituisce la somma dei voti letti.
*
* Pparam se un (riferimento non nullo a un) oggetto di tipo * Scanner da cui vengono letti i voti. * * greturn la soirma dei voti letti tramite lo scanner se. ♦ * gthrows InputMismatchException se, tramite se, viene letto un * valore che non sia un numero * intero.
*
publlc int addSeores(Scanner se) { int score, sum = 0;
ubile (tme) {
score = sc.nextIntO;
if (score -= SENTINE!) break; sum += score;
90
CAPITOLO 3 } // while
retum sum; } // metodo addScores } // classe Scoresl
Nella versione successiva, le informazioni prodotte in uscita vengono scritte in un file. Per consentire al lettore di quel file di controllare che il risultato sia corretto, sulla base dei dati forniti in ingresso, ogni voto viene scritto nel file prodotto. Durante la creazione di tale file viene catturata Teccezione lOException: il blocco try corrispondente contiene la creazione del file e il ciclo di lettura dei dati, cosi, per semplicità, non c’è bisogno di un blocco try separato per catturare le eccezioni di tipo InputMismatchException (derivanti da valori in ingresso che non siano numeri interi).
iiiport java.util.*; import java.io.*; public class Scoresl { pubUc final int SENTINEL = -1; public static void main(String[ ] args) new Scoresl().run(); } // metodo main public void run() { final String INPUT^PROMPT = ”\n0n each”line, enter a test score (or " + SENTINEL + " to quit):
final String RESULT * "\n\nThe sum of thè scores is PrintWriter printWriter = nuli; // così printWriter è stato // inizializzato prima di essere // chiuso, nel blocco finally
try {
Scanner se = new Scanner(System.in); printWriter = new PrintWriter(nen BufferedWriter (new FileWriter("scores.out")));
System.out.print(INPUT_PROMPT); addScores(sc, printWriter); } H try catch(IOException e)
{
System.out.println(e) ; } // catch lOException
finally {
U lteriori
caratteristiche di programmazione e di Java
91
printWriter.println(RESULT + sum); printWriter.close(); } // finally } // metodo run
public iiit addScores(Scanner se, PrintWriter printWriter) { ifit score, sum « 0;
Mhile (true) {
score » sc.nextIntO;
if (score »= SENTIREI) break; printWriter.println(score); sum +* score; } // while return sum; } // metodo addScores } // classe Scores2
Aver ignorato, per semplicità, le eccezioni derivanti da errori nel formato dei dati in ingresso ha una sfortunata conseguenza: se viene lanciata un’eccezione di tipo InputMismatchException, il programma termina senza visualizzare la somma finale. Il file che dovrebbe contenere il risultato viene chiuso prima che vi venga scritta la somma finale, mentre viene visualizzato il messaggio relativo a InputMismatchException, in seguito alla conclusione anomala del programma. Potremmo aggiungere un blocco catch per InputMismatchException subito prima (o subito dopo) quello relativo a I0Exception,ma questa modifica non apporterebbe un miglioramento significativo: la terminazione del programma non sarebbe più anomala, ma, di nuovo, la somma finale non verrebbe scritta nel file. Per consentire al programma di proseguire la propria esecuzione anche dopo il lancio di un’eccezione di tipo InputMismatchException, creiamo un nuovo blocco try e un corri spondente blocco catch all’interno del ciclo Mhile. Se i dati non contengono alcun valore valido, lanciamo un’eccezione relativa a questo fatto dopo che il ciclo è terminato, come si può vedere in questa nuova versione:
boolean atLeastOneScore » false; Mhile (true) { tiy { score * sc.nextIntO;
if (score == SENTINEL) break; printWriter.printIn(score); sum += score; } // try catch(InputMismatchException e)
{
printWriter. println(e + '*'* + sc.nextLineO); } // catch InputMismatchException } // while
92
CAPITOLO 3 if (latLeastOneScore) throM neM RuntimeException("The input contains no legai score.");
Ecco un esempio di esecuzione del programma così modificato (i dati forniti in ingresso sono in grassetto): Please Please Please Please Please
enter a test enter a test enter a test enter a test enter a test
score,or -1toquit:50 score,or -1toquit:x score,or -1toquit:80 score,or -1toquit:y score,or -1toquit:-l
Il file scores.out avrà ora il contenuto seguente: 50
java.lang.InputMismatchException: x 80
java.lang.InputMismatchException: y The sum of thè scores is 130
L’invocazione di nextLine() all’interno del blocco catch del metodo addScores consente di scrivere nel file il dato non valido ricevuto in ingresso, oltre a consentire allo scanner di ignorare queU’intera riga (altrimenti il dato non valido verrebbe riletto indefinitamente e si continuerebbe ad aggiungere al file copie del messaggio relativo all’eccezione). La cosa più importante da ricordare in merito alla produzione di file come effetto dell’esecuzione di un programma è che il gestore del file deve essere chiuso esplici tamente, altrimenti il file sarà incompleto e probabilmente vuoto (questo dipende dall’eventuale svuotamento intermedio del buffer, che può essere deciso dal sistema operativo). Come vedremo nella classe successiva, Scores3, possiamo garantire che il gestore di un file venga chiuso prima che un programma termini includendo la co struzione di tale gestore all’interno di un blocco try, seguito da un blocco finally che, appunto, lo chiuda. In quest’ultima versione del programma, leggiamo da un file i dati in ingresso (un voto per riga), anziché da tastiera. Come visto nel Paragrafo 1.2.5, la lettura da un file è quasi identica alla lettura dalla tastiera; ad esempio, per leggere dal file “scores.inl”,iniziamo con: Scanner fileScanner = nen Scanner(new File("scores.ini"));
Attenzione: questo enunciato ipotizza che il file scores.ini si trovi nella carteUa giusta: alcuni IDE (Integrated Development Environment, ambiente di sviluppo integrato) presumono che i file di ingresso si trovino nella cartella posta al livello immediatamente superiore, nella gerarchia, di quella contenente i file di codice sorgente. A volte sarà necessario specificare il percorso completo che consenta di reperire il file, come in questo esempio: Scanner fileScanner
n m Scanner(new File( "c :WprojectsWscore_project\\scores.ini" ));
I due caratteri di backslash (“barra rovesciata’’) consecutivi sono necessari perché un singolo backslash sarebbe interpretato come carattere di escape.
U lteriori
caratteristiche di programmazione e di Java
93
Raramente Tacquisizione di dati leggendo un file termina con una sentinella, perché sarebbe troppo facile dimenticarsi di aggiungerla al termine del file stesso; solitamente la lettura del file continua fin quando i metodi hasNext() o hasNextLìne() restituiscono true. Quindi, per leggere da un file, scriviamo:
while (fileScanner.hasNextO) Per semplicità, nel caso in cui vi sia un unico file da leggere, non ci preoccuperemo di chiuderlo al termine del programma: verrà chiuso automaticamente e, qualora venisse riaperto da un altro programma, il suo contenuto sarà invariato. Un programma che, però, lascia aperti molti file, dopo averli letti senza chiuderli, può incorrere nell’esaurimento delle risorse del sistema operativo dedicate ai descrittori di file, con il conseguente lancio di un’eccezione di tipo lOException. Come già detto in precedenza, la chiusura di un file in fase di scrittura comporta la copiatura nel file delle ultime informazioni ancora rimaste all’interno del relativo buffer, per cui tutti tali file dovrebbero essere chiusi esplicitamente prima della fine del program ma. E evidente che se il programma non dovesse terminare, ad esempio per la presenza di un ciclo infinito, il buffer del file non verrà copiato (a meno che il file non venga chiuso prima dell’esecuzione del ciclo infinito). Il programma seguente combina la lettura e la scrittura di file. Per essere più generico, i nomi dei file (come “scores.in” e “scores.out”) non sono “codificati” all’interno del programma, ma vengono forniti dall’utente del programma stesso, usando la tastiera, in risposta a esplicite richieste. Se non esiste un file da leggere che abbia il nome fornito, il programma cattura l’eccezione FileNotFoundException, visualizza un messaggio d’errore e chiede all’utente di fornire un nuovo nome. Per realizzare questa iterazione, i blocchi try e catch relativi al lancio e alla gestione di lOException sono posizionati in un ciclo uhile più esterno. Cosa succede se non esiste un file avente il nome fornito per scrivere le informazioni prodotte dal programma? Solitamente questo non è un problema: verrà creato un file vuoto avente quel nome. Se, però, il nome del file è giudicato troppo strano dal sistema operativo, come !^$%^&’*‘(), verrà lanciato un oggetto di tipo lOException (in particolare, di tipo FileNotFoundException).
11 programma contiene tre blocchi try: 1. Un blocco try più esterno, per impostare ed elaborare i file, con un blocco catch che gestisce NumberFormatException se i dati letti non contengono alcun voto valido, seguito da un blocco finally che chiude il file dopo che la sua scrittura è terminata; 2. un blocco try/catch nel ciclo while più esterno, per creare gli oggetti che gestiscono i file (uno scanner e uno “scrittore”, writer) a partire dai nomi di file acquisiti da tastiera; 3. un blocco try/catch nel ciclo while più interno, per acquisire i dati dal file di input, leggendo ed elaborando una riga per volta, e scrivendo le informazioni nel file di out put; se i dati letti non contengono alcun voto valido, dopo questo ciclo viene lanciata l’eccezione NumberFormatException. Ecco, infine, il programma, la cui struttura complessiva è simile a quella di tutti i programmi che elaborano file:
inport java.util.*;
V i^ r iiu L u j
i«port java.io.’*'; public class ScoresB { public static void main(String[ ] args) { M N Scores3()>run(); } // metodo main
public void run() { final String IN_FILE_PROMPT = "\nPlease enter thè name of thè input file:
final String 0UT_FILE_PR0MPT . "\nPlease enter thè name of thè output file:
final String RESULT ="\n\nThe sum of thè scores is Scanner keyboardScanner • nen Scanner(System.in), fileScanner; PrintWriter printWriter » nuli; // così printWriter è stato // inizializzato prima di essere // chiuso, nel blocco finally
int sum > 0;
T'ubile (tzue) { try
{
System.out.print(IN_FILE_PROMPT); fileScanner - neu Scanner( neu File(keyboardScanner.nextLineO)); System.out.print(OUT_FILE_PROMPT); printWriter » neu PrintWriter(new BufferedWriter( neu FileWriter(keyboardScanner.nextLineO))); sum = addScores(fileScanner, printWriter);
break; } n try catch(IOException) System.out.println(e); } // catch } // ubile "i nomi dei file non sono corretti" } H try catch(NumberForroatException) System.out.println(e); } // catch NumberFormatException
finally printWriter.println(RESULT + sum); printWriter.closeO;
U lteriori
caratteristiche di programmazione e di Java
95
} // finally } // metodo run
/** * Restituisce la somma dei voti letti.
*
* ^aram fileScanner l'oggetto di tipo Scanner da cui * vengono letti i voti. * 9param printWriter l'oggetto di tipo PrintWriter in cui * vengono scritti i voti. * Se un voto genera InputMismatchException, * viene preceduto dal messaggio: * "java.util.InputMismatchException: "
*
♦ ftreturn la somma dei voti letti tramite fileScanner.
*
* ^hrows NumberFormatException se i valori letti non contengono * alcun numero intero. *
♦/ public int addScores(Scanner fileScanner, PrintWriter printWriter) final String NO_LEGAL_SCORES_MESSAGE = "The input contains no legai scores."; int score, sum • 0; boolean atLeastOneScore > false; Nhile (fileScanner.hasNextO)
{
try
{
score » fileScanner.nextIntO; printWriter.println(score); sum -f> score; atLeastOneScore < txue; } H try catch(InputMismatchException e)
{
printWriter.println(e + ": " + fileScanner.nextLineO); } // catch InputMismatchException } // Mhile "ci sono voti ancora da leggere" if (IatLeastOneScore) thxoN new NumberFormatException(NO_LEGAL_SCORES_MESSACE); return sum; } // metodo addScores } // classe Scoresl
Come si può notare, l'invocazione printW riter.close() non si trova in un blocco catch, perché l’oggetto printWriter deve essere chiuso indipendentemente dal fatto che vengano lanciate eccezioni.
!fQ
WVPITOLO 3
Immaginiamo che il file “scores.ini” contenga le quattro righe seguenti: 82
8z 77 99
Inoltre, ipotizziamo che la cartella di lavoro in cui ci troviamo non contenga alcun file che si chiami “scores.ino” o “scores3.in”, mentre non è rilevante il fatto che esista o meno un file di nome “scores.outl”. Nel seguito, un esempio di utilizzo del programma, con i dati introdotti da tastiera riportati in grassetto: I Please enter thè name oi thè input file: scores.inO java.io.FileNotFoundException: scores.inO Please enter thè name of thè input file: scores3«in java.io.FileNotFoundException: scores3.in Please enter thè name of thè input file: Please enter thè name of thè output file:
scores.ini scores.outl
Il contenuto finale del file “scores.outl” sarà: 82
java.util.InputMismatchException: 8z 77 99
The sum of thè scores is 258 Quando si acquisiscono dati leggendoli da un file, in generale non è sufficiente che il file esista per essere certi che lo si possa associare a uno scanner; inoltre, occorre anche tenere sempre presente Teventualità che il file non esista. Il modo più semplice per fare entrambe le cose è inserire una clausola throns FileNotFoundException subito dopo Tintestazione del metodo che efifettua l’associazione tra lo scanner e il file. Lo svantaggio derivante da questo approccio è che, se il nome del file è sbagliato (sia che il file non esista, sia che il nome sia stato scritto in modo errato), l’utente del programma non avrà la possibilità di correggere l’errore. U n’alternativa migliore, come abbiamo fatto nel metodo run() della classe Scores3, prevede l’utilizzo di un blocco try e la cattura, in un blocco catch, dell’eccezione FileNotFoundExceptìon. Per consentire all’utente del programma di rimediare a un errore compiuto nel fornire il nome del file^ questi blocchi dovrebbero essere posizionati all’interno di un ciclo, che continui ad essere eseguito finché non è stato fornito un nome di file corretto. Analogamente, per costruire un oggetto da utilizzare per scrivere dati in un file, occorre catturare l’eccezione lOException, oppure dichiararla in una clausola throMS. Proprio que sto è il motivo per cui, nel programma precedente, il tipo del parametro del blocco catch relativo a quella sezione di codice è lOException, e non FileNotFoundException. Gli esempi precedenti mostrano un denominatore comune: il metodo run() gestisce gli aspetti del programma che richiedono l’intervento dell’utente finale, come l’inserimento di dati tramite la tastiera o mediante una finestra della GUI,oppure l’analisi dei risultati,sempre
U lteriori
caratteristiche di programmazione e di Java
97
da parte dell’utente. Conseguentemente, i metodi invocati dal metodo run() dovrebbero risultare coUaudabili mediante JUnit. 11 problema più rilevante che s o i^ nel collaudo del metodo addScores è la scrittura di informazioni in un file; dobbiamo creare un file che contenga le informazioni che si prevede debbano essere prodotte dal programma, per poi verificare che il file efièttivamente prodotto dall’esecuzione del programma corrisponda a quello previsto. Il file “previsto” avrà una riga per ciascuna riga del file di input, e non conterrà la somma finale, perché questa non viene scritta dal metodo addScores. Ci servirà anche un coUaudo per l’eccezione che viene lanciata quando il file di input non contiene alcun voto valido, e altri collaudi per i casi in cui uno degli argomenti (fìleScanner o printWriter) ha il valore nuli. Ecco una parte della classe ScoreslTest:
iaport iaport iajwrt lajwrt iajwrt iajwrt
org.junit.*; static org.junit.Assert.*; org.junit.runner.Result; static org.junit.runner.lUnitCore.runClasses; java.util.*; java.io.*;
public class Scores3Test { public static void main(String[ ] args) Result result > runClasses(Scores3Test.class); System.out.printlnC’Tests run = " + result.getRunCountO + "\nTests failed = " + result.getFailuresO); } // metodo main
protected Scores3 scores; gBefore
public void runBeforeEveryTestO scores « noi Scores3(); } // metodo runBeforeEveryTest gTest
public void scores3Testl() throMS lOException {
Scanner fileScanner = ne Scanner(ncH File("scores3.inl")); PrintWriter printWriter new PrintWriter(ncH BufferedWriter( new FileWriter("scores3.outl"))); int actualSum = scores.addScores(fileScanner, printWriter); printWriter.close(); Scanner scActual • new Scanner(new File("scores3.outl")), scExpected « new Scanner(new File("scores3.exp"));
final int INPUT LINES - 4; for (int i - 0;"i < INPUT_LINES; i++) assertEquals(scExpected.nextLine(), scActual.nextLineO); if (scActual.hasNextO) failO; } // metodo scores3Testl
98
C apitolo 3 fTest (expected ° NunberFormatException.class) publlc void scores3Test2() throMS lOExcéption
{
Scanner fileScanner = new Scanner(new File("scores3.in2")); PrintWriter printWrlter = new PrintWriter(new BufferedWriter( new FileWriter("scores3.out2")));
int actualSum ° scores.addScores(fileScanner, printWrlter); } // metodo scores3Test2 ?Test (expected « NullPointerExceptlon.class) public void scores3Test3() throws lOException»
{
int actualSum > scores.addScores(null,
new PrìntWriter(new FileWriter("scores3.out3”))); } // metodo scores3Test3 ^Test (expected = NullPointerExceptlon.class) public void scores3Test4() throws lOException
int actualSum > scores.addScores( new Scanner(new FileCscores3.ini")), nuli); } // metodo scores3Test4 } // classe Scores3Test
I file usati nel collaudo sono questi:
scores3«inl 80 X
50
y scores3«in2 X
y scores3«exp 80
java.util.InputMismatchException: x
so
java.u t il . InputMismatchException: y Tutti i collaudi progettati vengono superati dal metodo.
3.5 Collaudo di sistema Così come è insolito che una classe contenga un unico metodo, allo stesso modo è im probabile che un progetto sia costituito da un’unica classe. In un progetto composto da più classi, quale si dovrebbe collaudare per prima? In un ambiente di programmazione orientato a^i oggetti, di norma il collaudo avviene “dal basso verso Talto” (bottom~up). Nel
U lteriori
caratteristiche di programmazione e di Java
99
collaudo bottom-up, prima si collaudano le classi di basso livello del progetto, cioè quelle che sono utilizzate da altre classi ma non utilizzano altre classi, poi quelle dì livello un po* più elevato, e così via. Dopo che tutte le classi componenti il progetto hanno superato i propri collaudi, si può eseguire il cosiddetto collaudo di sistema (system testing), cioè il collaudo del progetto nel suo complesso, che va progettato non appena vengono delineate le specifiche del progetto stesso. Si noti che i collaudi di sistema non sono necessariamente collaudi di unità, perché spesso richiedono Tintervento umano, ad esempio per fornire il nome di un file mediante la tastiera. Lo scopo del collaudo è rilevare errori presenti in un programma (o aumentare la fiducia nel fatto che il programma ne sia privo). Quando il collaudo identifica un errore in un vostro programma, per prima cosa dovete capire cosa ha provocato tale errore, e ciò può richiedere una seria attività investigativa, ovviamente con lo scopo finale di giungere alla correzione dell’errore individuato. L’intero processo (collaudo, investigazione e correzione) è iterativo: dopo aver corretto un errore, bisogna effettuare nuovamente il collaudo, perché la “correzione” può aver creato nuovi errori.
Ì.6 La matdilna virtuale di Java_____________________ Le classi Java che si progettano vengono compilate per produrre una loro traduzione in un linguaggio (detto bytecode di Java) che è di basso livello ma risulta indipendente dall’unità di elaborazione (o, come si dice spesso, “indipendente dalla macchina”). Ad esempio, la versione bytecode del file HourlyEmployee. java viene memorizzata nel file HourlyEmployee. class. I file contenenti bytecode vengono interpretati ed eseguiti da un programma che si chiama Java Virtual Machine (macchina virtuale diJava,JVM), Il termine “macchina virtuale” deriva dal fatto che il codice che essa esegue (il bytecode) è sostanzialmente codice a livello macchina. Da questa infrastruttura: codice sorgente
bytecode
Java Virtual Machine
che è un’alternativa rispetto a quella utilizzata dai linguaggi compilati tradizionali: codice sorgente —►codice macchina derivano parecchi vantaggi, il principale dei quali è l’indipendenza dalla piattaforma. Non importa se il sistema operativo del computer è Windows, Linux o altro: i risultati dell’ese cuzione del vostro programma Java saranno esattamente (beh, quasi esattamente) gli stessi in tutte le piattaforme. Un secondo vantaggio è la possibilità di personalizzare i requisiti di sicurezza: se, ad esempio, il file di bytecode proviene dal Web, la macchina virtuale può non consentire a quell’applicazione di leggere o di scrivere file sul disco interno del computer, attività normalmente consentite a un’applicazione locale. La Java Virtual Machine sovrintende a tutti gli aspetti relativi all’esecuzione dei vostri programmi e nei prossimi due paragrafi esamineremo con maggiore dettagli due di tali compiti.
100
Capitolo 3
3.6.1 Pre-inizializzazionedei campi Uno dei compiti in carico alla Java Virtual Machine è Tinizializzazione dei campi, effet tuata subito prima delTinvocazione di un costruttore. Ad esempio, esaminiamo la seguente costruzione di un oggetto:
new FullTimeEmployee("Dilbert", 345.0) Dapprima, l’operatore neM assegna spazio all’oggetto di tipo FullTimeEmployee. Poi, per garantire almeno un livello minimo di inizializzazione per ogni suo campo,la Java Virtual Machine inizializza tutti i campi della classe in base al \oro tipo: i campi di tipo riferimento sono inizializzati al valore nuli, i campi di tipo intero al valore 0, i campi di tipo fraziona rio al valore 0.0, i campi di tipo char al carattere che si trova in posizione 0 nello standard Unicode e, infine, i campi di tipo boolean al valore false. Quindi, viene invocato il costrut tore specificato e, infine, viene restituito l’indirizzo in memoria dell’ometto così costruito. Questa pre-inizializzazione effettuata dalla Java Virtual Machine ha un’importante conseguenza: anche se un costruttore ha un corpo vuoto (come quello di default, fornito dal compilatore Java se la classe non dichiara alcun costruttore), tutti i campi della classe verranno comunque inizializzati. Diversamente dai campi, le variabili locali non vengono inizializzate automaticamente, come detto nel Paragrafo 1.2.4.
3.6.2 Garbagecollection La memoria assegnata agli oggetti viene riservata quando si invoca l’operatore new, ma quando viene resa nuovamente libera e assegnabile? In particolare, cosa succede allo spazio assegnato a un oggetto, quando questo non risulta più essere accessibile? Ad esempio, supponiamo che un oggetto venga costruito all’interno di un metodo e che, alla fine dell’esecuzione di tale metodo, non esistano più riferimenti che puntano all’oggetto: esso risulterà inaccessi bile, cioè, in un certo senso, diventerà spazzatura (garbage). Se un programma genera troppa spazzatura, esaurirà la memoria disponibile, portandosi in una situazione d’errore. Gli errori, diversamente dalle eccezioni, non dovrebbero essere catturati, per cui il verificarsi di questo errore provocherà la terminazione anomala del programma. È il programmatore ad avere la responsabilità deUsi garbage collection (^'raccolta della spazzatura”), cioè di rendere libera la memoria precedentemente assegnata a oggetti divenuti inaccessibili? Fortunamente non dobbiamo occuparci di questo. L’ambiente di esecuzione di Java contiene un metodo che esegue automaticamente la garbage collection e viene invocato se, in seguito all’invocazione dell’operatore neM, non c’è abbastanza spazio disponibile in me moria per l’oggetto di cui è suta richiesu la creazione. Si tratta di un evento sempre più raro, per effetto deU’aumento di memoria disponibile che si è verificato negli ultimi anni. Per rendere libera (e, quindi, riassegnabile) la memoria non più utilizzata, si può operare su qualsiasi oggetto al quale non esistano riferimenti: per prima cosa il metodo cerca zone di memoria di grande dimensione, come quelle rebtive a un array, ma, in ogni caso, tutto avviene “dietro il sipario”, per cui l’approccio corretto in merito a questo tema dovrebbe essere “non preoccuparti, sii felice” (Don*t worry. Be Happy), Il prossimo paragrafo si occupa della relazione esistente tra pacchetti e modificatori di visibilità.
U lteriori
caratteristiche di programmazione e di Java
101
3.7 Pacchetti Un pacchetto (package) è una raccolta di classi tra loro correlate e il file in cui ciascuna di esse è dichiarata inizia con una direttiva che identifica il pacchetto di appartenenza. Ad esempio, un file alfinterno di un pacchetto di classi relative alle reti neurali potrebbe iniziare così: package neuralNetwork; Ancora come esempio, la classe Scanner, appartenente al pacchetto java. ut il, è definita nel file Scanner, java, che inizia in questo modo: package java.util; Se un file contiene un esemplare della classe Scanner, essa può essere **importata*', mediante un'apposita direttiva d’importazione, che inizia con la parola riservata import: import java.util.Scanner; Il vanuggio dell’imporuzione sta nella comodità che ne consegue: è possibile utilizzare una dichiarazione come questa: Scanner se; al posto di una che usi il nome della classe qualificato in modo completo (fully qualijìed name): java.util.Scanner se; Molte delle classi che progetterete useranno almeno una classe appartenente al pacchetto java.util, per cui si può semplicemente importare l’intero pacchetto: import java.util.*; / / l ’asterisco indica che tu tti i file presenti nel / / pacchetto java.util saranno disponibili In qualche raro caso può essere preferibile l’uso del nome completamente qualificato. Ad esempio, supponiamo che un progetto utilizzi due classi di nome Widget, una appartenen te al pacchetto com.acme e un’altra definiu nel pacchetto com.doodads. Per dichiarare (un riferimento a) un esemplare di quest’ultima, si potrebbe scrivere: com.doodads.Widget myWidget; Ogni file Java deve contenere una classe (o un’interfaccia) con modificatore di visibilità public e il nome di tale classe (o interfaccia) pubblica deve coincidere con il nome del file (escludendo la sua estensione, .java).All’inizio del file devono comparire le direttive import per qualunque pacchetto (o singolo file) che sia necessario all’interno del file ma che non ne faccia parte, con l’eccezione del pacchetto java.lang, che viene importato automaticamente in qualsiasi file. Un membro di classe che sia privo di modificatore di visibilità acquisisce uisibilità di default 0 standard e vi si può accedere da qualsiasi oggetto (o classe, nel caso di membro statico) appartenente allo stesso pacchetto della classe in cui tale membro è dichiarato. Per
102
C apitolo 3
questo motivo, spesso questa modalità viene detta ^'visibilità di pacchetto*’ {package-friendfy uisibility).Tutte le classi prive di dichiarazione df pacchetto fanno parte di un “pacchettò privo di nome” {unnamed package), ma di questi ne possono esistere diversi, per cui, come regola generale, se un progetto contiene più di un file, ognuno di questi dovrebbe includere una dichiarazione di pacchetto. Dal punto di vista tecnico, è possibile che un filejava contenga più classi aventi visibilità pubblica, ma tutte queste, tranne una, devono essere annidate (nested) o interne (inner), cioè dichiarate aH’interno di un’altra classe: ad esempio, il Java Collections Framework, che fa parte del pacchetto java.util, contiene molte classi interne. Con l’eccezione deUe classi in terne, un filejava può contenere una sola classe (o interfaccia) con visibilità pubblica: ogni altra classe, non interna, deve avere visibilità di default. Per come si è evoluto il linguaggio Java, la visibilità protected non è limitata aUe sotto classi: in generale, se un identificatore viene definito con visibiUtà protetta all’interno di una classe, è accessibile anche dalle classi che si trovano nello stesso pacchetto a cui appartiene la classe. Ad esempio, qualsiasi classe che si trovi nello stesso pacchetto a cui appartiene la classe FullTimeEmployee, anche senza esserne una sottoclasse, ha accesso ai campi name c grossPay di un oggetto di tipo FullTimeEmployee. Nel Java Collections Framework, la maggior parte dei campi ha visibilità di default o private e quasi nessuno di essi ha visibilità protected: la definizione di sottoclassi che non appartengano al pacchetto è, così, disincentivata. Per quale motivo? La ragione principale è "filosofica”: si ritiene che la maggiore eflScienza che sarebbe consentita agli utilizzatori di sottoclassi non ha tanto valore quanto il rischio di violazione dell’integrità di tali sotto classi, che potrebbe accadere qualora la superclasse venisse in seguito modificata. Si tratta di un rischio per nulla ipotetico: nella versione 1.1 di Java, una deUe classi del pacchetto java.security era stata definita come sottoclasse di Hashtable, che venne modificata nella versione 2 del linguaggio, aprendo un falla nella sicurezza della sottoclasse. La possibilità di definire sottoclassi può essere un forte vìncolo e non un semplice esempio di utilizzo: in conseguenza di ciò, il fatto che una classe consenta di definire sue sottoclassi non è sempre la scelta più saggia. Non va dimenticato che la visibilità protected è ancor meno restrittiva della visibilità di pacchetto. Questo sostanziale fraintendimento del significato di visibilità protetta può rendervi riluttanti al suo utUizzo come modificatore dei campi che progettate: in alterna tiva, si possono progettare campi private, aggiungendo metodi public che consentano di ispezionare e/o modificare i valori di tali campi private. Come descritto nell’Esercizio di Programmazione 2.3, un metodo d'accesso restituisce una copia di un campo (o una copia dell’oggetto a cui fa riferimento, se il campo è un riferimento), mentre un metodo modificatore altera il campo stesso (o l’oggetto a cui il campo fa riferimento). L’utilità di questo approccio diminuisce all’aumentare del numero di campi. L’ultimo paragrafo di questo capitolo riguarda Timportanza della sovrascrittura del metodo equals ereditato dalla classe 0bject,i limiti imposti a questa ridefinizione e il modo di superare tali vincoli.
B.8 Sovrastrivere il metodo equals della dasse Objett Nel Paragrafo 2.3.3 abbiamo esaminato le specifiche del metodo equals definito nella classe Object, la superclasse di tutte le classi, e le riportiamo qui di nuovo:
U lteriori
caratteristiche di programmazione e di Java
103
/♦* * Determina se l'oggetto invocante è lo stesso che viene * fornito come argomento.
*
* j)param obj l'oggetto fornito come argomento, da confrontare * con l'oggetto invocante.
*
* ftreturn true se i due oggetti sono lo stesso oggetto.
*/ public boolean equals(Object obj) Questo metodo, come gli altri metodi della classe Object, è suto progettato perché venga sovrascritto dalle sottoclassi, che, ad esempio, sono in grado di confrontare i valori assunti dai campi. La classe Object non ha campi, quindi cosa confronta questo metodo? Confronu riferimenti e, in particolare, il riferimento all’oggetto invocante viene confrontato con il riferimento ricevuto come argomento. Ecco, infatti, la definizione del metodo:
public boolean equals(Object obj) retum this == obj; } // metodo equals Come detto nel Paragrafo 2.2.2, in qualsiasi classe la parola riservata this costituisce un riferimento all’oggetto invocante. Supponiamo che il metodo equals venga invocato in questo modo: objl.equals(obj2)
Di conseguenza, all’interno del codice usato nella definizione del metodo equals, this è un riferimento all’oggetto a cui si riferisce anche obj 1, mentre obj è un riferimento all’oggetto a cui si riferisce anche obj2. Dato che il metodo equals della classe Object confix>nta soltanto riferimenti, ogni classe dovrebbe definire la propria versione di tale metodo. Supponiamo, ad esempio, di decidere l’inserimento del metodo equals nella classe FullTimeEmployee. Un primo dubbio da chiarire è: dobbiamo usare il sovraccarico, cioè definire questo metodo:
public boolean equals(FullTimeEmployee full) oppure dobbiamo sovrascrivere, in questo modo?
public boolean equals(Object obj) Il sovraccarico di equals (in modo che abbia, ovviamente, un elenco di parametri diverso da quello presente nella versione ereditata da Object) si può realizzare in modo piuttosto semplice. L’unica difficoltà, in questo caso specifico, deriva dal fatto che valori di tipo doublé non dovrebbero mai essere confrontati per uguaglianza in modo esatto: si noti, ad esempio, che System.out.print(.4 == 10.0 - 9.6) visualizza "false”, ma, tanto per fare un altro esempio, System.out.print(.4 *= 1.0 - .6) visualizza "true”. Possiamo definire il metodo in questo modo:
public boolean equals(FullTimeEmployee full)
104
{
C apitolo 3
retum name.equals(full.name) &&
MONEY.format(grossPay).equals(MONEY.format(full.grossPay)); } // sovraccarico del metodo equals
Ricordiamo che il metodo format arrotonda il valore ricevuto, quindi non stiamo confix)ntando grossPay e full.grossPay per uguaglianza esatta. Questa versione confìx>nta oggetti, non riferimenti, per cui entrambi gli enunciati seguenti visualizzeranno true: System.out.println(ri
FullTimeEmployeeC'a" 100.0).equals FullTimeEmployee("a” lOO.t))));
System.out. println(ii
HourlyEmployee("a", 10, 10.0).equals FullTimeEmployeeC'a", 100.0)));
Questa versione, definita mediante sovraccarico, funziona adeguaumente nel caso in cui il compilatore può essere certo che Toggetto invocante sia di tipo FullTimeEmployee (o di una sua sottoclasse): purtroppo, ciò non è sempre vero. Ad esempio, molte classi del Java Collections Framework memorizzano raccolte di oggetti e hanno un metodo contains che determina se un dato oggetto appartiene alla raccolta. L'intestazione del metodo contains è: public boolean contains(Object obj) Solitamente, quando si effettua una verifica di appartenenza a una raccolta, viene invocato il metodo equals, usando obj come oggetto invocante. Per una determinata applicazione la raccolta in questione può essere costituita da oggetti di tipo FullTimeEmployee, ma, nel momento in cui l'invocazione del metodo equals, all'interno di contains, viene compilata, l'unica informazione disponibile in merito all'oggetto invocante è il tipo con cui è stato dichiarato, cioè Object. Il compilatore, quindi, genera bytecode corrispondente all'invocazio ne del metodo equals della classe Object, perché tale versione riceve un parametro di tipo Object.Al momento dell'esecuzione, anche se sarà allora nota la classe di cui è un esemplare l'oggetto a cui fa riferimento obj, verrà eseguito il metodo equals che riceve un parametro di tipo Object, cioè il metodo definito nella classe Object, e non il metodo che abbiamo aggiunto mediante sovraccarico. Il fatto che quest'ultimo sia stato definito è irrilevante! Ora che abbiamo subilito l'importanza della sovrascrittura (o ridefinizione) del me todo equals, piuttosto che il suo sovraccarico, vediamo come lo si possa fare, prendendo nuovamente ad esempio la classe FullTimeEmployee. L'idea di base è semplice: se il tipo effettivo dell'oggetto ricevuto come argomento non è FullTimeEmployee, bisogna restituire false,altrimenti, come abbiamo già fatto in precedenza, si confiiontano i valori restituiti dal metodo toStringO applicatò all'oggetto invocante e all'oggetto ricevuto come argomento. Vediamo i risultati di alcuni esempi: System.out.printIn(new FullTimeEmployee("a'*, ("yes")); System.out.println(new FullTimeEmployee("a", (neM FullTimeEmployeeC'a", System.out.printIn(new FullTimeEmployeeC'a", (neM FullTimeEmployee("b", System.out.printIn(neM FullTimeEmployee("a", (neM FullTimeEmployee("a",
100.0).equals // false 100.0).equals 100.0))); // true 100.0).equals 100.0))); // false 100.0).equals 200.0))); // false
U lteriori
caratteristiche di programmazione e di Java
105
Ecco, quindi, la definizione completa del metodo:
public boolean equals(0bject obj) { if (!(obj instanceof FullTimeEmployee)) retum false; FullTimeEmployee full = (FullTimeEmployee) obj;
retum name.equals(full.name) && MONEY.format(grossPay).equals(MONEY.format(full.grossPay)); } // ridefinizione del metodo equals in FullTimeEmployee
Possiamo cosi riassumere questo paragrafo: 1. Ogni classe i cui esemplari possano essere elementi di una raccolta dovrebbe avere un metodo equals che sovrascriva quello ereditato dalla classe Object. 2. Uoperatore instanceof restituisce true se e solo se, al momento dell*esecuzione, Toggetto a cui fa riferimento l’operando sinistro è un esemplare della classe che figura come operando destro. 3. Prima di confrontare l’oggetto invocante con l’oggetto ricevuto come argomento, si effettua un cast a partire dal tipo del parametro, che è Object, per ottenere un riferi mento del tipo della classe in cui si sta ridefinendo equals. L’Esercizio di programmazione 3.11 fornisce ulteriori informazioni sul metodo equals.
Biepllojo 11 modificatore static è usato per quegli identificatori che riguardano una classe nel suo complesso, piuttosto che un suo particolare esemplare. Le costanti dovrebbero essere di chiarate static, perché in tal modo ne esisterà un’unica copia, piuttosto che una copia per ogni esemplare della classe. Per accedere a un identificatore statico dall’esterno della classe in cui è definito, occorre qualificarlo mediante l’identificatore della classe, invece che usando un oggetto. JUnit è un prodotto software Open Source che consente di collaudare i metodi di una classe senza intervento umano. I singoli collaudi vanno progettati non appena sono state definite le specifiche del metodo e, in generale, i metodi dovrebbero essere progettati in modo da agevolare il collaudo senza la necessità di intervento umano, ad esempio evitando, nei metodi da sottoporre a collaudo, l’acquisizione di dati tramite System, in e la visualiz zazione di informazioni tramite System.out. Una eccezione è un oggetto che rappresenta una situazione anomala, solitamente il verificarsi di un errore. La gestione di un’eccezione avviene mediante i blocchi try/catch. Innanzitutto, viene eseguita la sequenza di enunciati contenuta nel blocco try. Se, durante tale esecuzione, viene lanciata un’eccezione (perché si è verificato un errore), allora viene eseguito il blocco catch corrispondente, per indicare quale azione vada intrapresa, se qual cosa può essere fatto. La scrittura di file {file output) è simile alla visualizzazione diretta alla console, tranne per il fatto che occorre creare esplicitamente un oggetto di tipo PrintWriter per poter scrivere nel file desiderato. I dati non vengono scritti immediatamente nel file, bensì in un buffer. Al termine dell’elaborazione del file, invocando il metodo dose, il buffer viene svuotato e i dati vengono scritti nel file.
106
Capitolo 3
Lambiente di esecuzione di Java, noto anche come JVM (Java Virtual Machine), è un programma che interpreta ed esegue il bytecode prodotto da un compilatore Java. Fra i suoi diversi compiti, la macchina virtuale ha la responsabilità di pre>inizializzare i campi, rendere nuovamente libera la memoria assegnata a oggetti non più accessibili e gestire i flussi (thread) di esecuzione. Un pacchetto (package) è una raccolu di classi tra loro correlate. Un identificatore privo di modificatore di visibilità ha la visibilità di default o standard, detta anche visibilità di pacchetto: vi si può accedere da qualunque oggetto (o elasse, nel caso di membro statico) appartenente allo stesso pacchetto a cui appartiene la classe in cui Tidentificatore è stato dichiarato. Se, in una classe, un identificatore ha visibilità protected, vi si può accedere da qualsiasi sottoclasse della classe, anche se appartenente a un diverso pacchetto. Sfortunata mente, però, tale identificatore è anche accessibile da qualunque classe appartenente allo stesso pacchetto, anche se questa non è una sottoclasse. Il metodo equals della classe Object dovrebbe essere sovrascritto da qualsiasi classe C i cui esemplari possano divenire elementi di una raccolta. Il metodo sovrascrivente invoca Toperatore instanceof per poter restituire false nel momento in cui riceve un argomento che non sia esemplare della classe C, poi effettua un cast per ottenere, dal riferimento di tipo Object, un riferimento di tipo C, tramite cui effettuare i confronti opportuni.
Esercizi di teoria 3.1 La classe System del pacchetto java.lang contiene un identificatore costante statico che è stato difiusamente utilizzato nei Capitoli 1, 2 e 3. Qual è? Perché è meglio che gli identificatori costanti presenti in una classe siano static? Dovrebbero essere static anche gli identificatori costanti definiti all’interno di un metodo? Fornire una spiegazione esauriente. 3.2 Progettare un blocco catch che gestisca qualsiasi eccezione. Progeture un blocco catch che gestisca qualsiasi eccezione relativa alle operazioni di input/output. Pro gettare un blocco catch che gestica qualsiasi eccezione di tipo run-time. 3.3 Cosa c’è di sbagliato in questa infrastruttura di programmazione? try { } // try catch(IOException e)
{ } // catch lOException catch(FileNotFoundException e) } // catch
3.4 Nell’ipotesi che f ileScanner sia un oggetto di tipo Scanner che legge un file e che printWriter sia un oggetto di tipo PrintWriter che scrive un file, cosa succede se, al termine del programma, il programmatore ha dimenticato di chiudere f ileScanner? E se ha dimenticato di chiudere printWriter?
U lteriori
caratteristiche di programmazione e di Java
107
3.5 In relazione alle classi appartenenti a un progetto, cosa significa effettuare un collaudo “bottom-up”? 3.6 Ipotizzare di aver creato un array bidimensionale (cioè, in pratica, un array i cui singoli elementi sono array), come nell’enunciato seguente, che crea un array di tipo int con 50000 righe e 100000 colonne: int[ ][ ] a = new int[50000][l00000]; Eseguendo questo codice, si assiste alla terminazione anomala del programma, con la visualizzazione di questo messaggio: java.lang.OutOfMemoryError Exception in thread "main”
Perché il garbage collector non ha recuperato memoria a sufficienza? È possibile gesti re questa terminazione anomala mediante un blocco try/catch? Provare e fornire spiegazioni. 3.7 E possibile che un campo protected sia accessibile al di fuori della classe in cui è dichiarato e delle sottoclassi di questa? Che significato ha la frase seguente? “Definire sottoclassi può essere un forte vincolo e non un semplice esempio di utilizzo”. 3.8 Gli array sono oggetti un po’strani, perché non esiste la “classe array”, ma un oggetto di “tipo array” può invocare i metodi definiti nella classe Object. Prevedere ciò che verrà visualizzato da questo frammento di codice, fornendone le motivazioni:
int[ ] a = nem int[l0]; lnt[ ] b
int[io];
a[3] = 7; b[3] = 7; System.out.println(a.equals(b));
Eserda di programmazione 3.1 Progettare le specifiche per un metodo che analizza una riga di testo che dovrebbe
contenere tre valori di tipo doublé, restituendo il valore maggiore. Vanno lanciate tutte le eccezioni possibili. Iniziare scrivendo uno stub del metodo e creare una classe di collaudo, eseguendola. Eseguire nuovamente la classe di collaudo dopo aver definito il metodo. Infine, aggiungere alla classe un metodo main e un metoro run() che invochi il metodo appena progettato. 3.2 Progettare le specifiche per un metodo che legge un file di testo che dovrebbe contenere valori di tipo doublé, restituendo il valore maggiore.Vanno lanciate tutte le eccezioni possibili. Iniziare scrivendo uno stub del metodo e creare una classe di collaudo, eseguendola. Eseguire nuovamente la classe di collaudo dopo aver defini to il metodo. Infine, aggiungere alla classe un metodo main e un metoro run() che invochi il metodo appena progettato. 3.3 Modificare il metodo run della classe Company in modo che legga i dati da un file e scriva i risuluti in un altro file; fare in modo che eventuali nomi di file errati vengano chiesti di nuovo all’utente.
108
CAPITOLO 3
3 ^ Fare ipotesi sugli errori presenti in questo metodo:
public static boolean isEven(int i) { if (i * 2 - 0) retum txue; If (i % 2 != 0) retum false; } // metodo isEven
< Verificare le ipotesi fatte progettando un metodo run() che invochi il metodo in esame. Si può gestire il problema con un blocco try/catch? Fornire spiegazioni. 3.5 Fare ipotesi su quanto verrà visualizzato dall’enunciato seguente: System.out.println(null + "nuli");
Verificare le ipotesi fatte e identificare, nella classe Strìng, il codice che giustifica ciò che viene visualizzato. 3.6 Fornire un esempio che mostri come la visibilità private sia più restrittiva di quella di default (o di pacchetto). Fornire un esempio che mostri come la visibilità di de fault sia più restrittiva di quella protected. Fornire un esempio che mostri come la visibilità protected sia più restrittiva di quella public. Per ognuno dei casi precedenti, collaudare il codice per accertarsi che l’opzione più restrittiva tra le due generi eflFettivamente un messaggio d’errore al momento della compilazione, mentre, con l’opzione meno restrittiva, non deve essere generato alcun messaggio d’errore. 3.7 La visibilità protetta si trasferisce attraverso i confini dei pacchetti, da un pacchetto all’altro, ma questo accade soltanto all’interno di una sottoclasse e solo per oggetti il cui tipo sia quello di tale sottoclasse. Per evidenziare il problema, si supponga di aver dichiarato la classe A nel pacchetto APackage:
package APackage; public class A { protected int t; } // classe A
Poi, si ipotizzi che le classi C e D siano sottoclassi di A, in un pacchetto diverso da quello in cui è stata definita A: di conseguenza, nella classe D il campo t si trova ad operare come se fosse stato dichiarato in Dinvece che in A. Nel seguito una possibile dichiarazione per le classi C e D: i^wrt APackage.
public class C extends A { } La classe Dè dichiarata in un altro file. Fare ipotesi sulla liceità di ciascuno dei quattro tentativi di accesso a t presenti nella seguente dichiarazione della classe D:
import APackage.’^; public class D extends A {
U lteriori
caratteristiche di programmazione e di Java
109
publlc void meth() {
D d = new D(); d.t * 1; // accesso l t = 2; // accesso 2 A a = new A(); a.t = 3; // accesso 3 C c = new C(); c.t » 4; // accesso 4 } // metodo meth
} // classe D
3^
3.9
3.10
3.11
Verificare le ipotesi fatte creando ed eseguendo un progetto che contenga i file appena descritti. Rifare l’Esercizio di programmazione 2.2 in modo che visualizzi il numero di sa lari superiori al salario medio. Usare, come campo, un array che contenga i salari e ipotizzare che i dati in ingresso conterranno al massimo 10 salari. Analizzare le specifiche del metodo arraycopy della classe System e scrivere un breve programma che lo usi per copiare tutti gli elementi da un array a un altro.Visualizzare, poi, gli elementi contenuti nell’array di destinazione, per verificare che la copiatura sia realmente avvenuta. Rifare l’Esercizio di programmazione 3.8 in modo che gestisca dati in ingresso contenenti un numero imprecisato di salari. Suggerimento Iniziare con un array avente lunghezza 10. Quando il numero di salari acquisiti eccede la lunghezza attuale dell’array, creare un nuovo array di lunghezza doppia, copiare il contenuto del vecchio array nel nuovo (si veda, a tal proposito, l’Esercizio di programmazione 3.9) e, infine, assegnare il (riferimento al) nuovo array al (riferimento al) vecchio array. In base alle specifiche complete dei metodi della classe Object, qualunque metodo che sovrascriva il metodo equals della classe Object dovrebbe soddisfare le seguenti cinque proprietà: 1. riflessività, cioè, per qualsiasi riferimento x diverso da nuli x.equals(x)
dovrebbe restituire true. 2. simmetria, cioè, per qualsiasi riferimento x e y diversi da nuli x. equals(y)
dovrebbe restituire lo stesso risultato di y. equals(x)
3. transitività, cioè, per qualsiasi riferimento x, y e z diversi da nuli, se x.equals(y)
restituisce true e
110
C apitolo 3 y.equals(z)
restituisce true, allora x.equals(z)
dovrebbe restituire true. 4. coerenza, cioè, per qualsiasi riferimento x e y diversi da nuli,ripetute invocazioni di x.equals(y)
dovrebbero coerentemente restituire true oppure coerentemente restituire false, a patto che nessuna deUe informazioni utilizzate nei confronti operati da equals sugli oggetti sia stata modificata. 5. realtà, cioè, per qualsiasi riferimento x diverso da nuli
x.equals(null) dovrebbe restituire false. Fornire esempi a supporto del fatto che il metodo equals della classe FullTimeEmployee (si veda il Paragrafo 3.8) soddisfi queste cinque proprietà, pur senza avere l’onere di dimostrare che ciò sia vero. 3.12 Progettare ed eseguire una classe di collaudo per il metodo equals definito nel Pa ragrafo 3.8 per la classe FullTimeEmployee.
Progetto di programmazione 3.1 Un browser web integrato con motore di ricerca. Parte 1 N o ta : per questo progetto è necessaria una certa familiarità con lo sviluppo di interfacce grafiche per Tinterazione con l’utente ( C iU i.^ r a p h ic a l u ser hiterface). E questa la prima parte di un progetto (suddiviso in sette parti e basato su un lavoro di Newhall e Meeden [2002|) che porterà alla creazione di un browser web integrato, con motore di ricerca. Le parti rimanenti si trovano nei ('apitoli 7,8, 13, 14, 15 e 16. In questa fise, in pratica, il progetto non fi altro che visualizzare pagine web. Inizialmente, nella finestra della (lUI dedicata al programma, la zona di visualizza zione (display) mostra la**home page’*, che contiene un collegamento ( l in k ) a un’altra pagina: se Putente “fa clic” su tale link (cioè preme il pulsante del mouse mentre il rebtivo puntatore si trova al di sopra del collegamento stesso), sarà tale nuova pagina ad occupare la zona di visualizzazione. Oltre all’area destinata alla visualizzazione della pagina corrente, la finestra della CiUl contiene anche quattro pulsanti, sempre da attivare mediante un “clic” del mouse: uno per tornare alla pagina precedente (backward, con etichetta “==”, in questa fise disabilitato), uno per visualizzare la “home page” (con etichetta “Hom e”, abilitato) e uno per iniziare una
U lteriori
caratteristiche di programmazione e di Java
111
ricerca (con etichetta “Search”, al momento disabilitato). Infine, la finestra della GUI conterrà una riga di acquisizione di testo, per consentire all’utente del pro gramma di introdurre il nome di un file: premendo il pulsante l l n t e r (o I n v io ) , verrà visualizzato quel file (o, per meglio dire, la pagina contenuta nel file). Analisi
l a (ìU l del programma può essere descritta dalle seguenti specifiche:
1. Le dimensioni della finestra saranno 700 pixel (dimensione orizzontale o larghezza, m d t ì i) e 500 pixel (dimensione verticale o altezza, h c ig lit), 2. L’angolo superiore sinistro della finestra si troverà nel punto di coordinate 150 (coordinata x) e 250 (coordinata y). 3. Ognuno dei quattro pulsanti, disposti nella parte alta della finestra, avrà dimensioni pari a 80 pixel (larghezza) e 30 pixel (altezza). 11 pulsante “Home” sarà di colore verde, mentre gli altri tre saranno di colore rosso. 4. La riga di acquisizione di testo avrà un’ampiezza di 50 pixel. 5. L’area di visualizzazione potrà essere sottoposta a scorrimento (sa o llin g ) in entrambe le direzioni. Ecco, quindi, uno schema della finestra da progettare: mean) count++;
retum count; } // metodo aboveMeanCount
A nalisi di algoritmi
117
Sono presenti sei enunciati che saranno eseguiti una sola volta: Tassegnazione degli argo menti ai relativi parametri, a e mean;rinizializzazione di n,count e i;la restituzione di count. In totale, all’interno del ciclo for: i sarà confìontato n + 1 volte con n; i sarà incrementato n volte; il confronto tra a[i] e mean verrà effettuato n volte. Se n - 1 elementi hanno il valore 1.0 e l’unico altro elemento ha il valore 0.0, a[i] sarà maggiore di mean per n - 1 volte, quindi count sarà incrementato proprio n - 1 volte. Il numero totale, worstTime(w), di enunciati eseguiti nel caso peggiore, quindi, è 6 + (fi + 1) + w + fi + (« - 1) = 4w + 6 Qualche volta saremo anche interessati alle presuzioni di un metodo nel caso medio, per cui definiamo il numero medio di enunciati eseguiti da un metodo: averageTime(n). Quesu media va eseguiu su tutte le possibili invocazioni del metodo, nell’ipotesi che tutti gli in siemi di fi parametri/ingressi siano equiprobabili, ben sapendo che per alcune applicazioni questa ipotesi non è realistica: in tali casi averageTime(fi) potrebbe non essere significativo. Nel ciclo for dell’esempio appena illustrato, in media a[i] sarà maggiore di mean la metà delle volte in cui si effettua il confronto, per cui count sarà incremenuto solunto n/2 volte. Quindi, averageTime(fi) è 3.5fi + 7. Di quando in quando, soprattutto nei Capitoli 6 e 12, saremo anche interessati a una stima dei requisiti di un metodo in merito all’occupazione di spazio di memoria (e parleremo di requisiti spaziali, invece che temporali). A tale scopo, definiamo worstSpace(n) come il massimo numero di variabili per le quali il metodo richiede spazio in memoria e, analogamente, averageSpace(n) come il numero medio di tali variabili. Nel caso di un array, consideriamo ciascun suo elemento (che è, in effetti, una variabile indicizzau) come una variabile disdnu, per cui un array di lunghezza n contribuirà al conteggio con n variabili. Il metodo aboveMeanCount non crea array e worstSpace(fi) = averageSpace(fi) = 5.
4.1.1 Notazione0*grande Dato che worstTinie(fi) e averageTime(fi) sono solunto drastiche approssimazioni del tempo richiesto per l’esecuzione di un metodo, così come worstSpace(fi) e averageSpace(fi) sono altrettanto drastiche approssimazioni dei suoi requisiti spaziali, non c’è bisogno di calcolare tali funzioni in modo esatto, per cui le approssimiamo mediante la **notazione O-grande” {Big‘Oh), definita nel prossimo paragrafo. Essendo interessati al metodo a sé stante, questa ''approssimazione di un’approssimazione” è abbastanza soddisfacente per avere un’idea della sua velocità di esecuzione. L’idea su cui si basa la nouzione O-grande è che spesso vogliamo determinare un limite superiore (upper bound) per l’andamento di una funzione, che, nel nostro caso, significa deter minare quanto possano divenute poco efficienti le presuzioni di un metodo. Supponiamo, ad esempio, che sia dau una funzione/ Se un’altra funzione è, per dirla con parole semplici, un limite superiore per/allora diciamo c h e/è O-grande di^. Sostituendo l’espressione “per dirla con parole semplici” e usando una definizione più specifica, otteniamo quanto segue: Siag una funzione a valori non negativi e definita soltanto per valori interi non negativi. Sidice che una funzione fé 0{g) se esistono una costante positiva C e una costante non negativa K taliche f(n) ^ C g (n )
per ogni n ' ^ K
118
C apitolo 4
S e /è 0(g), diciamo che “/ è O-grande d i^’\ ma si può anche dire che “/ è dell*ordine di^**. L*idea sottostante la notazione O-grande è che, s e / è 0(g), allora/sarà certamente limitata superiormente da g moltiplicata per una costante, per cui possiamo usare g come drastica stima del limite superiore della funzione / Compiendo un diffuso abuso di notazione, spesso associamo una funzione al valore che questa calcola. Ad esempio, sia ^ la funzione cosi definita: g(n) = fl^per « = 0 ,1 ,2 ,... Invece di scrivere 0(g), scriviamo 0(«^). * 1 tre esempi seguenti aiuteranno a comprendere alcuni dettagli relativi alla notazione O-grande, mentre nel Paragrafo 4.1.2 descriveremo il processo che porta alle stime di O grande senza passare per i dettagli qui esposti.
Esemplo 4.1 Sia/la funzione worstTime definita nel Paragrafo 4.1 per il metodo aboveMeanCount, qui ripetuto:
public static int aboveMeanCount(double[ ] a, doublé mean) { Int n = a.length, count « 0;
fòr (int i = 0; i < n; i++) if (a[i] > mean) count++;
retum count; } // metodo aboveMeanCount Dato che:
J{n) = 4« + 6, per n = 0 ,1 ,2 ,... Dimostrare c h e /è 0(«). Soluzione
Dobbiamo trovare due costanti non negative, C e K, tali che J{rt) < Cn per ogni n '^ K . Dimostreremo che ciascun termine presente nella definizione di / è minore o uguale a n moltiplicata per qualche costante, per ogni n maggiore o uguale a qualche numero intero non negativo. Infatti, si ha che: 4« < 4 n 6 < 6fi
per « ^ 0 per w ^ 1
Quindi, per ogni w> 1:
J[n) ^ 4w + 6fi = lOfi
A nalisi di algoritmi Conseguentemente, per C = 10 e /C =
119
< Cn per ogni n > K , Questo dimostra che
rè 0(M).
In generale, s e /è un polinomio avente questo formato: a,W +
••• +
+ hoursMorked; this.payRate = payRate;
if (hoursWorked "The elapsed time was final doublé NANO FACTOR
1000000000. 0 ;
/ / nanosecondi in un secondo final String ANSWER_2 = " seconds."; Scanner se • neu Scanner(System.in);
long elapsedTime; ubile (true) { try System, out.print(INPUT_PR0MPT) ;
int n • sc.nextIntO; if (n -= SENTINE!) break; elapsedTime • randomTimer(n); System.out.println(ANSWER 1 + (elapsedTime /~NANO_FACTOR) + ANSWER_2); } // try
catch(Exception e) {
System.out.println(e);
138
C apitolo 4 sc.nextLineO; } // catch } // while } // metodo run
/** ♦ Deteimina il tempo impiegato pei ordinare un array di ♦ numeri interi generati casualmente.
«
♦ Jiparam n la dimensione dell'array da generare e ordinare. ♦ ftreturn il tempo impiegato per ordinare 1'array di numeri ♦ interi generati casualmente. *
■¥
♦ ^hrows NegativeArraySizeException se n è minore di zero.
♦ ♦/
public long randomTimer(int n) { Random r > new Random();
long startlime^ finishTime, elapsedTime; int[ ] X s noM int[n];
for (int i * 0; i < n; i++) x[i] « r.nextIntO; startTime = System.nanoTime(); // ordina x in senso crescenti selectionSort(x); // calcola il tempo impiegato finishTime = System.nanoTime(); elapsedTime = finishTime - startTime; return elapsedTime; } // metodo randomTimer
/*♦ ♦ Ordina in senso crescente 1’array di numeri interi ricevuto. ♦ La funzione worstTime(n) è 0(n ♦ n).
♦ * ^aram x l'array da ordinare. ♦ ♦/ public stalle void selectionSort(int[ ] x) {
// rende la porzione x[0]...x[i] ordinata e con valori // non maggiori di quelli presenti nella porzione // x[i+l)...x[x.length-l] for (Int i = 0; i < x.length - l; i++)
int pos - i; for (Int j = i + 1; j < x.length; 1++) if (x[j] < x[pos]) pos » j;
A nalisi di algoritmi
139
int temp » x[i]; x[i] = x[pos]; x[pos] = temp; } // for i } // metodo selectionSort } // classe TimingRandom
Il numero di iterazioni eseguite dal ciclo ubile è indipendente da n, per cui la funzione worstTime(n) del metodo run() è determinata dalla stima della funzione worstTime(ft) relativa al metodo landomTimer, il quale contiene un ciclo che genera Tarray e, per tale generazione, worstTime(rf) è 0(fi). Poi, il metodo randomTimer invoca selectionSort: nel Paragrafo 4.1.2 abbiamo evidenziato come, per tale metodo, worstTime(n) sia 0(ri2). Dato che il numero di iterazioni è uguale per qualsiasi disposizione deg}i n elementi, la funzione averageTime(ft) è O(fi^) e, in effetti, la funzione costituisce tanto un limite inferiore quanto un limite superiore, per cui averageTime(ff) è una funzione quadratica in n. Come conseguenza, ci aspettiamo che il tempo di esecuzione medio (per tutte le possibili dispo sizioni di n numeri interi) dipenda da rt in modo quadratico. Come suggerito nel Paragrafo 4.2, usiamo il tempo impiegato per ordinare n numeri interi pseudo-casuali come stima del tempo di esecuzione medio per tutte le disposizioni di n numeri interi. Il tempo effettivamente impiegato ci conforta nella stima: per n = 50000, il metodo impiega 19.985 secondi, mentre per n = 100000 impiega 80.766 secondi. I tempi, come misura assoluta, non sono rilevanti, perché dipendono dal calcolatore utilizzato, ma sono significativi in senso relativo: quando n raddoppia, il tempo impiegato (quasi) quadruplica. Secondo quanto visto nel Paragrafo 4.1.4, in merito alla velocità di crescita delle funzioni, questo rapporto è sintomatico di una funzione quadratica.
Riepilogo La notazione O-grande ci consente di stimare velocemente un limite superiore per l*efficienza temporale e/o spaziale dei metodi. Dato che le stime O-grande consentono di utilizzare, come argomenti delle funzioni, numeri interi arbitrariamente grandi, conside riamo che i metodi siano algoritmi, svincolati dai requisiti di spazio imposti da Java e da uno specifico ambiente di esecuzione. Oltre alla notazione O-grande, abbiamo esaminato anche la notazione Q-grande (per i limiti inferiori) e O-grande (quando il limite superiore e il limite inferiore coincidono). L'analisi dell’esecuzione (run-time analysis) si riferisce al collaudo dei metodi su uno specifico computer, ma le stime che se ne ricavano sono spesso piuttosto grossolane, spe cialmente quando riguardano ambienti multi-tasking.Tra gli strumenti utilizzati in questa analisi troviamo il metodo nanoTime() e diversi metodi della classe Random.
Esercizi di teoria 4.1 Progettare un metodo, sample(int n), per il quale la funzione worstTime(n) sia
0(w), pur non essendo una funzione lineare in n. Suggerimento, 0{n) può essere visto come un limite superiore, ma “essere lineare in costituisce un limite tanto superiore quanto inferiore.
140
C apitolo 4
4.2 Analizzare Talgoritmo seguente, ipotiz^ndo che a sia un array di n elementi e i In
esista almeno un valore deU*indice k, appartenente all’intervallo 0 ... n - 1, tal» che a[k].equals(element) restituisca true. i = 0; Mhile (la[ì].equals(element)) i++;
Usare la notazione O-grande per stimare la funzione worstTime(fi). Usare le notazioni O-grande e 0-grande per stimare worstTime(/i). Stimare worstTime(w) usando frasi della lingua italiana. 4.3 Analizzare il metodo seguente: * Ordina in senso crescente 1*array di numeri interi che riceve. * La funzione worstTime(n) è 0(n * n).
*
* g)param x l'array da ordinare. « */ publlc static void selectionSort(int[ ] x) {
// rende la porzione x[o]...x[i] ordinata e con valori // non maggiori di quelli presenti nella porzione // x[i+l]...x[x.length-l] for (int i * 0; i < x.length - i; i++)
{
int pos = i; for (int j = i + l; i < x.length; j++) if (x[j] < x[pos]) pos = i;
int temp = x[i]; x[i] = x[pos]; x[pos] = temp; } // for i } // metodo selectionSort
a. Nel ciclo for più interno, quando i = 0, j assume i valori che vanno da 1 a n - 1, per cui, quando i = 0, vengono eseguite n - 1 iterazioni del ciclo for più interno. Quante sono le iterazioni eseguite quando i = 1? E quando i = 2? b. Determinare, in funzione di n, il numero totale di iterazioni del ciclo for più interno, con i che assume i valori che vanno da 0 a n - 2. c. Usare la notazione O-grande per stimare worstTime(fi). Poi, descrivere la stima di worstTime(M) in lingua italiana, scegliendo tra costante, logaritmica in //, lineare in M, log-lineare in rt, quadratica in n e esponenziale in n. 4.4 Per ognuna della seguenti funzioni /, con w = 0, 1, 2, 3, ..., stimare /m ediante la notazione O-grande e fornirne anche una descrizione in linguaggio naturale: a. A») = b. J{n) = c. J{n) = d. / « ) =
(2 + m) (3 + log(M)) 11 log(M) + n/2 - 3452 1 + 2 + 3 + ...+ « « (3 + «) - 7«
A nalisi di algoritmi
141
./{«) = + (w - 1) log (n - 4) f. J(n) = log (w2) + n g- A^) = [(w + 1) log (w + 1) - (w + 1) + l]/w h- ./(w) = « + w/2 + fi/4 + n/8 + w/16 + ... 45 Nella gerarchia degli ordini di grandezza, vista nella Figura 4.1, troviamo 0(log «), seguito da Dimostrare che, per numeri interi « > 16, log2 n < ^erimento. Dimostrare che, per qualsiasi numero reale jc > 16, la pendenza della funzione log2 jc è minore della pendenza dellafunzione Dato chelog2(16) è uguale a 16*''^^ possiamo concludere che, per ogninumero reale x > 16, log2 x < 4.6 Per ciascuno dei seguenti frammenti di codice, stimare worstTime(rt) usando la no
tazione O-grande oppure il linguaggio naturale. In ogni frammento S rappresenta una sequenza di enunciati che non contiene cicli dipendenti da n. a. for (Int i = 0; i ♦ i < n; i++) S b. for (Int i = 0; Math.sqrt(i)
< n; i++) S
c. int k = 1; for (int i = 0; i < n; i++) k *= 2; for (int i = 0; i < k; ì-h -) S
4.7
4.8
4.9
4.10 4.11
Su^erimento. In ciascun caso, il numero 2 fa parte della risposta. a. Nell’ipotesi che un metodo sia caratterizzato da una funzione worstTime(ri) lineare in n, stimare l’effetto sul tempo di esecuzione {runTime, ossia tempo effettivamente impiegato per eseguire il metodo in uno specifico ambiente di calcolo) dovuto alla triplicazione di n. Detto in altri termini, stimare runTime(3rt) in funzione di runTime(«). b. Nell’ipotesi x:he un metodo sia caratterizzato da una funzione worstTime(w) quadratica in n, stimare l’eflFetto sul tempo di esecuzione {runTime, ossia tempo effettivamente impiegato per eseguire il metodo in uno specifico ambiente di calcolo) dovuto alla triplicazione di w. Detto in altri termini, stimare runTime(3«) in funzione di runTime(fi). c. Nell’ipotesi che un metodo sia caratterizzato da una funzione worstTime(w) costante, stimare l’effetto sul tempo di esecuzione {runTime, ossia tempo effetti vamente impiegato per eseguire il metodo in uno specifico ambiente di calcolo) dovuto alla triplicazione di n. Detto in altri termini, stimare runTime(3w) in funzione di runTime(w). Questo esercizio dimostra che le famiglie di funzioni O-grande non costituiscono una gerarchia in senso stretto. Si consideri la funzione/, definita nel modo seguente per tutti i numeri interi non negativi:7(w) è uguale a w se m è pari, mentre è uguale a 0 se n è dispari. Si definisca, ora, per qualsiasi numero intero non negativo, una funzione g tale c h e /n o n sia 0(g) e g non sia 0 ( / ) . Dimostrare che 0(«) = 0 (n + 7). Suggerimento. Usare la definizione di O-grande. Dimostrare ch e,se/«) è una funzione polinomiale in « ,/«) non può essere espo nenziale in n. Dato un metodo la cui funzione worstTime(«) è w”, dimostrare che si tratta di un metodo caratterizzato da un tempo di esecuzione esponenziale, cioè che esiste un valore reale x > 1 per cui la funzione worstTime(w) è D(ac"), ma dimostrare anche che worstTime(w) non è &{x”) per qualunque numero reale x > 1.
142
CAPITOLO 4
4.12 Questo esercizio mette in evidenza alcune anomalie di 0(1).
a. Definire la funzioney(n) in modo che assuma il valore 0 per ogni rt ^ 0 e dimo strare che/ non è 0(1), mentre è 0(0). b. Definire la funzioney(M) in modo che assuma il valore {n + 2)/(« + 1) per ogni n ^ O e^dimostrare c h e /è 0(1) (e, quindi, può essere definita “costante”) nono stante non sia una funzione costante. 4.13 a. Nell'ipotesi che worstTime(n) sia uguale a C (enunciati),con C costante qualsiasi, per ogni « ^ 0, determinare worstTime(2«) in funzione di worstTime(w). b. Nell’ipotesi che worstTime(rt) sia uguale a log2 n (enunciati) per ogni ft ^ 0, determinare worstTime(2fi) in funzione di'worstTime(H). c. Nell’ipotesi che worstTime(fi) sia uguale a n (enunciati) per ogni « ^ 0, deter minare worstTime(2«) in funzione di worstTime(n). d. Nell’ipotesi che worstTime(w) sia uguale a n log2 n (enunciati) per ogni n ^ 0, determinare worstTime(2«) in funzione di worstTime(«). e. Nell’ipotesi che worstTime(n) sia uguale a (enunciati) per ogni w^ 0, deter minare worstTime(2«) in funzione di worstTime(w). f.Nell’ipotesi che worstTime(«) sia uguale a 2" (enunciati) per ogni w^ 0, determinare worstTime(2fi) in funzione di worstTime(«). 4.14 Se un metodo è caratterizzato da una funzione worstTime(fi) esponenziale in ti, quali delle seguenti afifermazioni sono vere? a. worstTime(fi) è 0(2"). b. worstTime(«) è 0(2"). c. worstTime(w) è 0(2"). d. worstTime(w) è 0(«"). e. nessuna deUe precedenti affermazioni è vera.
Esercìzi di programmazione 4.1 In matematica, la funzione “valore assoluto” restituisce un numero intero non nega
tivo per qualsiasi argomento di tipo intero. Progettare un metodo run che dimostri come il metodo Java Math.abs(lnt a) non sempre restituisce un numero intero non negativo. Suggerimento. Si veda l’Esercizio di programmazione 1.1. 4.2 Nell’ipotesi che r sia un (riferimento a un) esemplare della classe Random, dimostrare che il valore della seguente espressione non appartiene necessariamente aH’intcrvallo 0...99: Math.abs(r.nextlntO) % lOO
4.3
Suggerimento. Si veda l’esercizio precedente. Progettare un metodo run che inizializzi un oggetto di tipo Random usando il co struttore di default e, poi, determini il tempo impiegato dal metodo nextlnt() per generare il numero 123456789.
A nalisi di algoritmi
143
4.4 Neiripotesi che le specifiche di un metodo contengano una stima di tipo O-grande
per la funzione worstTime(rt), spiegare perché sarebbe impossibile progettare un collaudo di unità per tale stima O-grande. 4.5 Nel metodo bìnarySearch visto nel Paragrafo 4.1.2, la media tra i valori di low e high è stata calcolata dall’espressione seguente: (low + high) » 1
Confiontare tale espressione con questa: low + ((high - low) » 1)
Le due espressioni sono matematicamente equivalenti e la prima delle due è un po’ più efficiente, ma, per alcuni valori di low e high, restituisce un risultato sbagliato. Individuare valori di low e high per i quali la prima espressione restituisce un valore errato per la media tra low e high. Suggerimento. Il più grande valore possibile per una variabile di tipo in t è Integer.MAX VALUE, circa 2 miliardi.
Progetto LetoMakeabeali Questo progetto è basato su una modifica (proposta da Marilyn Vbs Savant) del gioco televisivo *"Let*s Make a Deal” {facciamo un Un concorrente si trova di fronte alla icelu tra tre porte: dietro a una delle porte si trova una costosa automdbile» mentre dietro ciascuna delle altre due c’è una capra. Dopo che il concorrente ha annunciato il suo tentativo iniziale, il conduttore del gioco guarda dietro le altre due porte ed elimina una di quelle che non nasconde l’automobile. Ad esempio, se il giocatore vuole tentare inizialmente con la porta numero 2 e Tautomobile si trova dietro b porta numero 3, il conduttore mostrerà la capra posizionata dietro la porta numero 1. Se la scelta iniziale del concorrente è quella corretta, il conduttore sce^ierà a caso quale delle altre due porte eliminare. Per esempio, se la scelta iniziale è la porta numero 2 e l’automobile si trova proprio dietro tale porta, il conduttore sceglierà, a caso, se mo strare la capra che si trova dietro h porta numero 1 oppure quella che si trova dietro la porta numero 3. Dopo questa prima scelta e la conseguente azione del conduttore, che elimina una delle altre due porte, il concorrente deve prendere la decisione finale. Progettare e collaudare un programma che determini la risposta alle seguenti do mande: 1. Il concorrente deve confermare la scelta iniziale, oppure cambiarla? 2. Quanto è maggiore la probabilità di vittoria di un concorrenice che cambia sempre idea, rispetto a quella di un concorrente che non la cambia mai? Per consentile la ripetibilità, il seguente collaudo di sistema inizialisza il generatore di numeri casuali usando un seme uguale a 100.
144
Capitolo 4
C b lb i^ o 41 tlitém ii 1 (i dati forniti in ingpiesso sono in grassetto)
"^
Numero di partire da giocare: 10000 0 = conconente che non cambia mai idèa, 1 = concorrente che cambia semine idea: 0 Il numero di vittorie è 3330
CoUaudo di sistema 2 (i dati forniti in ingresso sono in grassetto) Numero di partire da giocare: 10000 0 = concorrente che non cambia mai idea, 1 = concorrente che cambia sempre idea: 1 n numero di vittorie è 6628 Sulla base di quanto viene visualizzato, quali sono le risposte alle due domande precedenti? Supponiamo, ora, invece di operare con tre porte, che il numero di porte sia fornito come dato in ingresso, insieme al numero di partite da giocare. Dopo aver fatto ipotesi in merito alla probabilità di vittoria di un concorrente che cambia sempre idea, modificare e rieseguire il programma in modo da confermare o confutare Tipotesi fatta (continuando a fare ipotesi e a ripetere il ciclo di azioni fino a trovare un’ipotesi confermata).
Suggerimento per le ipotesi Supponiamo che ci siano n porte, dove n può essere qualsiasi numero intero positivo maggiore di 2. Pèrche un concorrente che cambia sempre idea possa vincere, la scelta iniziale deve essere sbag^ata e la scelu finale deve essere corretta. Qual è la probabilità che, con n porte, la scelta iniziale sia sbadata? Posto che la scelta iniziale sia sbagliata, tra quante porte dovrà scegliere il concorrente che cambia sempre idea, nel momento in cui deve fare la sua scelta finale (ricordando che il conduttore avrà eliminato una porta)? La probabilità di vittoria del concorrente che cambia sempre idea è uguale alla probabilità che b sua scelta iniziale sia sbagliata moltiplicata per la probabilità che la sua scelta finale sia giusta.
5 Java Collectìons Framework
L'infrastructura che prende il nome di Java Collections Framework è una raccolta di inter facce e classi, tra loro correlate, appartenenti al pacchetto java. ut il. Nella maggior parte dei casi, un esemplare di una classe di tale infrastruttura rappresenta una raccolta (collection), composta, quindi, di elementi. Queste classi di tipo**raccolu’*possono usare tipi parametrici, una nuova caratteristica del linguaggio Java, in modo che un utente possa specificare il tipo a cui appartengono gli elementi della raccolta nel momento in cui dichiara un esemplare di tale raccolta. In questo capitolo faremo una breve panoramica delle classi che rappre sentano raccolte alfinterno del Java Collections Framework, presentando anche le nuove caratteristiche che ne favoriscono Tutilizzo.
Obiettivi del capitolo Capire cos'è una raccolta e in che m o d o le raccolte di elementi contigui differiscono dalle raccolte concatenate. Essere in grado di creare e manipolare raccolte parametriche. Identificare alcuni dei metodi presenti nell'interfaccia Collection. In generale, descrivere uno schema di progetto (design pattern) e, in particolare, l'iteratore. Confrontare due realizzazioni dell'interfaccia List: ArrayList e LinkedList. Saper utilizzare le strategie di boxing e unboxing, nonché l'enunciato for applicato a raccolte.
146
Capitolo 5
5.1 Ractolte Una raccolta (collection) è un oggetto composto da elementi, i quali, a loro volta, possono essere valori di un tipo primitivo (come int) oppure riferimenti a oggetti. Per iniziare con un esempio già noto, un array è una raccolta di elementi, tutti dello stesso tipo, che vengono memorizzati in zone contigue della memoria. In questo ambito, contiguo ha lo stesso significato di ^‘adiacente’*, nel senso che i singoli elementi sono memorizzati uno accanto all'altro^Ad esempio, possiamo creare un array»di cinque elementi di tipo Strlng (anche se, a rigor di termini, ciascun elemento è, in realtà, un riferimento a un oggetto di tipo String) in questo modo: String[ ] names = na< String[5];
Qui l’operatore new riserva (o “alloca”) spazio in memoria per un array di cinque riferi menti di tipo String (ciascuno inizializzato a nuli dalla Java Virtual Machine) e restituisce un riferimento aU’inizio dello spazio cosi riservato: tale riferimento viene memorizzato nella variabile names. Il fatto che gli elementi di un array vengano memorizzati in zone contigue della me moria ha un’importante conseguenza: si può accedere a un singolo elemento dell’array senza dover accedere a nessun altro elemento. Ad esempio, si può accedere direttamente a names[2], senza dover prima accedere a names[0] e names[l]. Questa proprietà di accesso casuale {random access) degli array risulterà utile in molti casi, nei capitoli successivi, quando avremo bisogno di una struttura di archiviazione in cui si possa accedere rapidamente a un elemento di cui sia nota la posizione, relativamente agli altri elementi: un array sarà, quindi, adeguato. Gli array hanno, però, alcuni svantaggi. Innanzitutto, la dimensione di un array è fissa: prima che si possa memorizzare qualsiasi elemento all’interno di un array, lo spazio destinato all’intero array deve essere riservato in memoria, sia per array di tipi primitivi sia per array di riferimenti. Se la dimensione dell’array si rivela, poi, essere insufficiente, occorre creare un array più grande, copiandovi il contenuto dell’array più piccolo. Un altro problema che si riscontra nell’utilizzo degli array riguarda il programmatore, che deve fornire tutto il codice che serve per rendere operativo l’array stesso. Ad esempio, l’inserimento e la rimozione di un elemento in un array può richiedere lo spostamento di molti elementi. Supponiamo che gli indici di un array possano assumere valori compresi tra 0 e 999, estremi inclusi, e che siano presenti elementi ordinatamente in tutte le posi zioni aventi indice che va eh 0 a 756. Per inserire un elemento nella posizione di indice 300, dobbiamo prima spostare nelle posizioni di indice variabile tra 301 e 757 gli elementi associati agli indici che vanno da 300 a 756, come si vede nella Figura 5.1. Fino ad ora, nella vostra carriera di programmatori, avete dovuto convivere con gli svantaggi degli array che abbiamo appena citato, ma il paragrafo seguente descrive un’al ternativa quasi sempre migliore rispetto all’utilizzo di array: usare esemplari di classi che rappresentano raccolte.
' In realtà, ciò che im p o ru per Tutilizzatore di un array, è che gli elementi vengano memorizzati fossero contigui, in modo che si possa accedere a un elemento usando direttamente il suo indice.
conte se
JAVA CO LLEaiO NS F r AMEWORK
Prima di inserire Kalena
147
D opo aver inserito Kalena
0
Alice
0
Alice
1
Andrew
1
Andrew
•
•
299
Kaitlin
299
Kaitlin
300
Karen
300
Kalena
301
Rari
301
Karen
301
Karì •
. 755 Zelda 756 Zooey
756 Zelda 757 Zooey
999
999
Figura 5.1 Inserimento In un array: per Inserire 'Kalena* allindice 300 deirarray di sinistra, occorre prima spostare gli elementi corrispondenti agli indici 3 0 0 ,3 0 1 ,7 5 6 , in modo che, poi, si trovino ordinatamente nelle posizioni di indice 301,3 0 2 ,7 5 7 .
5.1.1 Classi cherappresentanoraccolte La maggior parte di ciò di cui parleremo da qui in avanti riguarda classi che rappresentano raccolte. Una classe che rappresenta una raccolta (o semplicemente raccolta, coHection class) è una classe i cui singoli esemplari sono raccolte di elementi, ciascuno dei quali, a sua volta, è un (riferimento a un) oggetto. Ad esempio, un oggetto di tipo String può essere un ele mento, oppure può esserlo un oggetto di tipo FullTimeEmployee. I valori memorizzati in una variabile di tipo primitivo non sono oggetti, per cui non possiamo creare un esemplare di una raccolta i cui singoli elementi siano di tipo int. Per ogni tipo primitivo, però, esiste una classe corrispondente, detta classe involuao (wrapper class), il cui scopo principale è proprio quello di consentire a un valore di tipo primitivo di essere rappresentato (cioè di essere “avvolto”) da un esemplare di una classe. Ad esempio, esiste la classe Integer e possiamo creare un suo esemplare a partire dalla variabile in t j in questo modo: new Integer(j); L’operatore new restituisce un riferimento a un oggetto di tipo Integer. La Tabella 5.1 mostra alcune importanti conversioni.
148
C apitolo 5
TalMlki 5.1 Alcune importanti fòrmule di conversione int i; Integer mylnt; String s; Object obj; PwottMwt
Da
Esempio
Integer
int
mylnt = i; // si vedail Paragrafo 5.2.2 i » mylnt; // si veda ilParagrafo5.2.2 s » Integer.toString(i); s « mylnt.toString(); • obj = mylnt; // Regola di Sostituzione per Sottoclassi obj » s; // Regola di Sostituzione per Sottoclassi i « new Integer(s); // se s ha un valore di tipo int mylnt « new Integer(s); // se s ha un valore di tipo int mylnt « (Integer)obj; // se obj si riferisce a Integer s » (String)obj; // se obj si riferisce a String_______
int
Integer
String String Object Object
int
int Integer Integer String
Integer Integer String String String Object Object
L’infnstruttura Java Collections Framework contiene un certo numero di classi che rappresentano raccolte e che sono di ampio utilizzo, inoltre alcuni metodi sono presenti in tutte. Ad esempio, ognuna di queste classi ha un metodo isEmpty, specificato in questo modo;
♦
Determina se questa raccolta non ha elementi.
♦ greturn true - se questa raccolta non ha elementi,
*
*f
public boolean isEmpty() Supponendo che myList sia un esemplare della classe ArrayList (che, come vedremo, rap presenta una raccolta) e che contenga quattro elementi, l’esecuzione di questo enunciato: System.out.println(myList.isEmpty());
visualizzerà: false
Come è ovvio che sia, le specifìche di un metodo non dicono come verrà portato a termine il compito assegnato al metodo stesso. Nei prossimi capitoli scopriremo alcuni dettagli relativi a parecchie classi che rappresentano raccolte, ma già ora possiamo in trodurre una loro semplice classificazione, basata sul modo in cui vengono memorizzati gli elementi.
5.1.2 Strutturedi memorizzazioneperdassi die rappresentanoraccoite Solitamente gli esemplari di classi che rappresentano raccolte hanno un’occupazione di memoria proporzionale al numero di elementi presenti nella raccolta stessa, per cui la loro modalità di memorizzazione può avere un impatto rilevante sull’efficienza di un programma
JAVA COLLECTIONS FRAMEWORK
149
dal punto di vista, appunto, delle esigenze di memoria. Un modo assai semplice di archi viare in memoria una raccolta prevede di memorizzare in un array i riferimenti ai singoli elementi presenti nella raccolta: in pratica, la classe ha un array come campo. Una classe di questo tipo viene detta raaolta contigua e, ad esempio, la classe ArrayList, che analizzeremo nel Capitolo 7, ha un campo di tipo array, nelle cui singole posizioni vengono memorizzati i riferimenti agli elementi della raccolta: si tratta, quindi, di una raccolta contigua e nei Capitoli 7 ,9 e 14 ci occuperemo di raccolte contigue. Per molte delle applicazioni tipiche delle classi che rappresentano raccolte contigue la caratteristica di accesso casuale messa a disposizione dagli array è molto comoda. Cosa possiamo dire in merito agli svantaggi degli array, citati in precedenza, cioè la dimensione fissa e la necessità di scrivere tutto il codice che li rende operativi? Per le classi che rappresentano raccolte contigue, questi problemi riguardano i progettisti delle classi stesse, non i loro utilizzatori: in pratica, il progettista di questo tipo di classi scrive, una volta sola, il codice dei metodi che manipolano l’array e i molteplici utilizzatori invocano semplicemente i metodi adeguati all’applicazione che devono sviluppare, senza nenuneno avere necessità di sapere che nella classe sia presente un campo di tipo array e, anzi, per il Principio dell’Astrazione per i Dati, non dovrebbero in alcun modo basarsi sull’esistenza di ule campo. È probabile che non abbiate ancora apprezzato adeguatamente l’accesso casuale che viene consentito dagli array, perché probabilmente non avete ancora visto soluzioni alter native agli array per archiviare in memoria gli elementi di una raccolta. Ora descriveiemo brevemente proprio una struttura che compete con fjii array per memorizzare gjUelementi di una raccolta. Invece di usare la contiguità, gli elementi vengono posti in relazione tra loro mediante collegamenti, dove collegamento (link) è un sinonimo di riferimento. Detto in termini ele mentari, ciascun elemento viene accolto in un oggetto speciale, chiamato entry (“voce”, nel senso di voce di un dizionario o di una raccolta, spesso in italiano semplicemente ''dato”) oppure nodo, all’interno del quale esiste almeno un collegamento a un altro nodo. In una classe che realizza una raccolta mediante collegamenti (linked-collection), ciascun elemento presente in un suo esemplare è memorizzato in un nodo. Le Figure 5.2,5.3 e 5.4 mostrano porzioni di tre raccolte realizzate mediante collegamenti e nei Capitoli 8, 11, 13, 15 e 16 analizzeremo in dettaglio classi che realizzano raccolte mediante colle gamenti.
¥
exhale
serene
mellow
Figura 5,2 Porzione di una raccoita realizzata mediante collegamenti (in particolare, una lista singly-linicad lisi), dove ciascun nodo contiene un elemento e un
sem plicem en te con caten ata,
riferimento al nodo successivo presente nella raccolta.
exhale
mellow
serene
Figura 5,3 Porzione di una raccolta realizzata mediante collegamenti (in particolare, una lista doubly-linkad lisi), dove ciascun nodo contiene un elemento, un
d o p p ia m e n te con caten ata,
riferimento al nodo precedente e un riferimento al nodo successivo.
150
C apitolo 5
Figura 5.4 Porzione di una raccolta realizzata mediante coliegamenti (in particolare, un albero binary saarch trae, dove ciascun nodo contiene un eiemento e riferimenti
binario d i ricerca,
ad aitri tre nodi.
5.2 Alcuni dettagli del lava CollectionsFram«»mrk________ In questo paragrafo forniremo qualche informazione in più sul Java Coilections Framewolk, che è costituito da un assortimento di interfacce e classi ben collaudate, a rappresentare algoritmi e strutture dati di ampio utilizzo. Questa infrastruttura software fornisce le ciani più adeguate alla maggior parte delle applicazioni che necessitano di una raccolta e il loro utilizzo può migliorare significativamente la produttività dei programmatori, che non sono costretti a “reinventare la ruota”. Una delle caratteristiche di questa infrastruttura che ne ostacola la comprensione è la sua grande dimensione: più di 200 metodi nelle sole otto classi che studieremo. Per fortuna, però, ci sono molte ripetizioni: ad esempio, come già notato nel Paragrafo 5.1.1, ciascuna di queste classi ha il metodo isEmpty e, in effetti, le definizioni di molti metodi sono identiche in parecchie classi. Uno degli strumenti unificanti nelPinfrastruttura è Tinterfaccia, che impone alle classi le firme di molti metodi; il prossimo paragrafo presenta un altro, analogo strumento utilizzato per rendere omogenea rinfnutruttura: la classe astratta.
5.2.1 Classi astratte Una classe astratta {abstract class) è una classe aUa quale viene consentito di avere metodi astratti, oltre che metodi definiti. I metodi astratti devono essere definiti in ciascuna SUI sottoclasse (a meno che non sia a sua volta astratta). Ecco lo scheletro di una classe astratta:
public abstract class Parent { /♦* * Restituisce l’oggetto di tipo String "I am
*
* greturn "I am
JAVA COLLECTIONS F r AMEWORK
151
public String getPrefix() { retum "I am } // metodo getPrefix ♦ Restituisce un oggetto di tipo String.
♦
* g)return un oggetto di tipo String.
* V
public abstract String getClassName(); } // classe Parent
Una classe astratta è identificata dal modificatore abstract presente all’inizio della sua Achiarazione e, all’interno di una classe astratta, l’intestazione di ogni metodo astratto lirvc contenere il modificatore abstract prima del tipo restituito, con un punto e virgola a chiudere l’intestazione stessa. Dal momento che alla classe Parent manca la definizione di lino dei suoi metodi, non se ne possono creare esemplari (o, come anche si dice, non la si può distanziare”). Non possiamo, quindi, definire un oggetto di tipo Parent: Parent p = nei Parent(); // non si può perché Parent è astratta
A questo punto possiamo dichiarare due sottoclassi di Parent: Childl e Child2. public class Childl.extends Parent ( * Restituisce l'oggetto di tipo String "Childl".
*
* g)return l'oggetto di tipo String "Childl".
♦ V
public String getClassName() { retum "Childl"; } // metodo getClassName ) // classe Childl
public class Child2 extends Parent /** * Restituisce l'oggetto di tipo String “Child2".
* * greturn l'oggetto di tipo String "Child2". * ♦/
public String getClassName() return "Child2"; } // metodo getClassName
152
Capitolo 5
} // classe Child2 Come principale vantaggio, i metodi astratti favoriscono la flessibilità (perché i metodi definiti possono essere sovrascritti nelle sottoclassi, ma non è necessario che lo siano) e la coerenza (perché le intestazioni dei metodi astratti devono essere identiche nelle sottoclassi). Ad esempio, a questo punto possiamo fare quanto segue: Parent p;
«
int code; // assegna un valore alla variabile code if (code == 1) p = neM ChildlO; else p = new Child2(); System.out.println(p.getPrefix() + p.getClassName()); La variabile p è un riferimento polimorfico, per cui la versione del metodo getClassName che viene effettivamente invocata dipende dal tipo (Childl o Child2) dell’oggetto a cui p fa riferimento: verrà visualizzata la scritta“1 am C hildl” oppure“I am Child2”, dipendt'ntemente dal valore della variabile code. L’infrastruttura Java CoUections Framework contiene un piccolo numero di classi astratte: AbstractCollection, AbstractList, AbstractSet e poche altre. Come è ovvio, ciascuna di queste classi dichiara come astratti quei metodi la cui definizione dipende da campi presenti nelle sottoclassi, mentre definisce tutti quei metodi che non ne dipendono. Aggiungiamo, ora, alcuni dettagli sulle relazioni esistenti tra interfacce, classi astratte e classi completamente definite: 1. Se una classe implementa alcuni metodi di un’interfaccia, ma non tutti, allora dovrà essere dichiarata astratta e, quindi, non se ne potranno creare esemplari. 2. U n’interfaccia può estendere una o più altre interfacce. Ad esempio, possiamo scrivere: public interface Container extends Collection^ Comparable { Container ha propri metodi astratti ed eredita anche i metodi astratti presenti nelle interfacce Collection e Comparable. 3. Una classe può estendere al massimo una sola altra classe e, in mancanza di un’in dicazione esplicita di estensione, la classe Object è la superclasse di qualsiasi classe. Vereditarietà multipla, cioè la possibilità che una classe abbia più di una superclasse diretta, non è lecita in Java: una scelta di progetto, per evitare i pericoli derivanti da ambiguità. Ad esempio, considerando che un assistente didattico possa essere tanto uno studente quanto un dipendente, potremmo definire una classe TeachingAssistant come sottoclasse diretta delle classi Student e StaffMember. Supponiamo, ora, che ciascuna delle due classi, Student e StaffMember, definisca un proprio metodo getHolidays(). Scrivendo:
JAVA COLLEaiONS FRAMEWORK
TeachingAssistant teacher
153
TeachingAssistantO ;
quale metodo getHolidays() viene effettivamente invocato quando scriviamo teacher. getHolidaysO? Non c’è modo di saperlo ed è per questo motivo chejava ha dichiarato fuori legge l’ereditarietà multipla. Il linguaggio C + + , invece, consente l’ereditarietà multipla, ma per renderla operativa sono necessarie complesse regole che eliminino, appunto, le ambiguità. 4. Una classe può implementare più interfacce. Ad esempio, possiamo scrivere:
class NewClass inplements Interfacei, Interface2 Quest’ultima caratteristica, specialmente se combinata con la precedente, ci consente di realizzare una situazione molto simile all’ereditarietà multipla, perché, ad esempio, possiamo scrivere:
class NewClass extends OldClass iiiplaiients Interfacel^ Interface2 { Quando un metodo viene invocato non c’è alcuna ambiguità, perché tutti i metodi delle interfacce sono astratti e qualunque metodo della superclasse (che non sia stato dichiarato final) può essere esplicitamente sovrascritto (cioè ridefinito) in una sottoclasse. Suppo niamo, ad esempio, che OldClass, Interfacel e Interface2 contengano, tutte, un metodo writeOut(). Se scriviamo: NewClass myStuff = new NewClass();
myStuff.writeOut(); quale versione del metodo writeOut verrà invocata? Di certo non le versioni presenti in Interfacel e Interface2, perché quei metodi sono astratti. Se NewClass definisce un metodo writeOutO, verrà invocato quello, altrimenti verrà invocata la versione di writeOut definita in OldClass (o in essa ereditata).
5.2.2 Tipi parametrìci Quando, nel Paragrafo 5.1.1, sono state presentate le classi di tipo “raccolta”, abbiamo messo in evidenza che gli elementi devono essere di tipo riferimento: i tipi primitivi non sono consentiti. A partire dalla versione 5.0 dell’ambiente di sviluppo J2SE (Java 2 Standard Edition), il tipo di un elemento può essere specificato in una classe, racchiudendolo tra parentesi angolari, nel momento in cui di tale classe si dichiara un esemplare. Supponete, ad esempio, di voler dichiarare e inizializzare un oggetto di tipo ArrayList in modo che ospiti una raccolta di medie di valutazioni studentesche (grade point average, gpa)^ cisscum delle quali verrà memorizzata sotto forma di oggetto di tipo Doublé. Non avete bisogno di conoscere i dettagli della classe ArrayList, alcuni dei quali, comunque, scoprirete nel C'apitolo 7. E sufficiente dichiarare e inizializzare l’oggetto in questo modo: ArrayList gpaList » new ArrayList();
154
Capitolo 5
Ora, in gpaList possono essere inseriti soltanto elementi di tipo Doublé:qualunque tentativo di inserire un elemento di tipo String o Integer, ad esempio, sarà impedito dal compilatore. In conseguenza di ciò, siete certi che qualunque elemento venga estratto da gpaList sarà di tipo Doublé. Vediamo come funzionano le operazioni di inserimento e rimozione di elementi in gpaList. Nella classe ArrayList,il metodo add inserisce Telemento ricevuto come argomento alla fine dell'oggetto di tipo ArrayList su cui opera. Ad esempio gpaList.add(neM Double(2.7)); » inserirà al termine di gpaList un (riferimento a un) oggetto di tipo Doublé, il cui valore di tipo doublé è 2.7. Per l'ispezione del contenuto, il metodo get restituisce l’elemento che si trova, nell’og getto di tipo ArrayList, in corrispondenza dell’indice specificato. Quindi, possiamo accedere all'elemento di indice 0 in questo modo: Doublé gpa « gpaList.get(0); Si noti come non ci sia bisogno di effettuare un cast per convertire in Doublé l’espressione che si trova a destra deU'operatore di assegnazione, perché l’elemento che si trova in gpaList in corrispondenza dell’indice 0 deve essere di tipo Doublé. Supponiamo, ora, di voler aggiungere quel valore medio alla variabile sum di tipo doublé, inizializzata a 0.0.11 metodo doubleValue() della classe Doublé restituisce il valore di tipo doublé corrispondente all'oggetto Doublé con cui lo si invoca. L'assegnazione è,quindi: sum = sum + gpa.doubleValue(); In questo esempio, Array List è un tipo parametrico. Un tipo parametrico (parameterized type) è costituito dall'identificatore di una classe o di una interfaccia, seguito, tra parentesi angolari, da un elenco di uno o più identificatori di classe, separati da virgole. Solitamente, un tipo parametrico inizia con l’identificatore di una classe che rappresenta una raccolta, ed è il tipo degli elementi della raccolta ad essere racchiuso tra parentesi angolari. Un tipo parametrico viene a volte anche chiamato “tipo generico*’ (generic type) e la caratteristica del linguaggio che ne permette l’utilizzo si chiama “genericità’’ (^ewerics). Le classi che rappresentano raccolte di tipo parametrico migliorano la produttività dei programmatori, che non hanno bisogno di ricordare quale sia il tipo degli elementi di una raccolta, perché questo viene specificato nel momento in cui si dichiara la raccolta stessa, come abbiamo fatto scrivendo ArrayList. Se si fa un errore e si cerca di inserire, ad esempio, un elemento di' tipo String, si ottiene una segnalazione da parte del compila tore, mentre, in mancanza di tipi parametrici, l’inserimento errato sarebbe consentito, ma l'assegnazione a gpa del risultato del cast, (Double)gpaLìst.get(O), provocherebbe durante l'esecuzione il lancio dell’eccezione ClassCastException, che, se non catturata, farebbe terminare bruscamente il programma. Nell’esempio precedente, è un po’ noioso convertire ripetutamente dal tipo doublé al tipo Doublé e viceversa. Per rendere più semplice tutto ciò, soprattutto quando si lavora con raccolte parametriche, il compilatore Java converte automaticamente valori di tipi primitivi nei corrispondenti oggetti involucro, mediante una tecnica chiamata boxing (“inscatolamen to’’o “impacchettamento’’). Ad esempio, in questo modo si può realizzare l’inserimento in gpaList:
JAVA COLLEaiONS F r AMEWORK
155
gpaList.add(2.7); // invece di gpaList.add(new Double(2.7));
La tecnica di unboxing effettua la conversione contraria, trasformando un oggetto involucro nel corrispondente valore di tipo primitivo. Così, ad esempio, per aggiungere alla variabile sum di tipo doublé, vista in precedenza, il valore dell*oggetto gpa di tipo Doublé, scriviamo semplicemente: sum = sum + gpa; // invece di sum * sum + gpa.doubleValue();
In questo modo non c è bisogno di invocare il metodo doubleValue() e il codice risulta di più facile lettura. L’idea generale che sottende i tipi parametrici e le tecniche di boxing/unboxing è quella di semplificare il lavoro dei programmatori, assegnando al compilatore alcuni compiti che sarebbero, altrimenti, stati di loro competenza. Il paragrafo seguente presenta l’asse portante dell’infrastruttura Java Collections Framework: l’interfaccia Collection.
5.23
Linterfacda Collection
L’infrastruttura di cui ci stiamo occupando (Java Collections Framework) è costituita, in pratica, da una gerarchia, che contiene classi astratte e interfacce a ogni livello, tranne l’ultimo, dove ovviamente sono presenti soltanto classi che implementano interfacce e/o estendono classi astratte. In cima alla gerarchia troviamo due interfacce: Collection e Map. In questo paragrafo ci concentreremo sull’interfaccia Collection, presentata, secondo la notazione UML, nella Figura 5.5, con i metodi elencati in ordine alfabetico. Non vi preoccupate se l’intestazione di alcuni metodi vi appare parzialmente (o totalmente) in comprensibile: imparerete tutto ciò che vi serve nei capitoli successivi, quando studieremo le implementazioni di questa interfaccia. Come si vede nella Figura 5.5, l’interfaccia Collection usa E (che sta per “elemento”) come parametro di tipo (type parameter), che viene sostituito da una classe effettiva, come Doublé o FullTimeEmployee, nel momento in cui si dichiara un esemplare di una classe che implementi l’interfaccia. Ad esempio, la parte iniziale dell’intestazione della classe ArrayList è:
public class ArrayList inplenents Collection ... ed ecco un esemplare della classe ArrayList, pronto ad accogliere elementi di tipo FullTimeEmployee: ArrayList employeeList = new ArrayList();
In questo esempio, FullTimeEmployee è la classe effettiva di cui gli elementi della raccolta saranno esemplari, cioè la classe che sostituisce il parametro di tipo, E, nel momento in cui si crea un esemplare della classe ArrayList. Volendo, potreste progettare una vostra classe che realizzi compiutamente l’interfaccia Collection, magari definendo effettivamente soltanto alcuni metodi e facendo in modo che i rimanenti si limitino a lanciare un’eccezione, ad esempio così:
156
C apitolo 5
I
I
-II ^E I'
«interface» Collection
•¥ add(element: E): boolean + addAll(c: Collection): boolean equals(obj: Object): boolean + hashCode(): Int + isEmptyO: boolean + iteratolo : Iteratole E> + remove(obj: Object): boolean + removeAll(c: Collection): boolean + retainAll(c: Collection): boolean + size(): Int + toArrayO: Object[ ] + toArray(a: T[ ]): T[ ] FÌ9ura 5.5 Linterfacda Collection: in UMU un parametro di tipo, come E, viene evidenziato da un rettangolo tratteggiato nell'angolo superiore destro dellinterfaccia o della classe.
public int hashCodeO throw neM UnsupportedOperationException(); Definizioni come queste soddisfano il compilatore, per cui è possibile creare esemplari della classe che ne risulta, ad esempio ArrayCollection. Si può, quindi, creare e inizializzare un oggetto di tipo ArrayCollection: ArrayCollection collection = new ArrayCollection();
5.23.1
Iteratori
L’interfaccia Collection definisce un nucleo di metodi utili per molte applicazioni, ma ciascuna di esse dovrà svolgere, quasi certamente, alcuni compiti specifici, che non corri-
JAVA COLLEaiONS Fr AMEWORK
157
spendono ad alcuno dei metodi deirinterfaccia Collection. Nota importante. Negli esempi che seguono,“oggetto Collection” è un*abbreviazione per “oggetto di una classe che im plementa l’interfaccia Collection”, così come “classe Collection” è un’abbreviazione per “classe che implementa l’interfaccia Collection”. 1. Dato un oggetto Collection di studenti, visualizzare gli studenti migliori (quelli che appartengono alla Dean's Ust), 2. Dato un oggetto Collection di parole, determinare quante di esse sono composte di quattro lettere. 3. Dato un oggetto Collection di membri di un club, aggiornare le quote dovute da ciascuno di essi. 4. Dato un oggetto Collection di dipendenti a tempo pieno, calcolare il loro salario medio. Non possiamo certamente progettare una classe che metta a disposizione tutti i metodi necessari a svolgere tutti i compiti richiesti da qualsiasi applicazione: il numero di questi metodi sarebbe illimitato. Notiamo, però, che, in ciascuno degli esempi qui citati, il com pito da svolgere richiede l’accesso a tutti gli elementi di un oggetto Collection, uno dopo l’altro. Questo ci suggerisce che dobbiamo consentire agli utilizzatori di una classe Collec tion di progettare un ciclo che acceda a tutti gli elementi di un oggetto Collection, uno dopo l’altro. Come vedremo quando studieremo le classi che implementano l’interfaccia Collection, i loro progettisti possono scrivere un ciclo di questo tipo molto agevolmente: perché? Perché il progettista di una classe ha accesso ai campi della classe stessa e sa come questa è organizzata al proprio interno, potendo cosi facilmente eseguire un’iterazione che coinvolga, uno dopo l’altro, tutti gli elementi di un esemplare della classe. Secondo il Principio dell’Astrazione per i Dati, il codice scritto
Per ogni classe che implementa Tinterfaccia Collection, la relativa classe iteratore fornisce i metodi che consentono di scandire gli elementi di qualsiasi suo esemplare. In altre parole, gli iteratori agiscono dietro le scene e consentono agli utilizzatori di una classe Collection di accedere a tutti gli elementi di un suo esemplare. Come possiamo associare un oggetto di tipo iteratore a un oggetto Collection? Il metodo ite ra to r() dell'interfaccia Collection crea proprio questo collegamento, e queste sono le sue specifiche: * Restituisce un oggetto di tipo Iterator che agisce su questo * oggetto di tipo Collection.
*
* jhreturn un oggetto di tipo Iterator che agisce su questo *
*
oggetto di tipo Collection.
Iterator iterator();
11 valore restituito è un (riferimento a un) oggetto di tipo Iterator, cioè a un oggetto che è esemplare di una classe che implementa l'interfaccia Iterator. Con l'aiuto di questo metodo, l'utilizzatore di un oggetto Collection può scandirne gli elementi.Ad esempio, supponiamo che myColl sia un (riferimento a un) esemplare di una classe Collection contenente elementi
JAVA LOLLECTIONS hRAMEWORK
di tipo String e che si vogliano visualizzare tutti i suoi elementi che iniziano con la lettera ‘a*. Per prima cosa creiamo un oggetto iteratore: Iterator itr = myColl.iterator();
La variabile i t r è un riferimento polimorfico: vi si può assegnare un riferimento a un og getto che sia esemplare di qualunque classe che implementa l’interfaccia Iterator. L’invocazione di myColl.iterator() restituisce proprio un riferimento a un tale oggetto, di tipo Iterator, posizionato aU’inizio della raccolta a cui fa riferimento myColl. A questo punto, eseguire la scansione è veramente semplice: String word;
Mhile (itr.hasNextO) {
word = itr.nextO; if (word.charAt(o) == 'a') System.out.println(word); } // while
Riuscite, invece, a capire cosa ci sia di sbagliato nel ciclo seguente? // sbagliato!
while (itr.hasNextO) if (itr.next().charAt(0) == 'a') System.out.printIn(itr.next());
Per effetto delle due invocazioni di i t r .next (), se la parola esaminata all’interno di un’ite razione del ciclo inizia con la lettera *a’, verrà visualizzata la parola successiva. Molto spesso accade che tutto ciò che si vuol fare durante uno di questi cicli è ispezionare gli elementi della raccolta, uno dopo l’altro. Per queste situazioni,Java mette a disposizione un enunciato for avanzato {enhanced, a volte chiamato enunciato for-each, “per ogni’’, in analogia con gli enunciati presenti in altri linguaggi di programmazione) e, ad esempio, la precedente (corretta) scansione degli elementi presenti in myColl si può abbreviare in questo modo:
for (String word : myColl) if (word.charAt(o) == ’a*) System.out.println(word);
Il carattere “due punti’’ all’interno dell’enunciato si dovrebbe leggere “in’’, in modo che la sezione di controllo di questo enunciato di iterazione si possa leggere “per ogni word in myColl’’. L’effetto prodotto dall’esecuzione di questo frammento di codice è identico al precedente, ma parte del duro lavoro è stato delegato al compilatore: creazione e inizializzazione dell’iteratore, nonché invocazione dei metodi hasNext() e next(). Ecco, infine, un esempio completo di scansione di un oggetto Collection per mezzo di un enunciato for avanzato. Non c’è bisogno di conoscere i dettagli relativi alla classe ArrayLìst, la particolare implementazione dell’interfaccia Collection usata in questo esempio: li imparerete nel Capitolo 7. Per semplicità, un unico blocco cattura le eccezioni ArithmeticException e InputMismatchException.
160
C apitolo 5
// Calcola il valor medio delle valutazioni medie
import java.util.*; publlc class EnhancedFor { public static void main(String[ ] args) new EnhancedFor().run(); } // metodo main public void run() { final doublé MIN ORA = 0.0, MAX_GPA = 4.0, SENTINEL = -1.0;
final String INPUT_PROMPT = "Please enter a CPA in thè range" + " from " + MIN_CPA + " to " + MAX_CPA + ", inclusive (or " + SENTINEL + " to quit):
final String RANGE ERROR > "The grade point average must" * " be at least " + MIN_GPA + " and at most " + MAX~GPA +
final String MESSAGE = “\n\nThe mean GPA is final String NO_VALID_INPUT = "\n\nError: there were no valid " + "grade-pòint-averages in thè input."; ArrayList gpaList » neu ArrayList(); Scanner se « new Scanner(System.in);
doublé oneOPA, sum > 0.0;
ubile (true) { try {
System.out.print(INPUTPROMPT ); oneCPA = sc.nextDouble(); if (oneCPA SENTINE!)
break; if (oneCPA < MIN_CPA || oneCPA > MAX_GPA) throw new ArithmeticException(RANGE ERROR); gpaList.add(oneGPA); // inserisce alla fine di gpaList } // try catch(Exception e)
{
System.out.println(e + "\n"); sc.nextLineO; } // catch Exception } // while for (Doublé gpa : gpaList)
JAVA COLLECTIONS F r AMEWORK
161
sum += gpa;
if (gpaList.sizeQ > o) System.out.println(MESSAGE + (sum / gpaList.size()));
else System.out.println(NO_VALID_INPUT); } // metodo run } // classe EnhancedFor
Il ciclo for avanzato semplifica il codice e rende i programmi più comprensibili, per cui lo dovreste usare ogni volta che questo è possibile, cioè quando usereste un iteratore di cui verrebbero invocati soltanto i metodi hasNext() e next(). Non si può, però, usare un enunciato for avanzato in quei casi in cui la raccolta su cui si opera può subire modifiche durante Tesecuzione del ciclo. Se, ad esempio, volessimo eliminare da gpaList tutti i valori minori di 1.0, dovremmo usare un iteratore in modo esplicito: Iterator itr - gpaList.iterator();
while (itr.hasNextO) if (itr.nextO < l.O) itr.removeO;
5.23.2 Schemi di progetto (design pattern) Nel paragrafo precedente abbiamo enunciato un problema: come può il progettista di una classe Collection fare in modo che i suoi utilizzatori possano eseguire un*iterazione che coinvolga, uno dopo Taltro, tutti gli elementi presenti in un suo esemplare, senza violare il Principio deir Astrazione per i Dati? La soluzione del problema ha previsto l’impiego di un iteratore e, in questo senso, l’uso di iteratori è un esempio di schema di progetto {design pattern, locuzione spesso usata anche in italiano), cioè di una tecnica di programmazione generale che può trovare applicazione in un vasto ambito di situazioni. Come vedremo nei prossimi capitoli, lo schema “iteratore” gioca spesso un ruolo importante. Nel testo, identificheremo diversi schemi progettuali e indicheremo le applicazioni in cui vengono utilizzati. L’idea fondamentale è che ciascuno schema si adatta a un problema frequente e ne delinea una soluzione: può darsi che si debba “aggiustare” la soluzione per un particolare esemplare del problema, ma almeno non c’è bisogno di reinventare la ruota. Nel prossimo paragrafo introdurremo brevemente un’estensione dell'interfaccia Collection e tre classi che implementano tale estensione.
5.2.4 linterfaccia List L'interfaccia List deljava Collections Framework estende l'interfaccia Collection, aggiun gendo alcuni metodi relativi all'uso di indici. Per esempio, contiene un metodo get, che restituisce l'elemento corrispondente a un determinato indice. In ogni oggetto List (cioè, come al solito, “in ogni esemplare di una classe che implemenu l'interfaccia List”) gli clementi sono memorizzati in sequenza, in base a un indice. Ad esempio, un oggetto List, il cui riferimento sia memorizzato nella variabile pets, potrebbe avere gli elementi disposti in questo modo: “dog”, “cat”, “iguana”, “gerbir',“cat”. Quindi, “dog” si trova all'indice 0, mentre “gerbil” è associato all'indice 3. Sono consentiti elementi duplicati: infatti, “cat” è presente tanto all'indice 1 quanto all’indice 4.
162
C apitolo 5
AbstractList
--------ZS--------! E !
J
I E 1
ArrayList »— p- LinkedList»— p-
- i'n
Stack *— r-
Figura 5.6 Porzione della gerarchla del Java Collections Framework che riguarda {Interfaccia List (in U M U lldentlficatore di una classe astratta siscrive In corsivo).
Vista come entità indipendente dal linguaggio di programmazione, una lista è un tipo di dato astratto. In Java, Tinterfaccia List è astratta, nel senso che non è legata ad alcuna particolare implementazione: in effetti, nel java CoUections Framework, Tinterfaccia List non ha alcuna implementazione diretta, mentre troviamo la classe astratta AbstractList che la implementa soltanto parzialmente e lascia il compito di completarne la realizza zione alle proprie sottoclassi, che sono ArrayList e LinkedList, come si può vedere nella Figura 5.6. La classe ArrayList realizza l’interfaccia List mediante un array-, mentre la classe Lin kedList realizza Tinterfaccia List usando la struttura concatenata che abbiamo visto nella Figura 5.3. Ne esamineremo i dettagli, rispettivamente, nei Capitoli 7 e 8. Per darvi un’idea dell’utilizzo di alcuni dei metodi presenti nelle due classi, la classe seguente crea e manipola un oggetto List contenente numeri interi casuali. iMpozt java.util.*^; public class RandomList
{
public staile void main(String[ ] args)
{
new RandomList().run(); } // metodo main
Anche la classe Stack realizza l’interfaccia L ist mediante un array, ma la definizione di stack (in italia no, pila, nel senso di catasta) restringe l’accesso all’array, per cui in questa trattazione ignoreremo la classe
2
stack.
JAVA COLLEaiONS FRAMEWORK
163
publlc void run() final int SEED ■ ili; List randLlst ■ ncM ArrayList(); Randcn r - naw Randon(SEEO); // inserisce in landList 10 numeri interi casuali, appartenenti // all'intervallo 0...99 far (int i - 0; i < 10; ì-h -) randList.add(r.nextInt(iOO)); // inserimento // visualizza randList System.out.println(randList);
U controlla se 22 appartiene a randList if (randList.contains(22)) Systen.out.println("Yes, 22 is in randList.*); else System.out.println(”No, 22 is not in randList.”); // visualizza il numero intero associato all'indice 3 Systeffl.out.println(randList.get(3) -•■ * is at index 3*); // elimina il numero intero associato all'indice 6 randList.remove(6);
H inserisce un nuovo numero casuale all'indice S randList.add(5, r.nextlnt(lOO)); // visualizza.randList System.out.println(randList); // elimina tutti i numeri pari Iterator itr - randList.iterator(); Hhile (itr.hasNextO)
i f (itr.nextO %2 — 0) itr.removeO;
,,
// visualizza 'randList System.out.println(randList); } // metodo run } // classe RandomList
La riga System.out.println(randList); è equivalente a System.out.println(randList.toString());
Il metodo toString restituisce un oggetto di tipo String che “descrive” randList. Ogni classe del Java Collections Framework ha un metodo toStringO , in modo che tutti gli
164
C apitolo 5
elementi di un esemplare di tali classi possano essere visualizzati con una singola invo cazione di println. Dato che un oggetto di tipo ArrayList memorizza i propri elementi in un array, quando l’elemento corrispondente all’indice 6 viene rimosso, tutti gli elementi associati a indici d\ valore maggiore vengono spostati nella posizione corrispondente all’indice inunediatanientc precedente, in modo che l’elemento che si trovava all’indice 7 viene spostato all’indice (k l’elemento che si trovava all’indice 8 viene spostato all’indice 7, e così via. Quando viene inserito un nuovo elemento, associandolo all’indice 5, tutti gli elementi che si trovano in posizioni di indice maggiore o uguale a quello venjgono spostati nella posizione corri spondente all’indice immediatamente successivo, in modo che l’elemento che si trovava all’indice 5 viene spostato all’indice 6, l’elemento che si trovava all’indice 6 viene spostato all’indice 7, e così via. L’esecuzione del programma visualizza: [93, 70, 57, 97, 9, 20, 84, 12 , 97, 65] No, 22 is not in randList. 97 is at index 3 [93, 70, 57, 97, 9, 60, 20, 12, 97, 65] [93, 57, 97, 9, 97, 65]
Non avremmo potuto usare un enunciato for avanzato per scandire randList, perché dobbiamo eliminare alcuni degli elementi contenuti nella lista e non soltanto ispezionarli. Nel programma appena visto, la variabile randList è stata dichiarata come riferimento polimorfico e, quindi, immediatamente inizializzata con un riferimento a un oggetto di tipo ArrayList. Per eseguire nuovamente il programma usando un oggetto di tipo LinkedList, l’unica modifica necessaria è l’invocazione del costruttore: List randList = nai LinkedList();
Quale delle due versioni si comporta meglio? Una parte del programma, quella che vi sualizza il numero associato all’indice 3, viene eseguita più velocemente quando si usa un oggetto di tipo ArrayList, a causa dell’accesso casuale consentito dall’array sottostante. Un’altra parte del programma, quella che elimina tutti gli elementi che sono numeri pari, viene eseguita più velocemente quando si usa un oggetto di tipo LinkedList, perché per rimuovere un nodo da una lista concatenata è sufficiente modificare alcuni collegamenti: non è necessario alcuno spostamento di elementi. In generale, non esiste “la migliore” implementazione dell’interfaccia List.
Riepilogo Una raccolta (collection) è un oggetto composto da elementi. Gli elementi possono essere contij^ui in memoria, cioè posizionati in locazioni contigue, oppure si può usare una struttura concatenata (linked), nella quale ciascun elemento viene memorizzato in uno speciale oggetto, detto entry o nodo, contenente anche un riferimento a un altro nodo. Una classe (che rappresenta una) raccolta è una classe i cui esemplari rappresentano raccolte di elementi. Il Java Collections Framework, che fa parte del pacchetto java.util, contiene alcune classi raccolta utilizzabili in svariate applicazioni. Ciascuna di queste classi può essere
JAVA COLLECTIONS Fr AMEWORK
165
parametricay per cui la classe di cui gli elementi sono esemplari può essere specificau nel momento in cui viene creato un esemplare di raccolta. Inoltre, per ogni esemplare di queste classi si può definire un iteratore, che è un oggetto che consente la scansione degli elementi di una raccolta mediante un ciclo che esamina tutti i suoi elementi, senza per questo violare il Principio dell’Astrazione per i Dati. Per rendere più semplice, per i programmatori, l’inserimento di elementi in un esem plare di una di queste classi parametriche, Java “avvolge” automaticamente valori di tipo primitivo nei corrispondenti elementi involucro. Analogamente, estrae automaticamente i valori di tipo primitivo contenuti negli elementi involucro estratti da un esemplare di queste classi parametriche. U n’ulteriore semplificazione consentita da java è l’uso dell’enunciato for avanzato, che automatizza la maggior parte del codice necessario all’accesso ai singoli elementi durante una scansione. L’interfaccia Collection contiene le specifiche di 15 metodi, dedicati all’accesso e alla manipolazione di un esemplare di una classe che implementi l’interfaccia stessa. L’interfaccia List aggiunge all’interfaccia Collection alcuni metodi relativi all’accesso agli elementi di una raccolta mediante un indice, e viene parzialmente implementata dalla classe astratta AbstractList, per poi essere realizzata completamente dalle classi ArrayList c LinkedList.
Etertiri di teoria_______________________________ 5.1 Cos’è una raccolta? Cos’è una classe che rappresenta una raccolta? Cos’è una classe Collection? Descrivere una raccolta che non sia un esemplare di una classe che rappresenta una raccolta. Il Progetto di programmazione 5.1 fornisce un esempio di una classe che rappresenta una raccolta senza essere una classe Collection. 5.2 Un array è una raccolta, pur non essendoci alcuna classe che descrive gli array. Inoltre, un array di oggetti può essere convertito in un esemplare della classe ArrayList. Esaminare il file ArrayList.java, nel pacchetto java.util, per identificare l’algoritmo generico (che è un metodo statico) che converte un array di oggetti in un esemplare della classe ArrayList che contiene quel tipo di oggetti. Come si può visualizzare il contenuto di quest’ultimo oggetto (di tipo ArrayList) senza utilizzare un ciclo? 5.3 a. Identificare ciascuna delle seguenti entità come interfaccia o classe: Collection LinkedList Iterator AbstractList Qual è la dififerenza tra interfaccia e classe astratu? c. A cosa serve una classe astratta? Cioè, in che modo una classe astratta può au mentare la produttività dei programmatori? 5.4 Cos’è una lista? b.
166
CAPITOLO 5
Esercizi di programmazione 5.1 Per ciascuno dei due casi seguenti, creare e inizializzare un esemplare parametrico,
aggiungervi due elementi e visualizzarne il contenuto: a. Un oggetto (scoreList) di tipo ArrayList, contenente elementi di tipo Integer;
b. Un oggetto (salaryList) di tipo LinkedList, contenente elementi di tipo Doublé. 5.2 Progettare un metodo main in cui vengono creati due oggetti di tipo ArrayList, uno
contenente elementi di tipo String e Taltro contenente elementi di tipo Integer. In ciascuna lista, aggiungere tre elementi, rimuovere l’elemento di indice 1, aggiungere un elemento nella posizione di indice 0 e, infine, visualizzare l’intera lista. 53 Trovare un metodo di ArrayList (che non sia un costruttore) che non sia presente nella classe LinkedList.Trovare un metodo di LinkedList (che non sia un costruttore) che non sia presente nella classe ArrayList. 5.4 Quali messaggi d’errore vengono generati dal seguente frammento di codice? Quando (durante la compilazione o durante l’esecuzione)? Verificare le ipotesi fatte. LinkedList team = new LinkedList(); team.add("Garcia"); Iterator i tr = team.iterator(); Integer player = itr.next(); 5.5 Utilizzare tre volte la classe ArrayList. Dapprima, creare un oggetto (teaml) di tipo
ArrayList, con elementi di tipo String, aggiungendovi tre elementi. Poi, creare un altro oggetto (team2) di tipo ArrayList, con elementi di tipo String, aggiungendovi quattro elementi. Infine, creare un oggetto (league) di tipo ArrayList, i cui clementi siano oggetti di tipo ArrayList, a loro volta con elementi di tipo String; aggiungere teaml e team2 a league.
Progetto di programmazione 5.1 Ricoprire il ruolo del progettista, poi quello dell'utilizzatore In questo progetto dovrete per prima cosa svolgere il ruolo del progettista di una classe parametrica, per poi diventarne utilizzatore. Per iniziare, ecco le specifiche dei metodi della classe paiametrica, Sfequence, dove E è il parametro di tipo. j /** * Inizializza questo oggetto di tipo Sequence in modo che sia vuoto, ♦ con una capacità iniziale di dieci elementi. «
publlc SequenceO
/** * Inizializza questo oggetto di tipo Sequence in modo che sia vuoto, * con la capacità iniziale specificata.
JAVA COLLEaiONS F r AMEWORK
167
* Spararti capacity la capacità iniziale di questo oggetto.
♦
* ^-throws IllegalArgumentException se capacity non è positiva. V public Sequence(int capacity)
* Restituisce il numero di elementi presenti in questo oggetto ^ di tipo Sequence. ¥ *
^return il numero di elementi in questo oggetto di tipo Sequence.
public int size()
Aggiunge r elemento specificato in fondo a qu(‘Sto oggetto di tipo ^ Sequence. ^ ^^param element Telemento da aggiungere in fondo a questo * oggetto di tipo Sequence.
♦ V
public void
append(E element)
/*' * Restituisce Telemento (orrispondente all'indice specificato in questo oggetto di tipo Sequence. La funzione worstTime(n) è * costante (n è il numero di elementi presenti in questo oggetto * di tipo Sequence). ¥
^ ^param k l'indice dell'elemento da restituire.
+
^return l'elemento corrispondente all'indice k in questo oggetto di tipo Sequence.
>•« * @>throws IndexOutOfBoundsException se k è un valore negativo * oppure maggiore o uguale al numero di elementi presenti * in questo oggetto di tipo Sequence. « */
public E get(iflt k) * Modifica releiaento che si trova in corrispondenza dell'indice * specificato all'interno di questo oggetto di tipo Sequence. * La funzione MorstTine(n) è costante (n è i l ramerò di * elementi presenti in questo oggetto di tipo Sequence). * ^aram k l'indice dell'elepiento restituito. * #param newElement l'elemento che va a sostituire quello che si * trova in corrispondenza dell'indice k in questo oggetto *
di tipo Sequence.
168
CAPITOLO 5
* ikhrm» * * *
oppuié Maggiore o uguale al ntnezo di eleMenti presenti in. questo oggetto di tipo Sequence.
public veld set(in t k, E newElefflent) Parte 1 Pn^ttare collaudi di unità basati sulle specifiche dei metodi, usando g|i stub òppottuni. Parte 2 Definite i metodi ndla classe Sequence. Siiggerimento. Usate i campi seguenti: protected E[ ] data; pnrtectad lirt sire; / / i l numero di elementi nella sequenza, / / non la capacità dell'array data Con rifèrimento al metodo append.se Tariay data è pieno.la sua d ^ d tà deve essete aumentata prima che vi si possa inserire il nuovo elemento; rivedere l’Esercizio di programmazione 3.10 prima di realizzate l’espansione richiesta. N òta i .
N ota 2. I metodi che possono lanciate un’eccezione non devono avete blocchi catch, ma devono propagate l’eccezione, in modo che la sua gestione possa essete decisa da chi realizza un’ap^licazipne.
Parte 3 Collaudate i metodi definiti nella classe Sequence.
6 Rìcorsìone
Obiettivi del capitolo Riconoscere le caratteristiche di quei problemi per i quali le soluzioni ricorsive p ossono essere appropriate. Confrontare metodi ricorsivi e iterativi sulla base del tem po di esecuzione, dell'occupazione di memoria e della facilità di sviluppo. Seguire passo d o p o passo l'esecuzione di un m etodo ricorsivo con l'ausilio delle "im m agini d'esecuzione" {e x e c u tio n fra m e s ). Capire lo schema progettuale detto b a c k tr a c k in g ("procedere a ritroso").
170
C apitolo 6
6.1 Introdmione In parole povere, un metodo è ricorsivo se contiene un’invocazione di se stesso^ Da questa descrizione, potreste avere inizialmente il timore che l’esecuzione di un metodo ricorsivo porti a una sequenza infinita di invocazioni ricorsive, ma, normalmente, questa calamità non avviene e, prima o poi, la sequenza di invocazioni termina. Ecco, infatti, lo schema del corpo di un tipico metodo ricorsivo, che mette in evidenza come i metodi ricorsivi terminino:
if
(caso sem p lic e ) r i s o l v i d ire tta m e rìte
,
else f a i un^invocazione r i c o r s iv a in un caso p iù sem p lice
Questo schema suggerisce che la ricorsione dovrebbe essere presa in considerazione ogni qualvolta il problema da risolvere possiede queste due caratteristiche: 1. Alcuni casi più semplici (eventualmente soltanto uno) possono essere risolti in modo diretto. 2. I casi complessi del problema possono essere ricondotti a uno dei casi semplici, avente la stessa forma del problema originale. Per inciso, se conoscete il Principio di Induzione Matematica, avrete probabilmente notato che queste due caratteristiche corrispondono, rispettivamente, al caso base e al caso induttivo (il Paragrafo A2.5 dell’Appendice 2 tratta l’induzione matematica). Esaminando gli esempi che seguono, non fatevi chiudere la mente da un vecchio modo di ragionare. Dopo aver enunciato un problema, cercate di immaginare una soluzione in termini di problemi più semplici ma aventi la stessa natura. Pensate ricorsivamente!
6.2 Fattoriale Dato un numero intero positivo, fi, il fattoriale di fi, che si scrive «!, è il prodotto di tutti i numeri interi compresi tra fi e 1, estremi compresi. Ad esempio 4! = 4 * 3 * 2 * 1 = 2 4 e 6! = 6 ^ 5 * 4 * 3 * 2 * 1 = 7 2 0 Il fattoriale di 4 si può anche calcolare in questo modo: 4! = 4 * 3! Quest’ultima formula non è di alcun aiuto se non si conosce il valore di 31, ma possiamo continuare a calcolare fattoriali in termini di fattoriali più piccoli (attenzione!): ' Più avanti, nel capitolo, daremo una definizione formale di “ricorsivo”.
R icorsione
171
3! = 3 ♦ 2! 2! = 2 » 1! A questo punto osserviamo che 1! si può calcolare in modo diretto: il suo valore è 1. Ora, basta procedere a ritroso per calcolare 4!:
2! = 2 * 1! = 2 * 1 = 2 3! = 3 * 2 ! = 3 * 2 = 6 Per ottenere, finalmente: 4! = 4 * 3! = 4 * 6 = 24 In generale, per n > 1, riduciamo il problema del calcolo di n\ al problema del calcolo di (fi - 1)1 e ci fermiamo quando arriviamo a li,che vale semplicemente 1 (per completezza^, possiamo anche definire che 0! valga 1). Prima di descrivere, collaudare e definire il metodo factorial, dobbiamo fare un'ulti ma considerazione: cosa possiamo dire sulle eccezioni? Se fi è minore di zero, dovremmo lanciare un’eccezione, e IllegalArgumentException ci pare adeguata. Inoltre, dato che fi! è una funzione esponenziale in fi, il valore di fi! sarà maggiore di Long.MAX_VALUE anche per valori di fi non molto grandi. In effetti, 21! > Long.MAX_VALUE, per cui dovremmo lanciare IllegalArgumentException anche per n > 20. Ecco le specifiche del metodo: /** Calcola il fattoriale di un numero intero non negativo^ cioè il prodotto di tutti i numeri interi compresi tra 1 e il numero dato, estremi inclusi. La funzione worstTime(n) è 0(n), dove n è il numero intero dato. Jparam n il numero intero di cui si calcola il fattoriale, greturn il fattoriale di n.
♦ ^hrows IllegalArgumentException se n è minore di 0 oppure ♦
♦ ♦/
maggiore di 20 (perché 2ll > Long.MAX_VALUE).
public static long factorial(int n) Come si può notare, l’intestazione del metodo factorial contiene il modificatore static (visto nel Paragrafo 3.1 del Capitolo 3). Perché? Tutte le informazioni che servono al fun zionamento di questo metodo sono fornite dal suo parametro e l’unico effetto di un’in vocazione di questo metodo è il valore che restituisce, per cui un eventuale oggetto che lo invocasse non avrebbe effetto sul metodo, né ne sarebbe affetto. Come detto nel Capitolo 2, seguiamo il modello di sviluppo che prevede di iniziare dalla progettazione del collaudo. 2 I! calcolo di 0! è utile nello studio della probabilità: il numero di combinazioni di n oggetti presi a gruppi di ik è pari a n \/ {k \ (m - k)\). Q uando fi = fe, si ottiene ff!/((fi!) (0!)), il cui valore è 1 proprio perche 0! = 1; si nod, ovviamente, che il numero di combinazioni di n oggetd presi a gruppi di n deve essere 1.
172
C apitolo 6
quindi definiamo la classe di collaudo, basata soltanto sulle specifiche del metodo, ancor prima di definire il metodo stesso. Ecco la classe di collaudo, che pone particolare enfasi suUe condizioni limite e contiene al proprio interno il consueto stub: import org.junit.*; import static org.junit.Assert.*; import org.junit.runner.Result; import static org.junit.runner.DUnitCore.runClasses; imfwrt java.util.*; public class FactorialTest
{
public static void maln(String[ ] args)
{
Result result ■ runClasses(FactorialTest.class); System.out.prlntln("Tests run « " + result.getRunCount() + "\nTests failed • " + result.getFailuresO); } // metodo main
^Test public void factorialTestiO assertEquals(24, factorial(4)); } // metodo factorialTestl gTest public void factorialTest2()
{
assertEquals(l, factorial(O)); } // metodo factorialTest2
gTest public void factorialTestlO
{
assertEquals(l, factorial(l));
} // metodo factorialTestl
^Test public void factorialTest4()
{
assertEquals(2432902008176640000L, factorial(20)); } // metodo factorialTest4
@Test (expected • IllegalArgumentException.class) public void factorialTestsC)
{
assertEquals(2l); } // metodo factorialTestS
9Test (expected = IllegalArgumentException.class) public void factorialTest6()
{
assertEquals(-l); } // metodo factorialTestS
public static long factorial(int n)
{
R icorsione
173
throM neM UnsupportedOperationException(); } // metodo factorial } // classe FactorialTest
Come previsto, tutti i collaudi del metodo factorial falliscono. Definiamo, ora, il metodo factorial. Per motivi di efficienza, la verifica che il valore di n sia minore di 0 o maggiore di 20 dovrebbe avvenire una volta sola, piuttosto che du rante ciascuna invocazione ricorsiva. Per ottenere questo risultato, definiremo un “metodo involucro** (mapper) che lancia un*eccezione per valori di n esterni all*intervallo di valori accettabili e, altrimenti, invoca il metodo ricorsivo che calcola effettivamente n!. Ecco le definizioni dei due metodi.
public static long factorial(int n) final int MAXJNT « 20; // perché 211 > Long.MAX_VALUE final String ERROR^MESSACE = "The value of n must be >= 0 and * 0 and = 0 se e solo se key è stato trovato. ♦ é^throws ClassCastException se 1’array contiene elementi che non * sono reciprocamente confrontabili (ad esempio, ♦ stringhe e numeri interi) oppure se l’oggetto da cercare * non è confrontabile con gli elementi dell’array.
U I*’ ;,.1; ti
R icorsione
197
* $see Comparable ♦ gsee #sort(Object[ ])
* V
public static int binarySearch(Object[ ] a, Object key) In javadoCfil marcatore H T M L significa “codice”, significa “corsivo” e il carattere presente in una delle righe ^see crea un collegamento al metodo sort avente la firma
indicau e presente nello stesso documento che viene generato da javadoc; quella riga diventa: See also: sort(Object[ ])
La classe BinarySearchTest contiene, tra gli altri, il seguente metodo di collaudo (dove names è Tarray di elementi di tipo String della Figura 6.5): g)Test
public void binarySearchTest6() {
assertEquals(-ll, binarySearch(names, "Doseph'*)); } // metodo binarySearchTestó
Avendo come obiettivo Tutilizzo della ricorsione, ci concentreremo sul primo (first) e suirultimo (last) indice che identificano la regione oggetto della ricerca. AlFinizio, first ■ 0 e last = a.length - 1, per cui il metodo binarySearch che abbiamo descritto sarà un metodo involucro che, semplicemente, effettua la seguente invocazione:
retum binarySearch(a, 0, a.length - l, key); L’intestazione corrispondente al metodo così invocato è:
public static int binarySearch(Object[ ] a, int first, int last, Object key) Per definire questa seconda versione del metodo binarySearch, la strategia fondamentale è questa: confrontiamo l’elemento cercato (chiave) con l’elemento che si trova in posizione centrale nella regione in esame; se l’elemento centrale è minore della chiave, faremo una ricerca ricorsiva nella porzione di array che va dall’indice centrale + 1 aU’indice last; se l’elemento centrale è maggiore della chiave, faremo una ricerca ricorsiva nella porzione di array che va dall’indice f ir s t all’indice centrale - 1; altrimenti, l’elemento centrale è uguale alla chiave e abbiamo finito. Per il momento ipotizziamo che sia first last). Seguendo la strategia basilare che abbiamo appena delineato, co minciamo cercando l’indice della posizione centrale:
int mid = (first + last) » l; L’espressione a destra, nell’enunciato di assegnazione, usa l’operatore di scorrimento a destra che opera sui bit (right-shift bihvise operator), facendo scorrere a destra di un posto i bit che costituiscono la rappresentazione binaria di ( f ir s t + last). Questa operazione è equivalente a quella seguente, ma viene eseguita più velocemente:
198
C apitolo 6
int mid = (first + last) / 2;
^elemento centrale deU*array a si trova in corrispondenza dell’indice avente valore mid. Dobbiamo, ora, confrontare (l’elemento a cui fa riferimento) a [mid] con (l’elemento a cui fa riferimento) key. Il metodo compareTo è quello ideale per effettuare il con6x>nto, ma non è definito nella classe Object, usata come tipo per definire i riferimenti in gioco. Fortunata* mente, il metodo compareTo è però definito in qualunque classe che implementi l’interfaccia Comparable, per cui possiamo, mediante un cast, convertire ajmid] in un riferimento di tipo Comparable, per poi invocare il metodo compareTo, in questo modo: Comparable midVal » (Comparable)a[mid]; int comp = midVaLcompareTo(key);
Se il risultato del confix>nto è minore di zero, eseguiamo una ricerca binaria neUa porzione dell’array che va dall’indice mid + l all’indice last, restituendo il risultato di tale ulteriore ricerca, così: if (comp < o) retum binarySearch(a, mid + 1, last, key);
Altrimenti, se comp > 0, eseguiamo una ricerca binaria nella porzione dell’array che va dall’indice f ir s t aU’indice mid - 1, restituendo il risultato di tale ulteriore ricerca: if (comp > 0) retum binarySearch(a, first, mid - l, key);
Altrimenti, ancora, restituiamo mid, perché comp
0 e, quindi, a [mid] è uguale a key.
first
mid
last
a [mid]
kev
0
4
9
Ed
Fiank
Ada
a[0]
Ben
a [l]
Carol
a 12]
Davc
a (3]
Ed
•a [4]
Frank
a 15]
G erri
a [6]
Hclen
a [7]
iggy
a [8]
Joan
a [9]
Figure 6.5 Lo stato del programma alUnizio dell'esecuzione dellinvocazione binarySearch(names, 0,9, "Frank'O; l'elenco dei parametri è Object] ] a, Int fìrst, int last e Object key (per semplicità, abbiamo raffigurato names come se fòsse un array di oggetti di tipo String invece che un array di riferimenti a oggetti di tipo String).
R icorsione
199
Ad esempio, usiamo questa strategia per cercare “Frank” nell’array names di Figura 6.5, che mostra anche lo stato del programma quando il metodo binarySearch viene invocato per cercare “Frank”. L*assegnazione seguente mid = (first + last) » l;
fa in modo che mid assuma il valore (0 + 9)/2, cioè 4. L’elemento centrale, “Ed”, è minore di “Frank” (cioè lo precede neU’ordinamento lessicografico), per cui eseguiamo una ricerca binaria nella porzione che va da mid + l a last, con questa invocazione: binarySearch(a, mid + 1, last, key);
Al parametro first viene assegnato il valore deU’argomento corrispondente, cioè mid + 1. Poi, durante questa nuova esecuzione di binarySearch, l’enunciato seguente mid * (first + last) » l;
assegna a mid il valore (5 + 9)/2, cioè 7, per cui midVal corrisponde a “Helen”, come si può vedere nella Figura 6.6. L’elemento centrale,“Helen”, è maggiore di “Frank” (cioè lo segue nell’ordine lessicografico), per cui eseguiamo una ricerca binaria neUa porzione che va dall’indice 5 all’indice 6, con questa invocazione: binarySearch(a, first, mid - i, key);
Al parametro last viene assegnato il valore dell’argomento corrispondente, cioè mid - 1. Poi, durante questa nuova .esecuzione di binarySearch, l’enunciato seguente mid = (first + last) » 1; first 5
mid 7
last
a Tmidì
key
9
Helen
Frank
Ada
a 10)
Ben
*m
Carol
a [21
Dave
a (3)
Ed
a [4)
Frank
a (5)
G erri
a (6)
Helen
a|71
iggy
a [8]
Joan
a (9)
Figura 6.6 Lo stato del programma alllnizio della ricerca binaria di ‘Frank* allinterno della porzione di array che va dall'indice 5 allindice 9.
assegna a mid il valore (5 + 6)/2, cioè 5, per cui Telemento centrale è *'Frank*\ come si può vedere neUa Figura 6.7. Trovato! L'elemento centrale è uguale a key, per cui viene restituito il valore di mld^ che è l'indice dell'attuale elemento centrale nella ricerca. L'unico problema irrisolto è capire cosa accade se l'array non contiene un elemento uguale a key. In tal caso, vogliamo restituire il valore -insertionPoint - 1, dove insertionPoint è l'indice in cui verrebbe inserito il valore key se si volesse preservare l'ordinamento dell'array. Si noti che non si restituisce -insertionPoint, perché ci sarebbe ambiguità nei caso in cui insertionPoint fosse uguale a 0: il valore zero cosi restituito potrebbe essere interpretato come l'indice in cui è stato trovato key. Come facciamo a determinare il valore da assegnare a insertionPoint? Se inizialmente first > last,la regione di ricerca è vuota, con first * Oelast = -l,per cui insertionPoint deve essere uguale a first; altrimenti, durante la prima invocazione di binarySearch si ha first last. Ogni volu che first 0
return binarySearch(a, 0, 3, "Dan”);
Frame 0
Ricorsione
Passo 3:
a * ["Ada”, "Ben", "Carol", "Dave", "Ed", "Frank", "Cerri", "Helen", "Iggy", "Doan"] first « 3 last » 3 key * "Dan" mid * 3 midVal « "Dave" comp è > 0
Framc 3
retum binarySearch(a, 3, 2, "Dan"); a . ["Ada", "Ben", "Carol", "Dave", "Ed", "Frank", "Cerri", "Helen", "Iggy", "Joan"] first - 2 last » 3 key « "Dan" mid - 2 midVal - "Carol" comp è < 0
Framc 2
return binarySearch(a, 3, 3, "Dan"); a - ["Ada", "Ben", "Carol", "Dave", "Ed", "Frank", "Cerri", "Helen"', "Iggy", "Doan"] first » 0 last » 3 key ■ "Dan" mid - 1 midVal » "Ben" comp è < 0
Framc 1
return binarySearch(a, 2, 3, "Dan"); a . ["Ada", "Ben", "Carol", "Dave", "Ed", "Frank", "Cerri", "Helen", "Iggy", "3oan"] first = 0 last « 9 key - "Dan" mid *> 4 midVal « "Ed" comp è > 0
return binarySearch(a, 0, 3, "Dan");
Framc 0
205
206
Capitolo 6
Passo 4:
Frame 4
Framc 3
Frame 2
Frame 1
R icorsione
207
a = ["Ada", "Ben", "Carol", "Dave", "Ed", "Frank", "Cerri", "Helen", "Iggy", "Doan"] first = 0 last » 9 key * "Dan" mid = 4 midVal = "Ed" comp è > 0
Frame 0
return binarySearch(a, 0, 3, "Dan"); -4
Quanto tempo richiede l’esecuzione del metodo binarySearch? Occorre distinguere tra una ricerca che non ha successo, nella quale l’elemento non viene trovato, e una ricerca che ha successo, quando l’elemento cercato viene trovato. Partiamo dall’analisi di una ricerca infruttuosa. Durante ciascuna esecuzione del metodo binarySearch nella quale l’elemento centrale è diverso da key, la dimensione della regione di ricerca che verrà utilizzata dall’esecuzione successiva viene circa dimezzata. Se l’elemento cercato non appartiene all’array,si continua a dividere per 2 fino a quando la regione di ricerca ha un solo elemento. Sia wla dimensione della zona di ricerca. Il numero di divisioni per 2 a cui si può sottoporre n fino a quando si ottiene w = 0 è logaritmico in w, perché, in pratica, segue la Regola della Suddivisione vista nel Capitolo 4. Quindi, per una ricerca infruttuosa, la funzione worstTime(«) è lo garitmica in n. Dato che stiamo ipotizzando che la ricerca sia infruttuosa, nel caso medio verrà eseguito Io stesso numero di ricerche che servono nel caso peggiore, per cui anche la funzione averageTime(w) è logaritmica. Il caso peggiore per una ricerca che ha successo richiede un’invocazione di binarySearch in meno di quanto visto nel caso peggiore (o nel caso medio) di una ricerca infruttuosa. Quindi, nel caso di una ricerca che ha successo, la funzione worstTime(w) è ancora loga ritmica in «. Nel caso medio, l’analisi è più complicata (si veda l’Esercizio di teoria 6.15), ma il risultato è lo stesso: la funzione averageTime(n) è logaritmica in n. Durante ciascuna invocazione viene memorizzata una quantità costante di informazioni (non viene memorizzato l’intero array, ma soltanto un riferimento ad esso), per cui anche l’occupazione di spazio in memoria è logaritmica in n, sia per una ricerca che ha successo sia per una ricerca infruttuosa, tanto nel caso peggiore quanto nel caso medio. Nella classe Arrays del pacchetto java.util è presente una versione iterativa dell’algorit mo di ricerca binaria. Nell’ipotesi di fare esperimenti per confrontare il tempo d’esecuzione di una ricerca ricorsiva in un array di int, di una ricerca iterativa in un array di in t e di una ricerca iterativa in un array di Int egei, quale delle tre versioni sarà più lenta, secondo voi?
208
C apitolo 6
Come ulteriore esercizio, suggeriamo di implementare un altro algoritmo ricorsivo decisamente più semplice della controparte iterativa, la generazione delle permutazioni di un insieme di valori, consultando l’ottimo libro di Roberts, Thinking Recursiuely [RoberO* 1986]. Il Paragrafo 6.6 ha come argomento un altro schema di progetto (una strategia generale per risolvere una grande varietà di problemi): il backtracking. Awctc certamente già utiliz zato questa strategia, ogni volta che avete dovuto tornare sui vostri passi per raggiungere un determinato obiettivo. La classe BackTrack mostra anche l’utilità che deriva dall’uso di interfacce.
6.6 Backtrackinq Ecco l’idea su cui si basa il backtracking (^tornare sui propri passi”). Partendo da una deter minata posizione iniziale, vogliamo raggiungere una certa posizione-obiettivo e scegliamo ripetutamente la posizione successiva, cercando eventualmente di indovinare: se una scelta si rivela valida (cioè la nuova posizione raggiunta potrebbe trovarsi sul percorso che porta all’obiettivo) ci portiamo in quella posizione e continuiamo; se, invece, una scelta porta a un vicolo cieco, torniamo alla posizione precedente e facciamo una scelta diversa. Si chia ma backtracking, appunto, la strategia che tenta di raggiungere un obiettivo mediante una sequenza di posizioni scelte, attraversate in ordine inverso se si giunge in una posizione che non consente di arrivare all’obiettivo. Guardiamo, ad esempio,la Figura 6.8. Partiamo dalla posizione PO e vogliamo individuare un percorso che porti all’obiettivo, PI 4. Possiamo spostarci in due sole direzioni, verso nord (in alto, nella figura) e verso ovest (a sinistra, nella figura), ma non possiamo “vedere” più in là deUa posizione successiva. Ecco, quindi, una strategia: da qualsiasi posizione, cerchiamo per prima cosa di andare verso nord; se non possiamo andare verso nord, cerchiamo di andare verso ovest; se non possiamo andare verso ovest, torniamo indietro fino ad arrivare alla più recente posizione in cui avevamo scelto di andare verso nord e, da lì, scegliamo, invece, di andare verso ovest. In questo modo non torniamo mai a visitare una posizione che ci ha portato in un vicolo cieco. Le posizioni della Figura 6.8 sono numerate nell’ordine in cui vengono visitate seguendo questa strategia. La Figura 6.8 rende un po’ più chiara la frase “attraversare in ordine inverso”: quando, ad esempio nella posizione P4, non siamo in grado di andare né verso nord né verso ovest, per prima cosa torniamo alla posizione P3, dove, però, non si può scegliere di andare verso ovest; di conseguenza, torniamo ancora più indietro, alla posizione P2, e cosi via. Prima o poi,questo ci porta in un vicolo cieco e torniamo alla posizione iniziale,PI,da cui possiamo procedere verso l’obiettivo. Quando una posizione viene visitata, la si contrassegna come possibile membro del percorso che porta all’obiettivo, ma questo contrassegno deve essere rimosso quando ci si accorge che la posizione porta solamente a un vicolo cieco, contrassegnandola, invece, con un diverso marcatore. Questo ci consente di evitare di visitare nuovamente una posizione che appartiene a un vicolo cieco, come avviene nella Figura 6.8 quando P5 non viene visitata di nuovo a partire da P8 perché, nel momento in cui si giunge in P8, la posizione P5 è già stata contrassegnata come “vicolo cieco”. A questo punto possiamo raffinare la nostra strategia. Per cercare di raggiungere un obiettivo a partire da una determinata posizione, elenchiamo tutte le posizioni direttamente
Ricorsione
209
P15 (O BIETTIV O)
P14
t
P4
t ! t t t t P3
P13
P7
P12
P6 ◄---- P5 ◄---P2
PII
1
PIO ◄------ P9 ◄------P8 ◄------- PI
PO
Figura 6.8 Uso della strategia di backtracking per individuare un percorso verso un obiettivo (PO, PI, P8, P9, PIO, PII, PI 2, PI 3, PI4, PI 5). accessibili dalla posizione attuale e continuiamo a ripetere queste operazioni finché non si raggiunge Tobiettivo oppure non si riesce più a procedere verso una posizione nuova. Durante ciascuna di queste iterazioni del ciclo, si procede verso la successiva posizione accessibile: se può appartenere al percorso che porta all’obiettivo, la si contrassegna in tal modo e, se si tratta proprio dell’obiettivo, la ricerca termina con successo; altrimenti, si cerca di raggiungere l’obiettivo da tale posizione, contrassegnandola poi come “vicolo cieco’’ se il tentativo fallisce. Prima di procedere, è bene che siate certi di aver compreso a fondo la frase precedente, che contiene lo spirito fondamentale del backtracking: al confronto, la parte rimanente di questo paragrafo e il successivo, 6.6.1, sono quasi superflui. Invece di sviluppare un metodo di backtracking per una specifica applicazione, useremo un generico algoritmo di backtracking progettato daWirth [1976,p. 138]. Poi,applicheremo tale algoritmo a un particolare problema, la ricerca di un’uscita da un labirinto, lasciando altre quattro interessanti applicazioni ai progetti di programmazione presentati al termine del capitolo. Inoltre, il Capitolo 16 presenta un’altra applicazione del backtracking: un pro getto di programmazione per effettuare ricerche in una rete. Il backtracking è uno schema progettuale, perché è una tecnica di programmazione generale, applicabile in molti contesti diversi. La classe BackTrack,qui presentata,si basa su quella di Noonan [20(X)]. 1 dettagli relativi alla classe che descrive l’applicazione saranno trasparenti per la classe BackTrack, che svolge le proprie funzioni attraverso un’interfaccia. Application, che sarà implementata da ciascuna specifica applicazione. Un utilizzatore della classe BackTrack fornisce: • una classe che implementa l’interfaccia Application (nota: per accedere aUe posizioni raggiungibili da una particolare posizione, si usa lo schema progettuale “iteratore”, definito da una classe interna); • una classe Position, che definisce il significato di “posizione” per la particolare appli cazione.
210 CAPITOLO 6 I metodi dell’interfaccia Application sono generalizzazioni derivanti dalla precedente presentazione del backtracking: inport java.util.*; public interface Application
{
/♦* * Determina se la posizione specificata è valida e non porta * a un vicolo cieco. •
*
* ^aram pos la posizione in esame. * * Return true se pos è una posizione valida e non porta a un *
* */
vicolo cieco.
boolean isOK(Position pos);
/**
* Contrassegna una posizione come appartenente a un percorso che * porta a un obiettivo.
*
* ^ r a m pos la posizione che viene contrassegnata come *
*
appartenente a un percorso che porta a un obiettivo.
void markAsPossible(Position pos);
* Determina se una posizione è un obiettivo oppure no. ♦
* 9paiam pos la posizione che può essere un obiettivo oppure no. ♦ * ftreturn true se la posizione è un obiettivo; false altrimenti.
* */
boolean isGoal(Position pos);
/*♦
* Contrassegna una posizione come non appartenente a un percorso * che porta a un obiettivo.
*
♦ ^aram pos la posizione che viene contrassegnata come non ♦ appartenente a un percorso che porta a un obiettivo.
♦ */
void markAsDeadEnd(Position pos);
/♦* * Converte questo oggetto di tipo Application in un oggetto * di tipo String. * Return l'oggetto di tipo String che rappresenta questo * oggetto di tipo Application.
*
Ricorsione
211
String toStringO;
/♦*
* Fornisce un oggetto iteratole che parte dalla posizione * specificata. * 9param pos la posizione da cui parte l'oggetto di tipo Iterator. « * ftreturn un oggetto di tipo Iterator che accede alle posizioni * direttamente accessibili a partire da pos.
*
Iterator iterator(Position pos); } // interfaccia Application
La classe BackTrack svolge due compiti (cioè ha due responsabilità): inizializza un oggetto di tipo BackTrack a partire dall’oggetto applicazione che viene fornitole cerca di raggiun gere la posizione di un obiettivo a partire da una posizione assegnata. Le specifiche dei suoi metodi sono: /♦*
* Inizializza questo oggetto di tipo BackTrack a partire da un * oggetto che rappresenta un'applicazione.
* 9param app l'applicazione * */ public BackTrack(Application app) * Cerca di raggiungere un obiettivo a partire da una posizione * assegnata.
*
* 9param pos la posizione assegnata.
*
* g)return true se il tentativo ha successo; altrimenti, false. * */ public boolean tryToReachCoal(Position pos) L'unico campo di cui ha bisogno è un (riferimento a un) oggetto di tipo Application e la definizione del costruttore è banale, mentre la definizione del metodo tryToReachCoal è basata sulla strategia di backtracking delineata in precedenza, dove, per elencare tutte le posizioni raggiungibili da una specifica posizione, viene creato un iteratore e, per cercare di raggiungere un obiettivo, si invoca ricorsivamente il metodo tryToReachCoal. Ecco, quindi, la classe BackTrack completa, priva di informazioni relative a un'applicazione specifica:
import java.util.*; public class BackTrack { protected Application app;
212
C apitolo 6 /♦*
* Inizialìzza questo oggetto di tipo BackTrack a partire da un * oggetto che rappresenta un'applicazione.
*
* 9param app l'applicazione
* *1
public BackTrack(Application app) { this.app > app; } // costruttore
*
/**
* Cerca di raggiungere un obiettivo a partire da una posizione * assegnata.
«
* ^aram pos la posizione assegnata.
*
* jhreturn true se il tentativo ha successo; altrimenti, false. * public boolean tryToReachCoal(Position pos) {
Iterator itr * app.iterator(pos);
ubile (itr.hasNextO) {
pos » itr.nextO; if (app.isOK(pos))
{
app.markAsPossible(pos); if (app.isGoal(pos)) || tryToReachCìoal(pos))
retum true; app.markAsDeadEnd(pos); } // pos può appartenere a un percorso verso un obiettivo } // while
retum false; } // metodo tryToReachCìoal } // classe BackTrack
Concentriamoci sul metodo tryToReachCoal, il cuore del backtracking. Esaminiamo le possibili scelte di uno spostamento a partire dalla posizione indicata dal parametro pos. Ci sono tre casi: 1. Una delle alternative è una posizione che costituisce un obiettivo: viene restituito true, per segnalare un successo. 2. Una delle alternative è una posizione valida ma non è un obiettivo: si effettua un'ul teriore invocazione di tryToReachCjoal, a partire dalla scelta valida. 3. Nessuna delle alternative è praticabile: il ciclo ubile termina e viene restituito false, per segnalare il fallimento del tentativo di raggiungere un obiettivo a partire dalla posizione indicata. L'argomento del metodo tryToReachGoal rappresenta una posizione che è stata contrasse gnata come possibile componente di un percorso che raggiunga un obiettivo. Ogni volta che il metodo tryToReachGoal esegue un enunciato return, viene ripristinato il valore di
R icorsione
213
pos presente prima delFinvocazione, in modo da poter contrassegnare tale posizione come vicolo cieco se non ha consentito di raggiungere un posizione che costituisca un obiettivo. Ora che abbiamo sviluppato un’infrastruttura per il backtracking, è veramente semplice utilizzarla per risolvere svariati problemi.
6.6.1 Uscireda unlabirinto Come applicazione deUa strategia di backtracking, sviluppiamo un programma che cerca di trovare un percorso che attraversi un labirinto (maze). La Figura 6.9 descrive un labirinto 7-per-13, dove 1 rappresenta un passaggio e 0 costituisce un muro. Gli unici spostamenti consentiti sono quelli che seguono un passaggio, in senso orizzontale o verticale; le mosse in diagonale sono proibite. La posizione di partenza si trova nell’angolo superiore sinistro, mentre la posizione-obiettivo {Vusàta dal labirinto) è l’angolo inferiore destro. Un attraversamento di questo labirinto che abbia successo metterà in evidenza un per corso che porti dalla posizione iniziale alla posizione identificata come obiettivo, cioè l’uscita dal labirinto (contrassegniamo con il numero 9 ciascuna posizione appartenente a questo percorso). Dato che in questo labirinto esistono due percorsi che risolvono il problema, il percorso effettivamente scelto dipenderà dal modo in cui l’iteratore ordina le alternative possibili. Per semplicità, ipotizziamo che le posizioni vengano sempre elencate in questo ordine: nord, est, sud e ovest. Ad esempio, trovandosi nella posizione avente coordinate (5, 8), la prima scelta sarebbe quella di andare nella posizione (4,8), per poi tentare, nell’ordine, (5,9), (6,8) e (5,7). Dalla posizione iniziale, (0,0), si identificano come potenzialmente appartenenti a un percorso risolutivo le seguenti posizioni: (0,1) (0,2) (1.2) (1.3) (1.4) (0,4) (0,5)
// // // // // // //
verso est verso est verso sud verso est verso est verso nord verso est
Quest’ultima posizione è un vicolo cieco, per cui vengono “annullate” (mediante un’o perazione di undó) le mosse che hanno portato nelle posizioni (0, 5) e (0, 4), tornando (mediante, quindi, un’azione di backtracking) alla posizione (1,4), per poi contrassegnare 1
1
0
1
0
0
0
1
1
0
1
1
0
1 0
0
0
0
0
0
0
1 1 0
1
1
1
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
0 1
1 1
1 1
1 0 1 0 0
0
1 1
1 0
1
1 1 1 1
0
1 0
0
0
0
0
0
0
1 1 1 1 1 1 1 1
Figura 6*9 Un labirinto (1 = passaggio, 0 = muro), nel quale si Ipotizza che la posizione iniziale sia l'angolo superiore sinistro, mentre l'obiettivo da raggiungere è l'angolo inferiore destro.
214
CAPITOLO 6
come potenzialmente appartenenti a un percorso risolutivo, che porti verso l’obiettiva Ifl seguenti posizioni: (2.4) / / verso sud (3.4) / / verso sud (3.5) / / verso est Anche questo percorso porta in un vicolo cieco. Dopo aver annullato la mossa che d ha portato nella posizione (3, 5) ed essere tornati nuovamente neUa posizione (3, 4), d spostiamo nella posizione-obiettivo, senza ulteriori azioni di backtracking. La Figura 6.10 mostra, mediante la cifìa 9 sul labirinto, il percorso cosi individuato attraverso il labirinto rappresentato nella Figura 6.9, con i vicoli ciechi contrassegnati con la cifra 2. Questa applicazione rappresenta una **posizione” con una semplice coppia: indice di riga e indice di colonna. Questo ci consente di progettare rapidamente la classe Position:
public class Position {
protected Int row^ column;
/** * Inizializza questo oggetto di tipo Position al valore (0^ 0).
public PositionO {
row = 0; column = 0; } // costruttore di default ♦ Inizializza questo oggetto di tipo Position al valore
♦ (row, column).
♦
♦ ^aram row l'indice di riga con cui verrà inizializzato * questo oggetto di tipo Position. * 9param column l'indice di colonna con cui verrà inizializzato ♦ questo oggetto di tipo Position.
0
2 0 0 0
9
9
0
2
0
0
0
2
2
0
2
0
0
2
2
2
2
2
9
9
0
0 0
1 1 1 1
2
2
2
2
2 2 2 2
1
0
2
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
9
9
9
9
9
9
9
Figura 6*10 Un percorso che attraversa il labirinto delia Figura 6.9; ie posizioni che appartengono ai percorso sono identificate dalla cifra 9, mentre la cifra 2 contrassegna le posizioni che fanno parte di vicoli ciechi.
R icorsione
215
public Position(int row, int column) { this.row « row; this.column • column; } // costruttore * Restituisce l'indice di riga di questo oggetto. * treturn l'indice di riga di questo oggetto.
* ♦/ public int getRowO return row; } // metodo getRow
/**
* Restituisce l'indice di colonna di questo oggetto. «
* Return l'indice di colonna di questo oggetto.
«
V
public int getColumnO { retum column; } // metodo getColumn
} // classe Position Per questa applicazione, rinterfaccia Application viene implemenuta dalla classe Maze, che rappresenta un labirinto. Gli unici suoi campi sono le posizioni iniziale e finale, oltre a una griglia (o matrice) per memorizzare il labirinto. La Figura 6.11 mostra gli schemi U M L per la classe Maze e l’interfaccia Application. A parte il costruttore e isuoi tre metodi d’accesso (il metodo getCrid è stato aggiunto soltanto per agevolare ilcoUaudo),le specifiche dei metodi della classe Maze sono identiche a queUe viste precedentemente nell’interfaccia Application. Per la classe interna Mazelterator vengono fornite le sole specifiche del costruttore, perché quelle dei metodi hasNext, next e remove sono standard e non c’è bisogno di riportarle qui. Ecco, quindi, le specifiche dei costruttori delle classi Maze e Mazelterator:
♦ Inizializza questo oggetto di tipo Maze usando le informazioni ♦ lette da un file mediante uno scanner.
«
♦ ^aram fileScanner lo scanner che agisce sul file che contiene ♦ le informazioni relative al labirinto.* * ^hrows InputMismatchException se il numero di righe o di colonne non è un numero intero, oppure se uno dei valori * presenti nella griglia non è un numero intero. * ^hrows NumberFormatException se uno dei valori presenti nella * griglia è un numero intero ma è diverso da WALL (muro) * e da CORRIDOR (passaggio).
*
216
Capitolo 6 ♦ V
public Maze(Scanner fileScanner) /*♦ * Inizializza questo oggetto di tipo Mazelterator in modo che
«interface» Application ■h isOK(pos: Position): boolean + markAsPossible(pos: Position)
•¥ goalReached(pos: Position): boolean + markAsDeadEnd(pos: Position) + toStringO: String + iterator(pos: Position): Iterator
Maze
# grid: byte[ ][ ] Position
# start: Position # finish: Position
# row: int
Maze(fileScanner: Scanner)
# column: int
+ isOK(pos: Position): boolean
+ PositionO
+ markAsPossible(pos: Position)
+ Position(row: int, column: int)
+ goalReached(pos: Position): boolean
+ getRow(): int
+ markAsDeadEnd(pos: Position)
+ getColumn(): int
+ toStringO: String + iterator(pos: Position): Iterator + getStart(): Position + getFinish(): Position + getCridO: byte[ ][ ] Figura 6.11 Schema UML per la classe Maze, che implementa linterfaccla Application e contiene I campi grid, start e finish.
R icorsione
217
* parta dalla posizione specificata.
* «
^aram pos la posizione da cui parte questo iteratore.
public NazeIterator(Position pos) La classe di collaudo, MazeTest, comincia con la dichiarazione di un campo, maze, poi crea il labirinto (quello visibile nella Figura 6.9) leggendo le informazioni da un fìle:
protected Maze maze; jCefore
public void runBeforeEachTestO throMS lOException { fileScanner > new Scanner(neM File("maze.txt")); maze > new Maze(fileScanner); } // metodo runBeforeEachTest
Ed ecco, come esempio, i collaudi per quattro delle condizioni limite in cui opera il me todo isOK: jiTest
public void isOKTestlO { Position pos - new Position(0, 0); assertEquals(tzue, maze.isOK(pos)); } // metodo isOKTestl ^Test
public void ls0KTest2() Position pos > new Position(6, 12); assertEquals(tzue, maze.isOK(pos)); } // metodo is0KTest2
?Test
public void {
isOKTestl 0
Position pos • new Position(7, 0); assertEquals(false, maze.isOK(pos)); } // metodo isOKTestl
^Test
public void isOKTest4() { Position pos » new Position(0, il); assertEquals(false, maze.isOK(pos)); } // metodo isOKTest4
Infine, la classe Maze completa, con la classe interna Mazelterator;
import java.util.*; public class Maze implements Application
218
Capitolo 6
public publlc public public
static static static static
final final final final
byte byte 1^ l^ e
WALL > 0; CORRIOOR ° l; PATH => 9; DEAD ENO = 2;
protected Position start, finish;
protected byte[ ][ ] grid; * Inizìalìzza questo oggetto di tipo Maze usando le informazioni * lette da un file mediante uno scanner.
«
* Jiparam fileScanner lo scanner che agisce sul file che contiene * le informazioni relative al labirinto.
*
* ^hroMs InputMisfflatchException se il numero di righe o di *
colonne non è un numero intero, oppure se uno dei valori
* presenti nella griglia non è un numero intero. * ^hrows NumberFormatException se uno dei valori presenti nella * *
griglia è un numero intero ma è diverso da MAIL (muro) e da CORRIOOR (passaggio).
*
*/ publlc Maze(Scanner fileScanner) { int rows * fileScanner.nextIntO, columns * fileScanner.nextlnt(); grid s new byte[rows][columns]; start * new Position(fileScanner.nextInt(), fileScanner.nextInt()); finish = new Position(fileScanner.nextInt(), fileScanner.nextInt());
for (int i = 0; i < rows; i++) for (int j = 0; j < columns; j++) grid[i][i] = fileScanner.nextByteO; if (gridìi][j] != WALL && grid[i][j] != CORRIDOR) throw new NumberFormatException(''At position ('* + i + j + "), - + grid[i][j] + •• should be " +W ALL + " or " + CORRIDOR + *•.”); } // for j } // costruttore Determina se la posizione specificata è valida e non porta * a un vicolo cieco. * ^aram pos la posizione in esame. *
R icorsione * Return true se pos è una posizione valida e non porta a un * vicolo cieco.
*
public boolean ìsOK(Positìon pos) retum pos.getRow() >« 0 && pos.getRow() < grid.length && pos.2etColumn() >« 0 8& pos.getColumn() < grid[o].length && grid[pos.getRow()][pos.getColumn()] == CORRIDOR; } // metodo isOK * Contrassegna una posizione come appartenente a un percorso che * porta a un obiettivo.
* *
* */
^aram pos la posizione che viene contrassegnata come appartenente a un percorso che porta a un obiettivo.
public void markAsPossible(Position pos) {
grid[pos.getRow()][pos.getColumn()) = PATH; } // metodo markAsPossible
/*♦ ♦ Determina se una posizione è un obiettivo oppure no.
♦
* ^aram pos la posizione che può essere un obiettivo oppure no. ♦ * greturn true se la posizione è un obiettivo; false altrimenti.
♦ ♦/
public boolean isGoal(Position pos) retum pos.getRow() == finish.getRow() && pos.getColumnO =« finish.getColumn(); } // metodo isCioal
* Contrassegna una posizione come non appartenente a un percorso * che porta a un obiettivo.
*
* 9param pos la posizione che viene contrassegnata come non * appartenente a un percorso che porta a un obiettivo.
public void markAsDeadEnd(Position pos) {
grid[pos.getRow()][pos.getColumn()] = DEADJNO; } // metodo markAsDeadEnd * Converte questo oggetto di tipo Application in un oggetto * di tipo String. *
* greturn l'oggetto di tipo String che rappresenta questo * oggetto di tipo Application.
219
220
Capitolo 6
publlc Strlng toStringO {
String result » "\n"; result +» start.getRowO + " ” + start.getColumn() + "\n"; result +■ finish.getRowQ + " ” + finish.getColumn() + "\n"; for (int row - O; row < grid.length; iou++)
{
for (int column « o; column < grid[o].lengHi; column-t-t-)
result +« String.valueOf(grid[row][column]) + ' result +• "Xn"; } // for row return result; } // metodo toString
/**
* Fornisce un oggetto iteratore che parte dalla posizione * specificata e opera su elementi di tipo Position.
*
* ^ r a m pos la posizione da cui parte l'oggetto di tipo Iterator.
*
* ^return l'oggetto di tipo Iterator. «
•/
public Iterator iterator(Position pos) {
return new Mazelterator(pos); } // metodo iterator
/**
* Restituisce la posizione di partenza di questo labirinto. *
^return la posizione iniziale di questo labirinto.
*/
public Position getStart() {
retum start; } // metodo getStart
/♦* Restituisce la posizione di arrivo di questo labirinto. * ftreturn la posizione finale di questo labirinto.
♦ ♦/
public Position getFinishO retum finish; } // metodo getFinish * Restituisce un array bidimensionale che contiene una copia * della configurazione del labirinto.
*
R icorsione * greturn un array bidimensionale che contiene una copia * della configurazione del labirinto.
* */
public byte[ ][ ] getCrid()
{
][ ] gridCopy » new byte[grid.length][grid[0].length]; for (Int i = 0; i < grid.length; ì -h -) for (int j = 0; o; i1 < grid[i .length; j-H-)
gridCopy[ i][3l = grid[i [j]; retum gridCopy; } // metodo getCrid protected class Mazelterator iMplenents Iterator
{
protected statlc final int MAX_M0VES * 4; protected int
icm,
column, count; * Inizializza questo oggetto di tipo Mazelterator in modo che * parta dalla posizione specificata.
* * j)param pos la posizione da cui parte questo iteratore. * *1 public HazeIterator(Position pos)
{
row « pos.getRowO; column » pos.getColumnO; count = 0; } // costruttore * Determina se questo iteratore può avanzare di un'ulteriore * posizione.
*
* g)return true se questo iteratore può avanzare; *
*
false altrimenti.
public boolean hasNext()
{
retum count < MAX_M0VES; } // metodo hasNext
* Fa avanzare questo iteratore alla posizione successiva.
♦
* greturn la posizione in cui si è portato 1*iteratore.
*
221
222
Capitolo 6
public Position next() {
Position nextPosition = M H PositionO; switch (count++)
{
case
0:
nextPosition = nem Position(row - l, column); // nord
break; case 1:
>
nextPosition = neM Position(row, column + i); // est
break; case 2: nextPosition = new Position(row + l, column); // sud
break; case 3: nextPosition = new Position(row, column - l); // ovest } // switch return nextPosition; } // metodo next
public void removeO {
// roperazione di rimozione non è lecita per un oggetto // di tipo Mazelterator throw new UnsupportedOperationException(); } // metodo remove
} // classe Mazelterator } // classe Maze
Per mostrare come un programmatore potrebbe utilizzare la classe Maze, progettiamo la classe MazeUser, che: crea un labirinto, leggendolo tramite uno scanner che opera su un file; contiene un metodo che cerca un percorso che attraversi il labirinto; visualizza la soluzione trovata, oppure una frase che segnala che non esiste soluzione. Le specifiche dei metodi (con l’esclusione del consueto metodo main) sono le seguenti: ♦ Esegue l'applicazione.
♦ */
public void run() /** * Cerca un percorso attraverso il labirinto, partendo dalla sua * posizione iniziale.
**
* gparam maze il labirinto in cui cercare. ♦ * Return true se esiste un percorso attraverso il labirinto; * false altrimenti.
* ♦/
public boolean searchMaze(Maze maze)
R icorsione
223
La Figura 6.12 mostra gli schemi di classe UML che illustrano il progetto complessivo. Dal momento che la classe Position è piuttosto semplice e il suo schema è già stato presentato nella Figura 6.11, in questo schema è stato omesso. Ecco il codice della classe MazeUser:
import java.io.'*^; im ^rt java.util.*; public class MazeUser MazeUser + main(args: String[ ]) + run() # searchMaze(maze: Maze): boolean
Maze
BackTrack
# grid: byte[ ][ ]
# app: Application
# start: Position
+ BackTrack(app: Application)
# finish: Position
+ tryToReachCoal(pos: Position): boolean
+ Maze(fileScanner: Scanner)
■¥ isOK(pos: Position): boolean + markAsPossible(pos: Position) + goalReached(pos: Position): boolean + markAsDeadEnd(pos: Position) + toStringO: String + iterator(pos: Position): Iterator + getStart(): Position + getFinish(): Position
«in terface» Application + isOK(pos: Position): boolean + markAsPossible(pos: Position) + goalReached(pos: Position): boolean markAsDeadEnd(pos: Position) + toStringO: String + iterator(pos: Position): Iterator
+ getCridO: bytet ][ ]
Figura 6.12 Schema UML per leclassi usate nel progetto di ricerca di un percorso in un labirinto.
224
C apitolo 6
public static void fflain(String[ ] args) ncM MazeUser().run(); } // metodo maio
public void run() final String INPUT_PROMPT » "\n\nPlease enter thè path for thè file whose first line " "contains thè number of roMS and column»,\nwhose 2nd line " "thè start row and column, whose 3rd line thè finish row " "and column, and then thè maze, row-by-row: ";
final String INITIAL_STATE = "\n\nThe initial state is as follows " + "(0 - WALL, 1 = C0RRID0R):\n";
final String START INVALID « "The start position is invalid."; final String FINISHJNVALIO = "The finish position is invalid."; final String FINAL_STATE = "\n\nThe final state is as follows " + "(2 . DEAD END, 9 - PATH):\n";
final String SUCCESS = "\n\nA solution has been found:"; final String FAILURE = "\n\nThere is no solution:"; Maze maze » nuli; Scanner keyboardScanner « nai Scanner(System.in), fileScanner > nuli; String fileName;
while (tzue) { try {
System.out.print(INPUT_PROMPT); fileName » keyboardScanner.next(); fileScanner >.new Scanner(nM File(fileName));
break; } H try catch(IOException e) System.out.println("\n" + e); } // catch lOException ) // while
tiy {
maze • new Maze(fileScanner); System.out.println(INITIAL_STATE + maze); Scanner stringScanner = new Scanner(maze.toStringO); Position start - new Position(stringScanner.nextInt(),
R icorsione
225
stringScanner.nextInt()), finish » new Position(stringScanner.nextInt(), stringScanner.nextint ()); if (!inaze.isOK(start)) System.out.println(START INVALID); else If (!niaze.isOK(finish)y System.out.println(FINISH_INVALID);
else { if (searchMaze(maze, start)) System.out.println(SUCCESS);
else System.out.println(FAILURE); } // else ricerca valida } H try catch(InputMismatchException e) System.out.println("\n" + e + " } // catch InputMismatchException catch(NumberFonnatException e)
+ fileScanner.nextLineO);
System.out.println("\n" + e); } // catch NumberFormatException catch(RuntimeException e) System.out.println("\n" + e); System.out.println(FINAL_STATE + mare); } // catch RuntimeException' } // metodo run
/**
* Effettua la ricerca nel labirinto. * *
9param maze il labirinto in cui cercare.
* Return true se esiste un percorso in questo labirinto; ♦ false altrimenti. «
*/ public boolean searchMaze(Maze maze) Position start • maze.getStart(); maze.markAsPossible(start); BackTrack backTrack > nem BackTrack(maze); if (maze.isCoal(start) || backTrack.tryToReachCoal(start)) zetuzn txue; maze.markAsDeadEnd(start); return false; } // metodo searchMaze } // classe MazeUser
In questo progetto, e in generale, il metodo run non viene sottoposto a collaudo, perché prevede un’interazione, tanto in ingresso quando in uscita, con l’utente finale. Quanto tempo serve per l’esecuzione metodo tryToReachCoal della classe BackTrack? Supponiamo che il labirinto sia costituito da n posizioni. Nel caso peggiore, come quel-
226
Capitolo 6
1 0
1 1 0
1 1 0
1 0 1 0
1 1 0 1 1 0
1 1 0 1 1 0
1 0 1 0
1 1 0 1 1 0
1 1 0 1 1 0
1 1 1 1 1 1 1 1 Figura 6.13 Un labirinto che costituisce un caso peggiore (la posizione iniziale è nell'angolo superiore sinistro, quella finale nell'angolo inferiore destro): nelle colonne 1 , 4 , 7 , ogni riga tranne l'ultima contiene uno 0 (cioè un muro), mentre tutte le altre posizioni del labirinto contengono un 1 (cioè un passaggio).
la dì Figura 6.13, è necessario prendere in esame tutte le posizioni, per cui la funzione worstTime(«) è lineare in n, E con più di metà delle posizioni che appartengono al per corso che porta all’obiettivo, occorrerebbero almeno n/2 invocazioni ricorsive del metodo tryToReachCoal, per cui anche la funzione worstSpace(ft) è lineare in n. I Progetti 6.2,6.3,6.4 e 6.5 riguardano altri esempi di backtracking. Dal momento che il progetto appena esaminato ha tenuto distinti gli aspetti relativi al backtracking da quelli riguardanti l’attraversamento del labirinto, la classe Back!rack e l’interfaccia Appli cation non avranno bisogno di modifiche. La classe Position usata nei Progetti 6.2,6.3 e 6.5 è uguale a quella appena utilizzata, mentre quella per il Progetto 6.4 è soltanto un po’ diversa. All’inizio di questo capitolo abbiamo dato una descrizione non formale di metodo ricorsivo, come “metodo che invoca se stesso’’. 11prossimo paragrafo spiega per quale modvo quella descrizione non definisce la ricorsione in modo adeguato e, quindi, fornisce la sua definizione.
6.7 Ritorsione indiretta Java consente la progettazione di metodi a ricorsione indiretta. Ad esempio, se il metodo A invoca il metodo B e questo invoca il metodo A, allora tanto A quanto B sono ricorsivi. La ricorsione indiretta si verifica spesso durante lo sviluppo di una grammatica che descriva un linguaggio di programmazione, come Java. Dato che la ricorsione indiretta è lecita, non possiamo definire un metodo ricorsivo come, semplicemente, un metodo che invoca se stesso. Per arrivare a una definizione formale di ricorsione, definiamo prima cosa si intenda per metodo attivo: un metodo è attivo se è in fase di esecuzione oppure ha invocato un altro metodo attivo. Consideriamo, ad esempio^ una catena di invocazioni di metodo, come questa: -►B-
-► C-
-►D
R ic o r s io n e
227
In questo esempio, A invoca B,B invoca C e C invoca D. Quando viene eseguito il metodo D, i metodi attivi sono: • D, perché è in esecuzione; • C, perché ha invocato D e D è attivo; • B, perché ha invocato C e C è attivo; • A, perché ha invocato B e B è attivo. A questo punto possiamo definire la “ricorsione”. Un metodo è ricorsivo se viene invocato mentre è attivo. Ad esempio, analizziamo la seguente sequenza di invocazioni: -►B-
-► C -
-►D
In questo caso, B, C e D sono metodi ricorsivi, perché ciascuno viene invocato mentre è attivo. Quando viene invocato un metodo ricorsivo, bisogna archiviare e preservare una certa quantità di informazioni, in modo che queste non vengano sovrascritte durante Tesecuzione dell'invocazione ricorsiva. Queste informazioni vengono, poi, recuperate e ripristinate nel momento in cui l’esecuzione del metodo invocato è terminata. Queste azioni di salvataggio e ripristino (sai/e e restore), così come altre azioni relative svolte a supporto della ricorsione, costituiscono un costo in termini di tempo di esecuzione e di spazio richiesto in memoria, li Paragrafo 6.8 stima proprio questi costi e cerca di determinare se siano giustificati.
6.8 Costo della ritorsione Abbiamo già osservato che, ogni volta che un metodo invoca se stesso, viene tempora neamente archiviata una certa quantità di informazione, che prende complessivamente il nome di nota di attivazione {activation record), perché è relativa allo stato di esecuzione del metodo che risulta essere attivo durante l’invocazione. In effetti, viene creata una nota di attivazione ogniqualvolta si invoca un metodo qualsiasi: in questo modo il compilatore non deve preoccuparsi di stabilire se un determinato metodo è ricorsivo, eventualmente indirettamente. In pratica, una nota di attivazione è un’immagine di esecuzione priva di enunciati e contiene: a. l’indirizzo “di ritorno” {return address), cioè l’indirizzo dell’enunciato che sarà eseguito quando l’attuale invocazione sarà terminata; b. il valore di ciascun argomento, facendone una copia (se l’argomento è di tipo “riferi mento a oggetto”, viene copiato il riferimento); c. i valori delle variabili locali dichiarate all’interno del corpo del metodo invocato. Dopo che l’invocazione è terminata, viene ripristinata la nota di attivazione precedente e si riprende l’esecuzione del metodo invocante. Se il metodo terminato restituisce un valore, questo viene posizionato, immediatamente prima che venga ripresa l’esecuzione del me
228
CAPITOLO 6
todo invocante, in cima alle informazioni contenute nella nota di attivazione precedente:!^ prima preoccupazione del metodo invocante sarà quella di recuperare tale valore restituita L’archiviazione e il ripristino di queste note di attivazione ha, ovviamente, un costQ in termini di tempo di esecuzione, oltre che di spazio in memoria, ma si tratta di costi trascurabili rispetto al costo del tempo impiegato dal programmatore per sviluppare un metodo iterativo in quei casi in cui un metodo ricorsivo sarebbe più adeguato. I metodi ricorsivi, come move e tryToReachCoal,sono molto più semplici e più eleganti delle proprie controparti iterative. Come si può decidere se sia più adeguato un metodo ricorsivo o un metodo iterativo? Semplicemente, se si riesce a progettare rapidamente una soluzione iteradva,si sceglie quella; altrimenti, occorre capire se la ricorsione non possa essere più appropriata per il problema in esame. Cioè, occorre capire se i casi più complessi del problema possono essere ricon dotti a casi più semplici aventi la stessa forma dell’originale e se, dopo eventuali ulteriori semplificazioni, i casi più semplici che si ottengono possono essere risolti in modo diretto: se si presenta una situazione di questo tipo, allora si dovrebbe decidere di sviluppare un metodo ricorsivo. Se non risulta semplice progettare un metodo iterativo, e la ricorsione si rivela ade guata, come la si può confix>ntaie con una soluzione iterativa, dal punto di vista delle prestazioni? Nella situazione peggiore, la ricorsione richiederà lo stesso tempo di sviluppo (con prestazioni simili sia per il tempo che per lo spazio) della versione iterativa; nel caso migliore, lo sviluppo del metodo ricorsivo richiederà un tempo decisamente inferiore di quello necessario per giungere a una soluzione iterativa, con prestazioni simili. Si vedano, come esempi, i metodi move e tryToReachCoal. Naturalmente è possibile anche progettare metodi ricorsivi ineflicienti (come un metodo che calcoli i numeri di Fibonacci seguendo direttamente la loro definizione), cosi come i metodi iterativi possono avere prestazioni scadenti. In questo capitolo ci siamo concentrati su cosa sia la ricorsione, rimandando al Capitolo 9 una discussione sul meccanismo, chiamato stock (pila, nel senso di oggetti impilati uno sull’altro), mediante il quale il compilatore realizza l’archiviazione e il ripristino delle note di attivazione. Come abbiamo visto nel Capitolo 2, questa astrazione (cioè la separazione tra ciò che si fa e come lo si fa) ha un’importanza cruciale nelle strategie di problem solving (“risoluzione di problemi”).
Riepilogo Questo capitolo ha avuto l’obiettivo di acquisire familiarità con l’idea fondamentale della ricorsione, in modo da poter comprendere i metodi ricorsivi presentati nei prossimi capitoli e anche saper progettare metodi ricorsivi, qualora se ne presenti la necessità. Un metodo è ricorsivo se può essere invocato mentre è attivo, e un metodo è attivo se è in fase di esecuzione oppure ha invocato un metodo attivo. Se risulta semplice progettare un metodo iterativo per risolvere un determinato proble ma, si dovrebbe agire in quella direzione. Altrimenti, è opportuno prendere in considerazione la ricorsione, se il problema gode delle seguenti proprietà: 1. 1 casi più complessi del problema possono essere ricondotti a casi più semplici, pur mantenendo la stessa forma del problema originale. 2. I casi più semplici possono essere risolti in modo diretto.
R ic o r s io n e
229
Per queste categorie di problemi è spesso semplice individuare un metodo ricorsivo di soluzione. Ogni volta che viene invocato un metodo (sia esso ricorsivo oppure no), viene creata una nota di attivazione (activation record) come quadro di riferimento per Tesecuzione del metodo stesso. Ognuna di tali note contiene: a. Tindirizzo “di ritorno’*, cioè l’indirizzo in memoria dell’enunciato che verrà eseguito quando l’invocazione del metodo è terminata; il valore di ciascun argomento, sotto forma di copia dell’argomento corrispondente (se l’argomento è di tipo “riferimento a oggetto’’, è il riferimento a essere copiato); i valori delle altre variabili locali del metodo. Le note di attivazione rendono possibile la ricorsione, perché preservano quelle informazioni che, altrimenti, potrebbero andare perdute quando un metodo invoca se stesso. Quando termina l’esecuzione di un metodo, il controllo torna all’indirizzo specificato nella corri spondente nota di attivazione e torna ad essere utilizzata la nota di attivazione precedente, per costituire ora il quadro di riferimento per l’esecuzione di quel metodo. Qualunque problema che possa essere risolto con metodi ricorsivi può essere risolto anche iterativamente, cioè facendo uso di un ciclo. Solitamente i metodi iterativi sono un po’più efficienti delle proprie controparti ricorsive, perché è richiesta la creazione e gestione di un minor numero di note di attivazione, ma l’eleganza e la semplicità di codifica delle soluzioni ricorsive compensano abbondantemente questo lieve svantaggio. Una strategia di hacktracking procede, passo dopo passo, verso un obiettivo. Ad ogni passo viene effettuata una scelta, ma, quando si raggiunge un vicolo cieco, si torna sui propri passi, a ritroso, cioè la decisione presa più recentemente viene annullata e si compie una nuova scelta. In questo capitolo abbiamo usato il backtracking per realizzare un’applicazione che tenta di attraversare un labirinto; inoltre, sarà usato nei Progetti di programmazione 6.2 (problema delle otto regine), 6.3 (percorso del cavallo), 6.4 (Sudoku) e 6.5 (Numbrix).
Eserdri di teoria__________________________ 6.1 Cosa c’è di sbagliato nel seguente metodo che calcola il fattoriale? ♦ Calcola il fattoriale di un numero intero non negativo, che è il prodotto di tutti i numeri interi che vanno da l al numero dato, ♦ compreso. La funzione worstTime(n) è 0(n), essendo n il numero ♦ intero assegnato. ♦ gparam n il numero intero non negativo di cui viene calcolato il ♦ fattoriale.
♦
* greturn il fattoriale di n * public static long fact(int n) { i f (n * 1, Tinvocazione move(n, orig, dest, temp) restituisce i passi che spostano correttamente n dischi dal piolo orig al piolo dest. Suggerimento. Per n = 1 ,2 ,3 ,..., sia l’enunciato: move(n, orig, dest, temp) restituisce i passi che spostano correttamente n dischi dal piolo orig al piolo dest.
è vero. a. Caso base. Dimostrare che l’enunciato b. Caso induttivo. Sia n un numero intero qualsiasi, maggiore di uno, e si ipotizzi che l’enunciato S„_, sia vero. Conseguentemente, dimostrare che l’enunciato S,, è vero. Sulla base del codice scritto per il metodo move, cosa succede quando si invoca move(n, orig, dest, temp) con n maggiore di uno? 6.6 Nella traccia d’esecuzione del metodo move usato nell’applicazione che risolve le Torri di Hanoi, il numero di passi è uguale al numero di invocazioni ricorsive del
232
C a p it o l o 6
metodo move, a cui va sommato il numero di mosse effettuate. Dato che ogni invo cazione del metodo move contiene una mossa, il numero di invocazioni ricorsive dd metodo move è sempre inferiore di uno rispetto al numero di mosse. Ad esempio^ nella traccia d’esecuzione delineata all’interno del capitolo appena concluso, si aveva fi = 3: in tal caso il numero di invocazioni del metodo move è 2" - 1 = 7 , quindi il numero di invocazioni ricorsive di move è 6 e il numero di mosse è 7, per un totale di 13 passi (ricordando che la traccia inizia al Passo 0, l’ultimo passo è il Passo 12). Quanti passi ci sarebbero nella traccia d’esecuzione per w = 4? 6.7 Dimostrare che, nel caso di una ricerca che abbia successo, la (unzione averageTime(ff) del metodo ricorsivo binarySearch è logaritmica in n. Suggerimento. Sia n la dimensione dell’array in cui si effettua la ricerca. Dato che il numero medio di invocazioni è una (unzione non decrescente di n, è sufficiente dimostrare che la tesi è vera per valori di n che siano inferiori di un’unità rispetto a una potenza di 2, per cui si può ipotizzare che sia w = 2* - 1, per un qualche valore k intero positivo. Durante una ricerca che ha successo, serve un’invocazione per trovare un oggetto che si trovi nella posizione centrale della regione sottoposta a ricerca, servono due invocazioni per trovare un oggetto che si trovi nella posizione pari a un quarto o tre quarti della dimensione della regione sottoposta a ricerca, servono tre invocazioni per trovare un oggetto che si trovi neUa posizione pari a un ottavo, tre ottavi, cinque ottavi o sette ottavi della dimensione della regione sottoposu a ricerca, e cosi via. Il numero totale di invocazioni per tutte le possibili ricerche che hanno successo è, quindi, uguale a (1 ♦ 1) -H (2 ♦ 2) + (3 * 4) + (4 ♦ 8) + (5 ♦ 16) + ... + (fe ♦ 2*- *) e il numero medio di invocazioni, nella stessa situazione, è uguale a questa somma divisa per n e costituisce una stima della funzione averageTime(fi). A questo punto, si può usare il risultato dell’Esercizio 2.6 dell’Appendice 2, oltre al fatto che k = log2 (« + 1). 6.8 Se un’invocazione del metodo binarySearch ha successo, verrà sempre restituito l’indice minimo tra quelli corrispondenti a oggetti uguali alla chiave cercata? Spiegare perché.
toidzi di programmaTione 6.1 Progettare una versione iterativa del metodo getBinary visto nel Paragrafo 6.3. Col
laudare tale metodo con la stessa classe, BinaryTest, usata per collaudare la versione ricorsiva. 6.2 Progettare una versione iterativa del metodo permute, qui specificato: * Trova tutte le permutazioni della stringa fornita.
♦
* 6param s la stringa da permutare.
*
* 6return una rappresentazione, sotto forma di String, di tutte le *
* *
permutazioni trovate, con un carattere '\n' dopo ogni permutazione.
public static String permute(String s)
R ic o r s io n e
233
Ad esempio, se la stringa di partenza è “BADCGEFH”,il valore restituito deve essere ABCDEFGH ABCDEFHG ABCDEGFH ABCDEGHF ABCDEHFG e cosi via. Sottoporre il metodo a opportuno collaudo. Suggerimento. Una possibile strategia consiste nella conversione di s in un array di caratteri, c. Poi, gli elementi di c possono essere agevolmente scambiati tra loro, con Tausilio dell’operatore di indicizzazione, [ ]. Per ottenere la prima permutazione, si usa il metodo statico sort della classe Arrays, nel pacchetto java.util. Per dare un’idea di come si possa costruire la permutazione successiva a partire dalla permutazione attualmente in esame, supponiamo che, dopo aver generato alcune permutazioni, l’array c abbia questo contenuto: c = [W, ’H*, *E’, *G’, ’P, *D\ *C, *B*] Qual è il più piccolo indice il cui carattere sarà sottoposto a scambio per ottenere la permutazione successiva? E l’indice 2, perché i caratteri corrispondenti agli indici che vanno da 3 a 7 sono già in ordine alfabetico inverso:‘G’ > ‘F’ > ‘D’ > ‘C ’ > ‘B’. Scambiamo ’E’ con T ’, che è il più piccolo carattere maggiore di *E’ presente in una posizione con indice maggiore di 2. Dopo lo scambio, abbiamo: c = [A \ *H'. *P, *G*, *E, *D*, *C, *B*] Invertiamo, poi, l’ordine dei caratteri che si trovano nelle posizioni che vanno dall’indice 3 all’indice 7, in modo che siano in ordine alfabetico crescente: c = [W, *H*, ’P, ’B*, *C, *D*, *E’, *G*] e la successiva permutazione sarà, poi,‘A’,*H’, ‘E’, ‘G’, ‘F’, ‘D ’, ‘C ’, ‘B’. Ecco, infine, una bozza del metodo:
public static String permute(String s) { Int n « s.lengthO;
boolean finished = false; char[ ] c = s.toCharArrayO; String perms = Arrays.sort(c); // ora c è in ordine alfabetico crescente
Nhile (!finished) {
perms +» String.valueOf(c);
234
Capitolo 6 // trova il più alto indice p in 0 ...^n-i tale che // p * 0 oppure c[p-l) < c[p]
If (p == 0) finished * true;
else {
// trova il più grande indice i in p ... n-l tale che // c[i) > c[p-l] // scambia c[i] con c[p-l] // scambia c[p] con c[n-i], scambia c[p+l] con c[n-2], // scambia c[p+2] con c[n-3], ...
} // else } // while xetum perms; } // metodo permute
Nell’esempio precedente, p - 1 » 2 e i * 4,percuic[p - l],c io è ‘E’, viene scam biato con c [i], cioè ‘F’. Spiegare come le stringhe contenenti caratteri duplicati vengano trattate in modo diverso da questo metodo rispetto alla versione ricorsiva. 6.3 Dati due numeri interi positivi, i e j, il loro massimo comun divisore (greatest com^ moti divisar), gcd(i,j) è il più grande numero intero k tale che i % fe = 0 e^ % fe = 0, Ad esempio, gcd(35, 21) = 7 e gcd(8,15) = 1. Progettare e collaudare un metodo ricorsivo (e il corrispondente metodo involucro) che restituisca il massimo comun divisore di i e j. Ecco le specifiche del metodo involucro: ♦ Trova il massimo comun divisore di due numeri interi positivi.
*
* J)param i uno dei due numeri interi positivi. * gparam j l'altro numero intero positivo.
*
* greturn il massimo comun divisore di i e j. * * gthrows IllegalArgumentException se i o j non sono *
* V
interi positivi.
public static int gcd(int i^ int j)
Suggerimento molto rilevante. In base all’algoritmo di Euclide, il massimo comun divisore à ì i c j è j se i % j = 0 ,altrimenti è il massimo comun divisore di_/ e di (i %j), 6.4 Una palindrome è una stringa che appare identica da destra a sinistra e da sinistra a destra. Ad esempio, ecco alcuni esempi di palindrome:
R ic o r s io n e
235
ABADABA RADAR OTTO MADAMIMADAM EVE In questo esercizio, consideriamo soltanto stringhe composte da lettere maiuscole (nell’esercizio successivo questa limitazione verrà rimossa). Progettare e collaudare un metodo che usi la ricorsione per verificare se una stringa è una palindrome, avendo come unico parametro la stringa da sottoporre a verifica, secondo queste specifiche: * Determina se la stringa fornita, composta da sole lettere maiuscole, * è una palindrome, cioè una stringa che appare identica da destra a * sinistra e da sinistra a destra.
*
* ^aram s (un riferimento al)la stringa da verificare. *
* ^return true se la stringa s è una palindrome; altrimenti, false. *
* ^hrows NullPointerException se s è nuli. * èthrows IllegalArgumentException se s è una stringa vuota.
* ♦/
public static boolean isPalindrome(String s) 6.5 Estendere il metodo ricorsivo (e la corrispondente classe di collaudo) sviluppato
nell'Esercizio di Programmazione 6.4 in modo che, nel verificaie se s è una palin drome, i caratteri che non sono lettere vengano ignorati e non si faccia distinzione tra lettere maiuscole e minuscole. Se s non contiene lettere viene lanciata IllegalArgumentException. I seguenti esempi sono stringhe palindrome: Madam, l'm Adam. Able was I *ere I saw Elba. A man. A pian. A canal. Panama!
Suggerimento. Il metodo toUpperCase() della classe String restituisce la stringa corrispondente a quella con cui è stato invocato, ma con tutte le lettere maiuscole.
6.6 a.
Progettare e collaudare un metodo metodo ricorsivo e il corrispondente metodo involucro, power, che restituisca il risultato dell'elevamento a potenza con base ed esponente interi. Le specifiche del metodo involucro sono: ♦ Calcola il valore di un numero intero elevato a una ♦ potenza intera; entrambi i numeri vengono ricevuti come ♦ parametri. La funzione worstTime(n) è 0(n), dove n è ♦ il secondo numero intero^ cioè il valore dell'esponente.
♦
* 9param i la base intera (che verrà elevata a potenza). * 9param n l'esponente (la potenza a cui elevare i).
236
C a p it o l o 6
♦ Return il valore di i elevato alla potenza n-esima. * ^hrows IllegalArgumentException se n è negativo oppure * se i elevato alla n è maggiore di Long.MAX^VALUE. ♦
"
♦/ public static long power(long i, int n) Suggerimento, Definiamo 0^ = 1, in modo che, per qualsiasi numero intero U sia = 1. Si ricordi che, per qualsiasi coppia di numeri interi, i > 0 e « > 0, il ha i" = i * b. Progettare una versione iterativa del metodo power e collaudarla usando lo stesso insieme di prove sviluppato al punto precedente. c. Progettare un metodo ricorsivo, invocato dal metodo involucro power, la cui fini zione worstTime(n) sia logaritmica in n, e collaudarlo usando lo stesso insieme di prove utilizzato per i punti precedenti. Suggerimento. Se n è pari, power(i, n) = power(i * i, n/2); se n è dispari, power(ij n) = i *
« i ♦ power(i * i, n/2).
6.7 Progettare e collaudare un metodo ricorsivo che calcoli in quanti modi diversi una certa quantità di denaro può essere composta mediante monete da: venticinque centesimi di dollaro (quarter), dieci centesimi {dime), cinque centesimi {nichel) e un centesimo (pefiny). Ad esempio, la quantità pari a 17 centesimi può essere composta in sei modi diversi: 1 dime, 1 nickel, 2 penny 1 dime, 7 penny 3 nickel, 2 penny 2 nickel, 7 penny 1 nickel, 12 penny 17 penny Ecco, come ulteriore esempio, alcune coppie quantità/modi. 11 primo numero di ciascuna coppia è la quantità di denaro, mentre il secondo numero indica in quanti modi diversi può essere composta tale quantità usando le monete precedentemente elencate: 17 5 10 25 42 61 99
6 2 4 13 31 73 213
Queste sono le specifiche del metodo: /**
* Calcola in quanti modi diversi si può comporre una determinata * quantità di denaro usando monete i cui valori non siano maggiori
R ic o r s io n e
237
* di quello di una moneta specifica.
* * é^aram amount la quantità assegnata. ♦ é^aram denomination la nx)neta di massimo valore utilizzabile * (l = penny, 2 = nickel, 3 = dime, 4 = quarter). * « * *
^return O se amount è minore di 0; altrimenti, il numeri di modi in cui amount può essere composta usando monete i cui valori non siano maggiori del valore della moneta identificata da denomination.
*
V public static ifit ways(liit amount, int denomination)
Allo scopo di semplificare la realizzazione del metodo ways, progettare un tipo enumerativo. Coin, oppure un metodo, coins, che restituisca il valore di ciascun tipo di moneta,in modo che coins(l) restituisca l,coins(2) restituisca 5,coins(3) restituisca 10 e coins (4) restituisca 25. Suggerimento. Usando monete non più grandi di un quarterM quantità amount può essere composta in tanti modi diversi quanti sono i modi in cui si può comporre la quantità (amount - 25) usando monete non più grandi di un quarter^ più i modi in cui si può comporre la quantità amount usando monete non più grandi di un dime. 6Jè Modificare Tapplicazione che cerca di attraversare un labirinto in modo che l’utente possa fornire la configurazione del labirinto in modo diretto, senza ricorrere a un file, lanciando eccezioni nel caso in cui le posizioni iniziale e finale siano identificate da numeri di riga e/o di colonna non validi. 6.9 Modificare l’applicazione che cerca di attraversare un labirinto in modo che siano validi anche gli spostamenti in diagonale. Suggerimento. C ’è bisogno di modificare soltanto la classe Mazelterator.
Progetto di programmazione 6.1 Soluzione iterativa delle Torri di Hanoi Progettare una versione iterativa del metodo moveDisks che risolve il rompicapo delleTorri di Hanoi e collaudarla con lo stesso insieme di prove utilizzato per la versione ricorsiva. Sugi^erimento. Ad ogni passo, è possibile individuare la mossa corrètta se ai è in grado di rispondere aUe tre domande seguenti: 1. Quale disco bisogna muovere? Per rispondete a questa domanda impostiamo un contatore a n bit, dove n è U nume ro di dischi, e lo inizializziamo con uno zero in ogni bit.Tale contatore può essere realizzato come un array di // elementi, cia.scuno dei quali può valere zero o uno, oppure come un array di « valori di tipo boolean. Per questo progetto si dovrebbe usare questo unico array.
238
Capitolo b
Se, ad esempio, n = 5, si inizia con Tarray 00000. Ciascun bit corrisponde a un disco: il hit più a destra corrisponde al disco 1, il secondo bit da destra al disco 2, e così via. Ad ogni passo, il bit di valore zero che si trova più a destra indica il disco che deve essere spostato, per cui, al passo iniziale, il disco da spostare, come si poteva facilmente prevedere, è il disco 1. 1)opo che un disco è stato spostato, si incrementa il contatore seguendo questa regola: a partire dal bit più a destra e procedendo verso sinistra, si cambia valore ai singoli bit (cioè 0 diventa 1 e 1 diventa 0) finché non viene cambiato un valore che era uguale a 0 (e, così, diventa uguale a 1). Ecco, ad esempio, i primi incrementi del contatore a partire dalla posizione iniziale, con, a fianco, Tindicazione del disco che viene spostato: OOOOO / / ()()()() 1 / / ()()() 10 / / 00011 / / 00100 / / 00101 / /
sposta sposta sposta sposta sposta sposta
il disco il disco il disco il disco il disco il disco
1 2 1 3 1 2
Dopo 31 mosse, il contatore avrà tutti i bit al valore 1, per cui non sarà possibile, né necessaria, alcuna ulteriore mossa. In generale, si effettueranno 2" —1 mosse e, conse guentemente, lo stesso numero di incrementi del contatore. 2. In quale direzione va spostato il disco che deve essere mosso? Se ti è dispari, i dischi con numero dispari si spostano in senso orario ■>C
e i dischi con numero pari si spostano in senso antiorario A ◄----------------- n migliore’'realizzazione di List. Uno degli obiettivi principali di questi due capitoli è fot nire aiuto nell’identifìcazione delle situazioni in cui una di questa due classi è preferibili rispetto all’altra.
Obiettivi del capitolo Identificare i m etodi dell'interfaccia List che non fanno parte delUnterfaccia
Collection. Com prendere il punto di vista deH'utilizzatore della classe ArrayList. Saper decidere quando un esem plare di ArrayList è preferibile a un sem plice array, e viceversa. Analizzare la classe VeryLongint, tanto dal punto di vista dell'utilizzatore quanto da quello del progettista.
7.1
UnterfacciaList
L’interfaccia List estende l’interfaccia Collection con metodi che hanno un indice come parametro o come valore restituito, come si può vedere nelle sintetiche descrizioni se guenti, relative a cinque di essi (in ciascun metodo, la lettera E, che sta per “elemento”, è il parametro di tipo). // Restituisce l'elemento in posizione index in questo oggetto di tipo // List. La funzione worstTime(n) è 0(n). E get(int index); // Sostituisce l'elemento in posizione index in questo oggetto di tipo // List con relemento fornito come parametro e restituisce l'elemento // che si trovava in tale posizione. La funzione worstTime(n) è 0(n). E set(int index, E element); // Restituisce l'indice della prima occorrenza di obj in questo oggetto // di tipo List, se obj è presente. Altrimenti, restituisce -1. // La funzione worstTime(n) è 0(n). int indexOf(Object obj); // Inserisce element nella posizione index in questo oggetto di tipo // List; ogni elemento che si trovava in una posizione >= index prima // di questa invocazione si trova ora nella posizione immediatamente // successiva. La funzione worstTime(n) è 0(n). void add(int index, E element); // Elimina e restituisce l'elemento in posizione index da questo // oggetto di tipo List; ogni elemento che si trovava in una // posizione > index prima di questa invocazione si trova ora nella // posizione immediatamente precedente. // La funzione worstTime(n) è 0(n). E remove(irrt index);
Liste
basate su array
253
Un’implementazione di questa interfaccia può, ovviamente, migliorare le stime fornite come limite superiore per le prestazioni temporali dei metodi e, in effetti, per la classe Arr a y L i s t (che vedremo nel seguito) la funzione worstTime(w) è 0 ( 1 ) sia per il metodo g e t sia per il metodo s e t . Non possiamo, in questo momento, fornire esempi di invocazioni dei metodi di L i s t , perché non si possono creare esemplari di un'interfaccia, ma i cinque metodi appena visti dovrebbero essere sufficienti per capire che molti metodi di un og getto di tipo L i s t sono basati su indice. Sono ovviamente presenti anche metodi ereditati dairinterfaccia C o l l e c t i o n , come s i z e , isEmpty, c o n t a i n s , c l e a r e così via. Il metodo add(E e le m e n t ) inserisce Telemento specificato alla fìtte della lista. Il prossimo paragrafo presenta la classe ArrayList, che implementa Tinterfaccia List, ponendo l'enfasi sul punto di vista di un utilizzatore della classe e studiando le specifiche dei suoi metodi. Nel Paragrafo 7.3, invece, ci metteremo nei panni del progettista di Ar rayList, esaminando i campi e le definizioni dei metodi effettivamente presenti nel Java Collections Framework. Infine, torneremo al punto di vista dell'utilizzatore, sviluppando un'applicazione che usa la classe ArrayList.
12
La classe AnavList
Come vedremo tra poco, un oggetto di tipo ArrayList può essere visto come una versione migliorata dell'array unidimensionale. Proprio come un array, la classe ArrayList consente l'accesso casuale ai propri elementi, cioè si può accedere a ciascun elemento in un tempo costante, una volta che sia noto soltanto il suo indice. Diversamente dall'array, però, la di mensione di un oggetto di tipo ArrayList (così come la sua capacità) viene gestita in modo automatico durante l’esecuzione di un programma. Inoltre, ci sono metodi di ArrayList per inserire o eliminare oggetti in qualsiasi posizione (cioè in relazione a qualsiasi indice), mentre se si inserisce (o si rimuove) un elemento in un array occorre scrivere il codice necessario a fare spazio (o a “chiudere il buco''). Infine, se si vuole inserire un elemento in un array pieno, bisogna scrivere codice che crei un nuovo array, copi il contenuto del vecchio array nel nuovo e così via; con un oggetto di tipo ArrayList queste espansioni sono gestite automaticamente. La Figura 7.1 mostra una panoramica della classe ArrayList dal punto di vista dei suoi utilizzatori: le intestazioni di tutti i suoi metodi pubblici. Con l'eccezione dei costruttori, i metodi sono elencati in ordine alfabetico, in base al proprio nome (cioè al proprio iden tificatore). Il parametro di tipo. E, compare tanto come tipo restituito quanto come tipo di elemento che figura in un parametro. 11 Paragrafo 7.2.1 contiene maggiori dettagli: le specifiche di molti metodi di ArrayList, con relativi esempi.
7.2.1 Specifiche dei metodi della classe ArrayList Le specifiche dei metodi riportate nel seguito usano javadoc e sono molto simili a quelle fornite con la Application Programming Interface (API) di Sun, anche se un po'abbreviate. Si consiglia vivamente di consultare tale API per conoscere le specifiche nei minimi dettagli. Nel seguito, l'espressione “questo oggetto ArrayList'' fa riferimento all'oggetto con cui è stato invocato il metodo.
254
C apitolo 7
public ArrayList(int initialCapacity) public ArrayList() public ArrayList(Collection c) public boolean retainAll(Collection c) public E set(int index, E element) public int size() public List subList(int fromindex, int toindex) public Object[ ] toArrayO public T[ ] toArray(T[ ] a) // ClassCastExceptìon se T non estende E public String toString() public void trìmToSizeO Figura 7.1
Metodi pubblici della classe ArrayLlst, dove E è il parametro di tipo; tranne per i costruttori, le intestazioni dei metodi sono elencate in ordine alfabetico, in base allldentificatore.
I requisiti in termini di tempo di esecuzione che caratterizzano ciascun metodo sono espressi mediante la notazione O-grande, dato che costituiscono semplicemente un limite superiore, che può, naturalmente, essere ridotto da una specifica realizzazione del metodo. Se per un metodo non viene fornita alcuna specifica temporale, si deve assumere che la funzione worstTime(n) debba essere costante; se i requisiti per il tempo di esecuzione medio sono uguali a quelli del caso peggiore, viene specificata la sola funzione worstTime(rf). Le seguenti specifiche di metodi della classe ArrayList delineano il punto di vista di un suo utilizzatore; per ciascun metodo viene presentato anche un esempio e un confronto con quanto avviene utilizzando un array. 1. Il costruttore con capacità iniziale fornita come parametro
/** * Inizializza questo oggetto ArrayList in modo che sia vuoto, ♦ ma con la capacità iniziale specificata. ^aram initialCapacity la capacità iniziale della lista.
Liste
basate su array
255
* * J^hrows IllegalArgumentException se la capacità specificata * è negativa * V
public ArrayList(lnt initialCapacity) Esempio L’enunciato seguente crea un oggetto vuoto di tipo ArrayList,di nome fruits,predisposto per contenere elementi di tipo (riferimento a) String e con una capacità iniziale pari a 100: ArrayList fruits =
Nota.
new ArrayList(lOO);
Esiste anche un costruttore privo di parametri. Ad esempio:
ArrayList fruits =
new ArrayList();
costruisce semplicemente un oggetto vuoto di tipo ArrayList con una capacità iniziale predefinita (in particolare, pari a 10). Confronto con un array. Anche un oggetto di tipo array viene costruito specificandone la capacità iniziale. Ad esempio: String[ ] vegetables =
new String[io];
rende vegetables un (riferimento a un) oggetto di tipo array contenente il riferimento
nuli in corrispondenza degli indici che vanno da 0 a 9, estremi compresi. Diversamente da oggetti di tipo ArrayList, un oggetto di tipo array può avere elementi di un tipo primitivo e, ad esempio:
double[ ] salaries = new double[200]; costruisce un oggetto di tipo array, con capacità iniziale uguale a 200, i cui elementi saranno di tipo doublé. 2.
Il costruttore che agisce mediante copiatura * * * * *
*
Costruisce una lista contenente gli elementi della raccolta fornita, nello stesso ordine in cui sono memorizzati in essa. Questo oggetto di tipo ArrayList ha una capacità iniziale pari al 110% della dimensione della raccolta copiata. La funzione worstTime(n) è 0(n), dove n è il numero di elementi presenti nella raccolta fornita.
* g)param c la raccolta i cui elementi vengono usati per ♦
inizializzare questo oggetto di tipo ArrayList.
public ArrayList(Collection