A Clojure programozási nyelv

Típusok, típuskonstrukciók

Skalár típusok

Számok

Clojure-ben a számok (numbers) típusa egy esernyőfogalom, és igazából 3 típus tartozik ide: az integer típus (ami nem egészen azonos a Java hasonló nevű típusával), a lebegőpontos típus, valamint a racionális számtípus. Ezek közül az integer és lebegőpontos típusokra megkövetelhető a létező legnagyobb pontosság az M betű kirakásával:

     user=> [0.1234567890123456789  0.1234567890123456789M]  ; az első számot a nem megfelelő pontosság miatt levágja, a másodiknál viszont a pontosság garantált

     [0.12345678901234568 0.1234567890123456789M]

Ez problémát okozhat a nemtermináló tizedestörtes kifejezésekben:

     user=> (/ 1M 3)
     
     java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result. (NO_SOURCE_FILE:0)

...amit a with-precision makró old meg, a *math-context* beállításával:

	 user=> (with-precision 10 (/ 1M 3))
	 
	 0.3333333333M

A pontosságon kívül egy további szempont is fontos lehet: a túl-, illetve alulcsordulás. A Clojure számtípusai automatikus típuskibővítéssel dolgoznak, így ezeket legtöbbször el lehet kerülni, de például ha egy műveletben mindkét operandus a Java primitív számtípusú konstansai közül kerül ki, előfordulhat a hiba:

     user=> (+ Integer/MAX_VALUE Integer/MAX_VALUE)  ; túlcsordulás: mindkét paraméter Java integer konstans
     
     java.lang.ArithmeticException: integer overflow (NO_SOURCE_FILE:0)
     
     user=> (+ (* 1 Integer/MAX_VALUE) (* 1 Integer/MAX_VALUE))  ; logikailag ugyanaz a művelet, de nincs túlcsordulás, a háttérben típuskiterjesztés
     
     4294967294

Integerek

Elvileg korlátlan számjegyből állhatnak, a korlát csak a memória mérete. Tetszőleges számrendszerekben is megadhatjuk az integer típusú számainkat:

	[0x7F 0177 2r01101010 3r0122 32r3V]   ; rendre: hexadecimális, oktális, kettes, hármas, radix 32-es alapú számrendszerek
    

Lebegőpontos számok

Néhány példa:

	[3.14 +3.14 -3. 356e7 10.7E-3]  ; lebegőpontos számok egy vektorban: láthatjuk az exponenciális alak különböző megadásait
    

Racionális számok

Az egész számok halmaza kibővítve a valódi arányszámok halmazával, amik mindig két egész szám hányadosaként vannak reprezentálva. A rendszer automatikusan egyszerűsíti a törteket (tehát például a 25/5-ből 5-öt csinál, a valódi arányszámok esetén megkeresi a legnagyobb közös osztót), ami költségessé teszi a használatukat. A racionális számokhoz kapcsolódó függvények a ratio? és a rational?, amikkel lekérdezhetjük egy adott számról, hogy valódi arányszám, illetve racionális típusú-e, hasznos még a rationalize művelet, ami egy tetszőleges szám "racionalizálását" végzi.

Szimbólumok

A szimbólumok a programon belül mindig "szimbolizálnak" valamit, és bár a Clojure a szimbólumokra típusként tekint, valójában nem igazi típus: a szimbólum típusa mindig a szimbólum által jelölt "objektum" típusa. Amennyiben futtatás közben nem képes a futtatórendszer feloldani például a qwertz szimbólumot, a következő kivételt kapjuk:

	java.lang.Exception: Unable to resolve symbol: qwertz in this context (NO_SOURCE_FILE:81)
    

Amennyiben a futtatórendszer képes feloldani a szimbólumot, a szimbólum mindig az általa szimbolizált objektumra értékelődik ki.

Egy szimbólum literálban szerepelhet szám, betű és a +,!,-,_,?, & jelek.

Kulcsszavak

A kulcsszavak (keywords) hasonlóak a szimbólumokhoz, az egyetlen nagy különbség a kettő között, hogy a kulcsszavak mindig önmagukat jelölik, és önmagukra értékelődnek ki. Példák kulcsszavakra:

	user=> [:alma ::alma]  ; a második esetben a kulcsszó előtti kettőspont az aktuális névtérre (user) oldja fel a kulcsszót

	[:alma :user/alma]

Stringek

A Clojure stringek Java stringek, így, hasonlóan más programozási nyelvekhez, idézőjelek közé írandók. A sztringliterálok Clojure-ben lehetnek többsorosak is.

Reguláris kifejezések

Bár nem egészen nevezhetőek skalár típusoknak, de érdemes megemlíteni a stringek mellett a reguláris kifejezéseket (lásd: java.util.regex.Pattern), amiket a Clojure nyelvi szinten támogat, és amik így már beolvasási időben (a reader futása alatt) lefordíthatók:

     user=> (re-seq #"[0-9]+" "06-30-989-1239")  ; a re-seq segítségével szétbontjuk a stringként megadott telefonszámot a [0-9]+ reguláris kifejezés mentén

     ("06" "30" "989" "1239")

Karakterek

A karakterliterálokat \ (backslash) karakter előzi meg. A backslash karakter így éppen a \\ lesz, de lehetőség nyílik tetszőleges unicode karakterek írására is, például \uNNN, vagy oktálisan \oNNN formátumban.

Sok karakternek saját neve van, mint például: \space (szóköz), \newline (új sor), \tab (tabulátor).

Összetett típusok

A Clojure-ben az összetett típusok 3 egyenlőségi partícióra oszlanak: szekvenciális típusokra, halmaz típusokra, valamint map típusokra. Ez azt jelenti, hogy különböző partícióba tartozó dolgok sosem lehetnek egyenlők (például egy halmaz típusú valami egy szekvenciális típusú valamivel):

    user=> (= #{1 2 3} [1 2 3])

    false

Ez logikus is: a halmaz típus nem garantál semmilyen sorrendet, a szekvenciális típusok pedig definíció szerint azok, amik nem rendezik át a bennük található elemek sorrendjét. Itt meg kell jegyezni, hogy az előbb használt egyenlőség érték alapú vizsgálatot csinál - referencia alapú egyenlőséget pedig az identical? függvénnyel vizsgálhatunk.

Ezenkívül fontos megemlíteni, hogy a Clojure-ben a kollekciók egy közös, seq-nek nevezett API segítségével érhetők el, ami a clojure.lang.ISeq interfészben van deklarálva. Ez biztosítja a first, a next, a more és a cons műveleteket. Bármilyen típusra használhatóak a seq API műveletei, ami megvalósítja ezt az interfészt. Az, hogy egy típus (objektum) megvalósítja-e ezt az interfészt, a seq? függvénnyel kérdezhető le. A fenti műveletek (first, next, more és cons) biztosítják a seq immutable voltát. Ezenkívül, a helyzetet tovább bonyolítja (egyszerűsíti), hogy létezik egy seq nevű függvény is, ami hívható tetszőleges Iterable-t megvalósító kollekcióval (így listákkal, vektorokkal, stb.), stringekkel, Java tömbökkel, sőt még nil-lel is. A seq nevű függvény nem csinál mást, mint visszaad egy, a paraméterének megfelelő seq objektumot (ami persze garantálja az "alatta lévő" kollekcióhoz való immutable hozzáférést). A seq függvényt használják arra is, hogy ellenőrizzék, hogy egy lista tartalmaz-e elemet, hiszen a seq függvény mindig nil-t ad vissza üres adatszerkezetekre: listákra, vektorokra, halmazokra, stb.

Vektorok, vermek

Az üres vektort így írjuk: [], és ez nem azonos a nil-lel. Vektort, a szokásos kapcsoszárójeles szintaxis mellett megadhatunk a vec függvénnyel is. Vektorok összefűzése az into függvénnyel (vigyázat, egyik vektor sem változik, immutable!):

    user=> (into [1 2 4] [5 6 7])   ; a művelet O(n) költségű, ahol n = (length [5 6 7])

    [1 2 4 5 6 7]

Primitív vektort is létrehozhatunk a vector-of függvénnyel. Ebben az esetben a hozzáadás már típusbiztos:

    user=> (into (vector-of :int) [1 2 4 "d"])

    java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Character (NO_SOURCE_FILE:0)

A vektorokat gyakran használják a Clojure-ben, pl. let kifejezésekben, de adatok tárolására is. Összefoglalva 3 esetben jobbak a listáknál:

Elemek index alapján történő elérése, index alapján történő "változtatása":

    user=> (def vektor [1 2 3 4])
    #'user/vektor
    
    user=> (nth vektor 2) ; vagy lehetne: (nth vektor 4 :nincs_ilyen) , ha nem adjuk meg, out of range esetén dobhat exception-t is
    3
    
    user=> (get vektor 3) ; hiba esetén nil-t ad vissza mindenképpen, de itt is lehetne: (get vektor 4 :nincs_ilyen)
    4
    
    user=> (vektor 3)     ; hiba esetén mindig exception
    4
    
    user=> (def mtx [[1 2 3 5 6]  ; egy, nem a leghatékonyabb, de egyszerű mátrix adatszerkezet
                     [7 8 9]
                     [4 5 6 7]])
    #'user/mtx
    
    user=> (get-in mtx [1 2])     ; elérés index alapján
    9
    
    user=> (assoc vektor 3 "új érték")      ; index alapján történtő "változtatás" - az eredeti vektor nem változik, immutable!!!
    [1 2 3 "új érték"]
    
    user=> (assoc-in mtx [1 2] "új érték")  ; index alapján történtő "változtatás" - az eredeti mátrix nem változik, immutable!!!
    [[1 2 3 5 6] [7 8 "új érték"] [4 5 6 7]]
    
    user=> (replace {1 "új1", 2 "új2"} [1 1 2 3 1 2 5 6 7 2])  ; érték alapján történő csere
    ["új1" "új1" "új2" 3 "új1" "új2" 5 6 7 "új2"]

A vektor jobb szélének "változtatására" (amik, ahogy már írtuk, veremműveletekként értelmezhetők - ahol a verem teteje a vektor jobb széle) a peek, a pop és a conj függvények szolgálnak. Ezek a függvények egyébként tetszőleges olyan osztályon működnek, amik megvalósítják a clojure.lang.IPersistentStack interfészt - a felsorolt veremműveletek "invariánsai" (a "LIFO" tulajdonság), nem változnak meg, ha például vektorok helyett listákat használnánk a fenti veremműveletekkel; csupán annyi a különbség, hogy listák esetén nem az adatszerkezet jobb szélén dolgozunk, hanem az elején (láncolt listák lévén ez hatékonyabb). A veremműveletek tehát O(1) költséggel működnek, és érdemes betartani továbbá azt a konvenciót, hogy ha egy adatszerkezetet (például vektort) veremként használunk, akkor csak a fenti veremműveleteket használjuk.

	user=> (pop (conj (conj (conj (conj [] 1) 2) 3) 4))   ; egy üres verembe sorban belerakjuk az 1, a 2, a 3 és a 4 számokat, aztán egyet pop-olunk
	[1 2 3]     ;  az 1 van a verem alján, a 3 pedig a tetején, ahogy azt elvárjuk
	
	user=> (pop (conj (conj (conj (conj () 1) 2) 3) 4))   ; listákra ugyanez...
	(3 2 1)     ; fordított sorrend! itt a verem teteje a lista bal széle

Részvektorok definiálása: a subvec függvény nem allokál újabb vektort, hanem az eredetinek egy intervallumán dolgozik - definiálható részvektorok részvektora, ilyenkor is mindig az eredeti (valódi) vektornak a megfelelő intervallumával dolgozik, így ez nem okozhat gondot. Példa:

	user=> (def vektor [1 2 3 4 5])
	#'user/vektor
	user=> (subvec vektor 2 5)   ; a 2-es index már beletartozik, az 5-ös indexű elem (olyan nincs) már nem tartozik bele
	[3 4 5]

Fordított sorrendű bejárás: történhet az rseq és a reverse függvénnyel is, a különbség az, hogy az rseq lusta kiértékelésű, míg a reverse nem. Az rseq egyébként működik minden olyan objektumra, ami megvalósítja a clojure.lang.Reversible interfészt (ez ellenőrizhető a reversible? predikátummal), a vektorok ilyenek.

Érdekesség: a vektorok esetében a contains? predikátumfüggvény érdekesen működik, mert indexvizsgálatot végez, értékvizsgálat helyett:

	user=> (contains? [4 4 4 4] 4) ; nincs 4-es indexű elem (0-tól indexelünk)
	false

Listák

Üres listát ()-vel írhatunk, ami nem egyenlő a nil-lel. Ez azt is jelenti, hogy a lista váza nem úgy néz ki, mint más Lisp nyelvekben, a láncolt listát nem egy nil érték, hanem az üres lista zárja. Mivel Clojure-ben minden nil-től és false-tól különböző érték logikai igaznak számít, ezért ha például rekurzív függvényeket írunk, szükségünk van egy speciális függvényre, ami eldönti, hogy a sorozat végén vagyunk-e már - azaz, hogy a lista üres lista-e. Erre a már említett seq függvényt használjuk.

Listák építésére más Lisp nyelvekben a cons művelet használatos: ott a listák ilyen, cons hívások eredményeként kapott ún. cons cell-ekből épülnek fel. Clojure-ben kicsit más a helyzet, listák építésére ugyanis látszólag két függvény is használható:

	user=> (conj '(1 2 3) 4)
	(4 1 2 3)
	user=> (cons 4 '(1 2 3))
	(4 1 2 3)

A hasonlóság csak látszólagos, valójában a cons hívás nem listát épít, hanem egy clojure.lang.Cons objektumot (ami lényegében a Lisp-es cons cell Clojure változata némi megszorításokkal: egy cons cell-ben ugyanis a car ("fej") illetve a cdr ("farok") rész bármi lehet (ún. dotted pair), míg Clojure-ben a cdr csak egy ISeq objektum). Miért probléma ez? Például azért, mert a cons által visszaadott clojure.lang.Cons objektum nem valósítja meg a clojure.lang.Counted interfészt, így a cons-okkal épített listán a számlálás lineáris idejű! Ennek bemutatásához csináljunk két nagyon nagy listát: az egyikük készüljön conj, a másik pedig cons művelettel, végül hasonlítsuk össze, mennyi ideig tart a méretük lekérdezése! A kód kicsit körülményes lesz, hiszen nagy méretű cons-os "listák" építésére egyáltalán nincsenek előre definiált függvények, de még "sima" (értsd: conj-os) listákra se nagyon: például a range függvény is lusta listát ad vissza, az meg most nem jó, mivel a listaelemek realizációja befolyásolná a count futási idejét. Az lesz a legjobb, ha mi csináljuk:

    (defn reduce-right [f coll]                     ; foldr függvény
       (reduce #(f %2 %1) (reverse coll)))
       
    (defn cons_lista [val]                          ; olyan függvény, ami cons listát csinál 0-tól val-ig terjedő értékekkel (összesen val+1 db elem)
       (reduce-right cons
          (into (vec (range (inc val)))
                [()] )))

    (defn conj_lista [val]                          ; olyan függvény, ami conj listát csinál 0-tól val-ig terjedő értékekkel (összesen val+1 db elem)
       (reduce conj (into [()]
                          (vec (range val -1 -1)))))

    (def cons_listam (cons_lista 1000000))
    (def conj_listam (conj_lista 1000000))

    ; érték szerint egyenlőek:
    user=> (= conj_listam cons_listam)
    true

    ; DE:
    user=> (time (count cons_listam))
    "Elapsed time: 69.926828 msecs"
    1000001

    user=> (time (count conj_listam))
    "Elapsed time: 0.059016 msecs"
    1000001

    user=> (time (count '(1 2 3 4 5)))
    "Elapsed time: 0.057306 msecs"
    5

Az utóbbi két mérés mutatja, hogy a listákon (akárcsak a vektorokon) az elemek számlálása csakugyan O(1) művelet, míg a cons-szal épített listákon lineáris.

Lehetőség nyílik a hétköznapi listákon kívül lusta listák konstruálára is, a lazy-seq függvénnyel. A lusta adatszerkezetekkel kapcsolatban érdemes megjegyezni még, hogy a listákon hívható next és rest függvények közül a next "kevésbé lusta": mindig egy elemmel többet realizál a listában, hogy megnézhesse, valóban van-e még értékes elem a fejelem után (és nem csak a lezáró üres lista). A next tehát mindig a hamis értéknek megfelelő nil-t adja vissza, ha a vektorban, listában, stb. effektíve nincs következő elem.

Sorok

A listák és a vektorok nem használhatók sorokként, mivel a conj művelet mindig a leghatékonyabb végükhöz (listák esetén bal, vektorok esetén jobb szél) csatol, illetve vesz el. Így az immutable sorok külön típust kaptak. Működnek rajtuk a pop, a peek és a conj műveletek, sor típust csak úgy egyszerűen létrehozni nem tudunk, ehelyett a clojure.lang.PersistentQueue/EMPTY - hez kell conjoinolni az elemeket.

Halmazok

Üres halmazt a #{} szintaxissal írhatunk, és ez nem egyenlő a nil-lel. Elemek elérése:

	user=> (#{1 2 3} 2)
	2

	user=> (get #{1 2 3} 2)     ; vagy lehetne (get #{1 2 3} :nincs_ilyen)
	2

Halmazokra már elvárt módon működik a contains? predikátum is, halmazműveleteket a clojure.set/intersection , union, valamint difference függvényekkel végezhetünk. A some függvény halmazok tetszőleges elemének vektorokban való lineáris keresésére szolgál. Rendezett halmazok létrehozására szolgál a sorted-set függvény:

	user=> (conj (sorted-set 4 2 1 3) 0)
	#{0 1 2 3 4}
	

Érdemes megjegyezni, hogy ebben a nyelvben a halmaz típus függvényként is tud viselkedni. Amikor meghívjuk, a halmaz nil-t ad vissza, ha a kapott paraméter nem eleme, egyébként visszaadja a kapott paramétert. Ezt a viselkedést kihasználva még kifejezőbb kódot írhatunk.

Sok típus alternatív megvalósításai is helyet kapott a nyelvben. Például a (sorted-set) függvény egy olyan halmazt ad vissza, ahol a belső reprezentáció nem hasítótáblás, hanem rendezés alapú.

Hash map-ek

Üres hash map-et írhatunk például így: {}, ami nem egyenlő a nil-lel. Érdekesség, hogy a kulcs és az érték kulcs-érték páronként változó típusú és bármi lehet, akár még függvények vagy vektorok is (bár ennek sok értelme nem biztos hogy van). Elemek elérése a megszokott módokon:

	user=> (def hashmap {1 "egy" 2 "ketto"})
	#'user/hashmap
	
	user=> (hashmap 1)
	"egy"
	
	user=> (get hashmap 1)     ; vagy lehetne (get hashmap :nincs_ilyen)
	"egy"

A hash map-ek vektorokkal való kapcsolatát mutatja, hogy a kulcs-érték párok vektorokként reprezentálhatók: a seq függvény hash-mapekre vektorok listáját adja. Ez jól jöhet, ha a hash-map értékét akarjuk "megváltoztatni":

	user=> (into {1 "egy" 2 "ketto"}  {3 "harom" 4 "negy"})
	{1 "egy", 2 "ketto", 3 "harom", 4 "negy"}

	user=> (into {1 "egy" 2 "ketto"}  '([3 "harom"] [4 "negy"]))
	{1 "egy", 2 "ketto", 3 "harom", 4 "negy"}

Létező map-hez új elemet az (assoc) függvénnyel adhatuk, az elemek eltávolításához pedig a (dissoc) függvény használható.

Hasonlóan a halmaz típushoz, a map is használható függvényként. Amikor meghívjuk, visszaadja a kapott kulcshoz eltárolt értéket, vagy nil-t, ha nincs ilyen. Szintén hasonlóan itt is vannak más megvalósítások, az array-map például rendezetlen halmazt használ, a sorted-map pedig rendezéssel tárolja az elemeket. A map-ek egy elem kikereséséhez, hozzáadásához, vagy eltávolításához szükséges idő pedig a belső reprezentációtól függ.

Szintén érdemes megjegyezni, hogy a kulcsszó típus is használható függvényként: ha egy map-et adunk neki, kikeresi benne az kulcsszóhoz tartozó értéket.

Java tömbök

Előfordulhat, hogy olyan Java függvényt, vagy eljárást kell meghívni, ami tömböket vagy éppen változó számú paramétert fogad, ilyenkor van szükség Java tömbökre, ilyenkor lehetnek hasznosak a make-array vagy éppen az into-array függvények. A to-array-2d függvény 2-dimenziós tömböt készít, és számos függvény használható primitív típusok tömbjeinek létrehozására: char-array, int-array, stb.

	user=> (def array (into-array ["a" "b" "c" "d"]))
	#'user/array
	
	user=> (aset array 2 "x")   ; valódi értékadás
	"x"

	user=> (get array 2)
	"x"