Opus Polyglotis II: Clojure


torsdag 1. mars 2012 Clojure Polyglot XML

act4

OPUS POLYGLOTIS II, Act 4
Tagline: "Groteskt vakkert!"
På scenen: Clojure

Jeg har presentert 3 ulike programmer som gjør det samme - i Python, Ruby og Rebol – og her kommer den aller siste implementasjonen for denne gang.

Clojure er et herlig språk, men det kan være vanskelig å lese koden – spesielt når man ikke har skrevet den selv. Jeg vil likevel anbefale deg som er interessert i programmeringsspråk å ta en titt, og kanskje forsøke å sammenligne fremgangsmåten med de andre løsningene. Men jeg kommer ikke til å forklare alle detaljene, så hvis du vil at jeg skal utdype noe av koden ytterligere får du legge igjen en kommentar.

Litt om løsningen

I denne løsningen har jeg valgt å bruke multimethods for dispatch, dvs. for å velge hvilken strategi jeg skal bruke (les introduksjonen om du ikke vet hva programmet skal gjøre). Multimethods brukte jeg også nylig i artikkelen Template Method del 4: Multippel arv, og lengre tilbake i tid viste jeg teknikken i posten 1-2-3 Dispatch.

Jeg definerer også et par datatyper med DEFRECORD, og det er en av disse datatypene multimetodene fungerer på. I prinsippet er denne formen for dispatch det samme som jeg implementerte i Ruby og Rebol, bare at det er innbakt i språket, og at jeg dermed slipper å gjøre så mye av jobben selv.

Clojure-koden viser også litt interop med noen Java-klasser; jeg bruker java.util.Scanner til å sjekke om en streng er et tall (muligens overkill, men jeg hadde lyst til å teste dette objektet som jeg aldri før hadde benyttet).

Og så har jeg laget en aldri så liten regex table lexer til å parse CSV-filen. Geeks synes det er gøy med lexing!

Koden: Parsing av CSV-fil

Først deklarerer jeg et namespace, og inkluderer bibloteker jeg trenger i programmet:

 22 (ns polyglotis
 23     (:use clojure.contrib.json)
 24     (:require clojure.string)
 25     (:require [clojure.xml :as xml])
 26     (:import [java.util Scanner Locale]))

Og så definerer jeg datatypene jeg skal jobbe med:

 28 (defrecord OpusData [headers records])
 29 
 30 (defrecord OpusFormatResult [format data output])

Så kommer lexeren. Og dette er ganske vakkert, synes jeg selv. TOKENIZE splitter opp en semikolonseparert tekststreng ved hjelp av tre regulære uttrykk:

 32 (defn get-first-token [s]
 33   (first (or (re-seq #"^([^;\"]+)" s)    ; unqouted
 34              (re-seq #"^\"([^\"]*)\"" s) ; quoted
 35              (re-seq #"^;" s))))         ; separator
 36 
 37 (defn tokenize
 38   ([input] (tokenize input []))
 39   ([input tokens]
 40     (if-let [token (get-first-token input)]
 41       (if (= token ";")
 42         (recur (subs input 1)
 43                tokens)
 44         (recur (subs input (-> token first count))
 45                (conj tokens (second token))))
 46       tokens)))

Jeg trenger også en liten funksjon som kan splitte linjer; igjen bruker jeg regex, og sørger for å håndtere både unix og windows-format:

 48 (defn split-lines [text]
 49       (clojure.string/split text #"\r?\n"))

Nå bruker jeg alt det jeg har laget til å definere en funksjon som tar inn et filnavn, leser filen, splitter den opp i linjer, splitter linjene opp i tokens, og returnerer en OpusData-instans:

 51 (defn get-csv-data [filename]
 52   (let [data (->> filename          ; filename
 53                   slurp             ; Read the file content
 54                   split-lines       ; Split on rows/newline
 55                   (map tokenize))]  ; Split on fields
 56 
 57     ; Create record of headers and rows
 58     (OpusData. (first data) (rest data))))

Hvis jeg nå evaluerer (get-csv-data "data.csv") i REPL'en, vil det gi følgende output (data.csv er den testfilen jeg har brukt gjennom hele denne bloggserien):

 60 ; (get-csv-data "data.csv")
 61  #:polyglotis.OpusData{ :headers ["Field 1" "Field 2" "Field 3"],
 62                         :records (["Value 1" "Value 2" "123"]
 63                                   ["Value A" "Value B" "456"]
 64                                   ["Value x" "Value y;;" "789.45"])}

Koden: Dispatch

Nå har vi kommet til multimetodene. Først definerer jeg APPLY-FORMAT. Når denne blir kalt så vil den velge en funksjon basert på :format-verdien til argumentet sitt (argumentet kommer til å være av typen OpusFormatRecord, definert tidligere):

 69 (defmulti apply-format :format)

Implementasjonen av det første konkrete formatet ser slik ut:

 71 (defmethod apply-format :yaml [csv]
 72   (assoc csv :output "YAML not yet supported!"))

YAML-metoden setter :output-feltet i csv-objektet til "YAML not yet supported".

Da er vi klare for litt mere funky greier – her er definisjonen av XML-formatet. Begynn med å lese kommentarlinjene mot slutten av methode, og se om du kan nøste deg bakover og forstå hvordan det fungerer:

 75 (defmethod apply-format :xml [csv]
 76   (let [headers (->> csv :data :headers
 77                      (map #(.replace % \space \_)))
 78 
 79         xml-str #(with-out-str (xml/emit-element %))
 80 
 81         <tag> (fn [k v]
 82                 {:tag (if (keyword? k) k (keyword k))
 83                  :content (if (seq? v) (vec v) [v])})
 84 
 85         row-<tag> #(->> % (map <tag> headers)
 86                           (<tag> :record))]
 87 
 88     (->> csv :data :records      ; Pluck rows from CSV
 89          (map row-<tag>)         ; Create tags from them
 90          (<tag> :records)        ; Wrap in a <records> tag
 91          xml-str                 ; Convert to a string
 92          (assoc csv :output))))  ; Update output field

Clojure har ganske grei støtte for å konvertere hash-maps (disctionaries, om du foretrekker det navnet) til XML, og dette benytter jeg her.

Og da har vi bare ett format igjen, nemlig JSON:

100 (defmethod apply-format :json [csv]
101   (let [headers (-> csv :data :headers reverse)
102 
103         ; Fn: Coerse to Int or Float if possible
104         coerse-num #(let [s (Scanner. %)]
105                       (.useLocale s (Locale. "en" "US"))
106                       (cond
107                         (.hasNextInt s) (.nextInt s)
108                         (.hasNextFloat s) (.nextFloat s)
109                         :else %))
110 
111         ; Fn: For all rows, for all fields, coerse num
112         coerse-num-all (partial map
113                          (partial map coerse-num))
114 
115         ; Fn: Convert seq into map with headers as keys
116         mapify-row (comp (partial zipmap headers) reverse)
117 
118         ; Fn: Convert all seqs to maps
119         mapify-row-all (partial map mapify-row)]
120 
121     (->> csv :data :records     ; take the rows
122          coerse-num-all         ; coerse the numbers
123          mapify-row-all         ; make maps
124          json-str               ; convert maps to json
125          (assoc csv :output)))) ; update output field

Clojure har enda bedre støtte for JSON enn for XML. Igjen konverterer jeg dataene mine til hash-maps, og funksjone JSON-STR konverterer hash-maps til JSON. Dette er en generell teknikk innen funksjonell programmering: Omstrukturer dataene dine til noe som passer for det du skal løse, og så blir selve løsningen ganske enkel.

Koden: Main

Den siste funksjonen jeg definerer knytter sammen alt jeg har laget til nå. Den oppretter et OpusFormatRecord-objekt hvor dataene fra en CSV-fil mates inn vha. GET-CSV-DATA funksjonen. Jeg kaller APPLY-FORMAT, som vil kalle den riktige format-metoden, plukker resultatet og skriver det ut.

129 (defn main [out-format csv-file]
130   (->>
131       ; Create a record with CSV data and format info
132       (OpusFormatResult. (keyword out-format)
133                           (get-csv-data csv-file)
134                           nil)
135 
136        apply-format   ; Dispatch to correct format def.
137        :output        ; Pluck the output string
138        println))      ; Print it

Til slutt kaller jeg MAIN og sender inn kommandolinje-argumentene.

141 ; Kick it all off !!!
142 (main (first *command-line-args*)
143       (second *command-line-args*))

Og det var det :P

Oppsummering

Multimetoder er ideelt for denne typen dispatch mellom ulike strategier, men bortsett fra det kan jeg ikke peke på noen andre fordeler med Clojure. Annet enn at det er veldig gøy å kode Clojure da!

Da jeg implementerte dette her gikk forresten koden gjennom ganske mange refaktureringer. Selv den enkleste Clojure-funksjonen kan skrives på hundrevis av ulike måter, og det er vanskelig å bestemme seg for hva man liker best, hva som kommuniserer godt, hva som er enklest å vedlikeholde og så videre. Jeg skrev litt mer om dette problemet i Er Lisp's fleksibilitet et problem? tidligere i år.

Nå tror jeg vi lar dette polyglotiske opuset ligge. Det har sansynligvis vært mer interessant for meg selv enn for mine lesere, men jeg håper(?) noen har satt pris på å få se noen tilnærmet fullverdige men likevel små program skrevet i diverse språk.

Hei då!


comments powered by Disqus