Programmazione 5 commenti
I pericoli della programmazione con i mixin/1
diI mixin sono una tecnica di programmazione ad oggetti che permette di iniettare pacchetti di metodi in una classe madre, direttamente o indirettamente tramite l'ereditarietà multipla. I pro e i contro della tecnica sono molto dibattuti ed io personalmente nel giro di qualche anno sono passato da acceso sostenitore a fiero oppositore. Data la premessa è chiaro che non sarò imparziale e che non dovete prendere quanto dirò per oro colato. La mia opinione è che l'ereditarietà multipla, i mixin e i trait (per lo meno quando i trait sono intesi come sinonimo dei mixin come spesso avviene) sono esempi di tecniche molto cool sulla carta che si rivelano nella pratica un disastro, soprattutto quando ci si trova a dover mantenere del codice che ne fa un uso liberale. Ci possono essere dei casi particolari in cui il loro uso è accettabile, ma la maggior parte delle volte esistono alternative più valide che sono però meno note e meno utilizzate. Lo scopo principale di questa serie è proprio quello di illustrare le alternative, almeno nel contesto della programmazione in Python.
Il problema della sovrascrittura dei metodi
Ereditarietà multipla, mixin e trait vengono considerate delle tecniche di programmazione ad oggetti avanzate in quanto non sono supportate dalla maggior parte dei linguaggi mainstream (Java, C#, VisualBasic) oppure sono supportate male (C++). Tuttavia esse vengono comunemente utilizzate nella maggior parte dei linguaggi più trendy degli ultimi anni: per esempio Python supporta l'ereditarietà multipla e Ruby i mixin. I trait sono un concetto più recente e spesso il termine è usato impropriamente come sinonimo di mixin; per esempio i linguaggio Scala dice di supportare i trait quando invece supporta i mixin. I trait propriamente detti sono supportati in Squeak e in PLT Scheme. Molti sono interessati ad imparare i pro e i contro di queste tecniche "moderne" (uso le virgolette perché Flavors, un vecchio dialetto del Lisp aveva già queste features più di venticinque anni fa: le idee veramente nuove in programmazione sono meno di quello che si pensa :-/) e a loro si rivolge questo articolo.
L'ereditarietà multipla è la tecnica più generale fra le tre citate: i mixin possono essere visti come una forma ristretta di ereditarietà multipla e i trait come una forma ristretta di mixin. In altre parole, data l'ereditarietà multipla è banale usarla per implementare i mixin, ma il viceversa non è vero. L'ereditarietà multipla è disponibile in vari linguaggi (C++, Common Lisp, Python, Eiffel, ...) ma per motivi di brevità (nonché di ignoranza) in questo articolo parlerò soprattutto della sua implementazione in Python. Molte delle cose che dirò sono comunque valide anche per altri linguaggi.
In un linguaggio ad ereditarietà multipla una classe può avere più genitori e quindi ereditare metodi e attributi da più fonti contemporaneamente. Già questo vi fa capire che mantenere del codice che fa uso di ereditarietà multipla non è banale perché per capire come funziona una classe figlia di N genitori è necessario andarsi a studiare N classi diverse, più tutti i loro antenati. I metodi della classe figlia possono provenire da molti luogi e c'è un forte accoppiamento: cambiando uno qualunque dei metodi degli antenati avremo un effetto sulla classe figlia. Tutto questo vale anche per le gerarchie di ereditarietà singola e i miei lettori sanno già che io sono contrario agli eccessi anche nel caso dell'ereditarietà singola. Il problema è che l'ereditarietà multipla aggiunge ad una situazione brutta di per sé una serie ulteriore di problemi suoi propri.
Per esempio, l'ordine delle
classi madri è importante: una classe C1 figlia delle madri M1 ed M2
non si comporta necessariamente come una classe C2 figlia delle madri M2 ed
M1 in cui l'ordine delle madri è invertito. Il motivo è che se vi sono
dei metodi in comune tra M1 e M2, i metodi di M1
hanno la precedenza sui metodi di M2 per la figlia C1(M1, M2)
mentre per la figlia C2(M2, M1) avviene
l'opposto. Siccome i metodi in comune vengono sovrascritti silenziosamente
e i programmatori non sono molto bravi a ricordare
l'ordine, questo può dare luogo a bachi difficili da individuare.
La cosa è ancora peggiore se si va a guardare cosa succede con gli
antenati di ordine superiore, perché l'ordine di sovrascrittura
dei metodi in un gerarchia ad ereditarietà multipla è decisamente
non banale, tanto che in passato ho scritto un intero
saggio sul problema (il saggio sull'MRO).
Si noti che questo non è un baco: linguaggi tipo Python e il Common Lisp
sono stati disegnati in questo modo
onde poter far cooperare metodi con lo stesso nome.
Ciò detto, non è necessariamente detto che questo sia un buon design:
linguaggi diversi adottano soluzioni diverse. Per esempio, Eiffel
solleva un'eccezione se vi sono metodi con lo stesso nome ed obbliga
il programmatore ad effettuare un renaming esplicito.
Un tempo pensavo che un design di questo tipo fosse semplicistico (e anzi stupido) e che il design di Python e del Lisp fosse infinitamente superiore. Questo però avveniva anni fa, quando la mia esperienza con sistemi orientati agli oggetti di grandi dimensioni era molto limitata. Al giorno d'oggi invece ho rivalutato molto i design "stupidi". Anzi, penso addirittura che Smalltalk abbia fatto la scelta giusta trent'anni fa, decidendo scientemente di non supportare l'ereditarietà multipla e neppure i mixin. In generale non sono convinto che iniettare (direttamente o indirettamente, tramite l'ereditarietà) blocchi di metodi nel namespace di una classe sia una grande idea ed in principio non mi piace nessuna di queste tecniche. Spiegare perché non sono convinto e spiegare che alternative utilizzare richiederà almeno tre articoli, quindi armatevi di pazienza ;)
Il problema del sovraffollamento del namespace
Nella pratica il problema dell'overriding dei metodi non è molto frequente (è grave le poche volte che capita, ma appunto capita poche volte) perché di solito i framework sono disegnati per mescolare set indipendenti di funzionalità. Di solito quindi non si usa il pieno potere dell'ereditarietà multipla: il concetto di mixin o trait è sufficiente per implementare la maggior parte dei framework in esistenza (si tenga anche conto che la maggior parte dei framework Python sono stati scritti prima che la cooperazione dei metodi fosse possibile, ovvero prima di Python 2.2).
Un mixin è un contenitore di funzionalità che può essere trasferita alla classe con cui viene mixato; in Ruby i mixin sono implementati come moduli, in linguaggi con ereditarietà multipla come classi sprovviste di stato (questo significa che le classi mixin non contengono un costruttore). Il problema della cooperazione dei metodi (e quindi di super in Python o di call-next-method in Lisp) non si pone, perché non c'è nessun tipo di cooperazione. Ciò nonostante, c'è ancora il problema dell'ordinamento dei mixin: per esempio in Ruby includere prima il modulo M1 e poi il modulo M2 oppure viceversa non è la stessa cosa se vi sono dei metodi distinti con lo stesso nome.
I traits sono stati inventati esattamente per risolvere questo problema: i metodi comuni causano un errore e il programmatore deve rinominarli o specificare esplicitamente come risolvere l'ovverriding. Fatto questo, non c'è più problema di ordinamento: i traits commutano. A mio avviso, tra le varie tecniche che stiamo considerando, quella dei trait è la più semplice e robusta e quella dell'ereditarietà multipla la più complicata e fragile, con i mixin in una posizione intermedia, ma più vicina a quella dei trait. Si noti che un'implementazione appropriata dei trait dovrebbe includere anche dei tool di introspezione che permettono di vedere una classe sia come una collezione piatta di metodi sia come un'entità composta (consiglio l'articolo originale sui trait che spiega questo punto molto bene). Purtroppo però spesso si chiamano trait quelli che in realtà sono mixin, e i mixin sono afflitti dal problema del sovraffollamento del namespace. Per fare vedere cosa intendo illustrerò due framework Python reali che fanno uso della tecnica dei mixin: Tkinter e Zope.
Tkinter è un framework GUI di medie dimensioni che fa
parte della libreria standard di Python. Ogni classe di Tkinter,
anche la semplice Label, è composta da molti mixin:
>>> import Tkinter, inspect
>>> for i, cls in enumerate(inspect.getmro(Tkinter.Label)):
... # show the ancestors
... print i, cls
0 Tkinter.Label
1 Tkinter.Widget
2 Tkinter.BaseWidget
3 Tkinter.Misc
4 Tkinter.Pack
5 Tkinter.Place
6 Tkinter.Grid
La funzione di libreria inspect.getmro(cls)
ritorna una tupla con tutti gli antenati della classe cls, nell'ordine
definito dal cosiddetto Method Resolution Order (MRO). Non è questa la
sede per approfondire come funziona l'MRO; rimando i curiosi al
saggio sull'MRO già citato.
Nel caso in esame l'MRO di Label contiene i mixin che gestiscono
la geometria (Grid, Pack e Place) e il mixin generico
Misc che fornisce un gran numero di servizi delegando alla
libreria sottostante di Tk. Le classi BaseWidget, Widget
e Label contengono stato e sono quindi classi "vere", non mixin.

Già a questo livello si capisce cosa intendo con sovraffollamento
del namespace. Se usate una qualunque IDE con autocompletamento (o
anche ipython) e provate a completare l'espressione
Tkinter.Label., otterrete 181 possibilità. Ora 181 attributi
distinti su una singola classe sono un pò tanti. Se provate a
scrivere
>>> help(Tkinter.Label)
vedrete l'origine dei vari attributi, cioè le classi da cui sono ereditati: l'output dell'help occupa qualche centinaio di righe.
Per fortuna Tkinter è un framework molto stabile (leggasi: le classi base funzionano) e di solito non c'è bisogno di investigare le gerarchie per trovare bachi o i motivi di un comportamento inaspettato. Inoltre Tkinter è tutto sommato un framework piccolo; anche in caso di problemi i 181 attributi sono tanti ma non tantissimi, e con fatica uno potrebbe riuscire a mettersi in testa da dove derivano. Le cose però sono molto diverse con Zope: nel caso di Zope una tipica classe del framework è ottenuta componendo più di 20 classi mixin e contiene più di 400 metodi. Tracciare l'origine di più di quattrocento metodi e tenere in mente la gerarchia è praticamente impossibile. Sia l'autocompletamento che l'help da riga di comando diventano praticamente inutilizzabili, ed anche la documentazione autogenerata è di fatto illeggibile, perché troppo abbondante. Insomma, un design basato sui mixin funziona nel piccolo, ma non scala affatto per framework grandi, dove anzi si rivela essere un design disastroso. L'intero framework di Zope 3 è stato disegnato con l'idea di evitare l'abuso dei mixin di Zope 2 e di favorire la composizione al posto dell'ereditarietà.
Il mio odio per i mixin nasce proprio dalla mia esperienza con Zope/Plone. Comunque sospetto fortemente che quanto dirò si possa applicare a tutti i linguaggi che supportano i mixin (un'eccezione è costituita dal Common Lisp, in cui i metodi vengono definiti al di fuori della classi e quindi il problema dell'affollamento del namespace non sussiste) per lo meno meno quando si parla di grossi framework. Una conseguenza del sovraffollamento del namespace è che è facile avere conflitti di nomi. Siccome se ci sono centinaia di metodi è impossibile conoscerli tutti, e siccome la sovrascrittura di un metodo non causa nessun errore o warning, questo è un problema da non sottavalutare: la prima volta che ho sovrascritto una classe Plone mi è capitato esattamente questo, ho sovrascritto un metodo predefinito senza saperlo, causando danni difficili da diagnosticare, perché i risultati si sono visti in una parte apparentemente indipendente del codice.
Come difendersi dalle sovrascritture accidentali
La prima cosa che ho fatto dopo essere stato morso da Plone è
scrivere una funzioncina di utilità in grado di identificare
i metodi sovrascritti. Vale la pena riportare qui una versione
semplificata di tale funzione, chiamata warn_overriding.
Potete usarla quando vi trovate
a lavorare con grosso framework che non conoscete a menadito.
Prima di tutto, conviene definirsi
una coppia di funzioni di utilità: una funzione
getpublicnames che ritorna i nomi pubblici defini nel namespace di
un oggetto,
def getpublicnames(obj):
"Return the public names in obj.__dict__"
return set(n for n in vars(obj) if not n.startswith('_'))
e una funzione find_common_names che dato un insieme di classi ritorna
i nomi degli attributi comuni
def find_common_names(classes):
"Perform n*(n-1)/2 namespace overlapping checks on a set of n classes"
n = len(classes)
names = map(getpublicnames, classes)
for i in range(0, n):
for j in range(i+1, n):
ci, cj = classes[i], classes[j]
common = names[i] & names[j]
if common:
yield common, ci, cj
Inoltre conviene definirsi una classe di warning opportuni:
class OverridingWarning(Warning):
pass
A questo punto è facile implementare warn_overriding come un
decoratore di classe:
def warn_overriding(cls):
"""
Print a warning for each public name which is overridden in the class
hierarchy, unless if is listed in the "override" class attribute.
"""
override = set(vars(cls).get("override", []))
ancestors = inspect.getmro(cls)
if ancestors[-1] is object: # remove the trivial ancestor <object>
ancestors = ancestors[:-1]
for common, c1, c2 in find_common_names(ancestors):
overridden = ', '.join(common - override)
if ',' in overridden: # for better display of the names
overridden = '{%s}' % overridden
if overridden:
msg = '%s.%s overriding %s.%s' % (
c1.__name__, overridden, c2.__name__, overridden)
warnings.warn(msg, OverridingWarning, stacklevel=2)
return cls
Ed eccovi un esempio per capire come funziona il tutto. Date le classi base
class Base(object):
def m1(self):
pass
def spam(self):
pass
class M1(object):
def m2(self):
pass
def ham(self):
pass
class M2(object):
def m3(self):
pass
def spam(self):
pass
possiamo definire la sottoclasse
class Child(Base, M1, M2):
def ham(self):
pass
def spam(self):
pass
def m1(self):
pass
in cui vi sono svariate sovrascritture di metodi. L'ordine delle sovrascritture è specificato dall'MRO che in questo caso comprende cinque classi:
>>> inspect.getmro(Child)
(<class 'Child'>, <class 'Base'>, <class 'M1'>, <class 'M2'>, <type 'object'>)
La procedura find_common_names investiga le suddette classi
(tranne object, che non definisce nomi pubblici)
a coppie (ci sono quindi 4*3/2 = 6 combinazioni possibili)
per individuare eventuali sovrascritture e permette a
warn_overriding di stampare
i warning corrispondenti:
>>> Child = warn_overriding(Child)
OverridingWarning: Child.{m1, spam} overriding Base.{m1, spam}
OverridingWarning: Child.ham overriding M1.ham
OverridingWarning: Child.spam overriding M2.spam
OverridingWarning: Base.spam overriding M2.spam
In Python 2.6 (correntemente in beta release) e superiori è possibile usare la sintassi molto più elegante
@warn_overriding
class Child(Base, M1, M2):
...
per ottenere lo stesso risultato. I vantaggi sono molteplici:
il decoratore di classe è immediatamente incollato alla classe
che decora e quindi è molto più visibile; inoltre il warning
mostra la linea di codice corrispondente alla definizione della
classe, cioè il posto giusto in cui andare a fare i cambiamenti,
se necessari. Per evitare i warning è sufficiemente listare esplicitamente i
metodi sovrascritti, definendo l'attributo di classe override.
Per, esempio provate ad aggiungere la riga:
override = ['m1', 'spam', 'ham']
nella definizione di Child e vedrete che i warning spariranno.
warn_overriding è piccolo tool che può aiutarvi se
vi trovate a combattere con un grosso framework che fa uso dei
mixin, ma è soltanto un palliativo, non una soluzione del problema del
sovraffollamento del namespace. La soluzione vera è non usare i mixin.
Dedicherò il resto della serie alla discussione di soluzioni alternative.
Rimanete sintonizzati per le prossime entusiasmanti puntate!
- Pubblicato il
- 25 Giu 2008
- Tag


Ricorco ancora con terrore un progettino fatto in C++ che sfruttava l'ereditarietà multipla e noi, giovani studenti, naufragammo con l'esplosione dei distruttori virtuali... che esperienza negativa, da allora l'eredità multipla è solo un concetto teorico!
Mi sono dimenticare di segnalare un'ottima referenza per mixin e traits: http://www.cs.utah.edu/plt/publications/aplas06-fff.pdf
Grazie mille per questi articoli (ho letto anche quelli che hai scritto su Scheme). Io li trovo molto facili di capire, eppure non capisco perfettamente l'italiano, perché scrivi in modo molto chiaro su soggetti complicati.
Io non ammetto l'ereditareita' multipla come conetto di oggetto. Se una classe is-a superclass non puo' essere un'altra superclass. In tal caso, IMHO, si tratta di errori di progettazione. Per quanto riguarda l'uso dei traits in un ambiente come smalltalk affermo che non ha un negativo. Vengono usati con cautela senza assolutamente farne abuso e solo quando servono, piu' che altro per emulare il concetto di "interfaccia" o per wrappare librerie (come ad es. feci con SqueakGtk).
io la vedo, esempio scemo, il solito: una List is-a Collection (#each), Indexable (#at).
Un DataObject is-a Comparable(#same,#==), Serializable(#dump,#load).
Puoi fare tutto con i mixin/trait/interfacce/niente, ma in astratto una DataList può essere tranquillamente sia una List che un DataObject.
Anyway perl6's roles FTW :o)