Haskell 98 Puhdasta funktionalismia nonstriktissä paketissa Antti-Juhani Kaijanaho
Haskell 98: Puhdasta funktionalismia nonstriktissä paketissa Antti-Juhani Kaijanaho Copyright 1999 Antti-Juhani Kaijanaho Tätä esitystä saadaan levittää ja kopioida vapaasti. Muutettuja versioita saadaan myös levittää ja kopioida vapaasti, kunhan sekä alkuperäisen että muutetun tekstin tekijät mainitaan asianmukaisesti, ja muutettuja versioita ei voida erehdyksessä luulla alkuperäiseksi esitykseksi. Esitys on julkaistu Internetissä. (http://www.iki.fi/gaia/tekstit/ohjsem99/)
Luku 1 Johdanto Haskell on nonstrikti, puhtaasti funktionaalinen ohjelmointikieli (non-strict purely functional programming language). Tunnetuista ohjelmointikielistä sitä lähimpänä lienevät Lisp-sukuiset kielet, vaikka eroavaisuuksia onkin paljon. Haskell on hyvin erilainen kuin C, Pascal, C++ tai Java. Haskellissa voidaan esimerkiksi manipuloida funktioita ja aktioita (esimerkiksi merkin lukeminen tiedostosta, ikkunan avaaminen ja kahvin keittäminen) täysivaltaisina arvoina. Haskellissa on mahdollista periä rajapintoja vaan ei tyyppejä. Tyypit voivat olla rekursiivisia ja tietorakenteet äärettömiä; nonstriktiyden ansiosta vain tarpeellinen (useimmiten äärellinen) osa tietorakenteesta joudutaan todella konstruoimaan muistiin. Muuttujia ei ole; sen sijaan on olemassa funktiot (myös nollan argumentin funktiot eli vakiot) ja niiden parametrit. Silmukointirakenteita ei ole, vaan käytetään rekursiota, joka ei aina edes tarvitse päättymisehtoa! Onko Haskell sitten toivottoman hidas ja muistisyöppö? Nykyiset kääntäjät kykenevät tuottamaan koodia, joka on riittävän nopeaa melkein kaikkiin tarkoituksiin. Vain kovat reaaliaikasovellukset vaativat liikaa Haskellilta. Muistia Haskell-ohjelma vie enemmän kuin vastaava tyypillinen käsin koodattu C-ohjelma, mutta useimmissa tapauksissa sillä ei ole käytännön merkitystä. Joskus jopa nonstriksti laskenta voi vähentää muistin kulutusta, kun koko välitulosta ei tarvitse pitää muistissa. Haskell luotiin vonna 1987 yhtenäistämään nonstriktien puhtaiden funktionaalisten kielten joukot. Tuolloin nimittäin joka tutkijalla oli oma kielensä ja yhteisen sävelen löytäminen oli hankalaa. Nimi valittiin loogikko Haskell B. Curryn kunniaksi. Tavoite onnistui, ja kieli kehittyi huimaa vauhtia ottaen mukaan muutamia tutkimuksen viimeisimpiä tuloksia 1. Vuonna 1997 päätettiin luoda vakaa versio kielestä, Haskell 98, joka säilyisi samana ja joka toteutettaisiin laajasti, samalla kun tutkimusja kehitystyö keskittyy uuteen Haskell 2:een. Tarkoituksena oli, että Haskell 98 olisi standardoitu kieli, jota käyttäjät uskaltaisivat käyttää ilman muuttuvan kielen aiheuttamia murheita. Haskell 98 valmistui lopulta keväällä 1999 ja kolme neljästä tärkeimmästä Haskell-toteutuksesta tukevat sitä olennaisin osin. Haskell ei ole oliokieli, joten alityyppien luominen perimällä ei ole mahdollista. Sen sijaan monet asiat, jotka voidaan toteuttaa olioilla, voidaan kirjoittaa suunnilleen yhtä helposti käyttäen hyväksi Haskellin parametrisoitua polymorfismia, tyyppiluokkien perintää ja Haskell 98 -standardiin kuulumatonta mutta yleisesti toteutettua toisen kertaluvun polymorfismia ja sen tuomia eksistentialistisesti parametrisoituja tyyppejä. Toisaalta on olemassa kieli nimeltä O Haskell (http://www.cs.chalmers.se/~nordland/ohaskell/) sekä sen toteutus O Hugs, jotka laajentavat Haskellia niin, että olio-ohjelmointikin on mahdollista. Haskellista on olemassa neljä tärkeää toteutusta: Glasgow Haskell Compiler (GHC, saatavissa verkosta (http://www.haskell.org/ghc/)), Hugs (alunperin lyhenne sanoista Haskell Users Gofer System, myös saatavissa verkosta (http://www.haskell.org/hugs/)), Nearly a Haskell Compiler (NHC, saatavissa verkosta (http://www.cs.york.ac.uk/fp/nhc98/)) ja Haskell-B Compiler (HBC, saatavissa 1 Esimerkiksi monadinen I/O on alle kymmenen vuotta vanha tekniikka, ja sen ottivat ensimmäisten joukossa käyttöön Haskell-kielen kehittäjät. 3
Luku 1 Johdanto verkosta (http://www.cs.chalmers.se/~augustss/hbc/hbc.html)). Näistä kolme ensimmäistä tukevat Haskell 98:aa. Viimeksi mainittu on vielä vanhemman Haskell 1.4:n toteutus. Toteutuksista Hugs on kielen opiskeluun sekä nopeaan kehitystyöhön tarkoitettu tulkki, muut ovat optimoivia kääntäjiä: NHC on optimoitu minimoimaan tilavaatimukset (sekä ohjelmatiedoston koon että työmuistin osalta), GHC puolestaan optimoi lähinnä nopeutta. HBC on ainoa toteutus, joka tukee Unicode-merkistöä, kuten kielen määrityksen mukaan kuuluisi (muut käyttävät edelleen Latin 1 -merkistöä). Kaikki neljä toteutusta ovat vapaita ohjelmia. Tämä esitys perustuu määrityksen (http://www.haskell.org/definition) mukaiseen Haskell 98:een. Kaikkien täydellisten esimerkkiohjelmien on tarkoitus toimia kaikissa Haskell 98 -toteutuksissa. Osittaiset esimerkkiohjelmat toimivat sellaisenaan Hugs-tulkissa, jossa pääohjelma ei ole koskaan pakollinen. Ohjelmat on testattu vain Hugsissa, ja kun ohjelmointiympäristöä käsitellään, puhutaan vain Hugsista. Haskell-aiheista lukemista löytyy ihan kohtuudella. Kielen määrittelevät raportit Haskell 98: A Non-strict, Purely Functional Language ja Standard Libraries for Haskell 98 (toimittaneet Simon Peyton Jones ja John Hughes) ovat saatavilla verkosta (http://www.haskell.org/definition). Ne eivät kuitenkaan sovellu kielen opetteluun. Kohtuullinen opus opettelemista ajatellen on A Gentle Introduction to Haskell (http://www.haskell.org/tutorial/) (Paul Hudak, John Peterson ja Joseph H. Fasel). Erittäin hyvä johdatus funktionaaliseen ohjelmointiin on Richard Birdin ja Philip Wadlerin kirja Introduction to Functional Programming using Haskell, joka on uudistettu laitos myös erinomaisesta mutta jo hieman vanhasta samojen tekijöiden kirjasta Introduction to Functional Programming. Viimeksi mainittu teos löytyy Mattilanniemen kirjastosta. Kattava kirjallisuusluettelo (http://www.haskell.org/bookshelf/) löytyy Haskell-sivuston kautta. 4
Luku 2 Hello World Tässä on Haskell-kielellä kirjoitettu Hello World: {- hello.hs -} main :: IO () main = putstrln "Hello World!" Ohjelman voi ajaa kirjoittamalla komentoriville runhugs hello.hs (runhugs on Hugsin sellainen versio, joka lukee ohjelman sisäänsä ja ajaa sen suoraan; tällöin ei päädytä Hugsin normaaiin interaktiiviseen tilaan). Nyt siis pitäisi ruudulla näyttää jotakuinkin tältä: % runhugs hello.hs Hello World! % Vaihtoehtoisesti tämän ohjelman voi ladata interaktiiviseen Hugs-tulkkiin sanomalla hugs hello.hs Esiin tulee paljon tekstiä ja lopulta kehoite, johon pitää kirjoittaa main. Siihen voisi kirjoittaa minkä tahansa funktionnimen (tai itse asiassa minkä tahansa lausekkeen), mutta tiedostomme hello.hs määrittelee vain yhden: main. Sanomme siis main. Lopulta voi kirjoittaa :q, jolloin pääsee pois tulkista. Tämän kaiken pitäisi näyttää suurin piirtein tältä: % hugs hello.hs Hugs 98: Based on the Haskell 98 standard Copyright (c) 1994-1999 -- World Wide Web: http://haskell.org/hugs Report bugs to: hugs-bugs@haskell.org Version: May 1999 Haskell 98 mode: Restart with command line option -98 to enable extensions Reading file "/usr/share/hugs98/lib/prelude.hs": Reading file "hello.hs": Hugs session for: /usr/share/hugs98/lib/prelude.hs hello.hs Type :? for help Main> main Hello World! Main> :q [Leaving Hugs] % 5
Luku 3 Funktionaalisesta ohjelmoinnista Funktionaalisessa ohjelmointityylissä tärkeä abstraktiokeino on sellaisten funktioiden rakentaminen ja käyttö, jotka operoivat funktioilla. On myös olennaisen tärkeää osata käyttää hyväksi nonstriktiyttä, joka mahdollistaa aika yllättävienkin ongelmien jakamisen osiin. Tarkastelemme muutamaa esimerkkiongelmaa, jossa sekä Haskell-kielen että funktionaalisen tyylin perusteet tulevat hyvin esille. Summa, tulo ja foldr Aloitetaan yksinkertaisella ongelmalla: On annettu lista lukuja. Laske niiden summa. Imperatiivisesti ajatteleva ohjelmoija saattaisi ajatella for-silmukkaa. Matemaatikko puolestaan määrittelisi tämän Σ-funktion rekursiivisesti. Haskellissa ei for-silmukoita ole, joten ainoaksi vaihtoehdoksi jää matemaatikon rekursiivinen versio. Tämä ei ole niin paha asia kuin rekursion pahuuteen tottuneen imperaatikon korvin kuulostaa: nonstrikti laskenta ja häntärekursion poisto muuttavat tilannetta olennaisesti. Esimerkki 3-1 Summausfunktio {- sum.hs -} sum [] = 0 sum (x:xs) = x + sum xs Summafunktio vie vain kaksi riviä koodia (katso Esimerkki 3-1). Ensimmäisellä rivillä määritellään rekursion päättymisehto: tyhjän listan summa on nolla. Toinen rivi on funktion yleinen tapaus. Se sanoo, että laskeaksesi epätyhjän listan summan laske yhteen listan ensimmäinen alkio ja lopun listan summa. Haskellissa funktio määritellään rajoitettujen yhtälöiden avulla. Yhtälön oikea puoli voi olla mikä tahansa lauseke, mutta vasemmalle puolelle on asetettu rajoituksia, jottei kääntäjän tarvitsisi toimia myös yleisten yhtälöiden ratkaisukoneena. Vasen puoli tulkitaan monimutkaisten pattern matching -sääntöjen avulla. Lyhyesti sanoen vasemman puolen tulee sisältää määriteltävän funktion nimi sekä sen parametrien kuvaukset. Yleensä funktion nimi on koko vasemman puolen ensimmäinen asia ja sen jälkeen tulevat parametrit, yksi kerrallaan. Parametrin paikalle voidaan laittaa joko muuttuja tai konstruktorilauseke. Edellä summausfunktion määritelmässä [] on tyhjää listaa esittävä listakonstruktori, ja x : xs on konstruktorilauseke, joka liittää muuttujan x arvoksi argumenttina annetun listan ensimmäisen alkion ja muuttujan xs arvoksi listan loppuosan. Varsinainen konstruktori tässä on lausekkeen keskellä oleva kaksoispiste (nimeltään cons), joka jäsennetään kuin se olisi infix-operaattori. Sen "parametrit"ovat tässä muuttujia, mutta ne voisivat ihan yhtä hyvin olla toisia konstruktorilausekkeita. Sulut ovat tarpeen, koska cons sitoo heikommin kuin funktion parametrien esittelyoperaattori (joka on pelkkää tyhjää tilaa). Kuten havaitaan, argumenttiarvot voidaan ikään kuin dekonstruoida jo parametrinvälityksessä: koko parametrille ei tarvitse antaa nimeä, jos tarvitaan vain sen osia. Jos argumentti ei täsmää 6
Luku 3 Funktionaalisesta ohjelmoinnista parametrilistan kanssa, yritetään seuraavaa saman funktion määrittävää yhtälöä: ensimmäinen, jola täsmää, on se, jota käytetään. Huomaa: Yhtälön oikealla puolella konstruktoreita voidaan käyttää luomaan uusia olioita. Siinä missä x : xs purkaa vasemmalla puolella listan palasiksi, kokoaa oikealla puolella 5 : [] uuden listan laittamalla tyhjän listan alkuun viitosen. 1 Periaatteessa siis viiden kokonaisluvun listan voisi kirjoittaa 5 : 4 : 3 : 2 : 1 : [], koska cons sitoo oikealta vasemmalle, mutta parempi lienee kuitenkin käyttää yksinkertaisempaa kielioppimakeista [5, 4, 3, 2, 1]. Seuraava ongelma on listan lukujen kertominen keskenään. Innokkaasti voisi joku kirjoittaa leikkaa-liimaa-korvaa-tekniikalla sum -funktiota muistuttavan toteutuksen (ks. Esimerkki 3-2). Esimerkki 3-2 Tulofunktio {- prod.hs -} prod [] = 1 prod (x:xs) = x * prod xs Tähän liittyy kuitenkin hyvin tunnettuja ylläpito-ongelmia. Niinpä olisikin parempi jollakin tavalla abstrahoida summa- ja tulofunktioille yhteinen rekursiokaava ja käyttää sitä näiden funktioiden kirjoittamiseen. Ratkaisu on kirjoittaa funktio, joka ottaa parametrinaan itse listan lisäksi tyhjän listan kuvan sekä rekursiotapauksessa käytettävän operaattorin. Esimerkki 3-3 määrittelee tällaisen funktion, jonka perinteinen nimi on foldr. Esimerkki 3-3 Funktio foldr {- foldr.hs -} foldr _ e [] = e foldr op e (x:xs) = x op foldr op e xs Funktion nimi tulee englannin kielen sanoista fold right. Tämä viittaa siihen, että funktio ikäänkuin laskostaa listan niin, että ennen niin pitkästä listasta tulee yksi naseva tulos, ja siihen, että jos rekursio kirjoitetaan auki, niin sulut kasautuvat lausekkeen oikeaan reunaan. On myös olemassa funktio foldl, joka toimii muuten samoin paitsi että se kasaa sulut vasempaan reunaan. Lisäksi foldl on häntärekursiivinen, mikä saattaa joissakin tilanteissa pienentää ohjelman tilavaatimusta. Huomaa: Funktioiden sum, prod ja foldr nimissä todella on heittomerkki. Se siis on osa näiden funktioiden nimeä. Näin siksi, että kielen standardikirjastoon kuuluu jo funktiot sum ja foldr, joiden nimet menisivät näin päällekäin itsestäänselvien nimiehdokkaiden kanssa. Siispä esimerkkifunktioille täytyy valita jokin toinen nimi, ja heittomerkin lisääminen funktion nimen perään on tällaisissa tapauksissa melko tavallinen ratkaisu. 1 Koska lista yleensä toteutetaan yhteen suuntaan linkittämällä, on cons nopea operaatio (O(1)). 7
Luku 3 Funktionaalisesta ohjelmoinnista Edellisessä esimerkissä näkyy eräs Haskellin mukava piirre: jokainen kahden muuttujan funktio voidaan muuttaa binääriseksi operaattoriksi laittamalla funktion nimi graaviaksenttien ("takahipsujen") sisään. Vastaavasti voidaan mikä tahansa binäärinen operaattori muuttaa kahden muuttujan funktioksi laittamalla se yksinään sulkeiden sisään (ks. Esimerkki 3-4). Tässä mielessä funktiot ja operaattorit ovat aivan sama asia. Vertaapa muuten mainitun esimerkin summa- ja tulofunktioiden määrittelyjä aiempiin: tässä esimerkissä ei ole yhtään ylimääräisyyksiä! Esimerkki 3-4 Funktiot sum ja prod toteutettuna foldr:lla {- sumprod.hs -} sum = foldr (+) 0 prod = foldr (*) 1 Funktiota foldr sanotaan korkean kertaluvun funktioksi, koska se operoi funktioilla (tässä tapauksessa yhteenlasku- tai kerto-opreraattorilla). korkean kertaluvun funktiot ovat funktionaalisen ohjelmoinnin kulmakivi, ja niitä kannattaa opetella käyttämään. Esimerkiksi Esimerkki 3-5 määrittelee erään toisen korkean kertaluvun funktion, ja Kuva 3-1 kokoaa muutamien standardikirjastoon kuuluvien funktioiden määrittelyjä. Määrittelyjen lukeminen ja niiden ymmärtäminen on hyvä harjoitustehtävä, erityisesti pisteoperaattori ja funktio map olisi hyvä ymmärtää kunnolla. Esimerkki 3-5 Funktio filter {- filter.hs -} filter _ [] = [] filter p (x:xs) p x = x : filter p xs otherwise = filter p xs Funktion filter määrittelyssä esiintyy eräs uusi kielirakenne. Määrittelevän yhtälön oikea puoli voidaan valita mielivaltaisten loogisten väittämien avulla. Väittämät käydään läpi järjestyksessä, ja ensimmäisen väitteen, jonka totuusarvo on tosi, mukainen oikea puoli valitaan. (Nollan muuttujan funktio otherwise palauttaa aina arvon tosi.) Jokaisen väitteen eteen laitetaan tässä rakenteessa pystyviiva. Tämä rakenne on olennaisesti jonkinlainen if-then-elseif-elseif-else, ja usein ohjelmat ovat helppolukuisempia, kun tätä rakennetta käytetään iffin (joka kielestä silti löytyy) sijasta. Funktio filter kuuluu standardikirjastoon. Haskellissa on voimassa ns. asemointisääntö (layout rule eli offside rule), jonka vuoksi täytyy ohjelmaa kirjoittaessa olla tarkkana siitä, mistä kohtaa riviä mikin ohjelmarivi alkaa. Nyrkkisäännöllä "enemmän oikealle jatkaa edellistä alikohtaa, sama kohta aloittaa uuden alikohdan, takaisin vasemmalle lopettaa"pärjää jonkin aikaa, mutta vakavasti Haskellista kiinnostuneen on parasta tutustua kielen määrittelyn luvun 2.7. antamaan tarkkaan kuvaukseen. Esimerkiksi funktion filter määrittelyssä alikohtia ovat väittämä oikea puoli -parit, joten ne kaikki sisennetään yhtä paljon oikealle funktion määrittelyn alusta alkaen. 8
Luku 3 Funktionaalisesta ohjelmoinnista Kuva 3-1 Standardikirjaston (prelude) korkean kertaluvun funktioita {- prelhofs.hs -} (.) f g x = f (g x) {- käytetään: (f. g) x -} flip f x y = f y x curry f x y = f (x,y) {- (, ) on parin konstruktori -} uncurry f (x,y) = f x y until p f x p x = x otherwise = until p f (f x) map _ [] = [] map f (x:xs) = f x : map f xs Huomaa: Funktion määrittelevän yhtälön vasemmalla puolella alaviiva täsmää mihinkä tahansa, mutta siihen ei voi viitata yhtälön oikealla puolella. Sitä on siis hyvä käyttää silloin, kun jonkin parametrin arvo on täysin yhdentekevä. Eratostheneen seula Seuraava ongelma liittyy alkulukuihin: Toteuta Eratostheneen seula, ja kirjoita funktio, joka palauttaa kaikki annettua lukua pienemmät alkuluvut, sekä funktio, joka palauttaa halutun monta ensimmäistä alkulukua. Olisi toivottavaa, että funktiot käyttävät yhteistä seulan toteutusta, jottei leikkaa-liimaa-syndrooma alkaisi vaivaamaan. Osoittautuu, että nonstriktiyden ansiosta tämä on helppoa. Haskell on nonstrikti kieli: se laskee vain sen, mitä se välttämättä tarvitsee. Esimerkiksi C lähtee laskemaan lausekkeen arvoa jäsennyspuun lehdistä käsin: vapaasti kerrottuna se laskee ensin sulkulausekkeet, sitten kertolaskut ja lopulta yhteenlaskut. Samoin C aina laskee funktion argumentit valmiiksi ennen kuin funktiota edes kutsutaan. Haskell sen sijaan lähtee jäsennyspuun juuresta. Se huomaa ensin yhteenlaskut ja toteaa tarvitseansa yhteenlaskun argumenttien arvoja; tällöin se rupeaa laskemaan niitä. Jos Haskell joskus huomaa, että se tietää jo tarpeeksi voidakseen laskea koko lausekkeen arvon, se jättää laskematta kaiken sen, mitä se ei ole siitä lausekkeesta vielä laskenut. C:ssä nonstriktiyttä tavataan kaiketi vain loogisten konnektiivien yhteydessä: jos ja-lausekkeen vasen puoli on epätosi, ei oikeaan puoleen edes katsota. Haskell toimii tällä lailla kaikkialla. Esimerkiksi, jos Haskellissa kutsutaan vakiofunktiota, ei funktion parametria koskaan lasketa. Tästä on paljon iloa, esimerkiksi äärettömät tietorakenteet käyvät mahdollisiksi. Triviaali esimerkki äärettömästä tietorakenteesta on loputon ykkösten jono ones: 9
Luku 3 Funktionaalisesta ohjelmoinnista {- ones.hs -} ones = 1 : ones Tätä lienee helpointa ajatella nollan funktion rekursiivisena funktiona, josta puuttuu päättymisehto. Se siis tuottaa, kuten sanottu, loputtoman ykkösten jonon. Huomattava on, että koska ones ei ota yhtään argumenttia ja koska Haskellissa funktioilla ei ole sivuvaikutuksia, voidaan koko sen tuottama lista esittää koneen sisällä äärellisenä, vakiokokoisena syklinä. Jos joku erehtyy pyytämään funktion ones arvoa, koko ohjelma ei välttämättä juutu umpiluuppiin. Nonstriktiys tarkoittaa, että vain välttämätön lasketaan (tai vaikka laskettaisiinkin enemmän, niin ylimääräisyyksiä ei huomioida). Jos siis joku pyytää tuon äärettömän listan viittä ensimmäistä alkiota, ei loppulistaa yritetä laskea. Näin ollen lauseke take 5 ones (funktion take toteuttaminen on harjoitustehtävä) palauttaa listan [1,1,1,1,1]. Sen sijaan listan viimeistä alkiota etsivä last jäisi tätä listaa käsitellessään etsimään omaa häntäänsä vaikka maailmanloppuun asti. Kokeile vaikka! Äärettömiä listoja konstruoi myös standardifunktio iterate, joka iteroi annettua funktiota annetulla alkuarvolla loputtomiin. Funktion määrittelee Esimerkki 3-6. Määritelmässä on huomattava uusi where-avainsana, jolla voidaan sitä edeltävään lausekkeeseen määritellä lokaaleja funktioita. Avainsanan jälkeen tulee yksi tai useampia funktiomäärittelyjä, jotka voivat olla keskenään rekursiivisia. Esimerkki 3-6 Funktio iterate {- iterate.hs -} iterate f x = xs where xs = x : map f xs Palataanpa alkulukuongelmaan. Nonstriktiä laskentaa ja äärettömiä listoja voi käyttää tässä aika jännällä tavalla hyväksi: konstruoidaan ensin lista kaikista alkuluvuista ja otetaan siitä vain se osa, mitä tarvitaan. Jos alkulukulistan konstruoi nollan muuttujan funktio alkuluvut, niin edellä esitettyihin kysymyksiin "mitkä ovat n ensimmäistä alkulukua"ja "mitkä ovat n:ää pienemmät alkuluvut"vastaavat lausekkeet take n alkuluvut ja takewhile (<n) alkuluvut. (Funktion takewhile määritteleminen on harjoitustehtävä.) Huomaa: Funktion viimeisen argumentin voi aina jättää pois (kunhan laittaa syntyneen lausekkeen sulkeiden sisään). Tällaisen lausekkeen arvo on yhden muuttujan funktio. Tämä uusi funktio palauttaa sen, minkä alkuperäinen funktio olisi palauttanut, jos argumentti laitettaisiin listan viimeiseksi. Toisin sanoen: (f a b c) d == f a b c d. Vastaavasti voidaan operaattoria leikata antamalla sille vain toinen operandi ja laittamalla tämä koko lause sulkeiden sisään. Tuloksena on jälleen yhden muuttujan funktio. Esimerkiksi (2*) on funktio, joka ottaa yhden parametrin ja joka palauttaa tämän parametrin kaksinkertaisena; siis (2*) 5 == 10. Jäljelle jää nyt vain itse alkulukujonon konstruointi, minkä tekee Esimerkki 3-7. Merkintä [2..] tarkoittaa ääretöntä listaa kaikista kokonaisluvuista kakkosesta ylöspäin. 10
Luku 3 Funktionaalisesta ohjelmoinnista Esimerkki 3-7 Alkulukujonon konstruointi {- primes.hs -} alkuluvut = map hd (iterate seula [2..]) where hd (x:_) = x seula (p:xs) = filter (not. (p jakaa )) xs p jakaa q = q mod p == 0 Esimerkin lukemista auttanee Eratostheneen seulan perusperiaatteen muistaminen: otetaan luettelo kaikista kokonaisluvuista kakkosesta ylöspäin, tiputetaan siitä ensin pois kakkosen monikerrat, sitten kolmosen monikerrat, ja niin edelleen tiputtamalla lopulta kaikki alkulukujen monikerrat pois. Lopputulos on alkulukujen luettelo. 11
Luku 4 Tyypeistä Haskellin tietotyyppijärjestelmä on rikas. Se on huomattavasti C:n tai Pascalin tyyppijärjestelmää ilmaisuvoimaisempi ja (yhdessä Haskellin tyyppiluokkien kanssa) pystyy ilmaisemaan monet olio-ohjelmoinnin tyyppikonstruktiot jopa ilman täydellistä oliotukea. Haskell on pääosin staattisesti tyypitetty kieli, joka hallitsee myös hallitun funktioiden kuormituksen. Tässä luvussa tarkastellaan esimerkein Haskellin tyyppijärjestelmän peruspiirteitä. Haskellissa kaikki valmiiksi määritellyt tyypit kokonaisluvut, merkit, listat, merkkijonot, totuusarvot, parit, änniköt, rationaaliluvut, liukuluvut, kompleksiluvut, mielivaltaisen suuret kokonaisluvut, taulukot, funktiot ja niin edelleen voidaan teoriassa konstruoida kielen keinoin, vaikka monet niistä varmasti toteutetaankin primitiivityyppeinä tehokkuuden vuoksi. Osalla niistä on erityissyntaksia, joita ei voi toteuttaa kielen keinoin, mutta mikään niistä ei lisää kielen ilmaisuvoimaa: kaikki asiat voidaan toteuttaa ilmankin tällaisia kielioppimakeisia. Kaiken pohjalla on periaatteessa enumeraatiotyypit. Esimerkiksi totuusarvojen tyyppi määritellään seuraavasti: data Bool = True False Uusien tietotyyppien määritelmä alkaa avainsanalla data, jota seuraa tietotyypin nimi (joka alkaa aina isolla kirjaimella) ja yhtäsuuruusmerkki. Oikealla puolella luetellaan tyypin konstruktorit (tässä tapauksessa vakioarvojen nimet), jotka kirjoitetaan aina isolla alkukirjaimella. Konstruktoreiden väliin laitetaan pystyviiva. Konstruktorien käyttöä kuvannee hyvin Esimerkki 4-1, jossa määritellään eräs tunnettu looginen konnektiivi. Esimerkki 4-1 Looginen konnektiivi and {- andornot.hs -} and True True = True and True False = False and False True = False and False False = False Kuten totuusarvojen tyyppi, myös kokonaislukujen tyyppi Int ja merkkien tyyppi Char voidaan toteuttaa luettelemalla kaikki lailliset arvot (konstruktorit). Nyt vain sattuu olemaan niin, että nämä konstruktorit kirjoitetaan epätavalliseen tapaan: 42 ja p. Listatyyppiä ei voi määritellä edellä esitettyyn tapaan. Lista on polymorfinen astia: se sisältää useita tietoja, jotka ovat samantyyppisiä, mutta eri listoissa voi tämä tyyppi olla eri. Tätä varten tyypin nimen jälkeen kirjoitetaan tyyppimäärittelyssä yksi tai useampiatyyppiparametreja (pienellä kirjaimella alkava nimi); myös konstruktorien jälkeen voidaan kirjoittaa nolla tai useampi tyypin nimen yhteydessä mainittu parametri: data List a = Nil Cons a (List a) Tässä tyypin ja konstruktorien nimet on valittu tavalliseen tapaan. Sisäänrakennettu listatyyppi käyttää samoista asioista nimiä [a] (listatyyppi, List a), [] (tyhjän listan konstruktori, Nil) ja a 12
Luku 4 Tyypeistä : [a] (cons-konstruktori, Cons a [a]). Parametrisoidun tyypin konkreettinen esiintymä kirjoitetaan tyyliin List Char, (eli tässä tapauksessa myös [Char]). Tällaisen tyypin parametrisoituja konstruktoreita myös käytetään niin, että parametrin tilalle laitetaan parametria vastavan tyypin arvo, esimerkiksi Cons a Nil (ts. a : []). Merkkijonot ovat yksinkertaisesti merkkilistoja, joten ei tarvita erillistä merkkijonotyyppiä. Siitä huolimatta on kiva käyttää siitä nimeä String, joten määritellään tyyppisynonyymi: type String = [Char] Huomaa uusi avainsana. Jokaisen lausekkeen ja jokaisen funktion tyyppi voidaa kirjoittaa ohjelmaan näkyviin. Joskus se on välttämätöntä, ja funktioiden tapauksessa se on yleensä hyvä idea vaikkapa vain varmistamaan, että funktion tyyppi on se, minkä ohjelmoija kuvittelee sen olevan (sillä tulkki tai kääntäjä valittaa, jos funktion esitelty tyyppi ja todellinen tyyppi eivät ole yhteensopivia). Lausekkeen tyyppi esitellään laittamalla tyyppi lausekkeen perään kahden kaksoipisteen jälkeen. On varminta pistää lauseke tätä ennen sulkeisiin, vaikka se ei kaikissa tilanteissa olekaan välttämätöntä. Funktion tyyppi esitellään suurin piirtein samaan tapaan; tosin kahden kaksoispisteen eteen laitetaan vain funktion nimi. Yhden muuttujan funktion tyyppi kirjoitetaan A -> B, kuten matematiikassakin. Tässä A on argumentin tyyppi ja B on paluuarvon tyyppi. Koska kahden muuttujan funktio on oikeastaan vain yhden muuttujan funktio, joka palauttaa yhden muuttujan funktion (ts. pätee f x y = (f x) y), voidaan kahden muuttujan funktion tyyppi kirjoittaa A -> (B -> C). Tavallisesti juuri samasta syystä on tapana jättää tästä sulkeet pois, joten A -> B -> C on kahden muuttujan funktion tyyppi. Tästä saadaan induktiivisesti kolmen muuttujan funktion tyyppi A -> B -> C -> D ja neljän muuttujan funktion tyyppi A -> B -> C -> D -> E ja niin edelleen. Jos funktio ottaa funktioparametrin, laitetaan parametrifunktion tyyppi sulkeisiin (joita ei voi jättää pois), esimerkiksi näin: Char -> (Char -> String) -> String, joka kuvaa kahden muuttujan funktiota, jonka ensimmäinen parametri on merkki ja toinen parameri on merkin merkkijonoksi muuttava funktio ja joka palauttaa merkkijonon. Funktion tai lausekkeen tyyppi voi olla polymorfinen; tällöin tyypinesittelyssä esiintyy tyyppinimien ja -lausekkeiden seassa pienellä alkukirjaimella kirjoitettuja tyyppiparametreja. Tällöin funktion aktuaalinen tyyppi annetussa tilanteessa riippuu siitä, minkätyyppiset parametrit sille annetaan ja mitä sen halutaan siinä paikassa palauttavan. Tällöin kukin tyyppiparametri saa yhden konkreettisen tyyppiarvon ja nämä arvot ovat voimassa koko tyyppikuvauksessa siinä tilanteessa. Jos esimerkiksi funktion id tyyppi on a -> a, ja sille annetaan parametriksi kokonaisluku, on funktion tämän ilmentymän tyyppi Integer -> Integer. Samassa ohjelmassa ja mahdollisesti jopa samassa lausekkeessa saatetaan samaa funktiota kutsua yhteydessä, jossa se voi palauttaa vain merkkijonoja; tässä tilanteessa sen ilmentymän tyyppi on String -> String. Parametrisoidut tyypit ja polymorfiset funktiot Haskellissa muistuttavat mm. C++:n template-tyyppejä ja -funktioita, ja näiden muistaminen voi auttaa Haskellin ymmärtämistä. Polymorfiset funktiot ja lausekkeet eivät välttämättä kelpuuta mitä tahansa tyyppiä tyyppiparametrinsa arvoksi: esimerkiksi voi olla välttämätöntä, että parametrityypin arvoja täytyy voida vertailla. Tämä vaatimus esitetään tyyppikuvauksessa laittamalla sen alkuun rajoitelauseke ja tämän perään =>. Rajoitelauseke voi koostua vain yhdestä rajoitteesta, tai sitten sulkeiden sisään 13
Luku 4 Tyypeistä laitetusta jonosta useita rajoitteita, jotka on erotettu pilkuilla. Kukin rajoite on muotoa Ord a, missä Ord on rajoitteen tyyppiluokka ja a on se tyyppiparametri, jota rajoite koskee. Esimerkiksi Eq a => tarkoittaa, että a:n arvojen yhtäsuuuruutta pitää voida verrata (==)-operaattorilla; vastaavasti (Ord a, Ord b) => tarkoittaa, että sekä a:n että b:n arvoja pitää voida verrata erisuuruusoperaattoreilla (<) yms. Esimerkki 4-2 Binäärinen hakupuu Seuraavassa pätkässä on kirjoitettu Haskellia literate programming -tyyliin, jossa oletuksena kaikki on kommenttia ja jokainen koodirivi täytyy aloittaa vasemmassa reunassa olevalla suurempi kuin -merkillä. {- bintree.lhs -} Binäärinen hakupuu on parametrisoitu rekursiivinen tietotyyppi. Puu voi olla tyhjä. Toisaalta se voi sisältää jonkin tiedonpalasen sekä kaksi alipuuta. > data BinaryTree a = EmptyTree > NonEmptyTree a (BinaryTree a) (BinaryTree a) > deriving (Show) Määrittelyn lopussa olevasta deriving-lauseesta ei kannata välittää, sillä se on mukana vain varmistamassa, että Hugs osaa tulostaa ruudulle tarvittaessa binääripuun. Binäärisen hakupuun syvyys määritellään seuraavasti: - tyhjän puun syvyys on 0 - epätyhjän puun syvyys on yksi enemmän kuin suurempi alipuiden syvyyksistä > depth :: BinaryTree a -> Integer > depth EmptyTree = 0 > depth (NonEmptyTree _ tl tr) = 1 + max (depth tl) (depth tr) Binääripuussa on voimassa seuraavanlaiset ehdot: Kaikki puun vasemmassa alipuussa olevat tiedot ovat aidosti pienempiä kuin puun (juuren) tieto. Kaikki puun oikeassa alipuussa olevat tiedot ovat aidosti suurempia kuin puun (juuren) tieto. Lisäys seuraa näistä ehdoista kohtuullisen helposti. > addtotree :: Ord a => a -> BinaryTree a -> BinaryTree a > addtotree d EmptyTree = NonEmptyTree d EmptyTree EmptyTree > addtotree d (NonEmptyTree d tl tr) > d < d = NonEmptyTree d (addtotree d tl) tr > d == d = error "Puussa on jo tämä alkio!" 14
Luku 4 Tyypeistä > d > d = NonEmptyTree d tl (addtotree d tr) Puusta etsiminen ja siitä poistaminen ovat harjoitustehtäviä. Jos funktion tyyppiä ei kirjoiteta näkyviin, tulkit ja kääntäjät olettavat tyypiksi suppeimman mahdollisen, joka kattaa kaikki mahdolliset funktion käytöt. Tietyissä tapauksissa funktion tyyppi on pakko kirjoittaa näkyviin. 15
Luku 5 I/O Syöttö ja tulostus ja yleensäkin kaikki ulkomaailman kanssa leikkiminen tehdään Haskellissa monadien avulla. Lisää tästä myöhemmin tällä ja seuraavilla sivuilla. IO-monadi on abstrakti parametrisoitu tietotyyppi, joka ilmaisee tietyntyyppistä tekemistä: merkkijonon lukemista, tekstin tulostamista, merkkijonon lukemista ja sen muokatun version tulostamista ja niin edelleen. Tämä vaatii yleensä imperatiivista ohjelmointia harrastaneelta ohjelmoijalta hieman totuttelua, joten kaikki se, mitä tässä luvussa sanotaan, voi aluksi tuntua täysin käsittämättömältä. Jokaisessa Haskell-ohjelmassa tulee olla funktio nimeltä main. Tämä funktio ei koskaan ota parametreja, ja se palauttaa IO a -tyyppisen arvon (missä a on mikä tahansa tyyppi, tavallisesti triviaali tyyppi, C:n void-tyyppiä vastaava ()). Tämän funktion paluuarvo kuvaa jotakin aktiota, tekemistä, ja tämä aktio toteutetaan, kun main on palannut. On äärimmäisen tärkeää ymmärtää, että Haskell-ohjelma suoritetaan vasta kuin main on palannut: koko "ohjelmakoodi"on vain tulkille tai kääntäjälle tarkoitettu kuvaus siitä, minkälainen varsinainen ohjelma on, ja tämä ohjelma palautetaan main-funktion paluuarvona. Yksinkertaisin teko, minkä voi tehdä, on olla tekemättä mitään. Tämän teon (puutteen) kuvauksen palauttaa return, jonka ainoa argumentti kertoo, minkä arvon tämä teko tuottaa: tässä tapauksessa se tuottaa ei-mitään, siis triviaalin tyypin () ainoan arvon (). Siipä maailman yksinkertaisin toimiva Haskell-ohjelma on {- trivial.hs -} main :: IO () main = return () joka ei tee yhtikäs mitään. Muistakaamme, että Luku 2 tarkastelee perinteistä yksinkertaista ohjelmaa, sellaista, joka tervehtii maailmaa. Sen main palauttaa teon, joka tulostaa ruudulle tervehdyksen. Tällaisen teon tuottaa funktio putstrln, jonka ainoa argumentti on tulostettava merkkijono. Kuten nimestä voi päätellä, tulostetaan rivinvaihto merkkijonon perään. Tässä Hello World uudestaan: {- hello.hs -} main :: IO () main = putstrln "Hello World!" Ohjelma voi myös pyytää käyttäjää kirjoittamaan jotain ruudulle. Tämän teon kuvaa funktio getline, joka ei huomaa! palauta käyttäjän kirjoittamaa riviä vaan kirjoittamispyyntöä kuvaavan abstraktin arvon. Tässä vaiheessa osaame vain palauttaa sen pääohjelman paluuarvona, mutta pian pääsemme myös itse riviin käsiksi. Seuraava ohjelma kysyy käyttäjältä rivin, muttei tee saamallaan tiedolla mitään. {- ask.hs -} main :: IO String main = getline 16
Luku 5 I/O Ei riitä, että osataan tehdä yksi asia; tekoja pitää voida yhdistää. Operaattori (>>) tuottaa aktion, joka ensin tekee vasemmanpuoleisen ja sitten oikeanpuoleisen teon. Se siis yhdistää nämä jonoksi tekoja. Seuraava ohjelma tulostaa ensin pyynnön kirjoittaa jotain, lukee rivin käyttäjältä ja tulostaa lopuksi kiitoksen. {- ask1.hs -} main :: IO () main = putstrln "Kirjoittaisitko jotain?" >> getline >> putstrln "Kiitos." Jotkin teot tuottavat tuloksenaan tietoa esimerkki tällaisesta on funktion getline palauttama teko. Tähän tulokseen päästään käsiksi operaattorin (>>=) avulla. Sen vasen operandi on jokin teko, joka tuottaa tiedon. Oikea puoli on funktio, jonka argumentiksi tämä tieto kelpaa ja joka palauttaa toisen aktion. Operaattori tuotta aktion, joka ensin tekee vasemman argumentin kertoman teon, antaa tämän tuloksen oikeanpuoleiselle funktiolle argumentiksi ja tekee lopuksi tämän palauttaman teon. {- pallo.hs -} main :: IO () main = putstrln "Minkä muotoinen pallo on?" >> getline >>= vastaus where vastaus rivi = putstrln ("Pallo on " ++ rivi ++ ".") Useimmiten oikeanpuolen funktioita tulee paljon, joten nimien keksiminen käy hankalaksi. Tämän (ja monen muunkin asian) vuoksi Haskellissa on olemassa lambda-abstraktioksi kutsuttu rakenne, joka määrittelee nimettömän funktion lennossa. Rakenne alkaa kenoviivalla, jonka jälkeen tulee parametrien kuvaukset ja lopulta nuoli ->. Funktion määrittely jatkuu tästä niin pitkälle kuin se on mahdollista. Äskeinen ohjelma voidaan nyt kirjoittaa helpommin: {- pallolambda.hs -} main :: IO () main = putstrln "Minkä muotoinen pallo on?" >> getline >>= \rivi -> putstrln ("Pallo on " ++ rivi ++ ".") Huomaa, että sopivalla vaakatilan käytöllä näin kirjoitettu ohjelma alkaa olla helppolukuinen: operaattori (>>) on kuin puolipiste muissa kielissä, ja rakenne >>= \x -> on kuin sijoituslause. Tähän havaintoon perustuu erittäin mukava syntaksimakeinen, jota sanotaan do-rakenteeksi avainsanansa mukaan. Sen käyttö muistuttaa päällisin puolin kovasti imperatiivista ohjelmointia, vaikka se on mekaanisesti ja vieläpä helposti muutettavissa edellä kuvatuiksi funktionaalisiksi rakenteiksi. Do-rakenne alkaa avainsanalla do, jota seuraa yksi tai useampi alikohta. Kukin alikohta on kahta mutoa: se voi koostua pelkästä lausekkeesta, tai sitten se voi koostua muuttujannimestä, nuolesta <- ja lausekkeesta. Edellisessä tapauksessa lausekkeen tuottama teko liitetään yhteen muun do-rakenteen kanssa (»)-operaattorilla. Jälkimmäinen, muotoa muuttuja <- lauseke 17
Luku 5 I/O oleva alikohta muutetaan muotoon lauseke >>= \muuttuja -> Näin pallo-ohjelma voidaan kirjoittaa seuraavasti: {- pallodo.hs -} main :: IO () main = do putstrln "Minkä muotoinen pallo on?" rivi <- getline putstrln ("Pallo on " ++ rivi ++ ".") Do-notaatiolla useimmat simppelit I/O-toiminnot onnistuvat helposti. Sen etu on, että sen kanssa voi useimmiten ajatella imperatiivisesti. Sen haittapuolena on se, että monet itsestäänselvältä tuntuvat muunnokset eivät ole sallittuja; esimerkiksi do l <- getline putstrln l ei ole sama asia kuin do putstrln getline Itse asiassa tämä jälkimmäinen ohjelmanpätkä ei edes mene tulkista tai kääntäjästä läpi! (Miksi? Ajattele tyyppejä.) Näin esoteerinen I/O-kieli on itse asiassa hyvä asia. Puhtaasti funktionaalisessa kielessä sivuvaikutukset ovat kiellettyjä, koska ne rikkovat viittausten läpinäkyvyyden (referential transparency). Esimerkiksi C:ssä tarpeettomalta näyttävää funktiokutsua ei saa poistaa, ellei kääntäjä näe, miten tuo on määritelty; tällaisen poistaminen voisi rikkoa koko ohjelman, koska jokin tärkeä muuttuja jäisi päivittymättä. Haskellissa ja muissa puhtaasti funktionaalisissa kielissä tämä ei ole ongelma: on taattua, että kaikki funktiokutsut, jotka "näyttävät samalta"(so. samaa funktiota kutsutaan samoilla argumenteilla), palauttavat aina saman tuloksen eikä niillä ole muita huomioonotettavia vaikutuksia. Näin kääntäjä voi järjestellä, poistella ja yhdistellä funktiokutsuja mielensä mukaan. Koska Haskellissa sivuvaikutukset kuvataan ohjelman aikana arvoina, jotka toteutetaan vasta ohjelman "päätyttyä", säilyy viittausten läpinäkyvyys. Koska syöttö- ja tulostus teot ovat täysivaltaisia arvoja, voidaan niitä käsitellä monin tavoin. Esimerkiksi teot voidaan tallettaa listaan ja niitä voi antaa funktiolle argumentteina. Esimerkki 5-1 sisältää hauskan ohjelman, joka tulostaa ruudulle erään juomalaulun sanat. Katso myös 99 Bottles of Beer, 227 Computer Beer Styles (http://www.ionet.net/~timtroyr/funhouse/beer.html). Esimerkki 5-1 Ninety-nine bottles of beer on the wall {- bottles.hs -} beer :: Integer -> IO () beer n = putstrln (bottles n ++ " bottles of beer on the wall,") >> putstrln (bottles n ++ " bottles of beer.") >> 18
Luku 5 I/O putstrln ("Take " ++ one n ++ " down and pass it around,") >> putstrln (bottles (n-1) ++ " bottles of beer on the wall.") >> putstrln where bottles 0 = "No bottles" bottles 1 = "One bottle" bottles 2 = "Two bottles" bottles 3 = "Three bottles" bottles 4 = "Four bottles" bottles 5 = "Five bottles" bottles 6 = "Six bottles" bottles 7 = "Seven bottles" bottles 8 = "Eight bottles" bottles 9 = "Nine bottles" bottles 10 = "Ten bottles" bottles 11 = "Eleven bottles" bottles 12 = "Twelve bottles" bottles n = show n ++ " bottles" one 1 = "it" one _ = "one" main :: IO () main = foldl (>>) (return ()) (map beer (reverse [1..99])) Huomaa, kuinka ohjelmassa ei näy silmukkaa eikä rekursiota. Tämän abstrahoi pois funktio foldl, joka tässä tapauksessa muuttaa listan aktioita uudeksi aktioksi, joka tekee nuo teot järjestyksessä. Tämän listan konstruoi map, joka tässä kuvaa kunkin luvun 99 1 yhden säkeistön tuottavaksi teoksi. Luettelo luvuista saadaan kääntämällä luettelo luvuista 1 99 ylösalaisin. 19
Liite A Lopuksi Jos et Haskellista muista mitään muuta, muista nämä asiat: Funktiot ovat arvoja siinä missä kaikki muutkin. Tietorakenteet voivat olla äärettömiä. Osa-algoritmin ei tarvitse pysähtyä, kunhan koko ohjelma käyttää tuloksesta vain äärellisessä ajassa laskettavissa olevan osan. Teot ovat arvoja siinä missä kaikki muutkin. Sivuvaikutuksia ei ole. Tietorakenteita ei voi muuttaa. Haskell on hauska kieli, vaikka sen oppiminen vaatiikin aikansa. 20