Chapter 18 EreditarietàLa caratteristica più frequentemente associata alla programmazione orientata agli oggetti è l’ereditarietà, che è la capacità di definire una nuova classe come versione modificata di una classe già esistente. In questo capitolo illustrerò l’ereditarietà usando delle classi che rappresentano carte da gioco, mazzi di carte e mani di poker. Se non giocate a poker, potete leggere qualcosa in proposito sul sito http://it.wikipedia.org/wiki/Poker, ma non è un obbligo: vi spiegherò quello che serve. Il codice degli esempi di questo capitolo è scaricabile da http://thinkpython2.com/code/Card.py. 18.1 Oggetti CartaIn un mazzo ci sono 52 carte, e ciascuna appartiene a uno tra quattro semi e a uno tra tredici valori. I semi sono Picche, Cuori, Quadri e Fiori (in ordine decrescente nel gioco del bridge). I valori sono Asso, 2, 3, 4, 5, 6, 7, 8, 9, 10, Fante, Regina e Re. A seconda del gioco, l’Asso può essere superiore al Re o inferiore al 2. Se vogliamo definire un nuovo oggetto che rappresenti una carta da gioco, è evidente quali attributi dovrebbe avere: valore e
seme. È meno evidente stabilire di che tipo devono essere questi attributi. Una possibilità è usare stringhe contenenti parole come
Un’alternativa è usare degli interi per codificare valori e semi. In questo contesto, “codificare” significa determinare una corrispondenza tra numeri e semi o numeri e valori. Non significa che debba essere un segreto (quello è “criptare”). Per esempio, questa tabella mostra i semi e i corrispondenti codici interi:
In questo modo, diventa facile confrontare le carte: siccome ai semi più alti corrispondono numeri più alti, si possono confrontare i semi confrontando i loro codici corrispondenti. Nel caso dei valori, la corrispondenza è abbastanza immediata: ogni valore numerico corrisponde al rispettivo intero, mentre per le figure:
Uso il simbolo ↦ per chiarire che queste corrispondenze non fanno parte del programma Python. Fanno parte del progetto del programma, ma non compaiono esplicitamente nel codice. Ecco come si può presentare la definizione di classe per Carta: class Carta: """Rappresenta una carta da gioco standard.""" def __init__(self, seme=0, valore=2): self.seme = seme self.valore = valore Come al solito, il metodo init prevede un parametro opzionale per ciascun attributo. La carta di default è il 2 di fiori. Per creare una carta, si chiama la classe Carta con il seme e il valore desiderati. regina_di_quadri = Carta(1, 12) 18.2 Attributi di classePer stampare gli oggetti Carta in un modo comprensibile agli utenti, occorre stabilire una corrispondenza dai codici interi ai relativi semi e valori. Un modo naturale per farlo è usare delle liste di stringhe, che assegneremo a degli attributi di classe: # all'interno della classe Carta: nomi_semi = ['Fiori', 'Quadri', 'Cuori', 'Picche'] nomi_valori = [None, 'Asso', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'Fante', 'Regina', 'Re'] def __str__(self): return '%s di %s' % (Carta.nomi_valori[self.valore], Carta.nomi_semi[self.seme]) Variabili come Questo termine li distingue da variabili come seme e valore, che sono chiamati attributi di istanza perché sono associati ad una specifica istanza. Ad entrambi i tipi si accede usando la notazione a punto. Per esempio in Ogni carta ha i suoi propri seme e valore, ma esiste una sola copia di Mettendo insieme il tutto, l’espressione
Il primo elemento della lista Con i metodi che abbiamo visto fin qui, possiamo creare e stampare i nomi delle carte: >>> carta1 = Carta(2, 11) >>> print(carta1) Fante di Cuori La Figura 18.1 è un diagramma dell’oggetto classe Carta e di una Carta, sua istanza.
Carta è un oggetto classe, quindi è di tipo type. carta1 invece è di tipo Carta. (Per motivi di spazio ho omesso i contenuti di 18.3 Confrontare le cartePer i tipi predefiniti, esistono gli operatori relazionali
(<, >, ==, etc.)
che permettono di confrontare i valori e determinare quale è maggiore, minore o uguale a un altro. Per i tipi personalizzati, possiamo sovrascrivere il comportamento degli operatori predefiniti grazie a un metodo speciale chiamato
L’ordinamento corretto delle carte da gioco non è immediato. Per esempio, tra il 3 di Fiori e il 2 di Quadri, quale è più grande? Una carta ha un valore maggiore, ma l’altra ha un seme superiore. Per confrontare le carte, bisogna prima stabilire se è più importante il seme oppure il valore. La risposta dipenderà dalle regole del gioco a cui stiamo giocando, ma per semplificare supponiamo che sia più importante il seme, per cui le carte di Picche sovrastano tutte quelle di Quadri, e così via. Deciso questo, possiamo scrivere # all'interno della classe Carta: def __lt__(self, other): # controlla i semi if self.seme < other.seme: return True if self.seme > other.seme: return False # semi uguali... controlla i valori return self.valore < other.valore Potete scriverlo anche in modo più compatto, usando un confronto di tuple: # all'interno della classe Carta: def __lt__(self, other): t1 = self.seme, self.valore t2 = other.seme, other.valore return t1 < t2 Come esercizio, scrivete un metodo 18.4 Mazzi di carteOra che abbiamo le carte, il prossimo passo è definire i Mazzi. Dato che un mazzo è composto di carte, è ovvio che ogni Mazzo contenga una lista di carte come attributo. Quella che segue è una definizione di classe di Mazzo. Il metodo init crea l’attributo carte e genera l’insieme standard di 52 carte: class Mazzo: def __init__(self): self.carte = [] for seme in range(4): for valore in range(1, 14): carta = Carta(seme, valore) self.carte.append(carta) Il modo più facile di popolare il mazzo è quello di usare un ciclo nidificato. Il ciclo più esterno enumera i semi da 0 a 3; quello interno enumera i valori da 1 a 13. Ogni iterazione crea una nuova carta del seme e valore correnti e la accoda nella lista self.carte. 18.5 Stampare il mazzoEcco un metodo #all'interno della classe Mazzo: def __str__(self): res = [] for carta in self.carte: res.append(str(carta)) return '\n'.join(res) Questo metodo illustra un modo efficiente di accumulare una stringa lunga: costruire una lista di stringhe e poi usare il metodo delle stringhe join.
La funzione predefinita str invoca il metodo Dato che invochiamo join su un carattere di ritorno a capo, le carte sono stampate su righe separate. Ed ecco quello che risulta: >>> mazzo = Mazzo() >>> print(mazzo) Asso di Fiori 2 di Fiori 3 di Fiori ... 10 di Picche Fante di Picche Regina di Picche Re di Picche Anche se il risultato viene visualizzato su 52 righe, si tratta di un’unica lunga stringa che contiene caratteri di ritorno a capo. 18.6 Aggiungere, togliere, mescolare e ordinarePer distribuire le carte, ci serve un metodo che tolga una carta dal mazzo e la restituisca. Il metodo delle liste pop è adatto allo scopo: #all'interno della classe Mazzo: def togli_carta(self): return self.carte.pop() Siccome pop rimuove l’ultima carta della lista, è come se distribuissimo le carte dal fondo del mazzo. Per aggiungere una carta, usiamo il metodo delle liste append: #all'interno della classe Mazzo: def aggiungi_carta(self, carta): self.carte.append(carta) Un metodo come questo, che usa in realtà un altro metodo senza fare molto di più, da alcuni viene chiamato impiallacciatura. Questa metafora deriva dall’industria del legno: l’impiallaciatura consiste nell’incollare un sottile strato di legno di buona qualità sulla superficie di un pannello economico, per migliorarne l’aspetto. In questo caso, Per fare un altro esempio, scriviamo anche un metodo per un Mazzo di nome mescola, usando la funzione shuffle contenuta nel modulo random: # all'interno della classe Mazzo: def mescola(self): random.shuffle(self.carte) Non scordate di importare random. Come esercizio, scrivete un metodo per Mazzo di nome ordina che usi il metodo delle liste sort per ordinare le carte in un Mazzo. Per determinare il criterio di ordinamento, sort utilizza il metodo 18.7 EreditarietàL’ereditarietà è la capacità di definire una nuova classe come versione modificata di una classe già esistente. Come esempio, supponiamo di voler creare una classe che rappresenti una “mano” di carte, vale a dire un gruppo di carte distribuite a un giocatore. Una mano è simile a un mazzo: entrambi sono fatti di carte, ed entrambi richiedono operazioni come l’aggiunta e la rimozione di carte. D’altra parte, ci sono altre operazioni che servono per la mano ma che non hanno senso per il mazzo. Nel poker, ad esempio, dobbiamo confrontare due mani per vedere quale vince. Nel bridge, è utile calcolare il punteggio della mano per decidere la dichiarazione. Questo tipo di relazione tra classi—simili, ma non uguali—porta all’ereditarietà. Per definire una nuova classe che eredita da una classe esistente, basta scrivere tra parentesi il nome della classe esistente: class Mano(Mazzo): """Rappresenta una mano di carte da gioco.""" Questa definizione indica che Mano eredita da Mazzo;
ciò comporta che per Mano possiamo utilizzare i metodi di Mazzo come Quando un nuova classe eredita da una esistente, quest’ultima è chiamata madre (o superclasse) e quella nuova è chiamata figlia (o sottoclasse). In questo esempio, Mano eredita Ma se noi specifichiamo un nuovo metodo init nella classe Mano, esso andrà a sovrascrivere quello della classe madre Mazzo: # all'interno della classe Mano: def __init__(self, label=''): self.carte = [] self.label = label Allora, quando si crea una Mano, Python invoca questo metodo init specifico e non quello di Mazzo: >>> mano = Mano('nuova mano') >>> mano.carte [] >>> mano.label 'nuova mano' Gli altri metodi vengono ereditati da Mazzo, pertanto possiamo usare >>> mazzo = Mazzo() >>> carta = mazzo.togli_carta() >>> mano.aggiungi_carta(carta) >>> print(mano) Re di Picche Viene poi spontaneo incapsulare questo codice in un metodo di nome # all'interno della classe Mazzo: def sposta_carte(self, mano, num): for i in range(num): mano.aggiungi_carta(self.togli_carta())
In alcuni giochi, le carte si spostano da una mano all’altra, o da una mano di nuovo al mazzo. Potete usare L’ereditarietà è una caratteristica utile. Certi programmi che sarebbero ripetitivi senza ereditarietà, possono invece essere scritti in modo più elegante. Facilita il riuso del codice, poiché potete personalizzare il comportamento delle superclassi senza doverle modificare. In certi casi, la struttura dell’ereditarietà rispecchia quella del problema, il che rende il programma più facile da capire. D’altra parte, l’ereditarietà può rendere il programma difficile da leggere. Quando viene invocato un metodo, a volte non è chiaro dove trovare la sua definizione. Il codice rilevante può essere sparso tra moduli diversi. Inoltre, molte cose che possono essere fatte usando l’ereditarietà si possono fare anche, o talvolta pure meglio, senza di essa. 18.8 Diagrammi di classeSinora abbiamo visto i diagrammi di stack, che illustrano lo stato del programma, e i diagrammi di oggetto, che mostrano gli attributi di un oggetto e i loro valori. Questi diagrammi rappresentano una istantanea nell’esecuzione del programma, e quindi cambiano nel corso del programma. Sono anche molto dettagliati, per alcuni scopi anche troppo. Un diagramma di classe è una rappresentazione più astratta della struttura di un programma. Invece di mostrare singoli oggetti, mostra le classi e le relazioni che sussistono tra le classi. Ci sono alcuni tipi diversi di relazioni tra classi:
Un diagramma di classe è una rappresentazione grafica di queste relazioni. Per esempio, la Figura 18.2 mostra le relazioni tra Carta, Mazzo e Mano. La freccia con un triangolo vuoto rappresenta la relazione IS-A: in questo caso indica che Mano eredita da Mazzo. La freccia standard rappresenta la relazione HAS-A; in questo caso un Mazzo ha riferimenti agli oggetti Carta. L’asterisco (*) vicino alla testa della freccia indica una molteplicità, cioè quante Carte ha un Mazzo. Una molteplicità può essere un numero semplice, come 52, un intervallo come 5..7, o un asterisco che indica che un Mazzo può contenere un numero qualsiasi di Carte. In questo diagramma non vi sono dipendenze. In genere, verrebbero illustrate con delle frecce tratteggiate. Se vi sono parecchie dipendenze, talvolta vengono omesse. Un diagramma più dettagliato dovrebbe evidenziare che un Mazzo contiene in realtà una lista di Carte, ma i tipi predefiniti come liste e dizionari di solito non vengono inclusi in questi diagrammi. 18.9 DebugL’ereditarietà può rendere il debug difficoltoso, perché quando invocate un metodo su un oggetto, può risultare laborioso capire esattamente quale sia il metodo che viene invocato. Supponiamo che stiate scrivendo una funzione che lavori su oggetti Mano. Vorreste che fosse valida per Mani di tutti i tipi come ManiDiPoker, ManiDiBridge ecc. Se invocate un metodo come mescola, potrebbe essere quello definito in Mazzo, ma se qualcuna delle sottoclassi sovrascrive il metodo, avrete invece quella diversa versione. Questo comportamento è appropriato, ma a volte può confondere. Quando siete incerti sul flusso di esecuzione del vostro programma, la soluzione più semplice è aggiungere istruzioni di stampa all’inizio di ogni metodo importante. Se Mazzo.mescola stampa un messaggio come Sto eseguendo Mazzo.mescola, allora il programma traccia il flusso di esecuzione mentre viene eseguito. In alternativa, potete usare la funzione seguente, che richiede un oggetto e un nome di metodo (come stringa) e restituisce la classe che contiene la definizione del metodo: def trova_classe_def(obj, nome_metodo): for ty in type(obj).mro(): if nome_metodo in ty.__dict__: return ty Ecco un esempio: >>> mano = Mano() >>> trova_classe_def(mano, 'mescola') <class 'Carta.Mazzo'> Quindi il metodo mescola di questa Mano è quello definito inMazzo.
Un consiglio per la progettazione di un programma: quando sovrascrivete un metodo, l’interfaccia del nuovo metodo dovrebbe essere la stessa di quello sostituito: deve richiedere gli stessi parametri, restituire lo stesso tipo, rispettare le stesse precondizioni e postcondizioni. Se rispettate questa regola, vedrete che ogni funzione progettata per un’istanza di una superclasse, come Mazzo, funzionerà anche con le istanze delle sottoclassi come Mano e ManoDiPoker. Se violate questa regola, conosciuta come “principio di sostituzione di Liskov”, il vostro codice crollerà come (perdonatemi) un castello di carte. 18.10 Incapsulamento dei datiIl capitolo precedente ha illustrato una tecnica di sviluppo detta “progettazione orientata agli oggetti”. Abbiamo identificato gli oggetti che ci servivano—come Tempo, Punto e Rettangolo—e definito le classi per rappresentarli. Per ciascuno c’è un’evidente corrispondenza tra l’oggetto e una qualche entità del mondo reale (o per lo meno del mondo della matematica). Ma altre volte la scelta degli oggetti e del modo in cui interagiscono è meno ovvia. In questo caso serve una tecnica di sviluppo diversa. Nella stessa maniera in cui abbiamo scoperto le interfacce delle funzioni per mezzo dell’incapsulamento e della generalizzazione, scopriamo ora le interfacce delle classi tramite l’incapsulamento dei dati. L’analisi di Markov, vista nel Paragrafo 13.8, è un buon esempio. Se scaricate il mio codice dal sito http://thinkpython2.com/code/markov.py, vi accorgerete che usa due variabili globali— suffix_map = {} prefix = () Siccome queste variabili sono globali, possiamo eseguire una sola analisi alla volta. Se leggessimo due testi contemporaneamente, i loro prefissi e suffissi verrebbero aggiunti nella stessa struttura di dati (il che produce comunque alcuni interessanti testi generati). Per eseguire analisi multiple mantenendole separate, possiamo incapsulare lo stato di ciascuna analisi in un oggetto. Ecco come si presenta: class Markov: def __init__(self): self.suffix_map = {} self.prefix = () Poi, trasformiamo le funzioni in metodi. Ecco per esempio def elabora_parola(self, parola, ordine=2): if len(self.prefix) < ordine: self.prefix += (parola,) return try: self.suffix_map[self.prefix].append(parola) except KeyError: # se non c'e' una voce per questo prefisso, creane una self.suffix_map[self.prefix] = [parola] self.prefix = shift(self.prefix, parola) Questa trasformazione di un programma—cambiarne la forma senza cambiarne il comportamento—è un altro esempio di refactoring (vedi Paragrafo 4.7). L’esempio suggerisce una tecnica di sviluppo per progettare oggetti e metodi:
Come esercizio, scaricate il mio codice da (http://thinkpython2.com/code/markov.py), e seguite i passi appena descritti per incapsulare le varibili globali come attributi di una nuova classe chiamata Markov. Soluzione: http://thinkpython2.com/code/Markov.py (notare la M maiuscola). 18.11 Glossario
18.12 EserciziEsercizio 1
Dato il seguente programma, disegnate un diagramma di classe UML (Unified Modeling Language) che illustri queste classi e le relazioni che intercorrono tra esse. class PingPongMadre: pass class Ping(PingPongMadre): def __init__(self, pong): self.pong = pong class Pong(PingPongMadre): def __init__(self, pings=None): if pings is None: self.pings = [] else: self.pings = pings def add_ping(self, ping): self.pings.append(ping) pong = Pong() ping = Ping(pong) pong.add_ping(ping)
Esercizio 2 Scrivete un metodo per Mazzo di nome
Esercizio 3
Quelle che seguono sono le possibili combinazioni nel gioco del poker, in ordine crescente di valore e decrescente di probabilità:
Scopo di questo esercizio è stimare la probabilità di avere servita una di queste combinazioni.
Soluzione: http://thinkpython2.com/code/PokerHandSoln.py. |
ContributeIf you would like to make a contribution to support my books, you can use the button below. Thank you!
Are you using one of our books in a class?We'd like to know about it. Please consider filling out this short survey.
|