Vai al contenuto
Leo

Parte III — Organizzare

Capitolo 11 — Classi e oggetti

Finora abbiamo tenuto i dati da una parte (variabili, liste, dizionari) e il comportamento dall'altra (funzioni). Spesso, però, le due cose vanno insieme: un conto in banca ha un saldo e sa versare e prelevare; una persona ha un nome e sa salutare. Le classi permettono di unire dati e comportamento in un unico stampo, da cui creare tanti oggetti indipendenti. È il cuore della cosiddetta «programmazione a oggetti», e in Leo ha una forma particolarmente leggibile.

🦁 Curiosità — Nata in Norvegia, battezzata pensando alle cellule

Le idee alla base della programmazione a oggetti nascono negli anni '60 col linguaggio Simula, creato a Oslo da Ole-Johan Dahl e Kristen Nygaard. L'espressione «object-oriented» la coniò invece Alan Kay, che raccontò di essersi ispirato alle cellule biologiche: tante unità autonome, ciascuna coi propri dati, che collaborano scambiandosi messaggi. Non è poi così diverso da quello che facciamo noi con gli oggetti.

Definire una classe

Una classe si definisce con classe. Dentro, i dati si dichiarano con ha e i comportamenti con azione. All'interno di un'azione, l'oggetto su cui stai lavorando si chiama questo:

Leo
classe Persona
    ha nome
    ha eta = 0
    azione saluta()
        scrivi "Ciao, sono {questo.nome} e ho {questo.eta} anni"
    fine
    azione compleanno()
        questo.eta = questo.eta + 1
    fine
fine

variabile p = Persona("Ada", 36)
p.saluta()
p.compleanno()
scrivi "Dopo il compleanno: {p.eta}"
variabile q = nuovo Persona("Marco")
q.saluta()
Risultato
Ciao, sono Ada e ho 36 anni
Dopo il compleanno: 37
Ciao, sono Marco e ho 0 anni

Smontiamo con calma. ha nome dichiara un dato (un campo) obbligatorio; ha eta = 0 dichiara un campo con valore predefinito, che puoi quindi omettere alla creazione. Le azione sono funzioni che appartengono all'oggetto: dentro, questo.nome e questo.eta leggono e cambiano i dati di quel particolare oggetto.

Per creare un oggetto — un'istanza della classe — scrivi il nome della classe con i valori tra parentesi: Persona("Ada", 36). Se preferisci una forma più discorsiva, Leo accetta anche nuovo Persona("Marco"): le due scritture sono equivalenti, scegli quella che si legge meglio. L'oggetto creato risponde ai metodi con il punto: p.saluta(), p.compleanno().

Nota che p e q sono indipendenti: il compleanno di Ada non tocca Marco. Ogni oggetto porta con sé la propria copia dei dati. È questo che rende le classi così utili: uno stampo, infiniti oggetti distinti.

💡 L'idea — Lo stampino dei biscotti

Una classe è come lo stampino per i biscotti, e gli oggetti sono i biscotti. Lo stampino decide la forma — quali campi e quali azioni ci saranno — ma non è qualcosa che si mangia; ogni biscotto che ne esce è invece concreto, con la sua glassa e le sue decorazioni. Allo stesso modo Persona descrive la forma, e Ada e Marco sono due biscotti ben distinti usciti dallo stesso stampino.

Campi, tipi e protezioni

I campi possono avere un tipo, che Leo controlla alla creazione:

Leo
classe Punto
    ha x: intero
    ha y: intero
fine

Con questa definizione, Punto(3, 4) va bene ma Punto("ciao") dà un errore chiaro: manca y, e x dovrebbe essere un intero. Dichiarare i tipi è facoltativo, ma nei programmi seri aiuta a cogliere gli sbagli subito.

Vediamo una classe un po' più ricca, un conto corrente che si difende dagli usi scorretti sollevando un errore (ricordi lancia dal Capitolo 10?):

Leo
classe Conto
    ha intestatario
    ha saldo = 0
    azione versa(importo)
        questo.saldo = questo.saldo + importo
    fine
    azione preleva(importo)
        se importo > questo.saldo allora
            lancia ErroreValore("Fondi insufficienti")
        fine
        questo.saldo = questo.saldo - importo
    fine
    azione descrivi() dà testo
        restituisci "{questo.intestatario}: {questo.saldo} euro"
    fine
fine

variabile c = Conto("Giulia")
c.versa(100)
c.versa(50)
c.preleva(30)
scrivi(c.descrivi())
Risultato
Giulia: 120 euro

Un'azione può anche restituire un valore con restituisci, come una funzione qualunque; la notazione azione descrivi() dà testo dichiara — facoltativamente — che il risultato è un testo. L'oggetto Conto racchiude i suoi dati e li protegge: l'unico modo di muovere il saldo è attraverso versa e preleva, che possono imporre le regole (niente scoperti). Questo è il senso dell'incapsulamento: tenere insieme i dati e le regole che li governano.

L'ereditarietà: costruire sul già fatto

Capita spesso che diverse classi condividano gran parte del comportamento e differiscano solo in qualche dettaglio. Un cane e un gatto sono entrambi animali: hanno un nome, si presentano allo stesso modo, ma fanno un verso diverso. L'ereditarietà, con la parola estende, permette a una classe di partire da un'altra e cambiare solo ciò che serve:

Leo
classe Animale
    ha nome
    azione verso() dà testo
        restituisci "..."
    fine
    azione presentati()
        scrivi "{questo.nome} fa {questo.verso()}"
    fine
fine

classe Cane estende Animale
    azione verso() dà testo
        restituisci "bau"
    fine
fine

classe Gatto estende Animale
    azione verso() dà testo
        restituisci "miao"
    fine
fine

per ogni a in [Cane("Fido"), Gatto("Romeo"), Animale("Misterioso")] fai
    a.presentati()
fine
Risultato
Fido fa bau
Romeo fa miao
Misterioso fa ...

Cane e Gatto estendono Animale: ereditano il campo nome e il metodo presentati, ma ridefiniscono verso a modo loro. La magia è nella riga presentati, scritta una volta sola in Animale: quando chiama questo.verso(), Leo usa la versione giusta per quell'oggetto — bau per il cane, miao per il gatto, ... per l'animale generico. Questo meccanismo, per cui lo stesso metodo si comporta diversamente a seconda del tipo reale dell'oggetto, si chiama polimorfismo ed è ciò che rende l'ereditarietà potente: aggiungere un Mucca che fa muu non richiede di toccare presentati.

Richiamare il genitore

Quando ridefinisci un metodo ma vuoi comunque usare la versione della classe da cui erediti, c'è la parola genitore:

Leo
classe Veicolo
    ha nome
    azione descrivi() dà testo
        restituisci "Veicolo {questo.nome}"
    fine
fine

classe Auto estende Veicolo
    azione descrivi() dà testo
        restituisci genitore.descrivi() + " (un'automobile)"
    fine
fine

scrivi(Auto("Panda").descrivi())
Risultato
Veicolo Panda (un'automobile)

genitore.descrivi() chiama la versione di Veicolo, e Auto ci costruisce sopra aggiungendo la sua parte. È il modo pulito per estendere un comportamento invece di riscriverlo da zero.

Quando usare le classi

Una parola di equilibrio, perché le classi sono utili ma non obbligatorie. Se un dato è semplice, un dizionario basta e avanza. Le classi guadagnano terreno quando i dati e le regole che li governano cominciano a intrecciarsi: quando hai bisogno di garantire invarianti (il saldo non va sotto zero), di tanti oggetti simili ma distinti, o di una gerarchia di tipi che condividono comportamento. Non sentirti obbligato a incapsulare tutto in una classe fin dal primo giorno: introducile quando il programma te le chiede.

Prova tu

  1. Scrivi una classe Rettangolo con i campi base e altezza e le azioni area() e perimetro(). Creane un paio e stampane le misure.
  2. Aggiungi alla classe Conto un metodo trasferisci(altro, importo) che preleva da questo e versa su un altro conto. Collauda un trasferimento tra due conti.
  3. Crea una classe Quadrato che estende Rettangolo e, nel costruttore o in un metodo, imposta base e altezza uguali. Verifica che erediti area e perimetro.