Programmazione 4 commenti

I pericoli della programmazione con i mixin/3

di Michele Simionato

Dagli articoli precedenti dovrebbe essere ampiamente chiaro che io non amo i mixin, ma vale la vena di spiegare esattamente cos'è che mi dà così tanto fastidio. Tutti noi sappiamo che il modo migliore di risolvere un problema complesso è quello di spezzarlo in sottoproblemi più piccoli disaccoppiati, secondo la massima del dividi et impera. La cosa fastidiosa dei mixin è che questo principio viene applicato all'inizio (il problema viene scomposto in pacchetti disgiunti di funzionalità) ma alla fine tutte le funzionalità separate vengono rimescolate insieme in un calderone unico, che è il namespace della classe figlia. Che l'iniezione avvenga direttamente come in Ruby o indirettamente tramite l'ereditarietà come in Python, il risultato non cambia: alla fine l'utente della classe figlia si trova a dover gestire centinaia di metodi tutti assieme e non ha un modo ovvio per capirne la provenienza.

Overpopulation.jpg

Il problema del sovraffollamento

Il problema dei mixin e soluzioni possibili

Il problema dei mixin è che rendono la vita semplice all'autore del framework, ma la rendono difficile all'utilizzatore: questo è assolutamente in contrapposizione con lo Zen di Python che preferisce la facilità di lettura alla facilità di scrittura. Lo scenario che io ho in mente è sempre lo stesso: quello di un povero programmatore che si trova a dovere debuggare un oggetto proveniente da un framework di cui non sa nulla o quasi, in assenza totale o parziale di documentazione e con poco tempo a disposizione per imparare il minimo necessario per risolvere il problema del momento (questo scenario vi ricorda qualcosa?). In queste condizioni le tecniche tipiche di introspezione (l'autocompletamento, pydoc e strumenti simili) falliscono perché tutti i metodi vengono considerati equivalenti e la bella e ordinata catalogazione per mixin va a farsi benedire. Cosa si può fare in questa situazione? Direi che ci sono almeno tre possibili atteggiamenti:

  1. La rassegnazione. Rendersi conto che ormai il linguaggio permette l'ereditarietà multipla e/o i mixin, che molti framework li usano e che non cambierà mai. Inventare dei workaround per cercare di sopravvivere, come il decoratore warn_overriding che ho definito nel primo articolo della serie e scrivere dei tool di introspezioni migliori per orientarsi nella selva dei mixin (il problema di pydoc è che dà troppa informazione).
  2. L'educazione. Darsi da fare per rendere noti al grande pubblico i problemi dei mixin e convincere gli autori dei framework del futuro a usare design alternativi. È quello che ho cercato di fare con il secondo articolo di questa serie.
  3. La ricerca. Studiare implementazioni migliori dell'idea dei mixin: anche se non ci sono speranze per il linguaggio che si sta usando per motivi di compatibilità con il passato, la ricerca non è inutile perché potrebbe essere implementata nei linguaggi del futuro. Lo stesso Python può essere usato come linguaggio di sperimentazione ed in questo articolo mostro come implementare i mixin in termini di composizione e non di ereditarità.

Un problema di design

Siccome non mi piace parlare in astratto ma preferisco riferirmi ad esempi concreti, in questo paragrafo discuterò un problema di design concreto che risolverò con classi di mixin ma senza incorrere nel problema del sovraffollamento. A tale scopo riprendiamo in mano l'articolo precedente e torniamo alla classe PictureContainer2 che derivava dalla classe DictMixin. Come abbiamo detto, derivare da DictMixin è un'ottima idea perché DictMixin fornisce soltanto 19 attributi alle sue istanze (ottenuti con dir(DictMixin)) che sono tutti già noti a chi sa usare i dizionari in Python e quindi il carico cognitivo è nullo.

Il problema comincia quando si decide che è necessario aggiungere funzionalità al nostro PictureContainer2. Per esempio, se stiamo scrivendo un'applicazione GUI, potremo aver bisogno di metodi tipo set_thumbnails_size, get_thumbnails_size, generate_thumbnails, show_thumbnails, make_picture_menu, show_picture_menu, eccetera; diciamo che abbiamo almeno 50 metodi che hanno a che fare con la GUI (settaggio dei parametri, dei menu, dei bottoni, metodi ausiliari e chi più ne ha più ne metta). Potremmo mettere tutti questi 50 metodi in una classe mixin chiamata GUI ed ereditare da DictMixin e da GUI.

Fin qui nulla di male. Supponiamo però che la versione 2 della nostra applicazione debba andare su Web; può avere allora senso implementare gli 8 metodi del protocollo HTTP (HEAD, GET, POST, PUT, DELETE, TRACE, OPTIONS, CONNECT) in un'altra classe mixin; se vogliamo dare anche la possibilità di editare le immagini ai nostri utenti, può aver senso anche un'interfaccia WebDAV (quindi con 7 metodi addizionali rispetto al protocollo HTTP: PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK).

D'altra parte, ci sono utenti che potrebbero preferire il buon vecchio protocollo FPT per trasferire le immagini (quindi 43 metodi ABOR, ALLO, APPE, CDUP, CWD, DELE, EPRT, EPSV, FEAT, HELP, LIST, MDTM, MLSD, MLST, MODE, MKD, NLST, NOOP, OPTS, PASS, PASV, PORT, PWD, QUIT, REIN, REST, RETR, RMD, RNFR, RNTO, SIZE, STAT, STOR, STOU, STRU, SYST, TYPE, USER, XCUP, XCWD, XMKD, XPWD, XRMD). Infine, ci sarà bisogno di metodi di autorizzazione vari (is_admin, is_logged_user, is_anonymous_user, is_picture_owner, is_friend_of, eccetera), diciamo altri 20 metodi nella classe mixin AUTH.

A questo punto avremmo sei classi mixin (DictMixin, GUI, HTTP, WEBDAV, FTP, AUTH) ed un totale di almeno 20 (da DictMixin) + 50 (da GUI) + 8 (da HTTP) + 7 (da WEBDAV) + 44 (da FTP) + 20 (da AUTH) = 148 metodi derivanti dalle classi mixin. A questi vanno aggiunti i metodi specifici della classe PictureContainer. Non è un bello scenario, soprattutto se pensate che domani potrei avere bisogno di supportare un'altra interfaccia e quindi di aggiungere ancora altri metodi. Nelle mie stime sono stato conservativo, ma si fa presto a raggiunger le centinaia di metodi. Questo scenario non è affatto irrealistico: è esattamente quello che è avvenuto in Zope/Plone.

Per esempio, date un'occhiata alla gerarchia di un oggetto Plone Site (la figura mostra tra parentesi quadre il numero di metodi/attributi non speciali definiti per ogni classe). Il sito in oggetto è quello di un'applicazione Plone che ho in produzione da tre anni. Il conteggio totale è di 38 classi, 88 nomi sovrascritti, 42 nomi speciali, 648 nomi normali. Un mostro. In queste condizioni viene da chiedersi se esistono design alternative ai mixin che evitano il problema del sovraffollamento dei metodi. La risposta è sì ed è sempre la solita, usare la composizione al posto dell'ereditarietà, pratica comunemente consigliata in ogni buon manuale di programmazione orientata agli oggetti ma messa in opera non abbastanza spesso.

Una possibile soluzione

La mia soluzione, come anticipato nell'articolo precedente sarà quella di sostituire l'ereditarietà con la composizione + delegazione, ovverossia fare uso di oggetti proxy. Il carico cognitivo richiesto da un proxy - un oggetto che fa dispatch su di un altro oggetto - è molto inferiore al carico cognitivo imposto dall'ereditarietà.

Se un oggetto è un' instanza di una classe, mi sento obbligato a conoscere tutti i suoi metodi (inclusi quelli di tutti i suoi antenati) se non altro perché potrei sovrascriverli accidentalmente mentre se un oggetto è un proxy mi basta sapere qual è l'oggetto a cui si riferisce, so che se voglio posso andare a vedere i metodi dell'oggetto, ma non mi sento obbligato a farlo. È più un motivo psicologico che altro, ma il proxy piace perché permette di tenere confinata la complessità, mentre l'ereditarietà la espone direttamente.

Quest'ultimo punto è estremamente importante. Il cervello umano può memorizzare un numero limitato di cose. Un oggetto con dieci metodi può essere memorizzato abbastanza agevolmente mentre un oggetto con cento metodi esce dalle capacità del programmatore medio. La soluzione è quella di dividire i cento metodi in dieci categorie con dieci metodi ognuna: a questo punto le dieci categorie possono essere tenute in mente. La soluzione gerarchica scala: se avessi bisogno di mille metodi, basta definire dieci macro-categorie, ognuna contenente dieci categorie semplici, e tenere a mente le macro-categorie. La catalogazione gerarchica è la maniera naturale per la mente umana per memorizzare l'informazione, come formalizzato per lo meno dai tempi di Aristotele, quindi è questa la cosa giusta da fare, non tenere i mille metodi tutti sullo stesso piano nello stesso namespace.

Nel caso in esame, ho deciso di trasformare tutte le classi di mixin in proxy: se un attributo non viene trovato nel namespace del mixin, viene cercato anche nel namespace dell'oggetto sottostante. Tecnicamente questa idea può essere implementata definendo degli oggetti dispatcher da usare come attributi di classe (attribute descriptors). Potete trovare l'implementazione in appendice. Un esempio d'uso è il seguente:

class PictureContainer(DictMixin, object):
  # dispatchers objects have the same methods as the corresponding mixin
  # class; moreover they dispatch on self, i.e. on PictureContainer objects
  gui = dispatcher(GUI)
  http = dispatcher(HTTP)
  webdav = dispatcher(WEBDAV)
  ftp = dispatcher(FTP)
  auth = dispatcher(AUTH)

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

  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

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

Notate come io abbia rifattorizzato la classe PictureContainer2 dell'articolo precedente per renderla più pulita. In particolare ho definito esplicitamente la proprietà log invece di importarla dal modulo di utilità (from utility import log) come fatto. Tutto sommato, visto che si tratta di tre sole righe, può avere senso riscriverle ed evitare al lettore di andare a guardare un altro modulo. C'è sempre da trovare un punto di equilibrio tra riuso del codice da una parte e codice spaghetti dall'altra. Nel dubbio, tenete conto che readability counts.

Come vedete è sparita l'ereditarietà da tutte le classi mixin tranne DictMixin. Un oggetto PictureContainer è un dizionario e quindi è giusto che erediti da DictMixin; ha meno senso che erediti da GUI, HTTP, WEBDAV, FTP, AUTH, perché è uno stiracchiare la realtà dire che PictureContainer sia anche un oggetto GUI, HTTP, WEBDAV, FTP, AUTH.

Ho ereditato esplicitamente da object, perché la classe DictMixin è una cosiddetta classe old-style (per motivi di compatibilità con il passato), mentre il dispatcher è pensato per essere usato con classi new-style; ereditare da object fa sì che PictureContainer diventi una classe new-style. Questo è uno dei casi in cui l'ereditarietà multipla è comoda, ma il caso d'uso sparirà in Python 3.0, in cui tutte le classi sono new-style.

Discussione

Proviamo adesso che la nostra soluzione al problema di design è consistente con i principi enunciati nello Zen di Python. Il fatto che l'implementazione del dispatcher sia semplice - circa 20 righe di codice - è già un primo passo nella direzione giusta (if the implementation is hard to explain, it's a bad idea). Il fatto che tutti i metodi dei mixin restino localizzati nel loro proprio namespace senza inquinare il namespace della classe figlia è ancora più pythonico (namespaces are one honking great idea — let's do more of those!). Infine, il fatto che per accedere al metodo POST del mixin HTTP dobbiamo scrivere esplicitamente self.http.POST è pythonicissimo perché explicit is better than implicit; in questo modo non c'è bisogno di tirare ad indovinare per capire la provenienza di un metodo (in the face of ambiguity, refuse the temptation to guess).

La nostra soluzione, comunque, non è soltanto pythonica, ma anche usabile (practicality beats purity): provate ad istanziare un oggetto PictureContainer e a fare qualche esperimento dalla console interattiva:

>>> pc = PictureContainer('root', [])

Vedrete che l'autocompletamento funziona perfettamente

>>> pc.ftp. # press TAB
pc.ftp.RECV
pc.ftp.SEND
...

che l'help non vi sommerge di informazioni inutili

>>> help(pc)
Help on PictureContainer in module mixins2 object:
class PictureContainer(UserDict.DictMixin, __builtin__.object)
|  Method resolution order:
|      PictureContainer
|      UserDict.DictMixin
|      __builtin__.object
|
|  Methods defined here:
|  ...
|
|  auth = <AUTHDispatcher {is_admin ...} >
|  ftp = <FTPDispatcher {RECV, SEND ...} >
|  gui = <GUIDispatcher {draw_button, draw_buttons ...} >
|  http = <HTTPDispatcher {GET, POST ...} >
|  webdav = <WEBDAVDispatcher {GET, POST, LOCK, UNLOCK ...} >
...

e potete fare introspezione soltanto sulle caratteristiche che vi interessano, senza che tutto sia mescolato (sparse is better than dense):

>>> print dir(pc.http)
['GET, 'POST', ...]

È anche molto facile adattare un oggetto composto da dispatchers, per renderlo compatibile con diverse interfacce. Ma per spiegare propriamente questo punto mi servirebbe un'altra serie di articoli, che potrei intitolare I vantaggi della programmazione a componenti. Per il momento, mi fermo qui, sperando di avervi dato qualche spunto interessante. Ecco per i più curiosi l'implementazione del dispatcher:

$ cat mdispatcher.py
class BaseDispatcher(object):
    def __init__(self, obj=None):
        self.__obj = obj
    def __get__(self, obj, objcls=None):
        if obj is None:
            return self
        return self.__class__(obj)
    def __getattr__(self, name):
        obj = self.__obj
        if obj is None:
            raise AttributeError(name)
        return getattr(obj, name)
    def __repr__(self):
        names = ', '.join(n for n in dir(self) if not n.startswith('__'))
        if self.__obj is not None:
            msg = 'bound to %r' % self.__obj
        else:
            msg = ''
        return '<%s {%s} %s>' % (
            self.__class__.__name__, names, msg)

def dispatcher(mixin):
    "Converts a mixin class into a dispatcher"
    # could be implemented with single-inheritance only, but why bother?
    return type(mixin.__name__ + 'Dispatcher', (BaseDispatcher, mixin), {})()
Pubblicato il
26 Ago 2008
Tag

Commenti

  • giovacchino il 26 Ago 2008

    come al solito un articolo interessantissimo!

  • Marcob il 26 Ago 2008

    Fantastica serie. Michele, non puoi fermarti qui... ;-)

  • kiaz il 1 Set 2008

    Ho letto tutti e 3 gli articoli uno di seguito all'altro e devo dire che è stato molto utile! Complimenti per la spiegazione e per la chiarezza nell'esposizione! :)

  • micheles il 6 Set 2008

    I lettori di questa miniserie saranno interessati a questo mio recente post, in cui descrivo un approccio per evitare i mixin usando le funzioni generiche: http://www.artima.com/weblogs/viewpost.jsp?thread=237764

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