Još o sintaksi

Postoji još par pojedinosti Haskel sintakse koje nismo do sada obradili, a koje ćemo u nastavku prezentovati.

Podudaranje oblika

Nakon što smo se upoznali sa algebarskim tipovima, možemo dati nešto precizniji opis mehanizma podudaranja oblika. Podudaranje oblika smo koristili pri definisanju funkcija. U opštem slučaju, ovakve definicije su sačinjene od više linija koje predstavljaju primenu funkcije na oblike1. Svaka linija ima formu jednakosti f x₁ x₂ … xₘ = y gde su x₁, x₂ itd. neki oblici. Oblik može biti nešto od sledećeg:

  1. Literal broja, odnosno izraz poput 2, 3.14, 0x3A, itd...
  2. Literal karaktera, odnosno izraz poput 'a', 'B', 'ш', itd...
  3. Literal niske, odnosno izraz poput "hello", "world!!", itd...
  4. Proizvoljno ime, odnosno izraz poput x, ys, n, itd...
  5. Džoker _
  6. Literal uređene \(n\)-torka oblika, odnosno izraz poput (1, 'a'), (x, y, z), (_,1,_,x) itd...
  7. Literal liste oblika, odnosno izraz poput [], [5], [x,y,_], itd...
  8. Konstruktor primenjen na oblike ili zapise, odnosno izraz poput True, Maybe x, Vektor x y _ itd...

Svako podudaranje oblika koje možemo konstruisati samo kombinovanjem prethodno navedenih oblika. Primetimo da oblici \(n\)-torke, liste i algebarskog tipa podatka, dozvoljavaju da se konstruišu oblici proizvoljne složenosti2.

Numerički literal, karakter ili niska u obliku će se podudariti sa argumentom funkcije, ako i samo ako je taj argument jednak3 tom literalu. Oblik sačinjen od imena, podudariće se sa svakim argumentom, i to ime će imati vrednost argumenta sa desne strane jednakosti. Jedno ime može se koristi samo na jednom mestu sa leve strane jednakosti4. Džoker _ takođe se podudara sa svakom vrednošću argumenta, ali za razliku od imena džoker se može pojavljivati na više mesta sa leve strane jednakosti. Vrednost argumenata koji se podudario sa džokerom, ne može se iskoristiti sa desne strane jednakosti.

Oblici sa listama, \(n\)-torkama, i konstruktorima uglavnom u sebi sadrže neke druge podoblike (npr. literale). Ovi oblici će se podudariti sa argumentom funkcije ako i samo ako se svi podoblici podudare sa odgovarajućim vrednostima.

Primetimo da se liste mogu podudariti na dva načina: ili preko literala liste (npr [1,2,3]) ili korišćenjem konstruktora liste (npr: x:xs). Nekad se ova dva pristupa podudaranju lista koriste istovremeno (npr. u obliku x:[_]). Ovo takođe važi i za uređene n-torke koji se mogu podudariti korišćenjem konstruktora poput (,), (,,,). itd. ali se ovakav pristup retko koristi u praksi.

As sintaksa

Konstruktori se mogu koristiti na dva načina: kao funkcije pomoću kojih se konstruišu vrednosti nekog tipa (otuda i samo ime), ali i za dekonstrukciju vrednosti podudaranjem oblika. Neretko se dešava da u definiciji funkcije neki argument dekonstruišemo da bi u telu funkcije istu tu vrednost konstruisali.

Primer 1.

Neka je dat tip koji predstavlja petodimenzionalni vektor:

data Vektor = Vektor Float Float Float Float Float

Neka su date i dve funkcije g1, g2 :: Vektor -> Float. Zamislimo da je potrebno napisati funkciju f :: Vektor -> Int koja za dati vektor v vraća vraća vrednost g1 v ako je prva koordinata vektora v pozitivna, a suprotnom g2 v. Jedna implementacija tražene funkcije bi mogla da glasi ovako:

f :: Vektor -> Float
f (Vektor x y z w q) =
    if x >= 0
        then g1 (Vektor x y z w q)
        else g2 (Vektor x y z w q)

Vidimo da u kodu nepotrebno uvodimo promenljive y, z, w, q. Vrednosti koje predstavljaju ove promenljive nisu bitne za samu funkciju f. Situacija bi nešto bolja bila da smo iskoristili let in ili where konstrukciju, ali i osnovni problem bi i dalje ostao: istu vrednost dekonstruišemo pa konstruišemo.

As sintaksa nam omogućuje da isti argument istovremeno vežemo za jedno ime ali i da iskoristimo podudaranje oblika. Sintaksa ima oblik ime@oblik, pri čemu se ime ne nalazi u obliku. Na ovaj način vrednost čitavog argumenta će biti dostupna kao ime sa desne strane jednakosti.

Primer 2.

Koristeći as sintaksu za prethodni primer, funkciju f možemo definisati kao

f :: Vektor -> Float
f v@(Vektor x y z w q) =
    if x >= 0
        then g1 v
        else g2 v
As sintaksom smo istovremeno izvršili podudaranje oblika, u ovom slučaju to podudaranje oblika dekonstruiše vektor na koordinate, ali smo i imenu v dodelili vrednost čitavog vektora.

Zapravo, kako nas vrednosti y, z, w, q uopšte ne interesuju, možemo ih zameniti džokerima:

f :: Vektor -> Float
f v@(Vektor x _ _ _ _) =
    if x >= 0
        then g1 v
        else g2 v

Case notacija

Podudaranje oblika je do sada bilo isključivo vezano za definicije funkcija: da bismo iskoristili podudaranje oblika, morali smo da definišemo novu funkciju5. Case konstrukcija nam omogućava podudaranje oblika unutar izraza, bez potrebe za definicijom funkcije.

Case izraz započinje sa kodom case x of gde je x vrednost čiji oblik želimo da ispitamo. Nakon ovog početka, potrebno je navesti blok sačinjen od oblika i rezultata razdvojenih strelicom ->.

dužina :: [a] -> Int
dužina []     = 0
dužina (x:xs) = 1 + dužina xs

dužina' :: [a] -> Int
dužina' = (\ss -> case ss of
    []    -> 0
    x:xs  -> 1 + dužina' xs
)

dužina'' :: [a] -> Int
dužina'' ss = case ss of
    []    -> 0
    x:xs  -> 1 + dužina'' xs
Tri definicije funkcije koja računa dužinu liste. Prva definicija nam je dobro poznata. U drugoj definiciji je iskorišćen je case izraz unutar lambda izraza. Primetimo da je ovako napisan lambda izraz mogao da se navede bilo gde u kodu, i da nije bilo potrebe da se dodeljuje imenu dužina. Treća definicija je mešavina prethodne dve: parametar ss je vezan tehnikom podudaranja oblika (pri čemu je naveden samo jedan, opšti, oblik), a u telu funkcije je iskorišćena case notacija. Primetimo kako u case izrazu oblik x:xs nismo morali da obuhvatimo sa za zagradama kao što je to slučaj u prvoj definiciji.

Za blok oblika koji sledi nakon koda case x of važe iste napomene kao i za blokove poput where ili let in blokova. Blok se može napisati i u jednoj liniji razdvajanjem slučajeva sa znakom ;. Ako se blok prelomi u više linija, tada počeci svih linija moraju biti poravnati po vertikali.

Primer 3.

Iskoristimo primer iz prethodne glave za demonstraciju različitog preloma case izraza. Sve naredne definicije definišu istu funkciju

data Dužina = Metar Float | Milja Float | SvetlosnaSekunda
    Float deriving (Show)

uMetre' :: Dužina -> Dužina
uMetre' l = case l of
    Milja m -> Metar (1609.344 * m)
    SvetlosnaSekunda s -> Metar (299792458 * s)
    x -> x

uMetre'' :: Dužina -> Dužina
uMetre'' l = case l of Milja m -> Metar (1609.344 * m)
                       SvetlosnaSekunda s -> Metar (299792458 * s)
                       x -> x

uMetre''' :: Dužina -> Dužina
uMetre''' l = case l of Milja m -> Metar (1609.344 * m); SvetlosnaSekunda s -> Metar (299792458 * s); x -> x

Možda isprava ne deluje kao mnogo korsina, ali case notacija može skratiti kôd.

Zadatak 1. Napisati funkciju moždaZbir :: Maybe [Maybe Int] -> Int koja sabira sve vrednosti različite od Nothing u datom možda-nizu možda-brojeva. Iskoristiti case notaciju

Pragme i ekstenzije

Pragma je instrukcija za Haskel kompajler smeštena u kodu. Pragme se zapisuju u obliku {-# WORD ... #-}, gde WORD označava tip pragme, nakon čega mogu slediti dodatne informacije. Neki od dostupnih, i najčešće prisutnih, tipova pragmi su:

  1. LANGUAGE omogućava ekstenzije Haskel jezika. O ekstenzijama ćemo govoriti u nastavku.
  2. OPTIONS_GHC prosleđuje opcije GHC kompajleru, koje bi se inače prosledile kao opcije pri pokretanju kompajlera.
  3. WARNING i DEPRECATED omogućava ispis poruka upozorenja prilikom kompilacije koda.
  4. MINIMAL određuje minimalan skup metoda koje instanca neke klase mora da implementira.
  5. INLINE i NOINLINE sugerišu kompajleru da (ne) izvrši određenu optimizaciju (inlining) prilikom generisanja izvršne datoteke.

Mi smo se u prošloj lekciji upoznali sa MINIMAL pragmom koju ćemo imati prilike ponekad da koristimo. Ostale pragme ćemo ređe koristiti, osim LANGUAGE pragme koja je verovatno najčešće korišćena pragma i koja je prisutna u gotovo svakom netrivijalnom Haskel projektu.

Ekstenzije

Pragma LANGUAGE omogućuje aktivaciju Haskel ekstenzije koja na neki način menja Haskel jezik. Ekstenzije mogu da dozvole određenu sintaksu koja nije validna bez te ekstenzije, da promene neku pojedinost sistema tipova, sistem modula, integraciju sa drugim jezicima, ili jednostavno da zabrane neke konstrukcije, itd.. Skup ekstenzija se menjao tokom decenija: nove ekstenzije su se dodavale i nastavljaju da se dodaju, neke ekstenzije su prevaziđene, a neke su postale deo standardnog jezika.

Pragma LANGUAGE mora se navesti na vrhu datoteke, pre dekleracije modula, i odnosi se samo na modul u kom je navedena:

{ -# LANGUAGE LambdaCase #-}
module Main where

...
Primer aktivacije ekstenzije LambdaCase. Ako se aktivira više ekstenzija, tada se može navesti više pragmi, ili se u jednu pragmu postaviti više imena ekstenzija razdvojenih zarezima.

Većina ekstenzija je van domašaja ove lekcije, ali za sada možemo razumeti naredne:

  1. BinaryLiterals dozvoljava pisanje celobrojnih literala u binarnom obliku. Ovakvi literali moraju se započeti sa 0b ili 0B. Na primer, literal 0b100 predstavlja \(4\).
  2. DuplicateRecordFields dozvoljava da različiti zapisi imaju polja sa istim imenom.
  3. HexFloatLiterals dozvoljava korišćenje heksadecimalnih brojeva sa zarezom: literal 0x0.1 predstavlja \(\frac{1}{16}\), literal 0x0.0F predstavlja \(\frac{15}{256}\), itd...
  4. InstanceSigs dozvoljava da se prilikom instanciranja klase navedu tipovi metoda.
  5. LambdaCase dozvoljava da se lambda izraz oblika \x -> case x of {p₁ -> e₁; ...; pₙ -> eₙ} zapiše kao \case {p₁ -> e₁; ...; pₙ -> eₙ}. Ovo je jedna od najkorišćenijih ekstenzija.
  6. MagicHash dozvoljava da se na kraju imena postavi znak #, i uvodi još neke oblike literala. Ova ekstenzija se najčešće koristi pri radu sa nekim low-level tipovima Haskel jezika.
  7. NoImplicitPrelude stopira implicitni uvoz Prelude modula. Ovo oslobađa prostor imena.
  8. NumericUnderscores dozvoljava zapis numeričkih literala sa znakom _ radi bolje čitljivosti. To omogućava da npr. vrednost milion zapiše kao 1_000_000. Ako je ova ekstenzija aktivirana, znak _ u numeričkom literalu se u potpunosti ignoriše od strne kompajlera.
  9. OverloadedRecordDot dozvoljava da se polju b nekog zapisa a pristupi sa a.b umesto sa b a. Ovakva sintaksa je analogna drugim jezicima gde se sa tačkom pristupa poljima nekog objekta (strukture).
  10. UnicodeSyntax Dozvoljava da se umesto ASCII nizova poput ::, ->. <-, =>, itd, koriste UTF simboli poput , , , , itd... Ovo omogućava zapise poput f ∷ Num a ⇒ a → Int.

Ekstenzije su moćan deo Haskel jezika koje omogućuju programerima da olakšaju programiranje, ali i da istraže nove pravce u kojima bi Haskel jezik mogao da se razvija u budućnosti. Sa velikom moći dolazi i velika odgovornost, te je stoga potrebno razmisliti o posledicama6 pre aktiviranja ekstenzija. U praksi, neke ekstenzije su često prisutne na projektima (npr. LambdaCase), dok se neke ekstenzije zaobilaze u širokom luku (npr. UnicodeSyntax).

Zadatak 2. Uključiti ekstenziju LambdaCase i ponovo definisati funkciju uMetre :: Dužina -> Dužina koristeći novu sintaksu.

Programiranje bez tačaka

Tehnika programiranja bez tačaka7 je stil pisanja programskog koda u kom se ne navode parametri funkcija. Ova tehnika je moguća zahvaljujući osobinama sintakse Haskela zbog čega je navodimo ovde (iako sama po sebi nije deo Haskel sintakse).

Suština programiranja bez tačaka leži u osobini da se lambda funkcija \x -> f x može zameniti sa funkcijom f. Zaista, funkcija koja primenjuje f na argument x i vraća dobijeni rezultat, ne razlikuje se od same funkcije f, i možemo ih smatrati istima8. Zamena \x -> f x naziva se eta konverzija.

Eta konverzija je jednostavan princip, ali u kodu često koristimo podudaranje oblika umesto lambda izraza, zbog čega prilika za eta redukcijom može biti skrivena "ispred nosa". Na primer, neka je data funkcija h :: Int -> Int -> Bool i neka je funkcija g :: Int -> Bool definisana sa g x = h 10 x. Ovakvo jednostavno podudaranje oblika nije ništa drugo nego skraćenica za definiciju sa lambda izrazom g = (\x -> h 10 x). Uzimajući u obzir da je h 10 x zapravo (h 10) x, vidimo da lambda izraz (\x -> (h 10) x) možemo eta konverzijom da svedemo na h 10. Samim tim originalna definicija funkcije g je postala sada g = h 10. Dakle, eta konverzija nam omogućuje "skraćivanje" najdesnijeg parametra u definicijama oblika g x₁ ... xₙ = IZRAZ xₙ, pod pretpostavkom da se xₙ ne pojavljuje u IZRAZ9.

Primer 4.

Funkciju zbir :: [Int] -> Int koja vraća zbir svih elemenata liste, možemo definisati:

zbir xs = foldr (+) 0 xs

"Skraćivanjem" parametra xs dobijamo kod

zbir = foldr (+) 0

Primetimo da u definiciji sum = foldr (+) 0 iz primera, ne pojavljuje se parametar funkcije sum. Stoga za ovu definiciju kažemo da je napisana u stilu bez tačaka. Izraz bez tačaka potiče iz matematike, gde se često za argument funkcije kaže tačka10 (iako možda ta funkcija nema veze sa geometrijskim tačkama). Kôd bez tačaka može biti elegantniji od "koda sa tačkama", ali ta elegantnost može vrlo lako da preraste u nečitljivost11. Ipak, dobro je razumeti ovu tehniku, jer je popularna među Haskel programerima i može se videti u svim projektima.

Primer 5.

Napišimo definiciju f x = 2 * x - 1 u stilu bez tačaka. Prvo možemo izdvojiti operator oduzimanja u prefiksnu poziciju čime dobijamo f x = (-) (2 * x) 1. Pošto je za eta konverziju neophodno da parametar bude smešten na desnoj strani izraza, iskoristićemo flip :: f -> a -> b -> b -> a kombinator koji obrće redosled parametara funkcija. Kombinator ćemo primeniti na funkciju (-), čime stižemo do definicije f x = flip (-) 1 (x * 2). Ako postavimo i * u prefiksnu poziciju dobijamo f x = flip (-) 1 ((*) 2 x). Vidimo da se ovde radi o kompoziciji funkcija flip (-) 1 i (*) 2 primenjenoj na x, pa je f x = ((flip (-) 1) . ((*) 2)) x. Prostom eta redukcijom stižemo do f = (flip (-) 1) . ((*) 2)

U ovom primeru vidimo da ne treba po svaku cenu pisati kod u stilu bez tačaka12.

Ironično, u stilu bez tačaka često se pojavljuje operator kompozicije koji se zapisuje sa tačkom. Ovo često buni početnike, te je dobro napomenuti da stil bez tačaka samo podrazumeva da se funkcije definišu bez navođenja parametara, a da ne podrazumeva ništa o upotrebi operatora kompozicije.

Pokušajmo sad da rešimo "problem" koji se često može sresti u Haskel kodu. Neka su date funkcije f' :: A -> B -> C i g' :: C -> D. U kodu nam je potrebna funkcija h = \x y -> g' (f' x y), i voleli bismo ovu funkciju napišemo u stilu bez tačaka. Prva ideja koja nam pada na pamet je da iskoristimo operator kompozicije . i definišemo h = g' . f'. Međutim dobićemo tipsku grešku čim ovako definišemo h. Razlog tome je što je kodomen funkcije f tip B -> C13. To znači da domen funkcije g' mora biti isto B -> C ako želimo izvršimo kompoziciju, što naravno nije za naš primer. Dakle, funkcije više promenljivih ne možemo uvek lako komponovati sa ..

Pri traženju definicije bez tačaka, uvek je dobro krenuti od definicije za koju znamo da je dobra h x y = g' (f' x y). Videli smo da h nije kompozicija g' i f', ali h x jeste kompozicija funkcija g' i f' x: h x y = (g' . (f' x)) y. Sada možemo izvršiti eta konverziju, čime dobijamo h x = g' . (f' x). Pomeranjem operatora kompozicije u prefiksnu poziciju dobijamo h x = (.) g' (f' x), što se može shvatiti kao primena funkcije (.) g' na f' x. Drugim rečima, ovde se radi o kompoziciji h x = (((.) g') . f') x koja se može eta konvertovati u h = ((.) g') . f'. Ovim smo stigli do definicije "kompozicije" f' i g' koja je u stilu bez tačaka. 14

Ovaj postupak možda želimo da proizvoljne funkcije f :: a -> b -> c i g :: c -> d, a ne samo određene f' i g'. Stoga definišemo kombinator comp_1_2 :: (c -> d) -> (a -> b -> c) -> (a -> b -> d) sa comp_1_2 g f = ((.) g) . f. Jasno, za funkciju h iz prethodnog paragrafa važi h = comp_1_2 g' f'. Međutim, definicija comp_1_2 više nije u stilu bez tačaka. Stoga transformišimo i ovu definiciju u stil bez tačaka. Prebacivanjem operatora kompozicije u prefiksnu poziciju od comp_1_2 g f = ((.) g) . f dobijamo comp_1_2 g f = (.) ((.) g) f, odakle eta konverzijom dobijamo comp_1_2 g = (.) ((.) g). Ovde imamo kompoziciju dve (.) funkcije, odnosno comp_1_2 g = ((.) . (.)) g. Poslednjom eta konverzijom dobijamo comp_1_2 = (.) . (.).

Zadatak 3. Napisati funkciju koja računa aritmetičku sredinu dva broja u stilu bez tačaka.

Zadatak 4. U stilu bez tačaka dati definiciju kombinatora koji vrši kompoziciju jedne unarne funkcije i jedne ternarne funkcije comp_1_3 ::(d -> e) -> (a -> b -> c -> d) -> (a -> b -> c -> e)

Zadatak 5. U stilu bez tačaka dati definiciju kombinatora koji vrši kompoziciju tri unarne funkcije comp_1_1_1 :: (c -> d) -> (b -> c) -> (a -> b) -> (a -> d).