Metaclassi in Python 3000

Ho sentito per la prima volta la parola "metaclasse" nel 2002,
quando ho iniziato a studiare Python. Risale a quei tempi il mio primo
articolo su Python, che guarda caso riguarda proprio le metaclassi. A quei
tempi l'argomento era caldo perché con l'uscita di Python 2.2
il meccanismo delle metaclassi era stato appena riformato. Da allora molta
acqua è passata sotto i ponti e ormai sta per uscire Python 3.0 (per
gli amici Python 3000), che porterà un'altra riforma delle
metaclassi. Non potevo perdere l'occasione per scrivere due parole
sull'argomento.

Introduzione

Assumo qui che i miei lettori sappiamo qualcosa sul funzionamento delle
metaclassi in Python, dalla versione 2.2 alla 2.5; se avete bisogno di
rinfrescarvi la memoria, consiglio la trilogia di articoli di David Mertz e di
chi scrive, apparsi su DeveloperWorks, dal titolo Metaclass programming in
Python
, parti 1, 2 e
3.

Una metaclasse non è altro che una sottoclasse della madre di tutte
le metaclassi, type:

>>> class DoNothingMeta(type): # una metaclasse abbastanza stupida
...    pass

Una metaclasse può essere instanziata specificando una stringa
name, una tupla di classi bases e un dizionario
dic:

>>> C = DoNothingMeta('C', (object,), {})

Questo ritorna una classe di nome C, con genitore
object e dizionario vuoto. Tutto questo rimane uguale in Python
2.2+ e Python 3.0; la differenza è solo nello zucchero
sintattico
: in Python si può usare il __metaclass__
hook

>>> class C(object): __metaclass__ = DoNothingMeta

come sinonimo della sintassi di istanziazione esplicita; in Python 3.0
invece si può usare la sintassi più elegante:

>>> class C(object, metaclass=DoNothingMeta): pass

La cosa importante da notare è che in Python 3.0 è
apparentemente possibile scrivere:

>>> class C(object): __metaclass__ = DoNothingMeta

ma l'hook non è riconosciuto: Python 3.0 aggiungerà un
attributo __metaclass__ alla vostra classe, ma NON la
convertirà magicamente in un'istanza della metaclasse: la classe
di C sarà type e non
DoNothingMeta.

>>> type(C) is DoNothingMeta
False

La miglioria fondamentale

I cambiamenti sintattici, per quanto comodi, sono pur sempre soltanto
cosmesi. La sostanza del cambiamento da Python 2.2 a Python 3.0 sta in una
differenza semantica: quello che in Python 2.2 è un semplice
dizionario, in Python 3.0 può diventare un oggetto generico con un
metodo __setitem__. In pratica, il vantaggio principale è
che è possibile usare un dizionario ordinato al posto di un dizionario
ordinario: questo significa che è possibile conservare l'ordine
delle dichiarazioni nel corpo della classe. In Python 2.2 invece questo non
è possible, quindi se per esempio volessimo definire una class
Book con campi title e author
(nell'ordine) si è obbligati a ripetere il nome dei campi:

class BookPython22(Record):

    title = 'varchar(128)'
    author = 'varchar(64)'

    order = ['title', 'author']

La lista order è ridondante e fastidiosa, ma è
obbligatoria in Python 2.2, altrimenti non sarebbe possibile capire se
title viene prima di author o viceversa; in Python
3.0 invece possiamo eliminarla senza problemi, a patto di preservare
l'ordine delle dichiarazioni usando un dizionario ordinato.

Sfortunatamente, una classe OrderedDict manca nella libreria
standard di Python e continua a mancare anche nella versione 3.0; questo a mio
avviso è una gravissima pecca che genera un proliferare di
implementazioni. Non essendoci speranze di avere un'implementazione
standard, userò una mia implementazione:

class odict(dict):
    def __init__(self, alist=iter([])):
        self._alist = []
        for k, v in alist:
            self[k] = v
    def __setitem__(self, name, value):
        super().__setitem__(name, value)
        self._alist.append((name, value))
    def keys(self):
        return [k for (k, v) in self._alist]
    __iter__ = keys
    def values(self):
        return [v for (k, v) in self._alist]
    def items(self):
        return self._alist
    def __repr__(self):
        return '{%s}' % self._alist

È da notare che in Python 3.0 i metodi .iterkeys(),
.itervalues() e .iteritems() che si trovano in
Python 2.0 mancano, dunque non è necessario sovrascriverli e
l'implementazione di odict è più semplice.
Inoltre l'oggetto super funziona meglio perché non
è necessario ripetere il nome della classe, nè specificare
l'istanza, visto che sono ovvi dal contesto.

Resta da chiarire come dire alla metaclasse di usare un odict
invece di un dict normale. A questo scopo, Python 3.0 riconosce
un metodo speciale __prepare__(mcl, name, bases) che ritorna
un'istanza dell'oggetto da utilizzare per immagazzinare gli
attributi (è sufficiente che l'oggetto abbia un metodo
__setitem__ e il nostro odict ce l'ha). Dunque
basta aggiungere alla metaclasse un __prepare__ opportuno:

>>> use_odict = classmethod(lambda cls, name, bases: odict())

(si noti che __prepare__ dev'essere un
classmethod della metaclasse)

>>> class MetaRecordExample(type):
...     __prepare__ = use_odict
...     def __init__(cls, name, bases, odic):
...        cls.odic = odic

MetaRecord popolerà automaticamente il dizionario
ordinato di tutte le sue istanze con i valori introdotti nel class
scope
e manterrà un riferimento al dizionario nell'attributo
.odic:

>>> class BookExample(metaclass=MetaRecordExample):
...       'Un esempio'
...       title = 'Varchar(128)'
...       author = 'Varchar(64)'
>>> BookExample.odic
{[('__module__', '__main__'), ('__doc__', 'Un esempio'), ('title', 'Varchar(128)'), ('author', 'Varchar(64)')]}

Notiamo la comparsa degli attributi magici __module__ e
__doc__ che vengono passati alla metaclasse in maniera implicita.
Questi non costituiscono danno, non si possono confondere con i campi dei
record perché i nomi contengono i magici underscore ed
è facile scartarli se necessario.

Esempio: una zoologia di record

Così come le classi sono utili quando si vogliono definire delle
tipologie di oggetti, le metaclassi sono utili quando si vogliono definire
delle tipologie di classi. Con lo scopo di dare un esempio non banale,
considererò il problema di definire un insieme di classi record, tutte
sottoclassi una classe madre Record e tutte instanze di una
metaclasse comune MetaRecord, su cui è definita
un'operazione di somma. In termini matematici definirò un monoide,
ovvero un insieme con una legge di composizione interna associativa e con un
elemento neutro, che sarà la classe madre Record stessa.
Le tuple di Python costituiscono già un insieme di questo tipo,
perché la somma di due tuple è una tupla e la tupla vuota
corrisponde all'elemento neutro; tutti i miei record saranno quindi
tuple, ma con l'operazione di somma sovrascritta opportunamente.

Rispetto alle tuple ordinarie, i record hanno in più il concetto di
tipo: ogni campo (che ha un nome e non soltanto un indice posizionale) ha un
tipo specifico. Siccome Python è un linguaggio dinamicamente tipizzato,
ha senso descrivere i campi in termini di funzioni di cast, che
prendono in input uno o più tipi e ritornano un tipo prefissato, oppure
sollevano un'eccezione se il tipo in ingresso non è accettabile.
Tanto per poter dare degli esempi concreti considereremo le seguenti funzioni
di cast, da usare per records di tipo Book:

def varchar(n):
    def check(x):
        s = str(x)
        if len(s) > n:
            raise TypeError('Entered a string longer than %d chars' % n)
        return s
    check.__name__ = 'varchar(%d)' % n
    return check
def date(x):
    if isinstance(x, datetime.date):
        x = x.isoformat()[:10]
    return datetime.date(*map(int, x.split('-')))
def score(x):
    if set(x) != {'*'} or len(x) > 5:
        raise TypeError('%r is not a valid score.' % x)
    return len(x)

varchar(128) verrà usata per garantire che il titolo
sia una stringa lunga al più 128 caratteri;

>>> varchar(128)('a'*129)
Traceback (most recent call last):
  ...
TypeError: Entered a string longer than 128 chars

Analogamente, varchar(64) verrà usata per garantire che
il nome dell'autore sia una stringa lunga al più 64 caratteri;
date conterrà una data in formato ISO; score
convertirà una stringa con una o più asterischi in un numero
intero; l'idea è quella di dare un giudizio del libro in stelline,
da una a cinque:

>>> score('***')
3
>>> score('')
Traceback (most recent call last):
   ...
TypeError: '' is not a valid score.

L'idea è quella di poter definire dei record tipo questi:

class Book(Record):
    title_type = varchar(128)
    author_type = varchar(64)
class PubDate(Record):
    date_type = date
class Score(Record):
    score_type = score

su cui è definita un'operazione di somma che a classi associa
classi:

>>> Book + PubDate + Score
<class Book+PubDate+Score title:varchar(128), author:varchar(64), date:date, score:score>

Sarà possibile verificare l'associatività

>>> (Book + PubDate) + Score == Book + (PubDate + Score)
True

e l’esistenza dell’elemento neutro

>>> Book + Record == Book
True
>>> Record + Book == Book
True

A queste proprietà a livello delle classi corrispondono analoghe
proprietà a livello delle istanze; si considerino per esempio il record
nullo, analogo alla tupla vuota ()

>>> null = Record()
>>> null
<Record >

Si consideri poi un record di tipo Book:

>>> b = Book('Putting Metaclasses to Work', 'Ira Forman')
>>> b
<Book title=Putting Metaclasses to Work, author=Ira Forman>

Si consideri infine un record di tipo PubDate, contenente la
data di pubblicazione del libro:

>>> d = PubDate('1998-10-01')

Verifichiamo che null si comporta da elemento neutro:

>>> b + null == null + b == b
True

e vediamo che succede quando si sommano due record non banali:

>>> s = b + d
>>> s
<Book+PubDate title=Putting Metaclasses to Work, author=Ira Forman, date=1998-10-01>

I singoli campi sono anche accessibili per nome:

>>> s.title, s.author, s.date
('Putting Metaclasses to Work', 'Ira Forman', datetime.date(1998, 10, 1))

Come funziona tutto ciò? Il metodo __add__ della classe
Record determina le classi dei record in entrata, costruisce la
classe del record in uscita usando MetaRecord, e la instanzia con
i valori corretti. In particolare se le classi in entrata prendono N ed M
parametri rispettivamente (nel nostro caso N=2 e M=1) la classe in uscita
prende N+M parametri (nel nostro caso il titolo, l'autore e la data di
pubblicazione). Ecco il codice relativo alla classe Record:

class Record(tuple, metaclass=MetaRecord):
    def __add__(self, other):
        cls = type(self) + type(other)
        return cls (*super().__add__(other))
    def __repr__(self):
        slots = ['%s=%s' % (n, v) for n, v in zip(self.all_names, self)]
        return '<%s %s>' % (self.__class__.__name__, ', '.join(slots))

Siccome la metaclasse è abbastanza astuta da tenersi un registro
delle sue istanze, se una classe compatibile con la classe del record in
uscita esiste già, viene riutilizzata: in altre parole, non è
necessario ricreare classi ad ogni somma. A tale scopo, MetaRecord definisce
una relazione di equivalenza tra le sue istanze tramite il metodo
__eq__: due record sono considerati equivalenti
("compatibili") se hanno gli stessi campi. Un'altra magia che
viene effettuata al livello della metaclasse è il passaggio dei
parametri ai record concreti e la convalida degli stessi: il metodo
__call__ della metaclasse intercetta le instanziazioni dei record
concreti e passa il numero di parametri corretto: se un record contiene N
campi, controlla che vengano passati N valori, li converte nei tipi corretti
(se non ci sono eccezioni) e passa alla classe record una tupla di valori
convalidati. I campi della tupla sono poi accessibili per nome tramite delle
opportune proprietà che la metaclasse definisce sulle sua istanze
automaticamente. Infine, la metaclasse gestisce la somma di record, che in
pratica diventa zucchero sintattico per l'operazione di derivazione:
definire

>>> BookWithScore = Book + Score

è lo stesso che definire la classe figlio

>>> class BookWithScore2(Book, Score):
...      pass
>>> BookWithScore2 == BookWithScore
True

La differenza è che l'addizione riutilizza classi
pre-esistenti, se possibile, mentre la derivazione ne crea sempre di nuove,
che è il comportamento normale di Python.

Ecco il codice per MetaRecord:

class MetaRecord(type):
    __prepare__ = use_odict
    _repository = {} # repository of classes already instantiated

    def __new__(mcl, name, bases, odic):
        entered_slots = tuple((n,v) for n, v in odic.items() if n.endswith('_type'))
        all_slots = getslots(bases) + entered_slots
        check_disjoint(n for (n, f) in all_slots) # check the field names are disjoint
        odic['all_slots'] = all_slots
        odic['all_names'] = tuple(n[:-5] for n, f in all_slots)
        odic['all_fields'] = fields = tuple(f for n, f in all_slots)
        cls = super().__new__(mcl, name, bases, odic)
        mcl._repository[fields] = cls
        for i, (n, v) in enumerate(all_slots):
            setattr(cls, n[:-5], property(lambda self, i=i: self[i]))
        return cls

    def __eq__(cls, other):
        return cls.all_fields == other.all_fields

    def __call__(cls, *values):
        expected = len(cls.all_slots)
        passed = len(values)
        if passed != expected:
            raise TypeError('You passed %d parameters, expected %d' %
                          (passed, expected))
        vals = [f(v) for (n, f), v in zip(cls.all_slots, values)]
        return super().__call__(vals)

    def __add__(cls, other):
        name = '%s+%s' % (cls.__name__, other.__name__)
        fields = tuple(f for n, f in getslots((cls, other)))
        try: # retrieve from the repository an already generated class
            return cls._repository[fields]
        except KeyError:
            return type(cls)(name, (cls, other), {})

    def __repr__(cls):
        slots = ['%s:%s' % (n[:-5], f.__name__) for (n, f) in cls.all_slots]
        return '<%s %s %s>' % ('class', cls.__name__, ', '.join(slots))

check_disjoint e getslots sono delle funzioni di
utilità:

def check_disjoint(names):
    storage = set()
    for name in names:
        if name in storage:
            raise NameError('Field %r occurs twice!' % name)
        else:
            storage.add(name)
def getslots(bases):
    return sum((getattr(b, 'all_slots', ()) for b in bases), ())

Avvertenza

Il codice presentato qui ha valore pedagogico. Il fatto che le metaclassi
permettano degli hack spettacolari non significa che sia una buona idea
utilizzarle, soprattutto in codice di produzione. A chi non lo avesse
già fatto, consiglio di leggersi Metaprogramming
without Metaclasses
per farsi un'idea più chiara delle
alternative. In particolare, siccome Python 3.0 permette di decorare le
classi, i casi d'uso ragionevoli per le metaclassi si sono ulteriormente
ristretti, ed erano già molto pochi: questo significa che è
quasi impossibile che abbiate veramente bisogno di una metaclasse nel codice
di tutti i giorni. Usare tecniche sofisticate quando ci sono alternative
più semplici è un errore marchiano; non fatelo se avete rispetto
per chi deve mantenere il vostro codice (inclusi voi stessi).

Comments

  1. Una tecnica molto potente, devo dire che il Python mi affascina sempre di più e vedo con gioia che molte delle features più astruse dei linguaggi ad oggetto (ad esempio, i template c++) divengono semplici e naturali in Python.

    Un appunto è forse solamente che spesso discostarsi dal modo “pytonico” di fare le cose può risultare sconveniente, ma magari è semplicemente un punto a favore del linguaggio, e non un difetto.

  2. E’ sconveniente. Apri una python shell e digita import this, lo zen di python è tutto li.

  3. @Michele: ok, adesso lo so. Grazie!
    Meglio tardi che mai… 🙂

  4. Michele Simionato says:

    Per curiosita’ qual e’ il tuo caso d’uso? Di solito
    questi magheggi si fanno quando si fa programmazione dichiarativa e si scrivono descrizioni/configurazioni di oggetti

  5. @michele: ho una classe che utilizzo spessissimo per definire delle costanti, per esempio:

    class TIPO_IVA(Constants):
        ESENTE = "Esente"
        IVA20 = "20%"
        IVA10 = "10%"
    

    La classe base Constants (che mi trascino da tempo per pigrizia) non e` istanziabile ( mo’ di singleton), ha alcuni metodi che mi tornano utili (come choices() che uso come valori possibili di un campo ChoiceField in Django) e altre minuzie del genere.

    In questi giorni mi sono trovato ad usarla in una situazione in cui l’ordine delle opzioni era importante (in particolare mi serviva mantenere la prima) per cui volevo modificare la definizione della classe base Constants in modo che, senza cambiare tutto il codice già scritto che la usa, fosse mantenuto l’ordine dichiarativo degli attributi.

    Purtroppo questo articolo mi ha confermato che non e` possibile 😉

    P.S. Una namedtuple (magari modificata con i “metodini” che uso) poteva fare meglio questo lavoro, pero` al tempo della scrittura di Constants mica le conoscevo le namedtuple e adesso ho un tot di codice in giro. Aggiungerò un metodo opzionale _order che quando presente imporrà l’ordine degli attributi 😉

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.