Luku 3 Listankäsittelyä Funktio-ohjelmoinnin tärkein yksittäinen tietorakenne on lista. Listankäsittely on paitsi käytännöllisesti oleellinen aihe, se myös valaisee funktio-ohjelmoinnin ideaa. 3.1 Listat Lista on algebrallinen tietotyyppi, jolla on yleisyytensä vuoksi erityinen syntaksi. Tyyppiä α olevien alkioiden listan tyyppi on [α]. Tyypillä on kaksi koostinta: [] edustaa tyhjää listaa ja (:) :: α [α] [α] lisää olemassaolevan listan eteen uuden alkion. Koostinoperaattori (:) assosioi oikealle, joten 2 : 3 : 4 : 5 : [] on sama kuin 2 : (3 : (4 : (5 : []))). Äärellinen lista voidaan kirjoittaa paitsi koostimillaan myös luettelemalla sen alkiot hakasulkeiden sisällä pilkuilla ne toisistaan erottaen: [3, 9, 2, 7, 5] tarkoittaa 3 : 9 : 2 : 7 : 5 : []. Lista on persistentti tietorakenne. Tämä tarkoittaa, että listaa sinänsä ei voi muuttaa, vaan listan muuttaminen oikeasti tuottaa uuden listan. Näin alkuperäinen lista on yhtä lailla käytettävissä kuin uusikin. Muutettu ja alkuperäinen lista jakavat yhteisen loppuosan: jos muutos on uuden alkion lisääminen, muutettu lista koostuu oikeasti uudesta alkiosta ja alkuperäisestä listasta; jos muutos on vasemmanpuolimmaisen listan poistaminen, tilanne on sama kuin lisäämisessä, mutta alkuperäinen ja muutettu lista vaihtavat rooleja. Kaikki muut listaoperaatiot ovat näiden kahden operaation yhdistelmiä. Listojen yhtäsuuruus määritellään niin, että listat ovat yhtäsuuret silloin ja vain silloin kun niissä on samat alkiot (saman alkion mahdolliset kaksoiskappaleet mukaanlukien) samassa järjestyksessä: instance Eq a => Eq [a] where [] == [] = True 31
32 LUKU 3. LISTANKÄSITTELYÄ [] == (_:_) = False (_:_) == [] = False (x:xs) == (y:ys) = x == y && xs == ys Tämä määrittely tarkoittaa, että listojen yhtäsuuruusvertailu on mahdollista vain, jos alkiotyypille on yhtäsuuruusvertailu määritelty. Tosin yhdessä erityistapauksessa tätä ei tarvita, nimittäin listan tyhjyyden tarkastamisessa: null :: [α] Bool null [] = True null (_ : _) = False Funktio null kuuluu Haskellin varuskirjastoon. Kahden listan yhdistäminen (concatenation) on hyödyllinen perusoperaatio. Haskellin varuskirjastossa se on määritelty operaattoriksi (++): (++) :: [α] [α] [α] [] ++ ys = ys (x : xs) ++ ys = x : (xs ++ ys) Tämä määritelmä toimii vasemman argumentin hahmonsovituksella. Koska molemmat hahmot ovat koostinhahmoja, ei sovi niistä mihinkään. Tämän vuoksi ++ ys =. Oikeaa argumenttia sovitetaan sen sijaan koko ajan muuttujaan, joten xs ++ = pitää paikkansa vain, jos xs =. Esimerkiksi GHCi käyttäytyy näin 1 : Prelude> let bot = bot Prelude> [2,3,4]++bot [2,3,4*** Exception: <<loop>> Lista [2, 3, 4] ++ on siis osittaislista (partial list) 2 : 3 : 4 :. GHCi tunnistaa noin yksinkertaisesti määritellyn bot:n ja antaa siitä virheilmoituksen. Yhdistämisoperaattorin sukulaisfunktio on varuskirjastoon concat, joka yhdistää listaan kuuluvat listat yhteen: concat :: [[α]] [α] concat [] = [] concat (x : xs) = x ++ concat xs 1. GHCi:lle voi antaa vakiomäärittelyjä, funktiosidontoja ja hahmosidontoja let-avainsanan jälkeen.
3.1. LISTAT 33 Esimerkki 19 Yhdistelläänpä merkkijonolistoja ja merkkijonoja: Prelude> concat [["Kissa", "Koira"], [], ["Hiiri"], ["Koira", "Hiiri", "Kissa"]] ["Kissa","Koira","Hiiri","Koira","Hiiri","Kissa"] Prelude> concat ["Kissa", "Koira", "Hiiri", "Koira", "Hiiri", "Kissa"] "KissaKoiraHiiriKoiraHiiriKissa" Haskellin varuskirjastoon kuuluu myös funktio reverse, joka olisi helpointa määritellä näin: reverse :: [α] [α] reverse [] = [] reverse (x : xs) = reverse xs ++[x] Valitettavasti tämä on tehoton määritelmä; sen asymptoottinen aikavaativuus on O(n 2 ), missä n on parametrilistan pituus. Tämä johtuu siitä, että listan loppuun lisääminen on O(n)-operaatio, ja tämä tehdään n kertaa. Fiksumpi määritelmä on seuraava: reverse :: [α] [α] reverse = let f a [] = a f a (x : xs) = f (x : a) xs in f [] Tämän asymptoottinen aikavaativuus on O(n), sillä kaikki käytetyt perusoperaatiot ovat vakioaikaisia ja lista käydään läpi kerran. Tämä määritelmä on lisäksi häntärekursiivinen (tail-recursive), eli siinä rekursiivinen kutsu on viimeinen asia, mitä funktio tekee ennen palaamistaan. Häntärekursion tekee käytännölliseksi se, että se on oikeastaan rekursioksi naamioitu silmukka, ja hyvä (funktiokielen) kääntäjä käsittelee sitä sellaisena. Häntärekursio tarvitsee näin vain vakiomäärän pinoa, eikä häntärekursiivinen funktio siis voi aiheuttaa pinon ylivuotoa. Listan pituuden laskee varuskirjaston funktio length 2 : length :: [α] Int length [] = 0 length (_ : xs) = 1 + length xs 2. Tyyppi Int on lukualueeltaan rajattu kokonaislukutyyppi. Se on toteutettavissa suoraan konesanoilla ja konekielen laskukäskyillä, joten se on rajoittamattoman lukualueen kokonaislukutyyppiä Integer tehokkaampi.
34 LUKU 3. LISTANKÄSITTELYÄ Listan pituuden voi laskea, vaikka listan alkioina olisikin yksi tai useampi. Listan alkioilla ei siis ole varsinaisesti väliä: length [, ] = length ( : : []) = 2. Sen sijaan osittaislistoilla ei ole pituutta: length (2 : 3 : 4 : ) =. Listatyypin koostimet jo sinänsä jakavat listat kahteen osaan, päähän (head) ja häntään (tail). Pää on listan ensimmäinen alkio ja häntä on listan loppuosa. Varuskirjastossa on funktiot näiden hakemiseksi toisinaan tällaiset funktiot ovat kätevämpiä kuin saman asian hoitavat hahmonsovitukset casella tai vastaavalla: head :: [α] α head (x : _) = x tail :: [α] [α] head (_ : xs) = xs Nämä operaatiot ovat luonnollisestikin vakioaikaisia. Sen sijaan niiden peilioperaatiot last = head reverse ja init = reverse tail reverse ovat asymptoottiselta aikavaativuudeltaan lineaarisia operaatioita. Huomautus 3 Edellä käytetty pisteoperaattori ( ) on funktioidenyhdistämisoperaattori, jota matemaattisessa analyysissä tavataan merkitä :llä. Määritellään siis ( ) :: (β γ) (α β) (α γ) (f g) x = f (g x) Koneelle ( ) kirjoitetaan pisteenä: (.). Funktio-ohjelmoinnissa suositaan edellisten kaltaisia määritelmiä, joissa funktioiden parametrit eivät näy lainkaan vaan funktiot rakennetaan muista funktioista käyttämällä kombinaattoreita (combinators) eli funktioita, jotka yhdistelevät toisia funktioita tuottaen ulos uusia funktioita. Pisteoperaattori ( ) on esimerkki yksinkertaisesta kombinaattorista. Ohjelmointityyliä, jossa funktiot määritellään toisten funktioiden avulla niin, että funktioiden varsinaisia parametreja ei määritelmässä esiinny, sanotaan pisteettömäksi ( pointless tai point-free) tyyliksi. Tässä pisteettömyyden piste viittaa ajatukseen, jossa funktiot kuvaavat lähtöavaruuden pisteitä maaliavaruuden pisteiksi. Huomautus 4 Edellä annettu initin määritelmä on sikäli huono, että se käyttäytyy huonosti osittaislistoilla. Tämä on korjattavissa määrittelemällä se uu-
3.2. LISTA-AIHEISISTA TODISTUKSISTA 35 destaan pisteittäisesti: init :: [α] [α] init (x : []) = [] init (x : _) = x : init xs Varuskirjastoon kuuluu myös funktiot take ja drop, jotka ottavat tai tiputtavat pois, funktiosta riippuen, parametrina annetun määrän alkioita annetun listan alusta: take :: Int [α] [α] take _ [] = [] take n xs n < 0 = error "Negatiivinen taken parametri" n == 0 = [] otherwise = x : take (n 1)xs Lopuksi vielä esitellään varuskirjastoon kuuluva listan indeksointioperaattori: (!!) :: [α] Int α (x : xs)!! n n < 0 = error "Negatiivinen (!!):n parametri" n == 0 = x otherwise = xs!! n 3.2 Lista-aiheisista todistuksista Toisinaan on hyvä voida todistaa, että tietyllä funktiolla on tietty ominaisuus, ihan luotettavuuden parantamisen kannalta. Esimerkiksi voisi olla hyvä todistaa yhtälö (xs ++ ys) ++ zs = xs ++(ys ++ zs). Tällaiset todistukset etenevät parhaiten induktiolla. Listan yli tapahtuvassa induktiossa on kolme askelta; todistaakseen, että kaikilla listoilla on jokin ominaisuus, pitää todistaa seuraavat asiat: Perustapaus P( ) Todista, että ko. ominaisuus on (listatyyppisellä) pohjalla. Perustapaus P([]) Todista, että ko. ominaisuus on tyhjällä listalla. Induktioaskel Todista, että jos ko. ominaisuus on mielivaltaisella listalla xs niin silloin kaikilla (tyypiltään sopivilla) alkioilla x ko. ominaisuus on listalla x : xs.