mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 2/39

2. La programmazione a oggetti

Java 25 · Guida completa · Bozza in revisione

Questa guida conserva lo stato di revisione del libro. La verifica editoriale e le prove di comprensione con lettori indipendenti sono ancora da completare.

Cerca in tutta la guida →

Introduzione

Questo capitolo prepara il terreno per il codice che scriveremo più avanti. Parleremo di oggetti, classi, responsabilità, incapsulamento ed ereditarietà senza pretendere di esaurire argomenti su cui sono stati scritti interi libri. Se è la prima volta che incontri queste parole, non cercare subito di memorizzarle: prova a seguire il problema che le rende utili.

Immagina una piccola biblioteca. Un lettore chiede un libro, qualcuno controlla se è disponibile e, se lo è, registra il prestito. Possiamo descrivere il lavoro come una sequenza di istruzioni, come un insieme di funzioni oppure come una collaborazione fra entità che rappresentano libri, lettori e prestiti. Sono modi diversi di guardare lo stesso problema. Java permette di usarli insieme; l'orientamento agli oggetti è il filo che useremo per organizzare l'applicazione.

Definizione – Paradigma di programmazione. È un modo di formulare i problemi e organizzare le soluzioni. Non è un elenco di parole chiave, né una garanzia di qualità. Un buon programma può contenere parti procedurali, funzioni e oggetti, purché le scelte siano coerenti con il problema.

Un passo indietro: dalla macchina ai concetti

La storia dei linguaggi non è una scala sulla quale ogni gradino rende inutile il precedente. Il linguaggio macchina descrive istruzioni eseguibili da un processore; l'assembly dà nomi simbolici a istruzioni e dati, ma resta vicino all'architettura della macchina. I linguaggi di livello più alto consentono di esprimere algoritmi, strutture e vincoli senza riscriverli ogni volta come una successione di istruzioni elementari. Rimangono, però, linguaggi: nessuno di essi comprende da solo ciò che intendiamo per libro disponibile o prestito valido. Quel significato dobbiamo costruirlo noi.

Fermiamoci su Ada Lovelace e Charles Babbage, perché la domanda che li accompagnava è la nostra: come si descrive a una macchina una sequenza di operazioni, senza confondere la sequenza con il problema che vogliamo risolvere? Nelle note che accompagnavano la sua traduzione di un articolo sulla macchina analitica di Babbage, pubblicata nel 1843, Lovelace presentò anche una tabella di istruzioni per il calcolo dei numeri di Bernoulli. La riproduzione e la spiegazione conservate dal Computer History Museum mostrano il documento storico. Non occorre conoscere quei numeri per cogliere il punto: un calcolo complesso viene scomposto in passi che una macchina progettata per eseguirli avrebbe potuto seguire. Chiamarla semplicemente «il primo programma» cancellerebbe discussioni storiche che qui non ci servono; ciò che ci interessa è l'idea di rendere esplicite operazioni e dati.

Un altro nome importante per questo sguardo storico è Konrad Zuse. Il suo Plankalkül fu un progetto di linguaggio ad alto livello assai precoce, ricordato nella scheda storica del Computer History Museum. Non immaginare una linea retta che va da quelle idee a Java, come se ogni linguaggio fosse nato per correggere il precedente. I problemi, le macchine disponibili e gli scopi erano diversi. Il breve sguardo storico serve a vedere che la ricerca di un'espressione più vicina al problema accompagna la programmazione da molto prima dei moderni linguaggi a oggetti.

La figura confronta i due modi di descrivere il calcolo attraverso uno schema, senza presentarsi come facsimile dei documenti. Nel riquadro di Lovelace guarda le tre colonne: un'operazione usa variabili e porta a un risultato. Nel riquadro di Zuse osserva la diversa ambizione: una notazione per descrivere dati e regole a un livello meno legato alle istruzioni immediate della macchina. I due documenti non sono tappe di una discendenza diretta verso Java; sono prove storiche di domande che torneranno nel nostro esempio della biblioteca.

Due modi storici di descrivere il calcolo
Figura 2.1 – La tabella di Lovelace e il progetto di Zuse sono richiamati con uno schema originale, non con riproduzioni dei documenti. Le fonti storiche collegate nel testo permettono di vedere i materiali; lo schema indica che cosa osservare in essi.

Partiamo allora da ciò che esegue il processore. Nel linguaggio macchina le istruzioni sono codificate in una forma specifica dell'architettura: un programma costruito per una famiglia di processori non diventa automaticamente eseguibile su un'altra. Il linguaggio assembly sostituisce a molte codifiche numeriche nomi simbolici per operazioni, registri ed etichette. Se vogliamo sommare due valori, anziché ricordare l'intera codifica dell'istruzione possiamo scrivere un'operazione con un nome mnemonico. Questo aiuta una persona a leggere il programma, ma non elimina la necessità di ragionare su registri, indirizzi e istruzioni disponibili su quella macchina. Un esempio di assembly richiede sempre l'indicazione dell'architettura a cui appartiene: non esiste una singola sintassi assembly valida per ogni processore.

Un piccolo «Hello, world!» in assembly fa vedere quali dettagli il programmatore deve seguire. Questo è un esempio storico per Linux a 32 bit su x86, scritto nella sintassi NASM e con le vecchie chiamate di sistema tramite int 0x80; non è un listato Java e non va letto come una ricetta per un sistema moderno.

section .text
    global _start
_start:
    mov edx, len
    mov ecx, msg
    mov ebx, 1
    mov eax, 4
    int 0x80
    mov eax, 1
    mov ebx, 0
    int 0x80

section .data
msg db 'Hello, world!', 0xa
len equ $ - msg

Le ultime due righe definiscono il messaggio e ne calcolano la lunghezza. Le istruzioni mov collocano nei registri i dati richiesti dalla chiamata di scrittura: lunghezza, indirizzo del messaggio, destinazione e numero dell'operazione. int 0x80 passa il controllo al sistema operativo; la seconda chiamata termina il processo. Persino per stampare una frase dobbiamo quindi sapere dove si trovano i dati e come quella piattaforma chiede l'output. Nel programma della biblioteca vorremmo invece poter ragionare prima sulla richiesta di un prestito. La distanza fra questi due livelli spiega l'utilità delle astrazioni successive, senza cancellare il fatto che alla fine il programma deve pur essere eseguito da una macchina.

Con i linguaggi di livello più alto possiamo dare un nome a una procedura come registraPrestito e lasciare che compilatore e ambiente di esecuzione traducano la descrizione nelle operazioni necessarie. La distanza si riduce, ma non scompare: il computer non sa da solo che una copia già in prestito non può essere prestata di nuovo. Questa è una regola del nostro problema. Lo stesso valeva per l'esempio storico del linguaggio C, nato in un contesto diverso da Java: offriva strumenti per comporre procedure e lavorare vicino alla macchina, ma la qualità della soluzione dipendeva ancora da come il programmatore organizzava dati e responsabilità.

Approfondimento – Perché guardare indietro. La storia non dimostra che ogni paradigma sia «migliore» di quello precedente. Mostra piuttosto che le persone hanno inventato astrazioni diverse per governare la complessità. Un'astrazione è una descrizione che lascia sullo sfondo alcuni dettagli per mettere a fuoco quelli importanti in quel momento. L'assembly nasconde parte della codifica numerica delle istruzioni; una procedura nasconde i passi necessari a un compito; un oggetto può nascondere il modo in cui conserva il proprio stato. In ciascun caso bisogna ancora sapere quali dettagli tornano importanti quando qualcosa non funziona.

Guardare il lavoro come una procedura

Torniamo alla biblioteca. Possiamo scrivere il flusso come quattro passi: ricevere la richiesta, cercare il libro, controllarne la disponibilità e registrare il prestito. La figura mostra questo ordine. Una procedura raccoglie istruzioni che svolgono un compito; una funzione è una forma di procedura che produce un risultato utilizzabile da chi la chiama. Nel linguaggio quotidiano i termini si sovrappongono talvolta, ma qui ci interessa la domanda pratica: che cosa deve ricevere ciascun pezzo di programma e che cosa deve restituire?

Flusso procedurale del prestito
Figura 2.2 – Dal titolo richiesto alla registrazione del prestito. Lo schema rende visibile il flusso procedurale usando nomi che ritroveremo nelle figure successive.

Dividere il lavoro in passi piccoli rende più semplice leggere e correggere il programma. Il pericolo nasce quando ogni passo può modificare liberamente dati condivisi. Se, per esempio, il numero dei prestiti attivi è una variabile accessibile a qualsiasi parte dell'applicazione, chi registra un prestito può aggiornarla mentre un'altra parte la cambia per un motivo diverso. L'errore non dipende dal fatto che il programma sia procedurale: dipende da una dipendenza nascosta fra operazioni e stato.

Nota bene – I dati globali non sono obbligatori. Una procedura può ricevere i dati necessari come parametri e restituire un risultato. Anche un programma procedurale può essere modulare e ben progettato. La frase «le procedure usano necessariamente variabili globali» darebbe un'immagine falsa del paradigma.

Chiamiamo divide et impera questo modo di affrontare un problema: dividere ciò che sembra troppo grande in compiti più piccoli, ciascuno abbastanza semplice da poter essere compreso e controllato. La metafora non dice ancora come dividere. Se spezziamo una funzione lunga in dieci funzioni che leggono tutte la stessa variabile globale, abbiamo ottenuto file più corti ma non responsabilità più chiare. Se invece una funzione riceve il libro richiesto e restituisce un esito che dichiara se il prestito è stato registrato, chi la chiama può ragionare sul suo contratto senza ispezionare ogni istruzione interna.

La distinzione fra parametro, valore restituito e dato condiviso sarà più facile da vedere nel codice dei capitoli successivi, ma possiamo già seguirla a parole. Un parametro è un'informazione che il chiamante passa alla procedura; un valore restituito è la risposta che la procedura consegna al chiamante. Un dato condiviso rimane invece accessibile da più parti del programma anche quando non compare nella chiamata. Può essere utile, ma è una dipendenza che deve essere progettata e dichiarata. In una biblioteca il catalogo può essere un dato condiviso; il titolo che il lettore sta cercando è più naturalmente un parametro della ricerca. La funzione che cerca può restituire l'elenco degli esemplari trovati.

Seguiamo un errore, invece di limitarci a nominarlo

Immaginiamo che la biblioteca conservi in una variabile condivisa il numero di prestiti attivi. La procedura registraPrestito aggiunge una registrazione e aumenta quel numero. La procedura stampaRicevuta, invece, si limita a mostrare un messaggio. Finché tutti passano da registraPrestito, il numero corrisponde alle registrazioni. Un nuovo collega aggiunge prestitoRapido: crea direttamente la registrazione e poi chiama stampaRicevuta. Il lettore riceve la sua ricevuta, perciò a prima vista sembra tutto corretto. Solo più tardi un controllo mostra un prestito in meno del dovuto.

Il difetto non sta nella stampa né nel valore iniziale del contatore. Sta nel fatto che due operazioni che dovevano avvenire insieme erano accessibili separatamente. Se correggessimo soltanto prestitoRapido, un'altra funzione potrebbe ripetere domani lo stesso errore. La soluzione più solida è concentrare la regola in un unico punto: per creare un prestito valido bisogna chiamare un'operazione che registra il prestito e aggiorna, o ricava, il totale. La stampa riceve il risultato di quell'operazione e non modifica il conteggio.

Nel nostro schema, Print e Output rendono visibile lo stesso rischio. Print preparava la stampa e aggiornava alcuni dati; Output effettuava soltanto l'operazione di basso livello verso il terminale. Se una nuova funzione chiamava direttamente Output, saltava il passaggio affidato a Print. Il messaggio appariva sullo schermo, ma lo stato che il resto del programma si aspettava aggiornato rimaneva vecchio. L'errore poteva emergere molto più tardi, in una funzione che leggeva quel dato e sembrava del tutto estranea alla stampa. Non è necessario sostenere che ogni programma procedurale sia difficile da debuggare per riconoscere il problema: qui il nesso fra chiamata e modifica dei dati era invisibile a chi usava Output.

La dipendenza nascosta fra stampa e dato condiviso
Figura 2.2a – Due percorsi producono lo stesso messaggio, ma soltanto quello che passa da Print mantiene il dato condiviso atteso. La freccia tratteggiata indica l'aggiornamento mancante; la conseguenza può propagarsi fino a main.

Possiamo seguire la propagazione senza attribuirle qualcosa di misterioso. Supponi che f4() legga il contatore rimasto indietro e scelga un ramo errato; f2() e f3() ricevono allora valori incoerenti; infine il programma principale mostra un risultato sbagliato o termina per un controllo fallito. Il punto in cui si osserva il problema non coincide con il punto in cui è stato introdotto. Una catena che arriva fino a main è un buon motivo per disegnare i confini di un modulo prima di guardare soltanto la riga che fallisce.

Definizione – Effetto collaterale. Un'operazione produce un effetto collaterale quando modifica qualcosa che chi la chiama può osservare oltre al suo valore di ritorno: per esempio un contatore condiviso, un file o il contenuto dello schermo. Un effetto collaterale può essere necessario; diventa pericoloso quando resta nascosto oppure non è coordinato con le altre modifiche.

Supponiamo ora che una schermata legga il totale mentre un'altra parte del programma sta aggiungendo un prestito. Abbiamo un problema ulteriore, quello della concorrenza, che richiederà regole specifiche. Per il momento ci basta capire la causa più semplice: i dati condivisi moltiplicano i punti dai quali una regola può essere infranta. Limitare l'accesso a quei dati riduce i punti da controllare, ma non sostituisce la verifica delle operazioni che rimangono pubbliche.

Correggere una dipendenza nascosta

Raggruppiamo le operazioni secondo la loro responsabilità. Un modulo riceve la richiesta, un modulo conosce il catalogo, un modulo registra i prestiti. Chi usa il catalogo non deve conoscere i dettagli con cui è memorizzato; gli basta un'operazione che risponda alla domanda «questo libro è disponibile?». Nella figura, i nomi delle parti rendono riconoscibile lo stesso lavoro mostrato prima.

Moduli della gestione prestiti
Figura 2.3 – Lo stesso problema suddiviso per responsabilità. La separazione limita ciò che ogni parte deve conoscere e rende più facile localizzare una modifica.

Questo è un primo esempio di incapsulamento: i dettagli interni di una parte sono raggiungibili soltanto attraverso le operazioni che essa sceglie di esporre. L'incapsulamento non nasce con gli oggetti. Linguaggi procedurali e moduli lo possono esprimere con strumenti diversi. Gli oggetti porteranno la stessa idea in una forma che associa stato e comportamento a un'entità del modello.

Approfondimento – Due significati di static in C. Una variabile locale static conserva il proprio valore fra chiamate della funzione che la contiene, mentre il suo nome resta visibile soltanto in quella funzione. Una funzione dichiarata static a livello di file ha invece visibilità limitata al file sorgente in cui è definita. Il primo uso riguarda la durata del dato, il secondo la visibilità di una funzione. In Java incontreremo la stessa parola chiave, ma con regole proprie del linguaggio: non dobbiamo trasferire automaticamente l'intuizione costruita sul C.

Nella figura il modulo dei prestiti espone registra e cerca, mentre trattiene la struttura che contiene le registrazioni. Il modulo delle ricevute non aggiorna il conteggio: riceve i dati da mostrare. Questa distribuzione non è un trucco del linguaggio; è una decisione su chi può fare che cosa. Se cambiamo il modo di conservare i prestiti, chi stampa una ricevuta continua a usare l'operazione pubblica, purché il suo significato resti uguale.

Vale la pena riprendere con precisione il piccolo esempio storico del C. Una variabile locale dichiarata static conserva il valore fra chiamate alla funzione, pur essendo visibile solo lì. Una funzione dichiarata static a livello di file è invece visibile soltanto all'interno di quell'unità di traduzione. Sono due usi diversi della stessa parola chiave. Nessuno dei due rende automaticamente corretta la logica: una funzione che somma valori in una variabile persistente, per esempio, richiede comunque una decisione su come azzerare la somma e su che cosa accade se viene usata da chiamate concorrenti. In Java vedremo altri strumenti per esprimere visibilità e stato; non trasferiremo meccanicamente la regola del C alla parola static che incontreremo nel codice Java.

E le funzioni?

Possiamo affrontare una parte del problema come una trasformazione: dato un insieme di libri e un titolo, otteniamo i libri corrispondenti. Una funzione pura restituisce lo stesso risultato per gli stessi argomenti e non produce effetti osservabili all'esterno della funzione. È una proprietà utile perché rende più facile ragionare sul risultato. Non tutti i compiti possono essere puri: registrare un prestito cambia lo stato della biblioteca e, prima o poi, deve produrre un effetto.

Java mette a disposizione strumenti funzionali che incontreremo più avanti, in particolare quando tratteremo le funzioni come valori e lavoreremo con le collezioni. Per ora basta una distinzione: scegliere una funzione pura per una trasformazione di dati può essere opportuno; scegliere un oggetto per rappresentare un prestito con identità e durata può esserlo altrettanto. Le prestazioni e la leggibilità dipendono dal programma concreto, non dall'etichetta del paradigma.

Proviamo a formulare due domande diverse. «Quali libri del catalogo contengono la parola mare nel titolo?» è una domanda su dati già disponibili. Possiamo darle una funzione che riceve il catalogo e la parola cercata, e restituisce un risultato senza cambiare il catalogo. Se la richiamiamo con gli stessi dati, ci aspettiamo lo stesso risultato. «Presta questo esemplare al lettore» è invece un comando: dopo averlo eseguito, la disponibilità dell'esemplare cambia. Cercare di descrivere entrambe le operazioni come se fossero prive di effetti non chiarisce il problema.

Immutabilità e ricorsione possono comportare costi e rendere un programma difficile da leggere. È una possibilità, non un destino della programmazione funzionale. Un algoritmo può usare dati immutabili con strutture che condividono parti interne, oppure può essere espresso senza una ricorsione profonda. L'unico modo serio di confrontare due soluzioni per costo e chiarezza è guardare l'algoritmo, le strutture impiegate e il carico reale. Nei prossimi capitoli useremo oggetti e funzioni insieme quando ciascuno aiuta a raccontare una parte del problema.

Guardare il lavoro come una collaborazione fra oggetti

Nella nostra biblioteca riconosciamo almeno un Libro, un Lettore e un Prestito. Un libro ha informazioni, per esempio titolo e autore. Un prestito collega un lettore a un libro e conosce quando è iniziato. La biblioteca offre operazioni per cercare e registrare. Queste entità non sono copie automatiche degli oggetti fisici: sono scelte di modello. Se il programma deve soltanto stampare un catalogo, forse non serve rappresentare ogni prestito. Se deve controllare restituzioni e scadenze, quel concetto diventa importante.

Definizione – Oggetto. Un oggetto è un'entità del programma con un'identità nel modello, uno stato osservabile attraverso le operazioni previste e un comportamento. In Java molti oggetti sono istanze di classi. Non tutto ciò che il programma usa è un oggetto: il linguaggio ha anche tipi primitivi, che incontreremo nel capitolo sulla sintassi.

Chi decide le operazioni di un oggetto decide anche quali dettagli nascondere. Un Prestito, per esempio, può offrire un'operazione per chiuderlo. Se dall'esterno fosse possibile cambiare liberamente la data di restituzione senza rispettare le regole, il programma potrebbe rappresentare una situazione impossibile. Un nome significativo aiuta a leggere il codice, ma da solo non lo rende corretto o autodocumentante: servono responsabilità chiare, contratti comprensibili ed esempi che mostrino i limiti.

Seguiamo una richiesta completa. Lettore identifica chi chiede il volume; Catalogo trova l'esemplare; un'operazione della Biblioteca verifica che quell'esemplare sia disponibile e crea un Prestito. Infine la ricevuta legge i dati del prestito. Abbiamo costruito una sequenza di collaborazioni, non un elenco di sostantivi da trasformare automaticamente in classi. «Disponibile» potrebbe essere uno stato conservato nell'esemplare oppure una risposta calcolata dalle registrazioni: la scelta dipende da come il sistema dovrà mantenere coerenti i dati. Quando aggiungeremo le classi vere, questa domanda determinerà metodi e controlli.

Concetto chiave – Responsabilità e messaggi. Dire che un oggetto «chiede» qualcosa a un altro è un modo leggibile di descrivere una chiamata a un'operazione. Chi chiama conosce il contratto dell'operazione, non necessariamente i passi interni. Se il Catalogo offre cercaPerTitolo, la Biblioteca deve sapere quali dati fornire e che tipo di risposta aspettarsi; non deve conoscere l'algoritmo con cui il catalogo effettua la ricerca.

Riferimento essenziale – Una fotografia storica del paradigma. La ricerca di David E. Monarchi e Gretchen I. Puhr, A Research Typology for Object-Oriented Analysis and Design, pubblicata nel 1992, offre un quadro storico dell’analisi e della progettazione a oggetti. Quel quadro è utile per capire quali domande ponevano metodi e strumenti dell'epoca; non è una specifica di Java e non rende obbligatoria ogni caratteristica per ogni programma. Nel nostro esempio possiamo verificare le idee una per una: un Prestito conserva una parte dello stato, espone operazioni e collabora con il Catalogo; la qualità del modello dipende dalle regole che quelle operazioni proteggono, non dal semplice numero di oggetti.

La tabella chiamava gli oggetti «entità anonime». In un programma Java possiamo assegnare il nome prestito a una variabile, ma quel nome appartiene al riferimento nel codice, non all'oggetto come identità intrinseca: un altro riferimento può indicare la stessa istanza. La tabella ricordava anche gerarchie e oggetti astratti. Una classe generale può essere dichiarata astratta quando non ha senso creare direttamente un'istanza che soddisfi il suo contratto; non serve rendere astratta ogni classe base. Ereditarietà e messaggi sono strumenti possibili, mentre nomi chiari e responsabilità leggibili richiedono lavoro di progettazione: gli oggetti non diventano autodocumentanti da soli.

Infine, modellare lo stato della biblioteca non implica che ogni cambiamento del sistema sia rappresentato da un campo modificato in un oggetto. Alcuni risultati possono essere calcolati da dati immutabili; altri compiti, come registrare un prestito, modificano uno stato. Più attività possono agire contemporaneamente e allora occorrono regole di coordinamento, che studieremo nel capitolo sui thread. Java consente anche procedure e funzioni: la scelta utile è quella che rende riconoscibili il problema, la responsabilità della modifica e la prova che la regola resta vera.

Classi di oggetti

Pensa al libro che stai leggendo. Puoi parlare di un libro come concetto generale e, nello stesso tempo, distinguere questo esemplare dagli altri. La classe descrive le caratteristiche e le operazioni comuni; ogni oggetto creato secondo quella descrizione possiede il proprio stato. Due libri possono avere lo stesso titolo senza essere lo stesso esemplare. Più avanti vedremo anche che identità e uguaglianza sono domande diverse: due oggetti distinti possono avere contenuti uguali.

Definizione – Classe e istanza. Una classe è una dichiarazione che descrive un tipo di oggetti e ne può definire stato e comportamento. Un'istanza è un oggetto creato secondo quella classe. Una classe non è «l'unico oggetto generale» da cui nascono copie identiche: è la descrizione che la JVM usa per creare oggetti distinti.

Un modello utile nasce da ciò che il programma deve fare, non da ogni proprietà che un libro possiede nel mondo reale. Se dobbiamo trovare un volume nel catalogo, titolo e autore sono importanti. Il numero di pagine può esserlo per un libro cartaceo; per un libro digitale potrebbe avere un significato diverso. La figura mostra una generalizzazione possibile, non una classificazione valida per ogni biblioteca.

Per evitare una confusione frequente, distinguiamo anche il titolo dall'esemplare. «Il nome della rosa» è un titolo che può comparire in più copie fisiche. Se una sola copia è in prestito, le altre possono essere disponibili. Rappresentare tutto con un unico oggetto Libro dotato di un solo campo disponibile potrebbe quindi essere insufficiente. Possiamo descrivere l'opera con un oggetto e ogni copia con un altro, oppure usare un modello diverso se l'applicazione gestisce soltanto titoli digitali. La classe non arriva prima del problema: nasce dalle distinzioni che il problema ci obbliga a fare.

Allo stesso modo, due oggetti che descrivono copie distinte possono avere lo stesso titolo e lo stesso autore. Sono simili per contenuto, ma non sono la stessa copia. Il programma deve sapere quando sta chiedendo «sono la medesima istanza?» e quando invece sta chiedendo «rappresentano lo stesso valore?». Non serve conoscere ora gli operatori Java per fare questa distinzione; ci servirà quando confronteremo oggetti nel codice.

Libro e specializzazioni
Figura 2.4 – LibroCartaceo e LibroDigitale sono due possibili specializzazioni di Libro. Le frecce puntano verso la classe più generale; le proprietà specifiche restano nella classe che le possiede.

Possiamo classificare i libri anche per argomento: da Libro potremmo disegnare rami come storia, fisica, letteratura, scienze e matematica. Questo schema distingue categorie più generali e più specifiche, ma non decide da solo come progettare le classi. Un libro di matematica per la scuola appartiene sia alla matematica sia ai testi scolastici: sono davvero due tipi di oggetto da esprimere con due superclassi, oppure due proprietà con cui il catalogo lo classifica? Nel nostro modello di biblioteca il tema e la destinazione scolastica possono essere dati del libro, anche quando la distinzione cartaceo/digitale cambia le operazioni disponibili. Possiamo quindi assegnare più categorie allo stesso esemplare senza inventare una sottoclasse per ogni combinazione. La scelta si verifica chiedendo quali operazioni cambiano: una categoria usata soltanto per cercare libri può restare un dato; una differenza di comportamento richiede invece un contratto da spiegare.

Ereditarietà e polimorfismo

Quando diciamo che LibroDigitale è un Libro, stiamo proponendo una relazione di specializzazione. In Java una classe può estendere un'altra classe e acquisire le caratteristiche accessibili della classe base, detta anche superclasse. La sottoclasse aggiunge o modifica ciò che rende specifica la variante. Useremo questi due nomi in modo coerente: «classe base» per la parte generale e «sottoclasse» per quella specializzata.

Definizione – Polimorfismo. Il codice può usare un riferimento di tipo generale e incontrare oggetti di tipi più specifici. Quando chiama un metodo ridefinito, viene eseguita l'implementazione appropriata all'oggetto effettivo. Non significa che un oggetto cambi classe durante l'esecuzione: significa che un'operazione comune può avere comportamenti specifici.

Supponiamo che ogni Libro sappia produrre una breve descrizione. Il catalogo può chiedere la descrizione a un libro senza conoscere in anticipo se sia cartaceo o digitale; l'oggetto specifico fornisce il comportamento previsto. È un vantaggio reale quando la relazione fra i tipi è stabile e le regole sono chiare. Non è una scorciatoia automatica per riusare codice: una gerarchia sbagliata rende più difficile cambiare il programma.

I mezzi di trasporto ci aiutano a isolare il meccanismo. Un treno, un aereo e un'automobile possono tutti rispondere alla richiesta «descrivi come ti sposti». La richiesta è comune, la risposta cambia secondo il mezzo concreto. Il codice che raccoglie le descrizioni può lavorare attraverso un tipo generale. Questo non vuol dire che i tre mezzi siano intercambiabili in ogni operazione: soltanto le promesse contenute nel contratto comune valgono per tutti. Un'operazione «apri le ali» non appartiene a quel contratto.

In Java il riferimento attraverso cui usiamo un oggetto ha un tipo dichiarato, mentre l'oggetto creato ha un tipo effettivo. Se il metodo chiamato può essere ridefinito, la scelta dell'implementazione avviene in base all'oggetto effettivo. È il binding dinamico che rende concreto il polimorfismo in questo caso. La differenza tra tipo del riferimento e tipo dell'oggetto merita esempi di codice: la riprenderemo quando sapremo leggere dichiarazioni e metodi.

Ereditarietà e composizione
Figura 2.5 – «È un» e «ha una» portano a scelte diverse. LibroDigitale può essere un tipo di Libro; una Biblioteca ha una collezione di libri, ma non è una collezione di libri.

Un libro di matematica può appartenere a più categorie del catalogo. Se però usiamo frecce di ereditarietà per rappresentare queste categorie, rischiamo di confondere la classificazione dei libri con le regole delle classi Java. Java consente a una classe di estendere una sola classe, mentre può implementare più interfacce. Un'etichetta di catalogo può anche essere un semplice dato. Prima di disegnare le frecce chiediamo quindi quale contratto comune serve al codice e quali nomi descrivono soltanto modi diversi di trovare un libro.

La seconda relazione della figura è la composizione: un oggetto contiene o usa altri oggetti per svolgere il proprio lavoro. Quando una proprietà può cambiare indipendentemente dall'identità dell'oggetto, o quando vogliamo sostituire un comportamento senza inventare una parentela, la composizione è spesso più semplice. Nel seguito del libro torneremo sul confronto con esempi di codice.

L'ereditarietà permette anche di riusare implementazioni, ma il riuso da solo non basta a giustificare una gerarchia. Se LibroDigitale estende Libro, eredita ciò che la classe base rende disponibile e può aggiungere comportamenti specifici. Se però Libro promette che ogni esemplare ha pagine fisiche da sfogliare, il libro digitale non rispetta quella promessa: abbiamo scelto una generalizzazione troppo stretta. Conviene correggere il concetto comune oppure comporre l'oggetto con servizi distinti, invece di costringere una sottoclasse a simulare operazioni che non possiede.

Nota bene – Una modifica alla classe base si propaga davvero. Cambiare un metodo della superclasse può cambiare il comportamento di molte sottoclassi; non è automaticamente un vantaggio di manutenzione. Chi modifica una gerarchia deve verificare anche i contratti delle classi che la estendono. La promessa «basta cambiare una classe e tutto il resto resta corretto» non può essere fatta senza prove.

Incapsulamento e stato valido

Un oggetto non è un sacchetto di variabili liberamente modificabili. Se rappresenta un prestito, dovrebbe impedire combinazioni prive di senso: una restituzione precedente all'inizio, oppure una chiusura registrata due volte senza una regola. Nascondere i campi è un passo utile, ma non basta. Anche un metodo pubblico può rompere lo stato se permette qualunque valore. L'incapsulamento funziona quando le operazioni esposte proteggono le invarianti, cioè le condizioni che devono restare vere mentre l'oggetto viene usato.

Concetto chiave – Nascondere non significa garantire. Una classe con campi privati può comunque avere un comportamento sbagliato. I metodi devono controllare gli argomenti e mantenere le regole dell'oggetto; i test devono verificare anche i casi che potrebbero violarle.

Una pila di dati rende concreti push e pop e rende visibile che cosa l'incapsulamento deve difendere. Una pila conserva elementi in un ordine preciso: l'ultimo inserito è il primo estratto. La chiamata push("Ada") inserisce un nome; push("Grace") colloca il secondo sopra il primo. A questo punto il contenuto, letto dalla cima, è:

Posizione nella pila Valore dopo i due inserimenti
Cima: prossimo pop "Grace"
Sotto la cima "Ada"

Una prima chiamata a pop() restituisce "Grace" e lascia "Ada" in cima; una seconda restituisce "Ada". Se chi usa la pila potesse spostare direttamente i due elementi nel contenitore interno, la regola non sarebbe più garantita. Se chiama pop() una terza volta, però, non basta che il contenitore sia nascosto: il contratto dell'operazione deve dire che cosa succede a pila vuota. Può segnalare un errore o scegliere un'altra risposta dichiarata, ma non deve far indovinare al chiamante un comportamento accidentale. La tabella rende esplicito il comportamento della pila; un'implementazione completa della struttura dati potrà approfondire le scelte senza lasciare incompleto l'esempio di incapsulamento.

Un'interfaccia pubblica ben progettata permette spesso di cambiare la rappresentazione interna senza cambiare chi usa l'oggetto. Non promette però che ogni modifica resti locale: se cambiano il significato delle operazioni, i tempi di risposta o le eccezioni, gli altri componenti possono risentirne. Per questo gli oggetti richiedono attenzione alle dipendenze e verifiche, proprio come i moduli procedurali.

La pila ci mostra anche la differenza fra una promessa sull'ordine e una promessa sulla capacità. Una pila con spazio fisso può essere piena; una che cresce può incontrare comunque limiti di risorse. Le operazioni pubbliche devono rendere riconoscibili questi casi, e la rappresentazione interna deve sostenere il contratto scelto. Più avanti costruiremo una pila; il modello che serve a capire l'incapsulamento è già qui, con il caso normale e quello vuoto.

Un'altra regola utile è distinguere visibilità e consistenza. Rendere un campo private limita chi può raggiungerlo direttamente; non assicura che il valore sia valido. Una funzione pubblica chiudiPrestito che accetti qualunque data potrebbe comunque registrare una restituzione precedente al prestito. L'oggetto deve controllare la data al confine dell'operazione e decidere come comunicare il rifiuto. Questa è la parte dell'incapsulamento che protegge il significato, oltre alla memoria.

Alcune buone regole per cominciare

Un oggetto dovrebbe avere una responsabilità che il lettore sappia descrivere in una frase. Il suo stato dovrebbe restare valido dalla creazione e dopo ogni operazione. Prima di scegliere l'ereditarietà, chiediti se la frase «è un» ha davvero senso e se il comportamento della sottoclasse rispetta le attese di chi usa la classe base. Un televisore e una lampadina possono entrambi essere accesi, ma non per questo il televisore è una lampadina: possono condividere un'operazione senza appartenere alla stessa famiglia.

Torniamo al prestito. Se affidiamo a un unico oggetto Biblioteca la ricerca dei libri, il controllo delle copie, il calendario delle scadenze e la stampa delle ricevute, ogni modifica sembra richiedere di entrare nella stessa classe. Per esempio, cambiare il formato della ricevuta potrebbe costringerci a rileggere la regola che impedisce di prestare una copia già occupata. Possiamo dare al Catalogo la responsabilità di trovare una copia, al Prestito quella di rappresentare la durata e l'esito, e a un componente di presentazione quella di produrre la ricevuta. La Biblioteca coordina la richiesta senza custodire per forza tutti i dettagli. La divisione funziona se sappiamo dire quale oggetto conserva l'invariante «la stessa copia non può essere prestata due volte nello stesso periodo»: spezzare una classe grande in tre classi che si modificano liberamente a vicenda non risolverebbe il problema.

Prova a cambiare il requisito: la biblioteca consente una prenotazione quando tutte le copie sono occupate. Una prenotazione è una specie di libro, un comportamento del lettore o un nuovo concetto che collega lettore, opera e posizione in attesa? Disegna due alternative e segui una richiesta fino alla risposta. La soluzione migliore è quella in cui puoi individuare chi decide se la prenotazione è valida e chi aggiorna la disponibilità quando una copia torna libera. Questo esercizio verifica la regola delle responsabilità su un caso nuovo; non si risolve contando le classi o ripetendo l'espressione «è un».

Queste regole non sostituiscono la pratica. All'inizio è normale modellare troppi oggetti o dare a uno solo tutte le responsabilità. Il rimedio è verificare un caso d'uso concreto, cambiare il modello quando crea attrito e tenere il codice abbastanza piccolo da poterlo correggere.

È tutto oro quello che luccica?

No. Il linguaggio offre classi, metodi, interfacce ed ereditarietà; il programmatore sceglie come usarli. Si può organizzare un programma in modo simile agli oggetti anche in un linguaggio che non possiede classi, passando esplicitamente una struttura dati alle funzioni che la gestiscono. Immagina in C una struttura Libro e funzioni come presta(Libro *esemplare, Lettore *lettore): i dati e le operazioni collaborano attorno allo stesso concetto, ma il legame deve essere espresso dal programmatore. I prototipi devono dichiarare gli stessi parametri usati nelle definizioni, e la memoria associata ai nomi richiede una gestione esplicita. Questi dettagli fanno parte della correttezza dell'esempio, non sono una differenza cosmetica rispetto alla sintassi Java.

Allo stesso modo, si può scrivere Java pieno di classi ma difficile da leggere. Un nome elegante non salva una responsabilità confusa. Un oggetto che nasconde ogni dato senza spiegare che cosa fa non aiuta il lettore del codice. L'obiettivo è un modello che renda esplicite le regole del problema e consenta di verificarle.

Approfondimento – Un po' di storia. Simula rese pratici i concetti di classe e oggetto per descrivere sistemi da simulare. Alan Kay diede grande rilievo all'idea di entità che collaborano scambiandosi messaggi; in Smalltalk questo modo di pensare divenne centrale. La diffusione delle interfacce grafiche e dei calcolatori più potenti rese interessanti modelli capaci di tenere insieme stato e comportamento. Negli anni Ottanta il nome object oriented si diffuse fra linguaggi e strumenti anche molto diversi, alimentando un dibattito su che cosa fosse essenziale: messaggi, incapsulamento, collegamento dinamico delle chiamate o ereditarietà. Questa storia ci ricorda che una parola condivisa non garantisce un identico modello di programmazione.

Un oggetto senza la parola class

Una struttura C accompagnata da funzioni che ricevono un puntatore alla struttura mostra che organizzare dati e operazioni attorno a un concetto è una scelta di progetto, anche quando il linguaggio non offre la sintassi delle classi. Se omettiamo il parametro necessario nella dichiarazione delle funzioni o lasciamo incompleta la gestione della memoria associata al nome, il programma non è verificabile. Conviene rendere espliciti entrambi gli aspetti prima di usarlo come esempio.

Il confronto importante si può già fare senza compilare C. Nel modello con funzioni, una chiamata del tipo presta(esemplare, lettore) indica esplicitamente quale struttura sarà modificata. In Java una chiamata come esemplare.prestaA(lettore) fa comparire l'esemplare a sinistra del punto: il metodo viene invocato su quell'oggetto e riceve gli altri dati necessari. La forma sintattica cambia, ma in entrambi i casi dobbiamo decidere chi possiede la regola del prestito. Java ci dà strumenti per rappresentarla e limitarne l'accesso; non la progetta al posto nostro.

Come si è arrivati fin qui

Nella storia del paradigma a oggetti ricorrono Simula, Smalltalk e il lavoro di Alan Kay. Simula rese centrali classi e oggetti per descrivere sistemi; Smalltalk diede grande importanza agli oggetti che comunicano mediante messaggi. Il lettore può incontrare l'espressione invio di messaggi anche quando, in Java, scriviamo più concretamente chiamata di metodo. Non si tratta di due istruzioni Java diverse: è una differenza nel modo di raccontare la collaborazione fra entità.

Negli anni successivi linguaggi e strumenti hanno dato pesi diversi a ereditarietà, incapsulamento, interfacce e collegamento dinamico delle chiamate. Per questo non serve cercare una definizione storica che renda identici tutti i linguaggi «a oggetti». Per lavorare in Java useremo una domanda più utile: quali dati e operazioni appartengono a un concetto, che cosa promette agli altri e come manteniamo quella promessa quando il programma cambia? La popolarità di un paradigma non garantisce da sola risposte buone; gli esempi e le verifiche sì.

Mettiamo alla prova il modello

Immagina che la biblioteca debba ricordare la data di restituzione di ogni prestito. Aggiungeresti la data al Libro, al Lettore o al Prestito? Motiva la scelta dicendo a quale evento appartiene quel dato e quali operazioni possono modificarlo. Poi prova un secondo caso: la biblioteca deve sapere quanti libri sono disponibili. Conviene conservare un numero modificabile da chiunque, oppure ricavarlo o aggiornarlo attraverso un'operazione controllata? Non c'è bisogno di scrivere Java per rispondere; serve rendere visibile la responsabilità.

L'attività è riuscita se il tuo modello distingue almeno Libro, Lettore e Prestito, indica chi conosce la disponibilità e impedisce uno stato impossibile. Se due responsabilità sembrano plausibili, descrivi il caso d'uso che ne decide la scelta: ci servirà quando costruiremo le classi vere.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑