Programmazione 1 commento

Gestione dei record in Python/3

di Michele Simionato

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!

Pubblicato il
10 Giu 2008
Tag

Commenti

  • Maelstrom il 10 Giu 2008

    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.

Screencast e videocorsi di programmazione
Stacktrace RSS Feed Stacktrace via E-mail
Hai idee per un articolo? Faccelo sapere!