Dopo una breve pausa torniamo a parlare di Python e della sua implementazione più comune, la virtual machine CPython. Abbiamo già visto in che modo viene effettivamente realizzata la somma di due numeri interi e, in parte, la concatenazione di due stringhe.
Il “filo conduttore” di tutto è rappresentato dal tipo (o classe) più semplice, cioè dalla struttura PyObject definita in C dai due campi ob_ref e ob_type, rispettivamente contatore del numero di riferimenti di un certo oggetto, e puntatore alla struttura della classe a cui appartiene.
Quest’ultimo è stato visto più volte, in diverse occasioni, ed è normale perché rappresenta il cuore di tutto il sistema messo in piedi in questa macchina virtuale, a maggior ragione se consideriamo che qualunque dato in Python è un oggetto, ed è quindi associato sempre a una classe (da cui l’equivalenza di tipi e classi).
Finora ob_type è stato usato in due sole occasioni: per controllare se il tipo di un oggetto è esattamente PyInt_Type (la “classe” da cui derivano tutti gli interi “corti” in Python <= 2.7) o PyString_Type, oppure se l’oggetto è un discendente di PyLong_Type (da cui derivano gli interi “lunghi”, che sono anche gli unici disponibili in Python >= 3).
Questi controlli rappresentano già un “abbozzo” del protocollo utilizzato da Python per implementare le operazioni tipiche che coinvolgono gli oggetti, a volte in relazione fra loro quando queste operazioni lo prevedono (in particolare per quelle binarie, ovviamente), sebbene si tratti di “trucchetti” di basso livello volti a velocizzare l’esecuzione del codice nelle condizioni più comuni e frequenti.
Infatti il concetto è a dir poco arcaico informaticamente parlando. Ho una serie di tipi diversi da gestire, e ogni oggetto “integra” al suo interno un “identificativo” del tipo, per cui se voglio sapere se un oggetto è un intero, una stringa, ecc. (oppure un loro diretto discendente) è “sufficiente” confrontare questo identificativo con quelli noti (oppure controllare un bit, nel caso dei discendenti).
E’ facile immaginare che tutto ciò si traduca nelle famigerate “catene di if” di cui i programmatori non vanno certo fieri. Se sull’estetica si potrebbe anche disquisire, sulla sostanza è innegabile che questo approccio presenti problemi di manutenibilità ma, soprattutto, ponga grossi limiti all’espandibilità.
Una volta costruite queste catene di if, infatti, l’aggiunta di un nuovo tipo da gestire diventa molto difficile (bisognerebbe andare a rovistare il codice a caccia di queste sezioni, aggiornandole se necessario), soprattutto nel caso di un campo di bit utilizzato per “mappare” i tipi, poiché un singolo intero (nel caso di macchine a 32 bit) potrebbe non bastare più e richiedere l’uso di un long (64 bit; nelle piattaforme che lo definisco come tale) o, peggio ancora, passare a un vettore di byte che ci garantisca l’agognata espandibilità (a prezzo di un peggioramento della gestione e delle prestazioni).
Chi, però, mastica da tempo di programmazione a oggetti, arrivato a questo punto si sarà già sentito male e avrà avuto bisogno dei sali per ritornare in sé, ma non ha certo tutti i torti, considerato che siamo agli antipodi della OOP, dove gli if (e peggio ancora gli switch) cedono spesso il testimone ai metodi virtuali.
(C)Python, essendo un linguaggio a oggetti e in cui tutto è un oggetto, ovviamente non è alieno a questa cultura, e difatti quanto finora esposto ricade esclusivamente nella categoria degli hack che, come già detto, servono soltanto per accelerare l’esecuzione nei casi più frequenti.
Il protocollo di carattere generale adottato non risulta, quindi, basato sui meccanismi finora discussi, ma sui concetti di puntatore a funzione e vettore di puntatori a funzione, i quali simulano in C quanto implementato in linguaggi propriamente definiti a oggetti coi citati metodi virtuali (la famigerata VMT, Virtual Method Table, altri non è che un vettore di puntatori a funzione).
D’altra parte se un linguaggio è di altissimo livello e ci consente di astrarre i nostri pensieri in maniera “bella ed elegante”, alla fine deve comunque confrontarsi col mondo reale, che è costituito da bit, byte, puntatori, ecc., e tradurre il tutto in strutture e istruzioni di bassissimo livello.
Sia chiaro che si può vivere tranquillamente ignorando questi concetti e godersi il proprio linguaggio di alto livello per quello che offre, poiché non è indispensabile e non è scritto da nessuna parte che un programmatore debba necessariamente confrontarsi con queste problematiche per essere definito tale, contrariamente a quanto, purtroppo, si legge spesso in giro.
Il mito del “Vero Programmatore” è una storia squisitamente umoristica, tante volte sarcastica, anche se nasconde dei fondi di verità, e la verità in questo caso è che se vogliamo conoscere il funzionamento di una macchina virtuale dobbiamo necessariamente scendere un po’ di livello (di astrazione) dovendo far uso di nuovi strumenti adeguati allo scopo.
I programmatori devono imparare a usare gli strumenti necessari per portare a termine il loro lavoro. Frase scontata, “fatta”, lapalissiana se volete, ma che tante volte m’è capitato di dover ribadire.
Tornando a CPython, la struttura utilizzata per rappresentare un tipo / classe descrive bene in che modo viene implementato il concetto di oggetto, e il protocollo utilizzato per “rispondere” ai “messaggi” che gli vengono inoltrati (per usare il linguaggio tipico della OOP inaugurata da Alan Kay e collaboratori):
typedef struct _typeobject { PyObject_VAR_HEAD const char *tp_name; /* For printing, in format "<module>.<name>" */ Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */ /* Methods to implement standard operations */ destructor tp_dealloc; printfunc tp_print; getattrfunc tp_getattr; setattrfunc tp_setattr; void *tp_reserved; /* formerly known as tp_compare */ reprfunc tp_repr; /* Method suites for standard classes */ PyNumberMethods *tp_as_number; PySequenceMethods *tp_as_sequence; PyMappingMethods *tp_as_mapping; /* More standard operations (here for binary compatibility) */ hashfunc tp_hash; ternaryfunc tp_call; reprfunc tp_str; getattrofunc tp_getattro; setattrofunc tp_setattro; /* Functions to access object as input/output buffer */ PyBufferProcs *tp_as_buffer; /* Flags to define presence of optional/expanded features */ long tp_flags; const char *tp_doc; /* Documentation string */ /* Assigned meaning in release 2.0 */ /* call function for all accessible objects */ traverseproc tp_traverse; /* delete references to contained objects */ inquiry tp_clear; /* Assigned meaning in release 2.1 */ /* rich comparisons */ richcmpfunc tp_richcompare; /* weak reference enabler */ Py_ssize_t tp_weaklistoffset; /* Iterators */ getiterfunc tp_iter; iternextfunc tp_iternext; /* Attribute descriptor and subclassing stuff */ struct PyMethodDef *tp_methods; struct PyMemberDef *tp_members; struct PyGetSetDef *tp_getset; struct _typeobject *tp_base; PyObject *tp_dict; descrgetfunc tp_descr_get; descrsetfunc tp_descr_set; Py_ssize_t tp_dictoffset; initproc tp_init; allocfunc tp_alloc; newfunc tp_new; freefunc tp_free; /* Low-level free-memory routine */ inquiry tp_is_gc; /* For PyObject_IS_GC */ PyObject *tp_bases; PyObject *tp_mro; /* method resolution order */ PyObject *tp_cache; PyObject *tp_subclasses; PyObject *tp_weaklist; destructor tp_del; /* Type attribute cache version tag. Added in version 2.6 */ unsigned int tp_version_tag; #ifdef COUNT_ALLOCS /* these must be last and never explicitly initialized */ Py_ssize_t tp_allocs; Py_ssize_t tp_frees; Py_ssize_t tp_maxalloc; struct _typeobject *tp_prev; struct _typeobject *tp_next; #endif } PyTypeObject; #endif
La struct PyTypeObject risulta molto lunga e complessa, e non verrà analizzata in tutti i suoi aspetti, ma soltanto in quelli necessari alla comprensione di alcuni argomenti di cui ci occuperemo.
Scorrendola salta all’occhio il campo tp_flags, che abbiamo già incontrato nell’ultimo articolo dedicato alla somma dei long, per controllare che le due istanze siano effettivamente entrambe “eredi” della classe / tipo PyLong_Type.
Gli altri campi sono del tutto nuovi; alcuni sono semplici da capire (ad esempio il primo, tp_name), altri oscuri (a che serve tp_weaklistoffset?), altri (tp_str) possono suonare familiari, ma non se ne comprende ancora l’uso.
Comunque non tutti i campi sono utilizzati per implementare il protocollo che porta all’implementazione effettiva delle operazioni che siamo abituati a utilizzare (somme, prodotti, negazioni, ecc.), ma servono per gestire le istanze nell’ecosistema in cui si lavora (ad esempio tenendo conto che c’è un loro “allocatore” e il relativo garbage collector per la “finalizzazione” e rilascio delle risorse occupate).
Prima abbiamo parlato di puntatori a funzione e di tabella di puntatori a funzione in merito al protocollo, ed è esattamente in questi due concetti che si concretizza il tutto. In CPython si usa il primo nel caso di comportamenti/funzionalità “di base” o comunque ai quali si vuole accedere velocemente, mentre il secondo è delegato a “classi” (non nel senso di oggetti) di comportamenti che sono opzionali e che possono essere implementati.
Un esempio del primo tipo è rappresentato dal campo tp_richcompare che, se non NULL (perché un oggetto potrebbe anche non prevedere alcuna operazione di confronto), indica la funzione a cui è delegato il compito di confrontare l’istanza della classe attualmente in esame con un’altra, restituendone poi il risultato. Il prototipo di tp_richcompare è il seguente:
typedef PyObject *(*richcmpfunc) (PyObject *, PyObject *, int);
e si aspetta, oltre alle due istanze da esaminare, anche un intero che indica quale tipo di confronto è stato richiesto:
enum cmp_op {PyCmp_LT=Py_LT, PyCmp_LE=Py_LE, PyCmp_EQ=Py_EQ, PyCmp_NE=Py_NE, PyCmp_GT=Py_GT, PyCmp_GE=Py_GE, PyCmp_IN, PyCmp_NOT_IN, PyCmp_IS, PyCmp_IS_NOT, PyCmp_EXC_MATCH, PyCmp_BAD};
Il risultato è l’istanza degli oggetti Py_True e Py_False (singleton preallocati all’avvio della virtual machine), mentre viene restituito Py_NotImplemented se l’operazione non è fattibile o, peggio ancora, NULL se si è verificato un grave errore interno.
Un esempio del secondo tipo è, invece, tp_as_number, che si occupa di “modellare” il comportamento dell’oggetto in merito alle operazioni di carattere “numerico” (somma, sottrazione, ecc.), e il cui tipo PyNumberMethods è definito come:
typedef struct { /* Number implementations must check *both* arguments for proper type and implement the necessary conversions in the slot functions themselves. */ binaryfunc nb_add; binaryfunc nb_subtract; binaryfunc nb_multiply; binaryfunc nb_remainder; binaryfunc nb_divmod; ternaryfunc nb_power; unaryfunc nb_negative; unaryfunc nb_positive; unaryfunc nb_absolute; inquiry nb_bool; unaryfunc nb_invert; binaryfunc nb_lshift; binaryfunc nb_rshift; binaryfunc nb_and; binaryfunc nb_xor; binaryfunc nb_or; unaryfunc nb_int; void *nb_reserved; /* the slot formerly known as nb_long */ unaryfunc nb_float; binaryfunc nb_inplace_add; binaryfunc nb_inplace_subtract; binaryfunc nb_inplace_multiply; binaryfunc nb_inplace_remainder; ternaryfunc nb_inplace_power; binaryfunc nb_inplace_lshift; binaryfunc nb_inplace_rshift; binaryfunc nb_inplace_and; binaryfunc nb_inplace_xor; binaryfunc nb_inplace_or; binaryfunc nb_floor_divide; binaryfunc nb_true_divide; binaryfunc nb_inplace_floor_divide; binaryfunc nb_inplace_true_divide; unaryfunc nb_index; } PyNumberMethods;
Anche qui siamo in presenza di una struttura molto lunga, ma questa volta molto semplice da comprendere, poiché il nome dei campi, oltre che dei loro tipi, sono abbastanza intuitivi e riflettono quali operazioni l’oggetto in questione è in grado di supportare e quali no (nel caso in cui un campo sia posto a NULL).
Queste due sono sostanzialmente le basi su cui poggia il protocollo che implementa tutte le operazioni di varia natura, delle quali vedremo l’utilizzo “sul campo” in un prossimo articolo, in particolare su quest’ultimo concetto in modo da “chiudere il cerchio” con l’operazione di somma di cui abbiamo discusso finora.