Programmazione 5 commenti

I pericoli della programmazione con i mixin/1

di Michele Simionato

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!

Pubblicato il
25 Giu 2008
Tag

Commenti

  • maelstrom84 il 25 Giu 2008

    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!

  • Michele Simionato il 26 Giu 2008

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

  • Eddie Sullivan il 26 Giu 2008

    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.

  • Luca Bruno il 29 Giu 2008

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

  • riffraff il 30 Giu 2008

    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)

Screencast e videocorsi di programmazione
Stacktrace RSS Feed Stacktrace via E-mail
Hai idee per un articolo? Faccelo sapere!