Le avventure di un Pythonista in Schemeland/11

gears.gif

Dopo la teoria discussa nelle puntate precedenti, finalmente è il
momento di un pò di pratica. Dedicherò questa puntata alla discussione
di alcune applicazioni concrete delle macro. In
particolare implementerò un mini-framework di unit test ed un semplicissimo
sistema ad oggetti. Entrambe le cose si possono trovare come librerie
(per i test si vedano gli SRFI 64 e 78, di sistemi ad oggetti ce ne sono
a bizzeffe) ma secondo me è
estremamente utile avere un’idea degli ingranaggi che stanno dietro ad
una libreria di unit test o ad un sistema ad oggetti. D’altra parte, è
anche utile conoscere i problemi che possono far inceppare gli
ingranaggi della macrologia e quindi per prima cosa discuterò una
sottigliezza delle macro che teoricamente è facile da capire, ma che
in pratica può trarre in inganno anche programmatori esperti.

Una sottigliezza delle macro

Bisogna sempre ricordare
che le macro espandono codice, ma non lo valutano: questo significa
che le variabili di pattern non corrispondono ad espressioni valutate,
come le variabili ordinarie, ma a espressioni da valutare. La conseguenza
ultima di questa feature è che è facile scrivere macro che
valutano espressioni più volte di quanto non sia necessario. In
effetti, è così facile commettere questo errore, che lo abbiamo
commesso anche noi nella nostra prima macro, il ciclo for definito
nell’ottava puntata (da notare che il baco non è stato messo ad
arte, nella prima versione mi è proprio sfuggito, anche se in teoria conosco
questo particolare gotcha delle macro da anni; quando poi me ne sono poi accorto,
ho preferito pubblicare la versione bacata onde poter scrivere questa sezione).
Possiamo riscrivere la macro for in forma semplificata
in questo modo:

(define-syntax+ (for i start end body ...)
   (begin
     (assert (and (number? start) (number? end)))
     (let loop ((i start))
       (unless (>= i end) body ... (loop (+ i 1))))))

Supponete ora che il valore della variabile end venga determinato
dinamicamente con un calcolo:

> (define (get-end)
  (printf "computing the value of end\n")
 3)

> (for i 0 (get-end) 'do-nothing)
computing the value of end
computing the value of end
computing the value of end
computing the value of end
computing the value of end

Come vedete, end viene ricalcolato per ben 5 volte! Il motivo
è evidente se guardate l’espansione della macro:

> (for <expand> i 0 (get-end) 'do-nothing)
(begin
 (assert (and (number? 0) (number? (get-end))))
 (let loop ((i 0))
   (unless (>= i (get-end)) 'do-nothing (loop (+ i 1)))))

La funzione get-end è chiamata una volta nell’assert e quattro volte
nel loop; è chiaro che si tratta di una cosa potenzialmente drammatica
(se la funzione ha degli effetti collaterali) e certamente inefficiente.
La soluzione
è salvare il valore di end (e già che ci siamo anche di start, che
viene calcolato due volte) in una variabile:

(define-syntax+ (for i start end body ...)
   (let ((s start) (e end))
     (assert (and (number? s) (number? e)))
     (let loop ((i s))
       (unless (>= i e) body ... (loop (+ i 1))))))

Adesso get-end viene chiamato correttamente una sola volta e siamo tutti
più felici 🙂

Un micro-framework di unit test

Come primo esempio di uso pratico della macrologia di Scheme defineremo
un framework di unit test molto semplice, che chiameremo easy-test.
Il codice sorgente sta in una pagina:

(library (easy-test)
(export test print-nothing print-dot print-msg)
(import (rnrs) (only (ikarus) printf) (syntax-rules-plus))

(define (print-nothing descr expr expected)
  (display ""))

(define (print-dot descr expr expected)
  (display "."))

(define (print-msg descr expr expected)
  (printf "\n'~a' failed. Expected ~a, got ~a\n" descr expected expr))
  
(define-syntax+ test (success failure =>)
  ((test (success print-success) (failure print-failure)
         (descr e1 e2 ... => expect) ...)
   (let ((n-success 0) (n-failure 0))
     (let ((expr (begin e1 e2 ...)) (expected expect))
       (if (equal? expr expected)
           (begin
             (set! n-success (+ 1 n-success))
             (print-success descr expr expected))
           (begin
             (set! n-failure (+ 1 n-failure))
             (print-failure descr expr expected))
           ))
     ...
     (list n-success n-failure)))
  ((test (descr e1 e2 ... => expect) ...)
   (test (success print-dot) (failure print-msg)
         (descr e1 e2 ... => expect) ...))
  )
)

L’unica sottigliezza nell’implementazione è l’uso di
(let ((expr (begin e1 e2 ...)) (expected expect)): questo garantisce
che l’espressione in input (expr) e l’espressione in output
(expected) siano valutate una sola volta.
L’uso del modulo è banale:

> (import (easy-test))
> (test (success print-nothing) (failure print-msg)
     ("test 1+1=2" (+ 1 1) => 2)
     ("test 2*1=2" (* 2 1) => 2)
     ("test 2+2=3" (+ 2 2) => 3))
'test 2+2=3' failed. Expected 3, got 4
(2 1)

La macro test ritorna una lista con il numero di test passati ed
il numero di test falliti (nel nostro caso '(2 1)). Il framework
fornisce delle funzioni di reportistica predefinite print-nothing,
print-msg e print-dot ma è perfettamente possibile definirsi
delle funzioni di reportistica personalizzate. Una funzione di
reportistica è semplicemente una funzione con tre argomenti (descr
expr expected)
dove descr è la stringa di descrizione del test,
expr è l’espressione da testare e expected è il risultato che
ci si aspetta di ottenere.

È anche possibile chiamare la macro test senza specificare le funzioni
di reportistica: in tal caso il framework userà le funzioni di reportistica
di default, ovvero print-dot per i test che hanno successo e print-msg
per i test che falliscono:

> (define succ-fail (test
     ("test 1+1=2" (+ 1 1) => 2)
     ("test 2*1=2" (* 2 1) => 2)
     ("test 2+2=3" (+ 2 2) => 3)))
..
'test 2+2=3' failed. Expected 3, got 4

Un semplice sistema ad oggetti

Scheme non è un linguaggio particolarmente orientato agli oggetti e lo
standard non specifica nessun sistema ad oggetti canonico. Come
conseguenza in Scheme potete trovare tutti i sistemi ad oggetti che
potete immaginare e anche di più ;). Uno degli esercizi tipici per il
programmatore Scheme principiante è quello di definirsi un personale
sistema ad oggetti. Noi non ci sottrarremo a questa tradizione. Per
esempio, supponiamo per esempio di voler definire un oggetto
counter con una variabile di stato c (il contatore), un metodo
incr che incrementa, un metodo decr che decrementa,
ed un metodo get che ritorna il valore del
contatore. Tale oggetto può essere implementato come un’istanza di una
opportuna fabbrica di oggetti (“classe”), che chiameremo
Counter. Counter prenderà in ingresso un parametro (il valore
iniziale del contatore) e ritornerà una funzione (o meglio una
closure) che chiama i metodi dell’oggetto contatore con certi
argomenti. La macro dispatch-lambda definita nella puntata
precedente è fatta apposta per questa situazione:

(define (Counter c)
  (dispatch-lambda
   ((incr n) (set! c (+ c n)))
   ((decr n) (set! c (- c n)))
   ((get) c)))

Naturalmente potreste definire la “classe” Counter anche senza avere
a disposizione dispatch-lambda, basterebbe usare al suo posto l’espansione
della stessa. La caratteristica fondamentale che permette di costruire il
sistema ad oggetti non è l’esistenza delle macro (le macro permettono
soltanto di fornire una sintassi elegante) ma l’esistenza di
read-write closures. Notare che in Python le closure sono read-only,
ovvero non è possibile modificare i valori delle variabili della closure
(in questo esempio c) ma che questo cambierà in Python 3.0, grazie
all’introduzione della dichiarazione nonlocal.
A questo punto potete instanziare la “classe” Counter e verificare
che l’oggetto contatore si comporta come vi aspettate:

(define counter (Counter 0))
(test
 ("test incr" (counter 'incr 1) (counter 'get) => 1)
 ("test decr" (counter 'decr 1) (counter 'get) => 0))

Naturalmente il sistema ad oggetti appena definito è un giocattolo e
c’è ancora molto da fare: manca l’introspezione, manca l’ereditarietà,
manca la possibilità di ridefinire i metodi ed altro. Inoltre la
performance lascia molto a desiderare. Tuttavia, qualcosa da fare devo
pur lasciarlo ai miei lettori! Come ho detto più volte, lo scopo di
questa serie è quello di dare degli spunti al lettore, di porre le
domande, non di fornire le risposte 😉 Se volete usare un sistema ad
oggetti di qualità industriale, vi conviene usarne uno già fatto, come
per esempio TinyCLOS (che è portabile) o Swindle (che è integrato in
PLT Scheme): entrambi sono stati influenzati da CLOS, il sistema ad
oggetti del Common Lisp, che è il sistema ad oggetti più sofisticato
attualmente in circolazione. D’altra parte, se volete capire come
funziona un sistema ad oggetti, è molto istruttivo implementarne
uno. Per esempio, come esercizio per la prossima puntata, provate a
implementare introspezione ed ereditarietà (singola) per il sistema
appena definito. Non è difficile. Buon lavoro e arriverci alla
prossima!

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.