Magnar genererer testet og dokumentasjon [Luke 16, 2012]


søndag 16. desember 2012 Julekalender Lisp

Magnar Sveen er en meget hyggelig fyr jeg har presentert en gang tidligere på denne bloggen. I sommer klarte han med sin editor-magi å nesten få meg til å konvertere fra Vim til Emacs. Som Emacs-hacker programmerer Magnar endel Lisp, og i dagens blogpost deler han av sin erfaring omkring dette. Du behøver ikke kunne Lisp for å få utbytte av artikkelen, kun et åpent sinn – Magnar forklarer det meste underveis.

magnar

Hvem er du?
En glad utvikler som knaster taster på toget mellom Oslo og Fredrikstad hver dag.

Hva er jobben din?
Framsieutvikler og partner i Kodemaker.

Hva kan du?
Trives godt i hele stacken - oftest solgt inn som frontend.

Hva liker du best med yrket ditt?
Å bygge ting.


Eksempler i Lisp: Kode som genererer tester og dokumentasjon

Jeg fikk til noe kult her om dagen.

Som du vet så er ikke dokumentasjon det morsomste å skrive. Og det er skremmende lett å glemme å oppdatere dokumentasjonen når du gjør endringer.

Så når jeg skulle lage et liste- og et string-bibliotek til Emacs, så fant jeg en fet løsning på akkurat det problemet.

Se her:

(defexamples s-dashed-words
  "some words" => "some-words"
  "under_scored_words" => "under-scored-words"
  "camelCasedWords" => "camel-cased-words")

Her definerer jeg noen eksempler på hvordan en funksjon fungerer. Det kule er at jeg ut i fra disse genererer både tester og dokumentasjon.

La oss starte med det første:

Generere tester

Slik må testen se ut for at testrammeverket skal forstå den:

(ert-deftest s-dashed-words ()
  (should (equal (s-dashed-words "some words") "some-words"))
  (should (equal (s-dashed-words "under_scored_words") "under-scored-words"))
  (should (equal (s-dashed-words "camelCasedWords") "camel-cased-words")))

Så nå skal vi ta eksempelkoden øverst og bygge om til denne testkoden programatisk. Det gjør vi med makroer.

Noen introduksjoner

  • Makroer bygger ny kode. Du gir dem argumenter som så brukes til å sette sammen den endelige koden før den kjøres.

  • Homoikonisitet betyr at språket er definert ved hjelp av sine datastrukturer. Altså kan kode både kjøres, men også undersøkes, manipuleres og bygges på samme måte som annen data i applikasjonen.

Homoikonisitet er essensielt for å få bunnsolide makroer.

I lisp får vi dette fordi et funksjonskall er en liste:

(* 2 4)

Dette er en liste med tre elementer (*, 2 og 4), og når den evalueres så blir det første elementet i listen kalt som en funksjon, og resten som parametere til den funksjonen - og vi får 8.

Anatomien til en makro

I lisp så er altså jobben til en makro å lage en liste som skal evalueres. Du kan lage listen akkurat slik du selv vil, men det finnes noen triks for å gjøre det lettere. La oss starte med et enkelt eksempel:

(defmacro multiply (a b)
  (list '* a b))

Denne tar i mot to argumenter, og bygger en liste på tre elementer. Hvis du stusser over fnutten foran * så er det en quote. Det er slik man sørger for at symbolet ikke blir evaluert, men får stå som det er.

Altså vil (multiply 2 4) bygge koden (* 2 4) som deretter evalueres til 8.

Vi kan også quote hele lister, slik:

'(* 2 4)

Denne blir ikke evaluert til 8, men fortsetter å være listen på tre elementer. Det kan vi bruke når vi skal lage makroer:

(defmacro multiply (a b)
  '(* a b))

men det fungerer ikke helt. Dette vil returnere lista akkurat slik, altså (* a b) - så når denne skal evalueres så er a og b ikke lengre bundet. Men det peker mot trikset:

(defmacro multiply (a b)
  `(* ,a ,b))

Backtick lar oss quote lista, men hvor vi kan evaluere deler av uttrykket. Kommaet escaper ut av quotingen, slik at verdiene i a og b settes inn. Tenk ruby eller python sin string interpolation.

Kanskje skulle multiply kunne ta flere argumenter? Vi prøver:

(defmacro multiply (&rest numbers)
  `(* ,numbers))

Nøkkelordet &rest betyr at resten av argumentene havner i lista numbers. Men dette gir ikke ønsket resultat. Vi ender opp med (* (2 4 6)).

Istedet skriver vi:

(defmacro multiply (&rest numbers)
  `(* ,@numbers))

Escapingen ,@ tar lista i numbers og splicer inn. Da får vi (* 2 4 6) som ønsket.

Og med det er vi klare for å generere testene:

Generere tester, take two

Vi vil altså at dette:

(defexamples s-dashed-words
  "some words" => "some-words"
  "under_scored_words" => "under-scored-words"
  "camelCasedWords" => "camel-cased-words")

skal transformeres til dette:

(ert-deftest s-dashed-words ()
  (should (equal (s-dashed-words "some words") "some-words"))
  (should (equal (s-dashed-words "under_scored_words") "under-scored-words"))
  (should (equal (s-dashed-words "camelCasedWords") "camel-cased-words")))

Og det gjør vi ved å definere defexamples slik:

(defmacro defexamples (fn &rest examples)
  `(ert-deftest ,fn ()
     ,@(map (partial 'example-to-should fn) (partition 3 examples))))

Den tar i mot funksjonen vi tester som fn, og en liste med eksempler. Jeg stykker opp examples i tripletter, som da ser slik ut:

0: "some words"
1: =>
2: "some-words"

Disse mapper jeg over en example-to-should som ved hjelp av partial har fått satt første parameter til fnallerede.

Her er den:

(defun example-to-should (fn example)
  (let ((actual (nth 0 example))
        (expected (nth 2 example)))
    `(should (equal (,fn ,actual) ,expected))))

Som du ser så tar den første og siste element i lista, og bygger opp should-uttrykket. => blir bare ignorert.

Ved hjelp av kun disse to, en liten funksjon example-to-should og en liten makro defexamples, så gjør jeg eksemplene om til tester og kjører dem.

Men det som gjør dette fett, er jo at de samme eksemplene brukes til å generere dokumentasjonen.

Generere dokumentasjon

Ta en titt på den genererte dokumentasjonen her. Slik ser én entry ut:


s-dashed-words (s)

Convert s to dashed-words.

(s-dashed-words "some words") ;; => "some-words"
(s-dashed-words "under_scored_words") ;; => "under-scored-words"
(s-dashed-words "camelCasedWords") ;; => "camel-cased-words"

Som du ser så er eksemplene der, men også funksjonens parameterliste og en forklarende tekst. Hvor kommer de fra?

Homoikonisitetens vidundere

Se her:

(symbol-function 's-dashed-words)

Slik får jeg tak i funksjonen som symbolet s-dashed-words peker på.

Jeg kan kalle den:

(funcall (symbol-function 's-dashed-words) "camelCase")

;; => "camel-case"

Men det sprø her er at funksjonen er en liste som jeg kan grave meg inn i. Slik ser den ut:

(lambda (s)
  "Convert S to dashed-words."
  (s-join "-" (map 'downcase (s-split-words s))))

Det jeg funcall'er der oppe er ikke en peker til noe kompilert i minnet. Det er denne listen.

Så når jeg kaller

(nth 0 (symbol-function 's-dashed-words))

får jeg ut lambda. Og når jeg kaller

(nth 1 (symbol-function 's-dashed-words))

får jeg ut (s). Og når jeg kaller

(nth 2 (symbol-function 's-dashed-words))

får jeg ut "Convert S to dashed-words.".

Og det er ved å kombinere eksemplene, og ved å grave meg inn i datastrukturen som definerer funksjonene at jeg setter sammen dokumentasjonen.

Koden for det er litt mer omfattende, men hvis du er nysgjerrig så finner du den her på github.

Til slutt

Det aller kuleste er at dokumentasjonen aldri går ut på dato. Hvis navn eller signaturer endrer seg, så gjenspeiles det øyeblikkelig i dokumentasjonen. Og eksemplene er alltid riktig.

Konge.


comments powered by Disqus