CPython e il polimorfismo degli operatori

Manca ormai l’ultimo pezzo del puzzle, come si suol dire, per completare l’analisi del funzionamento e dell’implementazione degli operatori nella macchina virtuale di Python (CPython per l’esattezza).

Abbiamo visto nel precedente articolo che, per l’operatore di “somma” (il simbolo +), l’astrazione è racchiusa dentro un’apposita funzione, chiamata PyNumber_Add.

Ovviamente esistono altrettante funzioni per gli altri operatori, inclusi quelli unari, ma il concetto finora esposta è di carattere generale, per cui è applicabile anche a questi.

PyNumber_Add, però, esegue soltanto una piccola parte del lavoro, delegando l’implementazione, di più basso livello, a un’altra funzione, binary_op1, che si occupa del lavoro vero e proprio. Lo si vede dalla riga che la richiama:

PyObject *result = binary_op1(v, w, NB_SLOT(nb_add));

v e w sono i due oggetti di cui si vuole eseguire l’operazione di “somma”. binary_op1 richiede, però, un parametro in più, perché si tratta di una routine di carattere generale, che funziona e si applica a qualunque operatore binario, ma… deve sapere quale. La funzione del terzo parametro è proprio quella di specificare l’operatore da applicare, che fa uso della macro NB_SLOT:

#define NB_SLOT(x) offsetof(PyNumberMethods, x)

Come abbiamo visto nell’articolo che ha discusso dei “protocolli”, le classi “numeriche” mettono a disposizione una tabella di puntatori a funzione, PyNumberMethods, per esporre tutti gli operatori e funzioni “numerici” previsti dal protocollo.

Scopo di NB_SLOT è di estrarre l’offset, all’interno di questa struttura, del campo specificato come suo secondo parametro. Poiché vogliamo eseguire la somma binaria, il campo di PyNumberMethods che c’interessa è, ovviamente, l’nb_add che abbiamo visto prima.

A questo punto binary_op1 ha tutto ciò che gli serve per poter svolgere il suo lavoro:

/*
  Calling scheme used for binary operations:

  Order operations are tried until either a valid result or error:
    w.op(v,w)[*], v.op(v,w), w.op(v,w)

  [*] only when v->ob_type != w->ob_type && w->ob_type is a subclass of
      v->ob_type
 */

static PyObject *
binary_op1(PyObject *v, PyObject *w, const int op_slot)
{
  PyObject *x;
  binaryfunc slotv = NULL;
  binaryfunc slotw = NULL;

  if (v->ob_type->tp_as_number != NULL)
    slotv = NB_BINOP(v->ob_type->tp_as_number, op_slot);

  if (w->ob_type != v->ob_type &&
    w->ob_type->tp_as_number != NULL) {
    slotw = NB_BINOP(w->ob_type->tp_as_number, op_slot);
    if (slotw == slotv)
      slotw = NULL;
  }

  if (slotv) {
    if (slotw && PyType_IsSubtype(w->ob_type, v->ob_type)) {
      x = slotw(v, w);
      if (x != Py_NotImplemented)
        return x;
      Py_DECREF(x); /* can't do it */
      slotw = NULL;
    }

    x = slotv(v, w);
    if (x != Py_NotImplemented)
      return x;
    Py_DECREF(x); /* can't do it */
  }

  if (slotw) {
    x = slotw(v, w);
    if (x != Py_NotImplemented)
      return x;
    Py_DECREF(x); /* can't do it */
  }

  Py_INCREF(Py_NotImplemented);
  return Py_NotImplemented;
}

Com’era facile immaginare, complici anche i precedenti articoli in cui è stato accennato, la sua struttura a prima vista è complessa. In realtà si tratta di distinguere fra i vari scenari che si possono presentare, in base al tipo dei due oggetti che sono stati passati, per cui una volta “suddiviso” il codice tutto si rivela più semplice e comprensibile.

I due controlli (if) che troviamo all’inizio sono “preliminari”. Il primo controlla se l’oggetto v implementa il “protocollo numerico”; il campo tp_as_number, che ricordiamo appartenere al tipo/classe associato all’istanza tramite il suo campo ob_type, dev’essere non nullo, cioè puntare effettivamente a una tabella di puntatori a funzioni che definisce quali parti del protocollo numerico sono esposte dall’oggetto.

Se l’oggetto implementa il protocollo numerico, si recupera il puntatore a funzione dell’operazione richiesta, tramite la macro NB_BINOP:

#define NB_BINOP(nb_methods, slot) \
 (*(binaryfunc*)(& ((char*)nb_methods)[slot]))

I contorsionismi della sintassi del C non aiutano la leggibilità, ma il concetto è che si va a prelevare, dalla suddetta tabella di puntatori a funzione (passata come puntatore tramite il parametro nb_methods), il puntatore che si trova all’offset specificato come secondo parametro (che, lo ricordo, è stato estratto in precedenza tramite la macro NB_SLOT).

Quindi slotv conterrà il puntatore alla funzione che si occupa di gestire la somma binaria per l’oggetto v. Infatti slotv è definito come binaryfunc:

typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);

che è già più comprensibile.

Della stessa cosa si occupa il secondo if, ma con un paio di differenze. La prima è che se l’oggetto w ha lo stesso tipo dell’oggetto v, non c’è alcun bisogno di procedere con l’estrazione della funzione binaria a esso associata, perché ovviamente sarà la stessa già estratta per l’oggetto v.

Per cui la funzione che interessa viene estratta soltanto se w ha un tipo diverso. Una volta estratta salta all’occhio la seconda differenza. Infatti viene effettuato un ulteriore controllo per verificare se la funzione è la stessa di quella estratta per l’oggetto v.

Sembra assurdo, visto che è stato appurato che v e w hanno sicuramente due tipi diversi (altrimenti non si sarebbe estratta la funzione per w), ma questo caso può verificarsi se, ad esempio, w è l’istanza di una classe che deriva dalla classe dell’istanza v, e di cui ha conservato intatta l’operazione binaria in questione (non ha, quindi, ridefinito l’operatore).

Se è così, v e w si possono trattare esattamente allo stesso modo (visto che condividono lo stesso “metodo” per la stessa operazione), per cui si annulla il puntatore a funzione estratto per w.

Il terzo if rappresenta il caso più comune, perché generalmente l’oggetto v è in grado di gestire l’operazione binaria (pensiamo alla somma: il primo operando è al 99,99% l’istanza di una classe che è in grado di “sommarsi” con un’altra), e quindi slotv contiene il puntatore alla funzione da utilizzare.

Prima di procedere, però, serve un ulteriore verifica. Perché se anche l’oggetto w ha definito una sua funzione, ovviamente diversa (altrimenti saremmo rientrati nel caso precedentemente spiegato per l’oggetto w), si pone il problema di decidere quale delle due utilizzare, visto che entrambi gli oggetti sono in grado di farsi carico dell’operazione.

Non essendoci uno schema di priorità et similia la scelta risulterebbe arbitraria, ma gli sviluppatori di CPython hanno optato per l’aggiunta di un altro controllo. Infatti in questo caso si verifica se il tipo di w è una sottoclasse del tipo di v, e se è così lasciamo che sia la funzione dell’oggetto w a farsi carico dell’operazione.

La ratio che vi sta alla base è abbastanza semplice, ma efficace: se la classe di w ha esteso quella di v, molto probabilmente dovrà svolgere un’operazione più specifica, e in ogni caso si sarà assunta la responsabilità di completarla ugualmente anche nel caso in cui non fosse in grado di applicare il suo codice specifico.

Ciò non toglie che potrebbe ugualmente fallire (restituendo il singleton Py_NotImplemented), e allora, come si vede subito dopo nel codice, il controllo in ogni caso passerebbe alla funzione definita dall’oggetto v.

Se anche questa dovesse fallire (oppure l’oggetto v non ha definito alcuna funzione allo scopo), non rimane che vedere se l’oggetto w mette a disposizione una sua funzione, passandole quindi il controllo.

Qui c’è qualcosa di strano, perché personalmente avrei restituito immediatamente il risultato, uscendo dalla funzione, senza aspettare ulteriormente.

Infatti due sono i casi possibili: o slotv è stata in grado di eseguire l’operazione, e quindi restituiamo subito il valore che ci ha fornito, oppure… non è stata in grado visto che ci ha dato Py_NotImplemented e… possiamo, allo stesso modo, restituirlo immediatamente.

Invece in quest’ultimo caso viene sostanzialmente “buttato” il valore Py_NotImplemented restituito (decrementando il suo contatore dei riferimenti), per poi nuovamente utilizzarlo (incrementando il contatore dei riferimenti) e restituirlo con le ultime 2 righe di codice di binary_op1.

Giusto per essere chiari, l’ultimo pezzo di codice l’avrei scritto così:

 if (slotw)
 return slotw(v, w);

 Py_INCREF(Py_NotImplemented);
 return Py_NotImplemented;
}

evitando, quindi, un’inutile ripetizione che, tra l’altro, rallenta anche le prestazioni.

Completata anche questa parte, andando a ripescare i precedenti articoli sull’argomento e ripercorrendo il flusso delle operazioni, si capisce per quale motivo a partire da CPython 3.0 si è assistito a un brusco calo di prestazioni (circa il 30%), visto che tutto il lavoro che c’è da fare per portare a termine anche una banale, ma estremamente comune, operazione di somma fra interi.

D’altra parte in CPython 3.0 non c’è scelta, perché utilizza esclusivamente gli interi “lunghi”. CPython 2.x risulta, invece, molto avvantaggiato perché fa ancora uso degli interi “corti”, per i quali nell’opcode BINARY_ADD è stato previsto un code path speciale e veloce.

Purtroppo alla fine bisogna raggiungere un compromesso, e per ottenere una certa flessibilità ed omogeneità in termini di usabilità del linguaggio (c’è un solo tipo intero) si sono dovute sacrificare le prestazioni. Questo per lo meno con l’implementazione attuale.

In una prossima serie di articoli parlerò dell’implementazione degli interi “lunghi” che ho presentato alla scorsa EuroPython, che oltre a migliorare un po’ le prestazioni delle operazioni con questo tipo di dato, potrebbero rappresentare il fondamento di modifiche più consistenti che porterebbero, a mio avviso (l’idea, comunque, è da verificare), le prestazioni dell’interprete a livelli comparabili a quelli di CPython 2.x con gli interi corti.

Press ESC to close