Gestione dei record in Python/1

Qualunque programmatore prima o poi si sarà trovato a dover gestire dei record: interagendo con un database, leggendo un file CSV, programmando in un qualunque linguaggio (ricordate i record del Pascal e le struct del C?) e in mille altre occasioni. Questo dunque potrebbe sembrare un argomento elementarissimo, ben conosciuto e assolutamente noioso: tuttavia, si tratta di un soggetto su cui c’è ancora molto da dire. In questo miniserie tratterò della gestione dei record in Python, ma la miniserie è pensata per discutere, più che i dettagli implementativi, tecniche generali per risolvere il problema di leggere, scrivere e processare sequenze di record in maniera pulita. La miniserie si articola in tre parti: la prima, quella che state leggendo, introduce la materia e illustra una possibile soluzione al problema di leggere un file CSV con un numero qualunque di campi non noto a priori; la seconda parte si occupa del problema di leggere dei record da un database; la terza ed ultima parte, infine, si occupa del problema di generare dati in forma tabellare e di renderizzarli in vari formati.


Record vs namedtuple

Cominciamo enunciando un dato di fatto: a tutt’oggi (parlo di Python 2.5) non esiste un tipo record nel linguaggio. Questa omissione sembra inconcepibile, ma così è: nonostante i record siano stati richiesti a gran voce nella comunità Python fin dall’inizio, Guido ha sempre fatto orecchie da mercante e non sono mai stati aggiunti né come tipo builtin né come parte della libreria standard. La risposta canonica è sempre stata “usate una classe” con l’argomento che, nell’uso reale, c’è sempre bisogno di aggiungere funzionalità al record (metodi) e quindi tanto vale usare direttamente una classe. Io, come moltissimi altri, non ho mai bevuto tale argomento ed ho sempre visto l’assenza di record come una grave mancanza. La buona notizia è che adesso la situazione è cambiata: a partire da Python 2.6 i record faranno parte della libreria standard, in quanto verranno aggiunti al modulo collections sotto il nome di namedtuple (il nome mi suona in parte come un compromesso ;) La notizia ancora migliore è che potete usare le namedtuple da subito, anche in versioni vecchie di Python, semplicemente usando la ricetta di Raymond Hettinger che trovate sul Python Cookbook:

$ wget http://code.activestate.com/recipes/500261/download/1/ -O namedtuple.py

Lo stesso Hettinger si è occupato dell’implementazione della ricetta in Python 2.6 e Python 3.0. A pochi giorni di distanza dalla PyCon (la conferenza italiana sul linguaggio Python che si terrà a Firenze il 9-10-11 maggio) non posso non segnalare che Hettinger sarà il keynote speaker e che quindi molti di voi avranno l’occasione di vederlo dal vivo.

L’esistenza del tipo namedtuple nella libreria standard cambia di molto il modo di gestire i record in Python, perché adesso la namedtuple è diventata la maniera ovvia (the one obvious way) di gestire l’uso principale del record, ovvero il record come un pacchetto immutabile di coppie nome-valore. D’altra parte, è chiaro che se volete modificare il valore di un record non potete usare una namedtuple: come dice il nome una namedtuple è una tupla ed in quanto tale è immutabile. I record mutabili sono qualcosa di molto più complesso di una namedtuple e quindi più dibattuti; non c’è speranza che entrino nella libreria standard a breve termine. È anche vero però che i record mutabili si usano meno di quelli immutabili e che si gestiscono meglio con meccanismi specifici, come per esempio un ORM (Object Relational Mapper) quando state trattando record che vivono in un database. D’altra parte, uno potrebbe anche essere dell’opinione che i record mutabili sono il Male, e che non dovrebbero far parte del linguaggio. Questa è l’opinione dei linguaggi funzionali (SML, Haskell ed in maniera minore Scheme) in cui l’unico modo per modificare un campo di un record è creare un nuovo record in cui tutti i campi sono uguali al record originario tranne il campo modificato. In questo senso le namedtuple sono strutture funzionali e supportano il cosiddetto functional update tramite il metodo _replace che mostrerò tra poco.

Usare le namedtuple è molto semplice e la ricetta stessa contiene molti esempi utili, che fungono anche da test case: vi consiglio vivamente di far girare namedtuple.py e di leggere la sua docstring con attenzione. Qui duplicherò in parte quanto si trova in quella docstring, per far felici i lettori più pigri:

>>> from namedtuple import namedtuple
>>> Article = namedtuple("Article", 'title author')
>>> article1 = Article("Records in Python", "M. Simionato")
>>> print article1
Article(title='Records in Python', author="M. Simionato")

namedtuple è una funzione che opera come una fabbrica di classi; prende in input il nome della classe e i nomi dei campi (come una sequenza oppure anche come una stringa di nomi separati da spazi e/o virgole) e ritorna una sottoclasse di tuple. La caratteristica fondamentale è che i campi di una namedtuple sono accessibili anche per nome e non solo per indice:

>>> article1.title
'Records in Python'
>>> article1.author
"M. Simionato"

Il vantaggio principale della namedtuple sta in questo, nella maggiore leggibilità, il poter scrivere anche article1.author oltre a article1[1]. Inoltre, il costruttore di una namedtuple accetta anche una sintassi non posizionale oltre a quella posizionale, quindi è possibile scrivere

>>> Article(author="M. Simionato", title="Records in Python")
Article(title='Records in Python', author="M. Simionato")

in ordine inverso senza far confondere Python. Questo è un altro vantaggio sostanziale delle namedtuple. Il costruttore accetta anche dei keywords arguments, per cui avete la massima flessibilità: potete passare tutti gli argomenti come argomenti posizionali, tutti gli argomenti come keyword arguments, oppure un po’ e un po':

>>> title='Records in Python'
>>> kw = dict(author="M. Simionato")
>>> Article(title, **kw)
Article(title='Records in Python', author="M. Simionato")

Notate che questa magia non ha nulla a che fare con le namedtuple: è il modo standard in cui funziona il passaggio degli argomenti alle funzioni (e quindi ai metodi) in Python, anche se scommetto che molti non sanno che si possono mescolare gli argomenti, ovviamento mettendo i keyword arguments dopo gli argomenti posizionali ;)

Il terzo vantaggio è che le namedtuple sono a tutti gli effetti delle tuple e che quindi potete usarle al posto delle tuple in tutti i vostri programmi scritti nel passato, quando le namedtuple ancora non esistevano e tutto funzionerà ugualmente; in particolare funzionerà anche il tuple unpacking (title, author = article1 così come la notazione con l’asterisco * nelle chiamate a funzione). Un quarto vantaggio rispetto alle tuple tradizionali, infine, è il supporto per l’update funzionale a cui accennavo prima:

>>> article1._replace(title="Record in Python, Part I")
Article(title="Record in Python, Part I", author="M. Simionato")

vi ritorna una copia della namedtuple iniziale con il campo title aggiornato al nuovo valore.

Internamente namedtuple funziona generando codice per la classe da ritornare ed eseguendolo via exec. Potete vedere il codice generato usando il flag verbose=True quando definite la namedtuple. Ai lettori delle mie Avventure di un Pythonista in Schemeland sicuramente verrà in mente il meccanismo delle macro, ma in realtà exec è un qualcosa di più potente: la differenza è che le macro generano codice al momento della compilazione e quindi la struttura del record deve essere conosciuta prima di eseguire il programma, mentre invece exec funziona puramente a runtime e permette di definire il record al momento dell’esecuzione del programma. Per fare lo stesso in Scheme sarebbe necessario usare eval, non le macro. C’è anche da notare che di solito exec viene molto disprezzato perché molti programmatori principianti lo usano a sproposito e viene spesso visto come un code smell. Ciò nonostante, esistono dei casi in cui soltanto la potenza di exec può risolvere il problema: namedtuple è uno di questi casi, un’altro è il modulo doctest.

Parsare dei file CSV

Adesso basta con la teoria: in questo paragrafo passo alla pratica mostrandovi un esempio tipico di problema che sembra fatto apposta per essere affrontato con una namedtuple. Supponete di dovere parsare un file CSV con N+1 righe ed M colonne separate da virgole:

field1,field2,...,fieldM
row11,row12,...,row1M
row21,row22,...,row2M
...
rowN1,rowN2,...,rowNM

La prima riga corrisponde al nome dei campi. La struttura precisa del CSV non è conosciuta a priori, ma soltanto a runtime, quando il file viene letto. Il file per esempio potrebbe venire da un foglio Excel, o essere stato esportato da un database, o potrebbe anche essere un file di log. Uno dei campi potrebbe essere un campo data e voi potreste essere interessati ad estrarre i record compresi tra una data iniziale ed una data finale, fare delle statistiche su di essi e generale un report finale che potrebbe essere un altro file CSV, oppure una tabella HTML da mostrare su un sito Web, o anche una tabella LaTeX accompagnata da un grafico da includere in un articolo scientifico, o qualunque altra cosa. Sono sicuri che tutti prima o poi si sono trovati ad affrontare un problema simile.

log.gif

Risolvere il problema specifico è sempre facile: la cosa difficile è dare una ricetta generale. La cosa che vorremmo evitare assolutamente è quella di avere 100 scriptini simili, più o meno tutti uguali ma con una gestione leggermente diversa dell’input/output a seconda del problema specifico. È chiaro che a logiche diverse corrisponderanno script diversi, ma almeno la parte di input/output dovrebbe essere comune a tutti. Per esempio, se inizialmente il file CSV ha 5 campi e dopo sei mesi cambiano le specifiche e mi trovo con un CSV con 6 campi non voglio trovarmi obbligato a cambiare lo script; lo stesso se cambiano i nomi dei campi. Similmente, se ho deciso che l’output sarà una tabella HTML, tale tabella deve poter gestire un numero qualunque di campi; inoltre, deve essere possibile cambiare il formato di output (HTML, XML, CSV, …) con il minimo sforzo, senza dover cambiare lo script, ma cambiando soltanto qualche parametro di configurazione. Infine, e questa è la parte più difficile di tutte, non devo creare un mostro: dovendo scegliere tra un framework onnipotente in grado di gestire tutte le possibilità che posso immaginare (e che fallirà inevitabilmente quando mi troverò ad affrontare qualcosa che non ho immaginato prima) e un sistema povero e limitato, ma estensibile alla bisogna, dovrò avere il coraggio dell’umiltà, il che vuol dire che dovrò sfrondare senza pietà tutte le feature che ho faticamente messo in piedi per tenere soltanto l’essenziale. Questo in pratica vuol dire lavorare il doppio o il triplo di quello che ci avrei messo a scrivere il mostro, per scrivere molto di meno (come tutti sappiamo, a parità di funzionalità, avrebbe senso pagare i programmatori in maniera inversamente proporzionale al numero di righe di codice ;).

Comunque, bando alle ciance e risolviamo il problema in esame:

# tabular_data.py
from namedtuple import namedtuple

def headtail(iterable):
"Returns the head and the tail of a non-empty iterator"
it = iter(iterable)
return it.next(), it

def get_table(header_plus_body, ntuple=None):
"Return a sequence of namedtuples in the form header+body"
header, body = headtail(header_plus_body)
ntuple = ntuple or namedtuple('NamedTuple', header)
yield ntuple(*header)
for row in body:
yield ntuple(*row)

def test1():
"Read the fields from the first row"
data = [['title', 'author'], ['Records in Python', 'M. Simionato']]
for nt in get_table(data):
print nt

def test2():
"Use a predefined namedtuple class"
data = [['title', 'author'], ['Records in Python', 'M. Simionato']]
for nt in get_table(data, namedtuple('NamedTuple', 'tit auth')):
print nt

if __name__ == '__main__':
test1()
test2()

Eseguendo lo script otterrete:

$ python tabular_data.py
NamedTuple(title="title", author='author')
NamedTuple(title='Records in Python', author="M. Simionato")
NamedTuple(tit="title", auth='author')
NamedTuple(tit='Records in Python', auth="M. Simionato")

Ci sono molte cose da notare.

  1. In primo luogo, osservate come io abbia seguito la regola principe del buon programmatore, ovvero quella di cambiare la domanda: anche se il problema mi richiedeva di leggere un file CSV, io invece sono andato ad implementare un generatore get_table che è in grado di processare un qualunque iterabile nella forma header+data, dove l’header è lo schema (ovvero l’insieme ordinato dei nomi dei campi) e i dati sono i record. Il generatore ritorna un iteratore sempre nella forma header+data, ma in cui i dati sono costituiti da delle namedtuple. La maggiore generalità mi permette un maggiore riutilizzo del codice, ma non solo.

  2. L’aver seguito la regola 1 mi permette di rendere la mia applicazione molto più testabile; siccome la logica di generazione delle namedtuple è stata disaccoppiata dalla logica di lettura del file CSV posso testare quella logica senza bisogno di avere un file CSV. Questo significa che non ho bisogno di settare alcun ambiente di test (come per esempio dei file CSV di test da distribuire con la mia applicazione) e potete capire che si tratta di un vantaggio non da poco, soprattutto se pensate a situazioni più complicate, per esempio quando a che fare con un database.

  3. L’aver cambiato la domanda da “processare un file CSV” a “convertire un iterabile in una sequenza di namedtuple” mi permette di demandare la logica di lettura del CSV all’oggetto preposto allo scopo, ovvero il csv.reader che fa parte della libreria standard di Python e che posso probabilmente permettermi di non testare (io faccio parte della scuola di pensiero secondo cui non bisogna testare tutto, ma bisogna avere chiara una gerarchia di cosa va assolutamente testato, di cosa è utile testare e di cosa non è utile testare).

  4. Avendo a disposizione get_table, la soluzione del problema originale consiste in una sola riga e non merita nemmeno di essere messa in una propria libreria: get_table(csv.reader(fname)).

  5. È banale estendere la soluzione a casi più generali. Supponiamo per esempio di avere un file CSV la cui prima riga non contenga i nomi dei campi, ma di conoscere i campi per altra via. È possibile utilizzare get_table comunque, è sufficiente aggiungere l’header a runtime:

    get_table(itertools.chain([fields], csv.reader(fname)))
  6. Per mancanza di un nome migliore chiamo tabella (table) la struttura dati header+data. È chiaro che il formalismo è pensato per trasformare tabelle in tabelle (pensate ad applicazioni di filtraggio e manipolazione di dati) e si vuol poter comporre funzionalmente operatori che operano su tabelle. Questo è un tipico problema che è naturale risolvere con un approccio funzionale.

  7. Come puro ausilio tecnico ho introdotto la funzione headtail; val la pena di notare che anche se in Python 3.0 sarà disponibile una forma generalizzata di tuple unpacking e sarà possibile scrivere direttamente head, *tail = iterable invece di head, tail = headtail(iterable), quindi headtail non sarà più necessaria (i programmatori funzionali riconosceranno la tecnica del pattern matching di liste).

  8. get_table permette di sovrascrivere i nomi dei campi con dei sinonimi, come mostrato da test2. Questo può essere utile in varie situazioni, per esempio se i nomi dei campi sono molto lunghi e si preferisce usare delle abbreviazioni, oppure se i nomi dei campi letti dal file CSV non sono identificatori Python validi (in tal caso namedtuple solleverebbe un ValueError).

Con questo si conclude la prima parte di questa miniserie. Nella seconda parte darò qualche consiglio su come operare su record estratti da un database relazionale. Non mancate!

Comments

  1. Devo dire che ho avuto spesso la necessità di elaborare file di log o risultati ed ogni volta avevo la sensazione di reinventare la ruota e di usare codice poco leggibile che sfrutta le tuple: questa estensione è davvero molto utile e penso di usarla da qui a 10 minuti…

    Ottimo articolo e Python rulez!

  2. Alessandro says:

    Ritengo l’argomento molto interessante e utile.
    Fantastico il fatto che sia possibile utilizzare namedtuple anche in versioni non recentissime di python

  3. Scusate, una domanda sciocca sul namedtuple: ma qual è l’utilità di avere il nome del tipo esplicito?
    cioè, a me sembra pleonastico fare

    >>> Article = namedtuple("Article", 'title author')

    e preferirei poter scrivere

    >>> Article = namedtuple("title author")

    vedo dalla ricetta che in pratica serve “solo” per poter definire il nome della classe… ma non poteva essere “namedtuple_NOME_COLONNE” o qualche altra variante autogenerata?

  4. Michele Simionato says:

    Il nome della classe e’ utile durante il debugging, E’ un po’ come l’annosa questione delle funzioni anonime: il
    nome tecnicamente e’ inutile, ma in
    pratica invece e’ utilissimo quando si debugga.

  5. Molto bello!!
    Io lavoro molto con i record da file di testo o da database, e l’implementazione di un tipo di dato dedicato allo scopo mi potrà essere utile.

    Onestamente fino ad adesso mi sono sempre arrangiato con le classi, e mi sono trovato bene, vediamo se questo sistema offrirà dei vantaggi.

    Non mi piace la sintassi di iniziazzione delle namedtuple – secondo me dare degli argomenti in una unica stringa di testo, separati da spazi, non é molto pythonico.

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.