L’astrazione dell’operazione di somma/concatenazione in CPython

La lunga serie di articoli finora scritti che descrivono i meccanismo interni di CPython hanno fornito uno spaccato di come la macchina virtuale implementi le operazioni a cui siamo abituati (in particolare ci siamo soffermati su quelle binarie), ma il cerchio non è ancora stato chiuso.

Manca, infatti, l’analisi del workflow che dall’istruzione / opcode da eseguire porta alla routine che verrà effettivamente utilizzata per coprire il caso specifico, di cui quest’articolo presenta una prima parte.

CPython 2.x ci ha abituati a diversi hack, spezzoni di codice altamente specializzato per accelerare il più possibile i casi più comuni, il più evidente dei quali riguarda la somma di due numeri interi e la concatenazione di due stringhe, ma tolti di mezzo questi rimane da capire cosa si verifica nel caso generale.

Una sorpresa arriva da CPython 3.x, il cui opcode BINARY_ADD nasconde un’implementazione che personalmente ho trovato del tutto inattesa. Infatti l’unico hack presente riguarda quello della concatenazione delle stringhe:

TARGET(BINARY_ADD)
  w = POP();
  v = TOP();
  if (PyUnicode_CheckExact(v) && PyUnicode_CheckExact(w)) {
    x = unicode_concatenate(v, w, f, next_instr);
    /* unicode_concatenate consumed the ref to v */
    goto skip_decref_vx;
  }
  else {
    x = PyNumber_Add(v, w);
  }
  Py_DECREF(v);
 skip_decref_vx:
  Py_DECREF(w);
  SET_TOP(x);
  if (x != NULL) DISPATCH();
    break;

Confrontato lo stesso opcode con la versione presente in CPython 2.x, le differenze saltano subito all’occhio.

Sembra anomala, quasi assurda, l’assenza di un code path veloce per la somma degli interi, operazione frequentissima in tutti i linguaggi, e Python non è certo da meno.

Ma se consideriamo che in Python 3.0 gli interi “corti” sono spariti e vengono usati soltanto quelli “lunghi” (la cui somma non è affatto banale), delegare il tutto al caso generale alla fine non rappresenta la soluzione soluzione ottimale, ma nemmeno quella peggiore.

Il codice della funzione PyNumber_Add, che si occupa di gestire l’astrazione per il calcolo della “somma” (in realtà dovremmo parlare sempre dell’operatore +, poiché questo simbolo può ed è concretamente impiegato in operazioni ben diverse, ma ai fini puramente divulgativi penso che non crei ambiguità l’uso di questo termine), si presenta così:

PyObject *
PyNumber_Add(PyObject *v, PyObject *w)
{
  PyObject *result = binary_op1(v, w, NB_SLOT(nb_add));
  if (result == Py_NotImplemented) {
    PySequenceMethods *m = v->ob_type->tp_as_sequence;
    Py_DECREF(result);
    if (m && m->sq_concat) {
      return (*m->sq_concat)(v, w);
    }
    result = binop_type_error(v, w, "+");
  }
  return result;
}

Il funzionamento è molto semplice, anche perché il lavoro vero e proprio viene delegato alla funzione binary_op1. Soltanto in presenza di un suo fallimento (rappresentato dalla restituzione del singleton Py_NotImplemented), si mette in moto un meccanismo di fallback, che effettua un tentativo di “concatenazione” dei due operandi.

Ciò avviene grazie ad alcuni controlli preventivi. In particolare dalla classe (recuperata tramite il campo ob_type) associata alla prima istanza (l’operando v) si estrae la tabella dei puntatori a funzione che custodisce i metodi dei tipi “sequenza” (tuple, liste, e stringhe ne sono ben noti esempi), di cui abbiamo già visto qualcosa nel precedente articolo dedicato ai tipi / classi e ai protocolli.

Infatti il campo tp_as_sequence della classe è un puntatore alla struttura PySequenceMethods, che risulta così definita:

typedef struct {
  lenfunc sq_length;
  binaryfunc sq_concat;
  ssizeargfunc sq_repeat;
  ssizeargfunc sq_item;
  void *was_sq_slice;
  ssizeobjargproc sq_ass_item;
  void *was_sq_ass_slice;
  objobjproc sq_contains;

  binaryfunc sq_inplace_concat;
  ssizeargfunc sq_inplace_repeat;
} PySequenceMethods;

Rispetto all’elenco dei metodi messi a disposizione dalle classi “numeriche”, l’insieme è decisamente ristretto, ma d’altra parte si tratta di tipi completamente diversi, per cui queste differenze radicali non devono sorprendere.

Tornando alla funzione PyNumber_Add, i controlli effettuati dal codice di fallback sono due (il “minimo sindacale”). Innanzitutto la tabella PySequenceMethods non dev’essere “vuota” (cioè il campo tp_as_sequence non dev’essere NULL), perché la sua assenza significherebbe che l’oggetto in questione non implementa il protocollo dell’insieme delle classi “sequenza”.

Poi dev’essere definito (sempre non NULL) anche il suo campo sq_concat, il che sta a indicare che quest’oggetto è anche capace di concatenarsi con un altro oggetto.

Soddisfatte queste condizioni, è certo che l’oggetto v sia in grado di gestire l’operazione di concatenazione, ma ciò non significa affatto che possa portarla a compimento. Infatti queste precondizioni affermano soltanto una cosa alla fine: che il primo oggetto è in grado concatenarsi; null’altro.

Un esempio arriva proprio dal tipo stringa. Una stringa si può concatenare con un’altra stringa, ma non con un numero intero, un dizionario, un insieme, o altro ancora (tranne in linguaggi “esotici/esoterici”, con tutta la sequela di errori comuni e rognosi che una scelta del genere può comportare).

Sarà dunque il metodo/funzione sq_concat a farsi carico dei successivi controlli sul tipo del secondo operando (w), per procedere all’esecuzione della corretta concatenazione “specializzata” (sulla base dei due tipi), oppure alla restituzione del solito singleton Py_NotImplemented (o di un altro valore che potrebbe anche meglio identificare l’errore dovuto alla tentata somma/concatenazione).

Infine, se almeno uno due già citati controlli fallisce, viene sollevata un’apposita eccezione facendo uso della funzione binop_type_error, che si occupa di creare un apposito oggetto in grado di descrivere il motivo del fallimento.

Nel prossimo articolo della serie proseguiremo con l’analisi del workflow, ponendo finalmente lo sguardo all’interno meccanismo che implementa il polimorfismo degli operatori e la coercizione dei tipi, che è racchiuso nella funzione binary_op1.

Press ESC to close