I pericoli della programmazione con i mixin/1

I 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.

Label.png

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!

Comments

  1. 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!

  2. Michele Simionato says:

    Mi sono dimenticare di segnalare un’ottima referenza per mixin e traits: http://www.cs.utah.edu/plt/publications/aplas06-fff.pdf

  3. 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.

  4. 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).

  5. 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)

Policy per i commenti: Apprezzo moltissimo i vostri commenti, critiche incluse. Per evitare spam e troll, e far rimanere il discorso civile, i commenti sono moderati e prontamente approvati poco dopo il loro invio.