Programmazione 2 commenti

I pericoli della programmazione con i mixin/2

di Michele Simionato

Nella prima parte di questa serie ho discusso il problema principale dei mixin, il sovraffollamento del namespace. Il lettore potrebbe pensare che tale problema affligga soltanto i framework di dimensioni medio/grandi e che non ci siano problemi ad usare i mixin in framework piccoli. Questo è in parte vero, ma è anche vero che spesso e volentieri i mixin sono usati a sproposito anche in framework piccoli. In questa seconda parte illustrerò varie alternative all'ereditarietà multipla e ai mixin nel piccolo, per sistemi ad oggetti di piccole dimensioni che potreste scrivere anche voi.

Introduzione

Come dicevo la volta scorsa la programmazione a mixin è una tecnica della programmazione orientata agli oggetti che consiste nell'iniettare nel namespace di una classe dei metodi definiti esternamente (tipicamente in un'altra classe) direttamente o indirettamente tramite ereditarietà. Se il linguaggio supporta l'ereditarietà multipla (come Python) il modo naturale di aggiungere un mixin M ad una classe C è quello di ereditare da C e da M simultaneamente:

class C_with_mixin(C, M): # M is a mixin class
   pass

Alternativamente, un mixin potrebbe essere implementato aggiungendo dei metodi alla classe, a partire da un dizionario di metodi M:

class C_with_mixin(C):
   pass

for name in M: # M is a dictionary of methods
   setattr(C_with_mixin, name, M[name])

Usando questa tecnica sarebbe possibile definire i mixin in Python anche se il linguaggio non supportasse l'ereditarietà multipla. Similmente, anche se Ruby non supporta l'ereditarietà multipla, supporta lo stesso la programmazione a mixin, perché è possibile includere i metodi provenienti da un modulo:

class C_with_mixin < C:
   include M # M is a module

Un vantaggio dell'approccio di Ruby è che i moduli non hanno genitori, mentre in Python i mixin sono tipicamente delle classi e come tali possono avere dei genitori e per sapere cosa fa una classe di mixin devo andare a vedermi tutti i suoi antenati. Rimando i rubysti a questo articolo; io darò i miei esempi in Python ma quanto dico si applica più o meno a tutti i linguaggi che supportano i mixin (il Common Lisp però merita un discorso a parte).

Qual è il vantaggio dei mixin? Il vantaggio (sulla carta) è il riuso del codice, visto che è possibile includere tutta una serie di metodi con una semplice riga. Dico sulla carta perché spesso e volentieri esistono soluzioni più pulite dei mixin per ottenere il riuso del codice.

Un cattivo esempio di uso dei mixin

Se leggete un qualunque tutorial sull'uso dei mixin (per esempio Using Mix-ins with Python di Chuck Esterbrook che è ben scritto e molto informativo, anche se ha un punto di vista diametralmente opposto al mio) troverete scritto che i mixin servono per aggiungere funzionalità alle classi con cui si mescolano. Per esempio, potreste avere una classe mixin WithLog siffatta:

class WithLog(object):
  @property
  def log(self):
    return logging.getLogger(self.__class__.__name__)

Data una qualunque classe pre-esistente C senza funzionalità di logging, potete introdurre la funzionalità di logging ereditando da WithLog:

class C_WithLog(C, WithLog):
  pass

Un esempio d'uso è il seguente,

>>> c = C_WithLog()
>>> c.log.warn("hello")

che vi stampa su stderr la scritta WARNING:C_WithLog:hello.

Questo uso dei mixin è assolutamente sbagliato: perché usare l'ereditarietà di classi se avete bisogno di un solo metodo? Tanto vale importare quel metodo direttamente! In generale una classe mixin ha senso solo se avete un gruppo coeso di metodi che stanno logicamente insieme; se avete un solo metodo, o dei metodi slegati tra loro, ha molto più senso definire i metodi esternamente in un modulo di utilità ed importarli nel namespace della classe direttamente:

class CWithLog(C):
  from utility import log # log is a property

Non ho mai visto nessuno usare questo approccio in Python, probabilmente perché molta gente proveniente da altri linguaggi non sa neppure che questo è possibile, eppure si tratta di una soluzione molto più pulita dell'ereditarietà. Il problema dell'ereditarietà è che richiede un carico cognitivo molto più elevato: se io leggo il codice C_WithLog(C, WithLog) capisco che WithLog è una classe, ed immediatamente mi sorgono molte domande: quali metodi esporta WithLog? c'è forse qualche metodo di C che accidentalmente sovrascrive uno dei metodi di WithLog? se sì, devo stare attento ad un qualche meccanismo di cooperazione (super) oppure no? quali sono gli antenati di WithLog? che metodi esportano? sono forse sovrascritti da qualcuno dei metodi di C? c'è un meccanismo di cooperazione negli antenati di WithLog? D'altra parte, se leggo from utility import log non c'è molto da capire e molto poco di cui preoccuparsi. L'unica avvertenza in questo caso particolare è che ci sarà un unico oggetto logger condiviso tra tutte le istanze della classe perché logging.getLogger(self.__class__.__name__) ritornerà sempre lo stesso oggetto. Se servono dei logger configurati differentemente per istanze diverse è necessario sovrascrivere l'attributo .log caso per caso, oppure usare una strategia diversa, tipo il dependency injection pattern.

In generale, definire dei metodi/proprietà all'esterno di una classe è una tecnica molto potente che è usata troppo poco in Python, ma che andrebbe considerata con favore per qualunque metodo/proprietà abbastanza generico da poter essere applicato a più di una classe. Il sistema a oggetti del Common Lisp eleva la pratica di definire metodi all'esterno delle classi a regola, tanto è vero che in CLOS si parla di funzioni generiche più che di metodi nel senso tradizionale del termine.

Un esempio accettabile di uso dei mixin

Anche se rari, esistono degli esempi accettabili di uso dei mixin. L'esempio forse migliore è la classe UserDict.DictMixin della libreria standard, che è pensata appositamente per essere usata come mixin. Per illustrarne l'uso, discuterò un'applicazione fittizia ma verosimile.

Supponiamo di dover definire una classe PictureContainer che fa parte di un'applicazione per gestire fotografie. Il contenitore di fotografie sarà gerarchico e potrà contenere al suo interno non soltanto fotografie ma anche dei sotto-contenitori di fotografie. Dal punto di vista del programmatore Python può essere ragionevole implementare tale classe usando internamente come struttura dati un dizionario che associa a un codice un oggetto di tipo PictureContainer oppure un oggetto Picture che contiene informazioni tipo il titolo della foto, la data in cui è stata scattata, e dei metodi per salvarla/estrarla dallo storage (che potrebbe essere il file system, un database relazione, o anche un database ad oggetti come lo ZODB o il datastore dell'Appengine di Google).

La prima versione di PictureContainer potrebbe essere qualcosa del genere:

class SimplePictureContainer(object):

    def __init__(self, id, pictures_or_containers):
      self.id = id
      self.data = {}
      for poc in pictures_or_containers:
        # both pictures and containers must have an .id
        self.data[poc.id] = poc

A questo punto, ci si rende conto che è scomodo dover chiamare ogni volta il dizionario interno direttamente per accedere e modificare le fotografie e che sarebbe meglio esporre i suoi metodi all'esterno. Una soluzione implementativa semplice ed accettabile è sfruttare UserDict.DictMixin che è fatta apposta per questo caso d'uso. Già che ci siamo, possiamo anche aggiungere delle funzionalità di logging, di modo che la differenza tra usare l'interfaccia di basso livello (cioè chiamare direttamente i metodi del dizionario .data) e quella di alto livello (cioè i metodi di DictMixin) è che nel primo caso non si hanno chiamate al logger.

class PictureContainer(SimplePictureContainer, DictMixin):
  from utility import log

  def __getitem__(self, id):
    return self.data[id]

  def __setitem__(self, id, value):
    self.log.info('Adding or replacing %s into %s', id, self.id)
    self.data[id] = value

  def __delitem__(self, id):
    self.log.warn('Deleting %s', id)
    del self.data[id]

  def keys(self):
    return self.data.keys()

Usare DictMixin in questo caso è accettabile, visto che:

  1. DictMixin fornisce alle sue sottoclassi i metodi standard di un dizionario, che forniscono un gruppo coeso;
  2. i metodi forniti da DictMixin sono tutti già noti a chi sa usare i dizionari in Python e quindi il carico cognitivo è nullo;
  3. DictMixin permette un riuso di codice sostanziale: noi abbiamo ridefinito esplicitamente soltanto 4 metodi, ma di fatto stiamo influenzando implicitamente altri 17 metodi: __cmp__, __contains__, __iter__, __len__, __repr__, clear, get, has_key, items, iteritems, iterkeys, itervalues, pop, popitem, setdefault, update, values: se DictMixin non ci fosse, avremmo dovuto reimplementarli tutti!

Notate bene che io dico che usare DictMixin come classe di mixin in ereditarietà multipla è accettabile, ma non che questo sia la soluzione migliore. La soluzione migliore è usare DictMixin come classe base. Il problema di fondo è quello di un design sbagliato; abbiamo scritto SimplePictureContainer quando non conoscevamo l'esistenza di DictMixin ed ora a posteriori cerchiamo di correggere l'errore tramite l'ereditarietà multipla, ma questo non è la cosa giusta da fare. La cosa giusta da fare sarebbe modificare il codice sorgente di SimplePictureContainer e farla derivare direttamente da DictMixin. È chiaro poi che nel mondo reale quasi sempre non si ha il completo controllo del codice: potremmo avere bisogno di una libreria scritta da un terza parte con un errore di design (o anche senza nessun errore, potrebbe essere una libreria scritta per una versione vecchia di Python, quando DictMixin non esisteva ancora) e non avere modo di modificare il codice sorgente. In questo modo usare DictMixin con l'ereditarietà multipla è un workaround assolutamente accettabile, ma sempre di workaround si tratta, e non dovrebbe essere spacciato per un design brillante.

Come evitare l'ereditarietà multipla

Come ho preannunciato nell'introduzione a questa serie, negli ultimi anni io sono diventato un forte oppositore dell'ereditarietà multipla. Di fatto, la vedo soltanto come un utile escamotage per risolvere problemi in situazioni in cui non si ha il controllo del codice sorgente, ma non consiglio mai di partire fin dall'inizio con un design basato sull'ereditarietà multipla; anzi, in generale, consiglio di usare il meno possibile anche l'ereditarietà singola!

Per esempio in questo caso, se non avessimo avuto a disposizione l'ereditarietà multipla avremmo potuto risolvere il problema con la composizione+delegazione:

class PictureContainer2(DictMixin):
  from utility import log

  def __init__(self, id, pictures_or_containers):
    self._pc = SimplePictureContainer(id, pictures_or_containers)
    self.data = self._pc.data # avoids an indirection step

  def __getitem__(self, id):
    return self.data[id]

  def __setitem__(self, id, value):
    self.log.info('Adding or replacing %s into %s', id, self.id)
    self.data[id] = value

  def __delitem__(self, id):
    self.log.warn('Deleting %s', id)
    del self.data[id]

  def keys(self):
    return self.data.keys()

  def __getattr__(self, name):
    return getattr(self._pc, name)

Grazie al __getattr__ tutti i metodi originali del SimplePictureContainer sono a disposizione, ed inoltre sono a disposizione tutti i metodi di DictMixin, esattamente come se avessimo usato l'ereditarietà multipla. D'altra parte, abbiamo evitato di complicare la gerarchia. Uno svantaggio di PictureContainer2 è che le sue instanze non sono più istanze di SimplePictureContainer, quindi se nel vostro codice ci fosse stato qualche check del tipo isinstance(obj, SimplePictureContainer) (cosa sconsigliatissima in Python <=2.5, come dicevo anche nel mio terzo articolo sulla gestione dei record) il check fallirebbe. La cosa è stata risolta in Python 2.6 e superiori grazie al meccanismo delle ABC; basta registrare SimplePictureContainer come ABC di PictureContainer2 e il gioco è fatto.

Conclusione

Il punto di vista esposto in questo articolo è che i mixin andrebbero considerati più come un workaround (utili magari per interfacciarsi con codice esistente o come ausilio per il debugging) che come una tecnica legittima da usare nel design di un'applicazione. Versioni recenti di Python hanno reso possibili molte valide alternative all'ereditarietà e la tendenza generale dei framework Python moderni è quella di favorire la programmazione a componenti al posto dell'ereditarietà. Tenete conto di questo quando progettate un'applicazione. Tenete anche conto che gli svantaggi veri della programmazione a mixin si vedono soltanto quando si ragiona in grande, per cui non ve ne accorgerete fino a che la vostra applicazione crescerà fino ad andare fuori controllo. Per questo motivo nella prossima puntata discuterò come evitare i mixin in framework di dimensioni medio/grandi. Non mancate!

Pubblicato il
2 Lug 2008
Tag

Commenti

  • Maelstrom il 2 Lug 2008

    In definitiva, sono d'accordo con te: mixin da usare solo se non c'è altro modo, e aggiornandosi un po' si scoprono tantissimi nuovi modi per evitarli.. per esempio l'import locale non lo conoscevo e devo dire che entrerà subito nel mio stile!!

    Ottimi articoli!

  • Giovanni Bajo il 5 Lug 2008

    La tecnica di importare funzioni come metodi dentro una classe è effettivamente sconosciuta in Python. Ne avevo scoperto la prima volta l'esistenza poche settimane fa, in email/message.py nella libreria standard.

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