Gestione dei record in Python/3

Nei primi due articoli di questa serie abbiamo discusso come leggere e
come processare record omogenei. In questo terzo ed ultimo articolo ci
dedicheremo invece allo studio dei record non-omogenei, ovverossia
record in cui campi diversi vanno processati in
maniera diversa. Lo scopo ultimo è quello di disegnare un framework
per convertire record in testo in formato CSV, HTML, XML o altro. En
passant
, discuteremo varie tecniche di design e pattern tipici della
programmazione ad oggetti.

patchwork1.jpg

Fig 1: design a oggetti

Un micro-framework per convertire record in testo

Notoriamente io sono nel novero degli sviluppatori che non amano i
framework e certamente sono in buona compagnia nella comunità Python,
a partire da Guido. Più precisamente, il mio amore verso i framework
è inversamente proporzionale alla loro dimensione: odio i
mega-framework, ma tollero i framework di dimensioni medie e mi
piacciono abbastanza i mini o micro framework. In questo articolo
definirò un micro-framework per la renderizzazione di record non-omogenei
in formato testo basato sul template pattern: per definire un renderer, il
programmatore deriva da una classe madre RecordRenderer, aggiunge
dei metodi di rendering e il framework si occupa di chiamarli in
maniera magica ma non troppo.

Naturalmente questo genere di meccanismo
è accettabile soltanto quando la classe madre è semplice ed è ben
chiaro come funziona; è molto meno accettabile se si viene a generare
una gerarchia di ereditarietà profonda. Gerarchia profonda per me
significa più di due livelli: se devo andare a vedere anche la classe
nonna oltre che la classe madre, il framework è già troppo complesso.
I framework basati sull’ereditarietà in generale hanno la tendenza a
sfuggire dal controllo, perché diventa fin troppo naturale estendere
la gerarchia a dismisura (l’esempio più abominevole che io abbia mai
visto è quello di Zope 2) e quindi non mi piacciono, ma data la
struttura del linguaggio non è che ci siano molte alternative
all’ereditarietà per certe classi di problemi. In altri linguaggi la
cosa sarebbe diversa; per esempio, mi piacerebbe in un futuro articolo
delle mie Avventure di un Pythonista in Schemeland mostrare come si
potrebbe risolvere il problema senza ereditarietà in un linguaggio
senza classi. Comunque, è importante non combattere contro il
linguaggio che si sta usando ed adeguarsi ai suoi idiomi; siccome
stiamo parlando di Python, ha perfettamente senso usare il template
pattern
come tecnica implementativa.

patchwork2.jpg

Fig 2: il template pattern

In generale convertire in testo
un record con N campi indipendenti richiede N+1
funzioni: N funzioni per convertire i campi (che nel caso speciale
del record omogeneo sono tutte uguali) ed una funzione per
convertire l’insieme. È naturale raggruppare le funzioni necessarie
in una classe: le (al più) N funzioni di
renderizzazione saranno implementate come metodi che convertono valori
in stringhe mentre la N+1-esima funzione sarà implementata come un
metodo .render che converte il record di stringhe così ottenuto
in una stringa globale. Per implementare il template pattern
abbiamo bisogno in primo
luogo di una classe base che chiameremo RecordRendererABC dove
ABC sta per Abstract Base Class.
Notate che in Python una classe
è detto astratta quando non è pensata per l’ istanziazione, ma può benissimo
fornire dei metodi concreti
alle proprie sottoclassi e quindi il significato di ABC è diverso
in Python rispetto al C++: se volete, un ABC in Python si può
interpretare come una classe di mixin, nel senso che fornisce
anche implementazione e non pura interfaccia come in Java.

Per esempio, supponiamo di voler convertire un articolo in formato
CSV. I metadati dell’articolo sono contenuti in una namedtuple
Article:

Article = namedtuple("Article", "title author pubdate")

Come dobbiamo procedere? Il primo passo è quello di definire
una sottoclasse di RecordRendererABC adatta alla bisogna:

class CSVArticleRenderer(RecordRendererABC):
schema = Article("str", "str", "isodate")
delimiter = ','
def isodate(self, date):
return date.isoformat()[:10]

La sottoclasse CSVArticleRenderer definisce un attributo di
classe chiamato schema che è una namedtuple che contiene (i nomi dei)
metodi di conversione. In questo esempio
sia il titolo che l’autore sono convertiti usando il metodo str, che è
ereditato dalla classe madre, mentre il campo data di pubblicazione
(pubdate), che si assume essere di
tipo datetime, viene convertito usando il metodo isodate che
è definito direttamente nella class CSVArticleRenderer. Il metodo
.render ereditato dalla classe base converte la namedtuple in input
in una stringa convertendo in stringhe i singoli campi con i metodi
corrispondenti ed eseguendo un join del risultato, usando come
separatore la virgola. Ecco un esempio d’uso:

>>> a = Article("test title", "test author", datetime(2008, 05, 15))
>>> r = CSVArticleRenderer(a)

A questo punto è possibile verificare che il metodo .render ritorna quello
che ci aspettiamo:

>>> print r.render()
test title,test author,2008-05-15

Di default il separatore (delimiter) è settato alla stringa nulla.
Questo può essere utile per renderizzatori di altro tipo. Per esempio,
supponiamo di voler definere un renderer che converte gli
articoli in record in formato HTML. Supponiamo inoltre di avere
definito delle classi CSS red, green e blue e di voler
visualizzare il titolo in rosso, l’autore in verde e la data
di pubblicazione in blu. Questo può essere implementato tramite
il renderer seguente:

class HTMLArticleRenderer(RecordRendererABC):
schema = Article(title='red', author='green', pubdate="blue")
def red(self, title):
return '<span class="red">%s</span>' % cgi.escape(title)
def green(self, author):
return '<span class="green">%s</span>' % cgi.escape(author)
def blue(self, date):
return '<span class="blue">%s</span>' % date.isoformat()[:10]

come è facile verificare:

>>> r = HTMLArticleRenderer(a)
>>> print r.render()
<span class="red">test title</span><span class="green">test author</span><span class="blue">2008-05-15</span>

Note di design

Avendo discusso come usare il framework, è arrivato il momento di
descrivere in dettaglio l’implementazione della classe base e le
motivazioni che ci stanno dietro. Ecco il codice sorgente di
RecordRendererABC:

class RecordRendererABC(object):
schema = () # a namedtuple specifying the names of the converters
delimiter = ''

@classmethod
def frommap(cls, kw):
return cls(cls.schema.__class__(**kw))

@classmethod
def fromobj(cls, obj):
Schema = cls.schema.__class__
nt = Schema._make(getattr(obj, field) for field in Schema._fields)
return cls(nt)

def __init__(self, input):
li, ls = len(input), len(self)
if li != ls:
raise TypeError('%s has %d fields, expected %d' % (input, li, ls))
self.input = input

def __iter__(self):
for convertername, value in zip(self.schema, self.input):
yield getattr(self, convertername)(value)

def __len__(self):
return len(self.schema)

def str(self, value):
return str(value)

def render(self):
return self.delimiter.join(self)

Cominciamo discutendo il costruttore. Il metodo __init__ accetta
in ingresso un singolo argomento che deve essere una sequenza
di lunghezza pari al numero
di elementi nello schema. Non
viene richiesto che l’oggetto in ingresso sia una namedtuple
, ovvero
non c’è un controllo del tipo isinstance(input, self.schema.__class__).
Un check di questo tipo sarebbe un errore, perché restringerebbe
senza motivo l’applicabilità del renderer e obbligherebbe ad usare
dei convertitori di tipo senza necessità. Dopotutto, l’unica cosa
che è richiesta all’oggetto input è che zip(self.schema, input),
che viene usato nel metodo __iter__, dia un risultato sensato e
per questo è sufficiente che input sia una sequenza della lunghezza
giusta.

A rigore zip(self.schema, input) funzionerebbe anche se
input fosse una sequenza di lunghezza diversa, ma questo potrebbe
dare luogo a sorprese (mi immagino il caso in cui per errore si passi una
sequenza nulla, oppure una sequenza più lunga; non mettere un
check sulla lunghezza renderebbe l’errore silente e tutti noi
sappiamo che errors should never pass silently). Mettere il controllo
sulla lunghezza ha il vantaggio che nel caso di errore ce ne accorgiamo
subito, al momento dell’istanziazione e non troppo tardi, quando si
va a iterare sul renderer. È sempre meglio scoprire gli errori il
più presto possibile.

D’altra parte, è meglio non esagerare con i
controlli. Per esempio, se .input è una lista, è concepibile
un uso (malsano) in cui la lista viene modificata dopo l’istanziazione,
aggiungendo o rimuovendo degli elementi. Questo significa che
zip(self.schema, input) potrebbe comportarsi in maniera inaspettata.
Tuttavia non c’è modo di proteggersi contro la stupidità. Anche
se per esempio convertissimo .input in una tupla, che è immutabile,
la sua lunghezza potrebbe sempre essere cambiata, semplicemente
sovrascrivendo l’attributo .input dopo l’istanziazione.

In un linguaggio
dinamico non ha senso essere troppo stringenti e bisogna limitarsi ai
check dettati dal buon senso, quelli che servono a prevenire errori
involontari, ma non implementare i check derivati dalla paranoia,
quelli indotti dalla sfiducia verso l’utente: in un linguaggio dinamico
l’utente può fare cio che vuole, quindi tanto vale fidarsi
(trust the programmer è uno dei tenet dello spirito di
Python, non soltanto dello spirito del C).
In questo logica ho deciso di non aggiungere check ulteriori.

Se vi sentite più schizzinosi (cosa possibile se state implementando
una sottoclasse di RecordRendererABC che richiede espressamente
che .input sia una namedtuple) potreste volervi assicurare che
.input sia una namedtuple con i campi opportuni. Anche in questo
caso è meglio però non implementare il check
isinstance(input, self.schema.__class__) ma uno più blando
tipo input._fields == self.schema.fields: in questo modo
accettereste come equivalenti namedtuple che hanno gli stessi
campi. Insomma, l’idea è quella di seguire il più possibile
il principio del duck typing e di accettare per buono
qualunque cosa abbia gli attributi giusti. In questa ottica,
potreste volere accettare dizionari con le chiavi giuste come
sostituti per un record.

Un modo di implementare questa feature
sarebbe aggiungere un if nel metodo
__init__, aggiungendo un caso particolare nel caso in cui l’oggetto
in ingresso fosse un dizionario. Ma questa sarebbe una cattiva
idea: il punto principale nella progettazione orientata agli oggetti è quello
di evitare gli if e di sostituirli con dei metodi. Nel caso specifico,
il builtin classmethod è stato aggiunto in Python appositamente per
questo caso d’uso, per fornire costruttori alternativi senza dover
appesantire il metodo __init__: si tratta del cosiddetto
factory method pattern,
un altro esempio da manuale di uso dell’OOP. I suoi vantaggi sono ben noti,
soprattutto dal punto di vista della semplicità e della manutenibilità,
ma anche dal punto di vista del riuso del codice e dell’estensibilità.

patchwork3.jpg

Fig 3: il factory method pattern

Nel caso in esame la gestione dei dizionari è stata implementata in maniera naturale con un classmethod .frommap:

>>> r = CSVArticleRenderer.frommap(dict(
... title="test title", author="test author",
... pubdate=datetime(2008, 05, 15)))

Analogamente, esiste un classmethod .fromobj che
accetta in ingresso un qualunque oggetto con set di attributi che è un
soprainsieme (proprio o improprio) del set di attributi dello
schema. Questo è il duck typing allo stato puro:
non è necessario
che l’oggetto in ingresso sia una namedtuple, è sufficiente che abbia
gli attributi giusti. Se l’oggetto in ingresso non ha agli attributi giusti,
otterremo un AttributeError in fase di instanziazione, quindi un
messaggio chiaro e assolutamente appropriato; se invece l’oggetto ha
gli attributi giusti viene automaticamente convertito in
una namedtuple.

La classe base definisce anche i metodi __iter__ e __len__:
questo significa che ogni oggetto renderer si può trattare come una
sequenza di lunghezza finita e può essere dato in pasto ad un altro
renderer. In altre parole, i renderer sono componibili in maniera funzionale. Abbiamo
già spiegato nell’ultimo articolo come questa sia una buona idea,
permettendo di evitare l’ereditarietà. Sequenze di renderer
corrispondono a sequenze di record omogenei (in cui tutti i campi sono
stringhe) che possono essere passate a oggetti quali l’HtmlTable
definito nell’articolo precedente, permettendo il riuso del codice. Infine, può sempre far comodo
avere un modo semplice per convertire un renderer in una lista o una
tupla e avere definito il metodo __iter__ fa si’ che
list(renderer) e tuple(renderer) funzionino come uso si
aspetta; idem per len(renderer). Io sono assolutamente in favore delle
funzioni generiche quali list, tuple e len, proprio perché
favoriscono il duck typing accettando in ingresso (quasi) di tutto.

Oltre a notare quello che è stato implementato, varrebbe la pena anche di notare quello
che non è stato implementato. In particolare, un design che sulla carta
potrebbe sembrare appetibile NON è stato implementato: non abbiamo
implementato i renderer come named tuples. Questo per evitare
l’antipattern blob, ovvero il definire una classe che non è
né carne né pesce ma tutto in uno. In generale è molto meglio avere due
classi piccole e semplici da capire che una classe singola ma
grossa e informe. In questo caso particolare è molto meglio tenere
le namedtuple semplici (cioè senza aggiungere ad esse nessun metodo)
e definire i renderer come oggetti indipendenti, che comunque sono
oggetti iterabili ed all’occorrenza convertibili in namedtuple, se
servisse.

blob.jpg

Fig 4: il blob antipattern

Notate anche come io abbia definito direttamente
delle classi CSVArticleRenderer e HTMLArticleRenderer come
sottoclassi di RecordRendererABC. Un design alternativo sarebbe
stato quello di definire delle sottoclassi astratte intermedie
diverse a seconda del formato di output (per
esempio CSVRecordRenderer, HTMLRecordRenderer, XMLRecordRenderer
eccetera) e derivare da queste. Tuttavia ho
deciso di atternermi strettamente alla regola di non sfruttare
i nonni e di mantenere le gerarchie di ereditarietà
il più piatte possibili (flat is better than nested). In Python 2.6+
potrebbe aver senso definire delle interfacce astratte
CSVRecordRenderer, HTMLRecordRenderer e XMLRecordRenderer
e registrare le proprie classi concrete con tali interfacce, visto
che questo può essere fatto senza usare l’ereditarietà e quindi
senza complicare le gerarchie, ma siccome sto usando Python 2.5
ho pensato bene di tenere le cose semplici.

Questo è quanto, la serie si conclude qui. La prossima volta parlerò
di qualcosa di completamente differente, rimanete sintonizzati su
Stacktrace e ne vedrete delle belle!

Comments

  1. Wow, e con questo articolo si conclude una delle mie serie preferite: più uso il python e più lo apprezzo, poi articoli come questo sono degli ottimi spunti per imparare!!

    Purtroppo aver appreso la programmazione ad oggetti con linguaggi fortemente tipizzati come C++ e Java non rende facilissimo piegarsi al duck typing di Python, con lo svantaggio di non vedere soluzioni semplici che sono a portata di mano.

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.