Kassaapparat-kata i Clojure


tirsdag 29. juni 2010 Clojure Kata

kodekata

Et gjennomgående tema på denne bloggen er at utviklere selv må ta et aktivt ansvar for å å bli bedre. Blant annet må man trene på de ferdighetene man trenger i jobben sin, og her kommer kodekata'er inn som et viktig hjelpemiddel. Første gang jeg snakket om kodekata var i februar 2009.

Jeg har bl.a. én kata jeg har brukt flere ganger for å komme raskt inn i nye programmeringsspråk, og jeg viste hvordan jeg implementerte den med Erlang her. Det dreier seg om å lage et enkelt "kassaapparat" som et komandolinjeprogram. Det fine med denne oppgaven er at den kan begynne veldig enkelt, men at man kan utvide med ganske mye avansert funksjonalitet om man ønsker det. Man blir også nødt til å sette seg inn i hvordan man kommuniserer med kommandolinjen, noe som alltid er kjekt å beherske, og man må bruke noen enkle datastrukturer.

Jeg fokuserer ikke på automatiserte tester i denne kataen, men prinsippet om clean code er viktig. Når jeg lærer et nytt språk tenker jeg at det er viktig å raskt finne ut hvordan koden bør struktureres for å bli enkel å lese og refakturere.

Siden jeg nå holder på å lære meg Clojure har jeg selvfølgelig nettopp implementert et enkelt kassaapparat. Jeg tenkte det kunne brukes til å vise litt Clojure-kode til andre som kanskje har hørt litt om dette språket, og som vil se hvordan et lite program kan se ut. Så her er det komplette programmet, splittet opp i tre deler, med litt kommentarer.

Merk at Clojure-kode parses fra toppen av fila og nedover. Verdier og funksjoner må derfor deklareres før de kan brukes. Du vil dermed se detaljene først og "program-loopen" til slutt.

  1 ; SHOP INVENTORY, AND GETTING THE PRICE OF STUFF
  2
  3 ; Illustrates: Defining some global data as a Map (key/value)
  4 (def shop-items {"bread" 3.99 "milk" 2.50 "butter" 4.00})
  5
  6 ; Illustrates: Iterating over a collection (Map)
  7 (defn list-items []
  8       (println "Items in shop:")
  9       (doseq [[key value] shop-items]
10              (println key value)))
11
12 ; Illustrates: Use of Map (like a Hashtable/Dictionary)
13 (defn price [key]
14       (shop-items key))
15
16 ; Illustrates: Function overloading based on arity
17 (defn price-for-selection
18       "Gets the price for X number of a given shop item"
19       ([key quantity] (* (price key) quantity))
20       ([item]  (* (price (first item)) (second item))))
21

Gir det mening sålangt? Etter å ha skrevet Clojure-syntaks i en drøy uke er det plutselig ikke så lett å se om dette kan være vanskelig å tyde :) Jeg har nå definert et Map (tilsvarer Dictionary i .Net) med hvilke varer butikken har, og hva varene koster. Deretter har jeg definert noen funksjoner for å printe ut varene (med pris), for å finne prisen på en vare, og for å finne prisen på et gitt antall av en vare.

Og så kommer funksjonene får å kjøpe, vise innholdet av handlekurven, og for å gjennomføre betaling. Merk at handlekurven sendes inn som en parameter til alle disse funksjonene (i show-cart kalte jeg handlekurven for state, bare for å gjøre det vanskelig for deg).

Handlekurven er en array av arrays (eller Vector av Vectors, vi er tross alt i Java runtime nå), hvor hvert av de indre arrayene inneholder to elementer: navnet på en vare, og hvor mange det er av den. Buy, show-cart og checkout returnerer alle handlekurven – den samme eller en oppdatert kurv.

22 ; SHOPPING CART / BUYING
23
24 ; Illustrates use of higher-order function
25 (defn total [cart]
26       (apply + (map price-for-selection cart)))
27
28 ; Illustrates: Updating the state (cart)
29 (defn buy [what quantity cart]           
30       (println "Bought" quantity "of" what "for" 
31                (price-for-selection what quantity))
32       (conj cart [what quantity]))
33
34 ; Illustrates: Use of if
35 (defn show-cart [state]
36       (if (empty? state)
37         (println "Shopping cart is empty!")
38         (do 
39           (println "Shopping cart:")
40           (doseq [item state]
41                  (println (second item) "x" (first item)))
42           (println "Total:" (total state))))
43       state)
44
45 ; Illustrates: Local values using let
46 (defn checkout [cash cart]
47       (let [to-pay (total cart)]
48         (if (> to-pay cash)
49           (do (println cash "is not enough. You have to pay" to-pay) cart)
50           (do (println "To pay:" to-pay "Received:" cash
51                        "You get back:" (- cash to-pay)) 
52             []))))           
53

Og så kommer vi til siste del, som inkluderer en meny, input fra brukeren, og behandling av denne inputen.

54 ; MENU / INTERACTION AND MAIN LOOP
55
56 ; Illustrates: Read from command line using Java interop
57 (defn prompt [text]
58       (print text) 
59       (flush)
60       (let [reader (java.io.BufferedReader. *in*)]
61                   (.readLine reader)))
62
63 ; Illustrates: Anonymous functions used in Dispatch Table pattern
64 (def dispatch-command {
65      "menu" (fn [state] (do 
66                           (println
67                               "Commands: items | cart | buy | checkout | quit") 
68                           state))
69      "quit" (fn [state] (System/exit 0))
70      "cart" (fn [state] (show-cart state))
71      "items" (fn [state] (do (list-items) state))
72      "buy" (fn [state]
73                (let [item-to-buy
74                       (prompt " Buy what? ")
75                       how-many
76                       (Integer/parseInt (prompt " How many? "))]
77                  (buy item-to-buy how-many state)))
78      "checkout" (fn [state]
79                     (let [money
80                            (Double/parseDouble (prompt " Received money? "))]
81                       (checkout money state)))
82      :default (fn [state] (do (println "* Does not compute") state))})
83
84 ; Illustrates: Exception handling, and use of a Map
85 (defn get-and-run-command [state]
86       (try
87         ((dispatch-command (prompt "READY> ")) state)
88         (catch NullPointerException e
89                ((dispatch-command :default) state))))
90
91 ; Illustrates: Recursive tail call optimization
92 (defn run-program []
93       (loop [state []] ; starts with an empty Vector as state/cart
94             (let [new-state (get-and-run-command state)]
95               (recur new-state)))) ; resume with new state/cart
96
97 ; Alternative loop: Using pipelining/threading to avoid "let"
98 (defn run-program-2 []
99       (loop [state []]
100             (-> state get-and-run-command recur)))
101
102 (run-program) ; You'll never guess what this line does :P

De siste funkjonene her er kanskje det som er vanskeligst å skjønne. get-and-run-command prompter brukeren til å taste inn en kommando. Brukerens input brukes så til å velge en lambda-funkjon fra dispatch-table. Denne lambdaen kalles så med state som parameter, og returverdie fra lambdaen returneres som returverdi fra funksjonen. Alt dette skjer i linje 87.

Om det ikke finnes en lambda for det brukeren taster inn vil det bli kastet en NullPointerException, og da velger jeg en default lambda (linje 89).

rect4019 run-program illustrerer hvordan tail-recursion fungerer i Clojure. I kallet til loop (linje 93) defineres det en verdi (variabel) kalt state, som i utgangspunktet er et tomt array – dette er handlevognen. Det gjøres så et kall til get-and-run-command, hvor vi sender inn state. Tilbake får vi returnert verdien vi kaller new-state, den oppdaterte handlevognen.

Til slutt kaller vi funkjonen recur, og sender inn denne nye handlevognen. Recur gjør at vi starter fra loop igjen, som om run-program ble kalt på nytt, men state får nå verdien som sendes inn i recur. Hele programmet vil altså stå og spinne inne i run-program (uten at stacken blir spist opp), og handlevognen oppdateres for hver iterasjon.

Denne måten å sende state/handlevognen rundt i et rekursivt kall var noe jeg lærte meg da jeg så på Erlang. Dette er en helt normal måte å holde på state innenfor funkjonell programmering, hvor man normalt ikke tillater (eller ønsker) variabler som muterer (endrer verdi). I stedet for å ha en global handlevogn som metodene aksesserer og endrer har vi nå metoder som sender handlevogner til hverandre.

(Faktisk implementerte jeg kassaapparatet uten denne teknikken først, for det er mulig å mutere verdier i Clojure. Seriøs refakturering inngår ofte som en del av kodekata'er.)

Ta også en titt på run-program-2, som gjør det samme som run-program, men med pipelining/threading. I linje 100 sendes state som input til metoden get-and-run-command. Resultatet fra det kallet sendes så som input til recur. Altså samme logikk, men enklere å se på :)

Jeg håper noen orket å lese denne blogposten, og ikke ble alt for avskrekket av alle parantesene. Ble du kanskje inspirert til å utføre eller komme opp med egne kataer? Del gjerne erfaringer eller dine egne ideer til lignende oppgaver med oss andre i kommentarfeltet.


comments powered by Disqus