Programmazione 0 commenti

Le avventure di un Pythonista in Schemeland/11

di Michele Simionato
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!

Pubblicato il
15 Mag 2008
Tag

Commenti

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