Tällaista lisäparametria acc kutsutaan kerääjäksi (accumulator) koska siihen kerätään laskennan tulosta. Haluttu tehokkuus seuraa siitä, että nyt esimerkissämme pari (i,()) lisätäänkin oikeanpuoleisen alipuun tuottaman listan alkuun. Tämä acc sisältää [(i + 1,()),(i + 2,()),(i + 3,()),...,(n,())] ahkerassa ohjelmointikielessä laiskassa ohjelmointikielessä lausekkeen, joka tuottaa tarvittaessa tämän listan. Tällainen laiska acc on yksinkertainen erikoistapaus siitä yleisestä ideasta, että lisäparametrina saadaan se, miten laskentaa pitää jatkaa tästä eteenpäin ennen kuin laskenta on kokonaan lopussa. Yleisesti tämä idea on, että... lisäparametrina välitetäänkin funktioita t -> u joissa t on nykyisen lausekkeen ja u koko laskennan tuottaman arvon Tyyppi. liitännäisenä operaationa onkin tällaisten kuvausten yhdistäminen (.). neutraalialkiona tällä (.) on identtinen kuvaus id x = x jonka Prelude määrittelee. Tällaisia lisäfunktioita kutsutaan jatkeiksi (continuation) ja tätä ohjelmointityyliä jatkeenvälitystyyliksi (Continuation Passing Style (CPS). Näitä jatkeita käytetään esimerkiksi ohjelmointikielten... semantiikassa määriteltäessä funktionaaliseen tyyliin sellaisten rakenteiden semantiikkaa, joissa ohjelman kontrollivuo eteneekin poikkeuksellisella tavalla toteutuksissa sisäisesti vaikkapa tällaisissa rakenteissa. Esimerkiksi ohjelmointikielen Java kontrollirakenteen try... catch... finally... voi ajatella niin, että Java käyttää ohjelmoijalta piilossa kahta eri jatketta: Oletusjatke kuvaa miten normaali suoritus etenee tästä eteenpäin. Poikkeusjatke kuvaa miten suorituksen pitääkin edetä poikkeuksen (exception) tapahduttua perusjatkeen sijaan. Silloin catchillä ohjelmoidaan sitä poikkeusjatketta, jota tryn aikana käytetään. Ohjelmointikielen Scheme standardi määrittelee, että Scheme-ohjelma voi manipuloida toteutuksen sisäisiä jatkeita täysin samoin kuin tavallisia funktioita (first-class continuations). 58
Nimetyt kentät Haskellin data-tyypeissä Konstruktorin kenttiin viitataan Hahmoissa niiden sijainnin perusteella: 1 ensimmäinen kenttä on heti Konstruktorin jälkeen 2 toinen kenttä heti sen jälkeen 3 kolmas kenttä heti niiden jälkeen,... ja niin edelleen. Tämä vaikeuttaa ohjelmien ylläpitoa niiden kasvaessa: Muutos Konstruktorin kenttiin vaatisi jokaisen sitä käyttävän Hahmon päivittämistä ohjelmakoodissa. Siksi Haskell sallii myös Konstruktorin kenttien nimeämisen data-määritelmän yhteydessä. Jos Konstruktorin kentille antaa nimet, niin silloin niihin voidaan viitata myös näillä nimillä niiden sijainnin lisäksi. Syntaksi on Konstruktori { nimi_1 :: Tyyppi_1, nimi_2 :: Tyyppi_2, nimi_3 :: Tyyppi_3,..., nimi_q :: Tyyppi_q jossa määritellään tuttuun tapaan tämä Konstruktori sille q kenttää jokaiselle näistä kentistä i sen Tyyppi_i ja lisäksi myös nimi_i. Näitä aaltosulkuja {... ja pilkkuja, ei voi jättää pois sisentämällä koodiaan, koska ne ilmaisevat että nyt käytetään kentännimiä. Muistisääntö: aaltosulut ja puolipisteet voi korvata sisennyksellä, mutta pilkut pitää kirjoittaa. Lausekkeena kentän nimi_i on funktio joka palauttaa vastaavan kentän i arvon. Saman data-määritelmän eri Konstruktorit saavat käyttää samoja kentännimiä kunhan niillä kaikilla on täsmälleen sama Tyyppi jotta tämännimisellä funktiolla on tämä täsmällinen tulostyyppi. Eri data-määritelmät eivät saa käyttää samoja kentännimiä samasta syystä. 59
Jos tämän funktion parametrilla ei olekaan tämännimistä kenttää, niin seurauksena onkin suoritusaikainen virhe. Nyt voidaan kirjoittaa Hahmoja muotoa Konstruktori { nimi_i = Hahmo_i, nimi_j = Hahmo_j joista saa jättää pois osan Konstruktorin kentistä Haskell lisää jokaisen pois jätetyn kentän kohdalle hahmon _ eli sivuuttaa ne. Nyt voidaan kirjoittaa arvonluontilausekkeita muotoa Konstruktori { nimi_i = lauseke_i, nimi_j = lauseke_j Siitä saa jättää pois osan Konstruktorin kentistä. Haskell lisää jokaisen pois jätetyn kentän kohdalle lausekkeen undefined. Prelude määrittelee sen niin, että sen sievennys johtaa suoritusaikaiseen virheeseen. Voidaan kirjoittaa myös arvonluontilausekkeita muotoa lauseke { nimi_i = lauseke_i, nimi_j = lauseke_j Siinä oletetaan, että lauseke tuottaa sellaisen arvon, jolla on tämännimiset kentät. Jos niin on, tuloksena on muuten samanlainen arvo kuin lauseke paitsi että jokaisen mainitun kentän nimi_i arvona onkin vastaava lauseke_i. Jos niin ei olekaan, seuraa suoritusaikainen virhe. data Tree t = Empty Node { left :: Tree t, key :: Int, item :: t, right :: Tree t deriving (Show) -- Tässä luodaan melkein samanlaisia arvoja kuin this. additem :: Int -> t -> Tree t -> Tree t additem newkey newitem Empty = Node { left = Empty, key = newkey, item = newitem, right = Empty 60
additem newkey newitem this@node{ key = oldkey newkey < oldkey = this { left = additem newkey newitem $ left this newkey > oldkey = this { right = additem newkey newitem $ right this otherwise = this { item = newitem -- Tässä käytetään yhä kenttien sijainteja. build :: [(Int,t)] -> Tree t build = foldr (uncurry additem) Empty itemof :: Int -> Tree t -> Maybe t itemof _ Empty = Nothing itemof newkey this@node { key = oldkey newkey < oldkey = itemof newkey $ left this newkey > oldkey = itemof newkey $ right this otherwise = Just $ item this contentsof :: Tree t -> [(Int,t)] contentsof Empty = [] contentsof this@node{ = contentsof (left this) ++ (key this,item this) : contentsof (right this) contentsof :: Tree t -> [(Int,t)] contentsof = let cont Empty acc = acc cont this@node{ acc = cont (left this) $ (key this,item this) : cont (right this) acc in flip cont [] 4.4.7 Uusvanhat tyypit Jos data-määritelmässä data Uusi = Ainoa Vanha on vain yksi konstruktori jolla on vain yksi kenttä niin silloin se voidaankin kirjoittaa newtype Uusi = Ainoa Vanha 61
Tämä newtype on eräänlainen datan ja typen välimuoto. Tyypinpäättelyn aikana tämä Uusi tyyppi käsitellään datan tapaan, eli kokonaan uutena tyyppinä jonka arvojoukko on siis A suurin Uusi = { Ainoa x: x A suurin Vanha { Uusi. Siten tämä Uusi tyyppi on tyyppiturvallinen, koska sen ja Vanhan tyypin lausekkeita ei voi sekoittaa keskenään. Suorituksen aikana se käsitelläänkin typen tapaan, eli uutena lyhennenimenä tälle Vanhalle tyypille, jonka arvojoukko onkin siis A suurin Uusi = A suurin Vanha. Siten tämä Uusi tyyppi onkin itse asiassa sama kuin Vanha, eli sen Ainoa konstruktori katoaa. newtype Stack t = IntoStack { fromstack :: [t] deriving (Show) emptystack :: Stack t emptystack = IntoStack [] push :: t -> Stack t -> Stack t push x = IntoStack. (x :). fromstack pop :: u -> (t -> Stack t -> u) -> Stack t -> u pop e _ (IntoStack []) = e pop _ f (IntoStack (x:xs)) = f x (IntoStack xs) Esimerkiksi Haskell-listoja voisi mainiosti käyttää pinoina mutta se pitää tehdä tyyppiturvallisesti: listafunktioita kuten foldr ei saakaan soveltaa niihin pinoihin, joita käytetään listoina. newtype soveltuu juuri tähän: Se takaa käännösaikaisen tyyppiturvallisuuden ilman suoritusaikaista lisärasitetta. Edellisessä määritelmässä konstruktori IntoStack :: [t] -> Stack t on samalla funktio jolla listasta voi tehdä pinon. kentännimi fromstack :: Stack t -> [t] on samalla funktio jolla pinosta voi tehdä listan. 62
Suoritusaikana kumpaakaan niistä ei enää ole, joten nämä tyyppiturvalliset pinooperaatiot ovat yhtä tehokkaita kuin tavallinen listojenkäsittely. Pinosta poppaava koodi on lähes aina muotoa 1 if pino on tyhjä 2 käsittele tämä (virhe)tilanne jotenkin 3 else poppaa siitä päällimmäinen alkio käsiteltäväksi joten esitetään tämä yleinen idiomi turvallisena poppina, jonka argumentit ovat 1 mitä tehdään, jos pino 3 onkin tyhjä laiskuuden perusteella tämä virheenkäsittelylauseke suoritetaan vain jos näin on 2 mitä tehdään pinosta 3 popatulle alkiolle ja jäljelle jääneelle pinolle 3 pino josta yritetään popata. Kumpikin näistä haaroista 1 ja 2 tuottaa samaa tyyppiä u olevan tuloksen. Olemme tässä käyttäneet samaa suunnittelutapaa kuin esimerkiksi Preluden funktiossa maybe :: b -> (a -> b) -> Maybe a -> b 4.5 Heikko päänormaalimuoto Nyt täsmennetään, miten case-hahmonsovitus etenee laiskasti. Ajatellaan, että jokainen tietoalkio on luotu jollakin Konstruktorilla. Esimerkiksi ajatellaan, että jokainen Integer-vakio olisi oma Konstruktorinsa eli ikään kuin olisi määritelty data Integer =... -3-2 -1 0 1 2 3... jolla on äärettömän monta (parametritonta) konstruktoria. Määritellään se normaalimuoto, jota laiskat ohjelmointikielet kuten Haskell käytännössä laskevat, kun ne käsittelevät näitä Konstruktoriensa luomia tietoalkioita. Haskell-lauseke on heikossa päänormaalimuodossa (Weak Head Normal Form, WHNF) jos se on... funktio \ x ->... heikkous tarkoitti sitä, ettei funktioiden sisältä etsitä redeksejä kutsu muotoa nimi arg_1 arg_2 arg_3... arg_k jonka nimi on Konstruktori tai jokin sisäänrakennettu perusoperaatio kuten (+) mutta sillä ei ole tarpeeksi argumentteja jotta se voitaisiin suorittaa eli kyseessä on sittenkin funktio. 63
tietoalkio samaa muotoa, mutta jossa tämännimisellä Konstruktorilla onkin tarpeeksi argumentteja jolloin se onkin suoritettu, ja sitä vastaava tietoalkio luotu. Näitä argumentteja ei oleteta normalisoiduiksi eli myös Konstruktorien kentät ovat laiskoja. Jos lauseke ei ole mitään funktiotyyppiä, niin case lauseke of Konstruktori Hahmo_1 Hahmo_2 Hahmo_3... Hahmo_k ->... suoritetaan seuraavasti: 1 lauseke sievennetään WHNF-muotoon jotta nähdään onko sen Konstruktori sama kuin Hahmossa. 2 Jos on, niin sen WHNF-muoto on Konstruktori kenttä_1 kenttä_2 kenttä_3... kenttä_k ja jatketaan sovittamalla jokainen kenttä_i sitä vastaavaan Hahmo_ion tällä samalla tavalla. 3 Jos ei ole, niin jatketaan seuraavan case-haaran Hahmoon, ja yritetään sovittaa sitä tällä samalla tavalla, ja niin edelleen. Jos lauseke taas on jotakin funktiotyyppiä, niin sille voi kirjoittaa vain triviaalisti sopivia Hahmoja, eli muuttujat tai _. Haskell-ohjelmoija voi jossain määrin ohjata milloin tämä Hahmonsovitus tapahtuu. Hahmonsovitusta voi viivyttää Hahmolla muotoa Hahmo_i koska se ja vastaava kenttä_i ohitetaan nykyisessä Hahmonsovituksessa. 1 Ne sovitetaan toisiinsa vasta silloin jos Hahmo_in antamia muuttujannimiä käytetään myöhemmin. 2 Jos se sovitus silloin epäonnistuu, niin seuraa suoritusaikainen virhe. Haskell-ohjelmoija tarvitsee tätä viivytettyä Hahmonsovitusta vain harvoin, mutta Haskell-toteutus käyttää sitä usein sisäisesti. Hahmonsovitusta voi kiirehtiä huutomerkillä!. Sen intuitiivinen lukutapa on tämän voi tehdä ahkerasti. Tarkkaan ottaen kyse on lausekkeiden WHNF-normalisoinnista jo etukäteen ennen kuin niiden informaatiota tarvitaan, mikä on eri asia kuin aito ahkeruus. Prelude määrittelee 2 funktionkutsuoperaattoria: Laiska ($) tarkoittaa aivan samaa kuin tavallinen funktionkutsu, mutta sen assosiatiivisuus säästää sisäkkäisiä sulkuja, kuten Taulukossa 1. 64
Ahkera ($!) lisäksi WHNF-normalisoikin kutsuttavaan funktioon lähtevän argumentin jo nyt kutsuhetkellä, eikä vasta silloin jos kutsuttava funktio sen informaatiota tarvitsee. Siten tämä ($!) on intuitiivisesti ikään kuin ahkera funktionkutsu. Haskell-standardin data-määritelmässä Konstruktorin kentän Tyyppi voidaan kirjoittaa muodossa!(tyyppi) Ympröivät sulut (...) voi jättää pois, jos niiden sisällä oleva Tyyppi on yksiosainen kuten Int. Haskell käyttää näille kentille funktionkutsuoperaattoria ($!) mutta tavallisille kentilleen tavallista operaattoria ($). Tällainen kenttä on siis intuitiivisesti ahkera. Tavallinen käyttökohde on hakupuuesimerkkimme Node-Konstruktorin rekursiiviset kentät 1 ja 4 sen vasemmalle ja oikealle alipuulle, koska tavoitteenamme on rakentaa ihan tavallinen hakupuu johon ei tarvita laiskuutta. Lisäksi ghc toteuttaa kielilaajennuksen BangPatterns joka mahdollistaa Hahmon muotoa! Hahmo Hahmonsovituksen aikana sitä vastaava kenttä WHNF-normalisoidaan joka tapauksessa, vaikka Hahmonsovitus ei sitä vaatisikaan. Erityisesti siis Hahmolla muotoa! muuttuja WHNF-normalisoidaan vastaava kenttä ja nimetään sen tulos muuttujaksi...... vaikka tavallisesti muuttujan esiintyminen Hahmossa antaakin vain nimen vastaavalle lausekkeelle normalisoimatta sitä vielä. Intuitiivisesti näin voi siis esittää ahkeran Hahmon ja muuttujan. Kaikkien näiden huutomerkkien! taustalla on Haskelliin lisätty primitiivifunktio seq :: a -> b -> b jonka WHNF-normalisointi etenee seuraavasti: 1 ensin WHNF-normalisoidaan sen ensimmäinen argumentti 2 sitten WHNF-normalisoidaan sen toinen argumentti ja palautetaan sen antama tulos vaikka argumentin 2 WHNF-normalisointi ei tarvitsisikaan argumentin 1 WHNFnormalisointia. Argumentin 1 WHNF-normalisointi on siis seq-kutsun sivuvaikutus. seq siis luo keinotekoisen tietoriippuvuuden argumenttiensa 1 ja 2 välille. 65
seq ei noudata λ-laskennan periaatteita, joten sitä käyttävää koodia ei voikaan vapaasti käsitellä koodiyhtälöillä. Esimerkiksi η-konversio ei pädekään, koska Tämän vuoksi Haskell-ohjelma kannattaa seq seq (\ x -> ). ensin kehittää ilman huutomerkkejä! (ja seqiä) lopuksi optimoida lisäämällä huutomerkkejä! tarpeen mukaan. Riittää lisätä! muutamaan strategiseen paikkaan ohjelmakoodissa, koska Haskell-kääntäjän tiukkuusanalyysi (strictness analysis) päättelee niiden perusteella, missä muissa kohdissa olisi myös voinut olla!. Laiska suoritus muodostaa lausekkeita odottamaan, tarvitaanko niiden tuloksia myöhemmin. Huutomerkillä! voi ilmoittaa kyllä, sitä tullaan tarvitsemaan myöhemmin, joten sen voi laskea jo nyt. Huutomerkillä! voi nopeuttaa Haskell-koodia, koska on nopeampaa laskea lausekkeen tulos heti kuin muodostaa ensin lauseke odottamaan ja laskea sen tulos myöhemmin kunhan ei tule laskeneeksi mitään sellaista jonka laiskuus olisi jättänyt laskematta... Ajansäästöä tärkeämpi optimointi on tilansäästö: Odottamaan jääneet lausekkeet varaavat muistia, jonka voi vapauttaa laskemalla niiden tuloksen. Esimerkiksi foldr (+) 0 [1..10ˆ7] muodostaa lausekkeen 1+(2+(3+(...+(10ˆ7+0)...))) ennen kuin laskee sen tuloksen foldl (+) 0 [1..10ˆ7] muodostaa lausekkeen (...(((0+1)+2)+3)+...)+10ˆ7 ennen kuin laskee sen tuloksen foldl (+) 0 [1..10ˆ7] WHNF-normalisoi välituloksen joka kierroksellaan: foldl (+) 0 [1..10ˆ7] = foldl (+) 1 [2..10ˆ7] = foldl (+) 3 [3..10ˆ7] = foldl (+) 6 [4..10ˆ7] =... joten se kuluttaa paljon vähemmän muistia ja myös muistinhallintaan kuluvaa aikaa. Useilla muillakin kirjastofunktioilla on myös tällainen ahkerampi versio. 66
4.6 Valmiiden ohjelmamodulien käyttöönotto Haskell 2010 -standardi määrittelee Preludea laajemman kokoelman standardikirjastoja. Esimerkiksi foldl on kirjastossa Data.List. Haskell Platform sisältää niiden lisäksi monia muitakin kirjastoja. Esimerkiksi Control.DeepSeq sisältää funktion deepseq joka normalisoi argumenttinsa 1 kokonaan eikä vain WHNF-muotoon. Oman lähdekooditiedoston alussa (eli ennen sen ensimmäistä omaa määritelmää) voi ottaa niitä käyttöön lauseella import [qualified] Modulin.Nimi [as Lyhenne] jonka [hakasulkeissa] olevat osat ovat valinnaisia. Se tuo käyttöön kirjastossa nimeltä Modulin.Nimi tehdyt julkiset määritelmät kahdessa eri muodossa: pitkässä muodossa Modulin.Nimi.määritelty ja lyhyessä muodossa joka on pelkkä määritelty. Sama asia voi olla määritelty monessa eri modulissa, joten eri importit voivat tuoda sille käyttöön monta eri määritelmää yhtä aikaa nykyiseen lähdekooditiedostoosi. Haskell sallii monta yhtäaikaista määritelmää samalle asialle......mutta tällaista moneen kertaan määriteltyä asiaa et voi käyttää lähdekoodissasi, koska Haskellin pitää tietää mitä määritelmää tarkoitat. Siksi qualified tuokin käyttöön modulin sisältämät määritelmät vain pitkässä muodossa. Sen jälkeen eri määritelmät eroavat toisistaan, koska jokaisella on edessään vastaavan Modulin.Nimi. Jos tämä pitkä muoto Modulin.Nimi.määritelty tuntuu liian pitkältä, niin as Lyhenne sallii sen kirjoittamisen Lyhenne.määritelty. 5 Monimuotoisuus Monimuotoisuus (polymorfia, polymorphism) tarkoittaa funktionaalisessa ohjelmoinnissa sitä, että samaa funktiota voi soveltaa erityyppisiin arvoihin. Sen vastakohta on monomorfia eli yksimuotoisuus. Monimuotoisuus säästää suunnitteluohjelmointi- ja testaustyötä koska kertaalleen laadittuja tietorakenteita ja niiden funktioita voidaan käyttää yhä uudelleen eri tilanteissa. 67
Esimerkiksi määrittelimme yhden kerran tyyppikonstruktorin Tree :: * -> * ja saimme hakupuut sen tyyppiparametrin eri arvoille, kuten Tree Bool, Tree Int, Tree (Tree String),..., eli kaikille mahdollisille tietoalkiokentän eri tyypeille. Monimuotoisuutta on monenlaista riippuen siitä, millaista samankaltaisuutta halutaan esittää: Alityyppimonimuotoisuus (subtype polymorphism) järjestää tyypit alityyppihierarkiaan. Tyypille ja sen kaikille alityypeille yhteiset piirteet riittää esittää vain yhden kerran tyypissä itsessään, josta alityypit perivät (inherit) ne. Esimerkiksi jos hauki on kala niin silloin alityypillä hauki on myös kaikki tyypille kala määritellyt ominaisuudet. Luokkaperustaisessa olio-ohjelmoinnissa kuten Javassa olioluokat mudostavat tällaisen luokkahierarkian. Parametrinen monimuotoisuus (parametric polymorphism) Tyypeillä voi olla tyyppiparametreja, kuten t hakupuuesimerkissämme. Niiden avulla voi määritellä funktion, joka toimii aina samalla tavalla näistä eri tyypeistä t riippmatta. Esimerkiksi uuden parin(avain,sen tietoalkio) lisääminen additem tehdään aina samalla tavalla riippumatta tietoalkioiden nykyisestä tyypistä t. Funktionaalisen ohjelmoinnin ulkopuolella tätä kutsutaan geneerisyydeksi (genericity, generics). Esimerkiksi Javaan geneerisyys on lisätty kielen kehityksen kuluessa, jotta sitä ei tarvinnut enää teeskennellä epätyydyttävästi kielen oliopiirteillä. Tässä ja nyt -monimuotoisuus (ad hoc polymorphism) Haskellissa Tämä ad hoc ei ole halventava nimitys. Se on parametrisen monimuotoisuuden vastinpari : Sama funktio voidaan määritellä toimimaan eri tavalla eri tyypeille. Esimerkiksi monella eri tyypillä on järjestysrelaatio (<=) mutta Inteillä se on eri relaatio kuin Booleilla, jne. (Ja yritys vertailla esimerkiksi Inttiä ja Boolia toisiinsa on tyyppivirhe.) Silloin voidaan määritellä yhden kerran vaikkapa funktio järjestä tämä lista niin, että se käyttää listan alkiotyypin omaa järjestysrelaatiota. Luokkaperustaisessa olio-ohjelmoinnissa kuten Javassa perityn metodin uudelleenmäärittely aliluokassa on tällaista monimuotoisuutta. Javassa on myös rajapinnat (interface) tällaisen monimuotoisuuden esittämiseen luokkahierarkiasta riippumatta. ei ole käsitettä alityyppi eikä siten myöskään alityyppimonimuotoisuutta. Siten sivuutamme sen tällä kurssilla. on HM-tyypinpäättely joka tarjoaa luontevan ja automaattisen parametrisen monimuotoisuuden. 68
on lisännyt HM-tyypinpäättelyyn tässä ja nyt -monimuotoisuuden. Nämä tyyppiluokat (type class) ovat Haskellin oma erityispiirre. Funktionaalisen ja olio-ohjelmoinnin yhdistävät hybridi- tai moniparadigmaohjelmointikielet kuten Scala (http://www.scala-lang.org/) ja OCaml (http: //ocaml.org/) ovat puolestaan yhdistäneet tyypinpäättelyn ja alityypit. 5.1 Parametrinen monimuotoisuus Olemme nähneet tyyppiparametrit type-ja data-määritelmissä. Javassa tyyppiparametrit kirjoitetaan kulmasulkujen <...> sisään. Esimerkiksi tyyppi Lista jonka alkiot ovat Int kirjoitetaan Javassa List<Int>. Haskellin HM-tyypinpäättely automatisoi parametrisen monimuotoisuuden: Kun ohjelmoija... määrittelee uuden funktion, niin HM päättelee sille automaattisesti sellaisen pääasiallisen tyypin, jossa on tyyppiparametrit. käyttää parametrisesti monimuotoista funktiota ohjelmassaan, niin HM täydentää sen tyyppiparametreille vastaavat arvot automaattisesti käyttöyhteyden perusteella. Esimerkiksi tutun funktion foldr _ z [] = z foldr f z (x:xs) = f x (foldr f z xs) tyypinpäättely voisi edetä seuraavasti (jossa välivaiheita on yhdistelty): 1 Koska foldr saa 3 parametria, niin sen tyypin pitää olla foldr :: a -> b -> c -> d jossa emme vielä tiedä, mitä tyyppejä nämä a, b, c ja d ovat, joten pidetään ne tyyppimuuttujina joiden arvot saattavat selvitä tyypinpäättelyn jatkuessa eteenpäin. 2 Koska foldr n []-haara palauttaa sen 2. parametrin z niin sen tyypin b pitää olla sama tyyppi kuin d. Siis: foldr :: a -> b -> c -> b Näin tyypitys tarkentuu päättelyn edetessä. 3 Koska foldr n 3. parametri on lista, niin sen tyypin c pitää olla jokin listatyyppi [e] jossa e on sen alkioiden tyyppi. Siis: x :: e xs :: [e] foldr :: a -> b -> [e] -> b 4 Koska foldr n parametria f kutsutaan, niin sen täytyy olla jotakin funktiotyyppiä. Koska tämän funktion f kutsun 1. argumentti on x 69
2. argumentti on samaa tyyppiä b kuin foldr n tulos tulos on sekin samaa tyyppiä b kuin foldr n tulos niin syöttämällä nämä tiedot sen tyyppiin a saamme foldr :: (e -> b -> b) -> b -> [e] -> b ja foldr n tyypinpäättely on valmis, koska olemme löytäneet tyypin jokaiselle siinä esiintyvälle muuttujalle ja lausekkeelle. Tyypinpäättelyn ei tarvinnut täsmentää foldr n tyyppiin jääneitä muuttujia b ja e millään tavalla. Ne voidaan yleistää (generalize) tarkoittamaan mitä tahansa mielivaltaista tyyppiä. Siten foldr on parametrisesti monimuotoinen niiden suhteen. HM-tyypinpäättelyn a parametrisen monimuotoisuuden yhdistävä periaate: HM-tyypinpäättely päättelee annetulle lausekkeelle pääasiallisen tyypin siten, että tyyppimuuttujia rajoitetaan mahdollisimman vähän eli vain sen verran kuin on välttämätöntä tyypinpäättelyn läpiviemiseksi. Päätelty tyyppi jätetään parametrisesti monimuotoiseksi niiden tyyppimuuttujien suhteen, joita tyypinpäättelyn ei tarvinnut rajoittaa eli mahdollisimman yleiseksi. Standardi-Haskellissa tällaiset tyypit, joissa on tyyppimuuttujia, kuten foldr :: (e -> b -> b) -> b -> [e] -> b ovat siis parametrisesti monimuotoisia niiden suhteen. ghc sallii laajennuksia kuten ExplicitForAll, Rank2Types tai RankNTypes joilla voi kirjoittaa foldr :: forall b e. (e -> b -> b) -> b -> [e] -> b eli esitellä parametrisen monimuotoisuuden muodossa kaikille tyypeille b ja e. Standardi-haskellissa katsotaan siis olevan hiljainen forall tyypin alussa. Silloin monimuotoisen lausekkeen käyttöönotto voidaan katsoa olevan tämän (mahdollisesti hiljaisen) forallin poistamista korvaamalla sen sitomat muuttujat tuoreilla tyyppimuuttujilla eli sellaisilla, joita ei ole käytetty missään muualla. Esimerkiksi lausekkeen foldr ((:). g) [] tyypinpäättely 1 ottaa siinä käytettyjen muuttujien jokaiselle esiintymälle sen monimuotoisen tyypin: (.) :: forall a b c. (b -> c) -> (a -> b) -> a -> c (:) :: forall a. a -> [a] -> [a] [] :: forall a. [a] foldr :: forall a b. (a -> b -> b) -> b -> [a] -> b g :: forall a. a 70
2 korvaa niiden forallit tuoreilla tyyppimuuttujilla: (.) :: (q -> r) -> (p -> q) -> p -> r (:) :: s -> [s] -> [s] [] :: [t] foldr :: (u -> v -> v) -> v -> [u] -> v g :: w 3 päättelee niiden väliset riippuvuudet, tässä q = s r = [s] -> [s] w = p -> s u = p v = [s] t = s 4 päättelee niiden perusteella koko lausekkeen tyypiksi [p] -> [s] Jos lauseketta käytettiin määritelmässä map g = foldr ((:). g) [] niin näiden tietojen perusteella saadaan pääteltyä, että map :: (p -> s) -> [p] -> [s] joka voidaan yleistää map :: forall p s. (p -> s) -> [p] -> [s] jota voidaan käyttää muun tyypinpäättelyn askeleessa 1, ja niin edelleen. Jos määritelmä olisi ollut rekursiivinen, eli määriteltävä map olisi esiintynyt määrittelevässä lausekkeessa, niin se olisi voitu käsitellä kuten g eli olettaa sillekin aluksi tuntematon tyyppi. Tällaista HM-tyypinpäättelyn tuomaa parametrista monimuotoisuutta kutsutaan myös nimellä let-monimuotoisuus koska let määritelmät in lauseke (ja where) käsitellään 1 ensin päättelemällä tyypit jokaiselle määritelmälle 2 sitten yleistämällä edellisessä askeleessa 1 saadut tyypit mutta vain sellaisten muuttujien suhteen, jotka syntyivät vasta askeleessa 1 3 lopuksi päättelemällä ja vastaamalla se tyyppi, jonka lauseke saa, kun nämä paikalliset määritelmät ovat siinä parametrisesti monimuotoisia askeleen 2 tuloksena. Tyypinpäättely ilman tätä let-yleistysaskelta 2 on nopeaa. 71
Algoritmisesti kyse on samasta asiasta kuin (ensimmäisen kertaluvun) samastus (unification). Samastus on keskeinen operaatio esimerkiksi mekaanisessa teoreemantodistuksessa. Samastus on lineaarista syötteensä pituuden suhteen. Siten myös tämä yksinkertainen tyypinpäättely on lineaarista tarkasteltavan lausekkeen pituuden suhteen. Tämän let-yleistysaskeleen 2 lisääminen vaikeuttaa sitä rajusti onneksi vain periaatteessa. Siitä tulee PSPACE-täydellistä eli se on yksi vaikeimmista laskentaongelmista, joka voidaan ratkaista polynomisella eli realistisella määrällä muistia. Mutta johon luultavasti kuluu epärealistinen määrä aikaa... Onneksi se on hidasta vain sen suhteen, montako sisäkkäistä let-määritelmäosaa let a = let b = let c =... in lauseke_c in lauseke_b in lauseke_a ohjelmakoodissa on. Yleensä näitä tasoja on vain muutama ja se on OK. Onneksi tyypinpäättely on käytännössä lineaarista näiden sisäkkäisten inosien suhteen koska ne voivat olla pitkiäkin. Intuitiivisesti sisennetty ohjelmakoodi on usein pitkää mutta harvoin leveää. 5.2 Tyyppiluokat Täysi parametrinen monimuotoisuus on usein liian kova vaatimus, ja vaatii täsmentämistä. Mikä esimerkiksi olisi monimuotoinen tyyppi funktiolle sort, joka järjestää syötteenä saamansa listan alkiot suuruusjärjestykseen? Täysi parametrinen monimuotoisuus ei päde. sort :: forall t. [t] -> [t] On olemassa sellaisiakin tyyppejä, joilla ei ole mielekästä suuruusjärjestyksen (<=) käsitettä. Ei esimerkiksi liene hyvää määritelmää käsitteelle funktioiden välinen suuruusjärjestys. Haluamme siis sanoa jokaiselle sellaisella tyypille t jolle on määritelty (<=). Haskellissa tämä sanotaan sort :: forall t. (Ord t) => [t] -> [t] 72
Loogisesti => voidaan lukea kuten implikaationuoli jota se esittää: Jokaiselle tyypille t, jos t kuuluu tyyppiluokkaan Ord, niin... Se voidaan lukea myös rajoitteena niille t joita forall koskee: Jokaiselle tyyppiluokkaan Ord kuuluvalle tyypille t... Tämä tyyppiluokka Ord eli Ordered types puolestaan vaatii juuri sen, että jokaisella siihen kuuluvalla tyypillä t pitää olla määritelty se (<=) :: t -> t -> Bool Koska tyyppiluokan Ord jokaisella eri jäsenellä t on oma versionsa tästä samasta (<=) on kyseessä ad hoc -monimuotoisuus. Tämä (mahdollisesti hiljaisen) forallin ja tyypin väliin laitettava rajoite (constraint) on muotoa (fakta,fakta,fakta,...,fakta) => Sen jokainen fakta on muotoa Tyyppiluokka muuttuja jonka muuttuja on jokin forallin sitomista tyyppimuuttujista. Tällainen fakta vaatii siis, että sen muuttujan arvoksi saa valita vain jonkin sellaisen tyypin joka kuuluu tähän Tyyppiluokkaan. Niiden monikko vaatii, että jokaisen niistä pitää olla voimassa. Haskell sallii tällaiset rajoitteet monissa muissakin paikoissa joissa käsitellään tyyppimuuttujia. ghc sisältää monenlaisia syntaktisia ja semanttisia laajennuksia tähän perusideaan. Javassa voidaan asettaa vastaavia rajoituksia sen geneerisille tyyppiparametreille oliohierarkian suhteen: Arvojen pitää olla tämän luokan yli/aliluokkia. Tehdään hakupuuesimerkistämme entistä monikäyttöisempi vaihtamalla kiinteän avaintyypin Int tilalle tyyppiparametri k. Silloin on lisättävä myös rajoite (Ord k) => jokaiseen funktioon, jossa vertaillaan avaimia. Myös build on sellainen funktio, vaikka se ei itse vertailekaan avaimia: Se kutsuu funktiota additem joka vertailee. contentsof ei kuitenkaan tarvitse rajoitetta, koska siinä ei käytetä tyypin k järjestystä. Tällainen Tyyppiluokka on Haskellin vastine Javan interfacelle. Tyyppi liittyy jäseneksi Tyyppiluokkaan toteuttamalla sen vaatimat metodit kuten Ord vaatii metodin (<=). Ne eivät ole spesifikaatio- vaan ohjelmointikieliä, joten ne tarkistavat näiden metodien tyypit, mutta eivät niiden toiminnan järkevyyttä. 73
data Tree k t = Empty Node { left :: Tree k t, key :: k, item :: t, right :: Tree k t deriving (Show) additem :: (Ord k) => k -> t -> Tree k t -> Tree k t additem newkey newitem Empty = Node { left = Empty, key = newkey, item = newitem, right = Empty additem newkey newitem this@node{ key = oldkey newkey < oldkey = this { left = additem newkey newitem $ left this newkey > oldkey = this { right = additem newkey newitem $ right this otherwise = this { item = newitem build :: (Ord k) => [(k,t)] -> Tree k t build = foldr (uncurry additem) Empty itemof :: (Ord k) => k -> Tree k t -> Maybe t itemof _ Empty = Nothing itemof newkey this@node { key = oldkey newkey < oldkey = itemof newkey $ left this newkey > oldkey = itemof newkey $ right this otherwise = Just $ item this contentsof :: Tree k t -> [(k,t)] contentsof Empty = [] contentsof this@node{ = contentsof (left this) ++ (key this,item this) : contentsof (right this) contentsof :: Tree k t -> [(k,t)] contentsof = let cont Empty acc = acc 74
Java Jokainen olio kuuluu johonkin luokkaan. Sama luokka toteuttaa (implements) erilaisia rajapintoja. Olio varmaankin perii rajapintatoteutuksensa luokkahierarkiasta Javan säännöillä? Geneerisyyttä voi rajoittaa luokkahierarkian suhteen. Haskell Jokaisella arvolla on jokin tyyppi. Sama tyyppi on erilaisten tyyppiluokkien jäsen eli instanssi (instance). Arvon tyyppi määrää sen instanssit. Haskell-ohjelmoija esittää ne säännöt joilla hänen omalle data-tyyppikonstruktorilleen määrätään sen instanssit. Parametrista monimuotoisuutta voi rajoittaa tyyppiluokilla. Taulukko 2: Javan rajapinnat vs. Haskellin tyyppiluokat. cont this@node{ acc = cont (left this) $ (key this,item this) : cont (right this) acc in flip cont [] Haskell on laajentanut HM-tyypinpäättelyään siten, että se automatisoi myös näitä Tyyppiluokkarajoitteita: Se...... kerää rajoitteita tyypinpäättelyn aikana. Jos esimerkiksi lausekkeessa on (<=) x jossa x :: t niin se lisää rajoitteisiin vastaavan faktan Ord t.... sieventää rajoitteita tyypinpäättelyn aikana. Jos esimerkiksi havaitaan että t=[u] niin rajoite saa muodon Ord [u] joka sieventyy muotoon Ord u koska listatyypillä on järjestys jos (ja vain jos) sen alkiotyypillä on....käsittelee rajoitteet, joissa ei enää ole muuttujia. Jos esimerkiksi u=(int,char) niin rajoite saa muodon Ord (Int,Char) joten vertailuun käytetään nyt ad hoc -monimuotoisen (<=) versiota (<=) :: [(Int,Char)] -> [(Int,Char)] -> Bool jonka Haskell tuottaa.... muistaa ne pääasiallisessa yleistämässään parametrisesti monimuotoisessa tyypinpäättelyn lopputuloksessa. Taulukko 2 vertailee Javan rajapintoja ja Haskellin tyyppiluokkia. 5.3 Automaattinen jäsenyys Haskell voi liittää tyypin automaattisesti jäseneksi joihinkin tyyppiluokkiin. Sen tekee data-määritelmän ylimääräinen deriving-rivi data Uusi =... deriving (Luokka,Luokka,Luokka,...,Luokka) 75
sanoo, että tämä Uusi tyyppi liittyy jäseneksi jokaiseen siinä mainittuun Luokkaan sillä lailla miten tähän Luokkaan tavallisesti liitytään. Toisin sanoen, Haskell tuottaa jokaiselle oletusarvoisen määrittelyn instance Luokka Uusi where... automaattisesti, eikä ohjelmoijan tarvitse kirjoittaa sellaista itse käsin. Ohjelmoija voi kirjoittaa sellaisen itsekin käsin, jos hän haluaakin tehdä sen jotenkin toisin kuin Haskell sen tekisi. Tätä deriving-mekanismia voi käyttää data-määritelmissä vain 6 standardityyppiluokan kanssa: Eq eli ne tyypit, joiden arvoilla on samuusvertailu (==). Se tavallinen tapa tutkia onko x == y on: 1 Onko niillä sama konstruktori? 2 Jos on, niin onko myös niiden kentillä samat arvot? Useimmiten se onkin juuri se oikea tapa vertailla tyypin arvojen samuutta. Siten deriving (Eq,...) on useimmiten juuri se mitä halutaan. Toisaalta esimerkiksi hakupuille tämä tavallinen tapa testaisi ovatko nämä hakupuut täsmälleen saman muotoisia Niille voisi siis olla mielekkäämpää kirjoittaa käsin semanttisempi samuusehto eli sisältävätkö ne täsmälleen samat (avain,tietoalkio)- parit joka ei riipu puiden muodosta. Ord eli ne tyypit, joiden arvoilla on suuruusjärjestysvertailu (<=). Se tavallinen tapa suuruusjärjestää data-määritelmä on järjestää arvot niin, että vertaillaan 1 ensin eri konstruktorit ylhäältä alas 2 sitten saman konstruktorin kentät vasemmalta oikealle niiden kirjoitusjärjestyksessä. Siten data Maybe t = Nothing Just t deriving (Ord,...) järjestää niin, että 1 Nothing < Just mitä tahansa 2 Just x < Just y on x < y. Hakupuut ovat esimerkki tyypistä, jolla ei ole luontevaa suuruusjärjestyksen käsitettä. Show eli ne tyypit, joiden arvot voi tulostaa merkkijonoksi. Tämä tulostusmetodi on nimeltään show. Aiemmin käyttämämme deriving (Show) siis sanoo tämän tyypin arvot voi tulostaa merkkijonoksi ja ghci käyttää tätä metodia show näyttääkseen ne käyttäjälle. Se tavallinen tapa tulostaa arvo on samalla tavalla kuin data-määritelmässä kirjoitettiin. 76
Read eli ne tyypit joiden arvon voi lukea merkkijonosta. Tämä lukumetodi on nimeltään read. deriving (Eq,Show,Read) toteuttavat ehdon read (show x) == x jokaiselle äärelliselle arvolle x (read-write invariance). Tätä metodia read käytettäessä sen tulokselle on annettava monomorfinen tyyppi, jotta tiedetään täsmälleen minkä tyyppistä arvoa nyt luetaan. Tämä metodi read ei korvaa oikeaa jäsennysfunktiota (parser) koska se on hidas pitkillä merkkijonoilla eikä käsittele kirjoitusvirheitä niissä käyttäjäystävällisesti. Bounded eli ne tyypit joilla on pienin ja suurin arvo. Pienimmän arvon nimi on minbound. Suurimman arvon nimi on maxbound. Esimerkiksi minbound :: Int on pienin ohjelmoijan kokonaisluku. Enum eli ne tyypit joilla on luontevat käsitteet seuraava suurempi arvo ja edellinen pienempi arvo. Seuraavan antaa metodi succ. Esimerkiksi numeroilla succ = (+ 1). Edellisen antaa metodi pred. Näillä tyypeillä voi käyttää listanotaatiota [eka,toka..vika]. deriving (Enum,...) on mahdollista kaikille niille tyypeille, joiden yhdelläkään konstruktorilla ei ole lainkaan parametreja esimerkiksi lukutyypeille. Uusvanhan tyypin automaattinen jäsenyys vanhassa tyypissään Näitä 6 standardityyppiluokkaa voi käyttää myös määritelmässä newtype Uusi = Ainoa Vanha deriving (...) Lisäksi ghc sisältää laajennuksen GeneralizedNewtypeDeriving jolla newtypen deriving voi mainita minkä tahansa muunkin tyyppiluokan, johon sen Vanha tyyppi kuuluu. Silloin tämä Uusi tyyppi saa automaattisesti ne samat jäsenyydet kuin Vanhakin. Sillä voi ilmaista vaivattomasti että tämä Uusi tyyppi on näissä metodeissa samanlainen kuin Vanhakin ja ohjelmoija voi keskittyä kirjoittamaan vain ne funktiot joissa Uusi onkin erilainen kuin Vanha. 5.4 Jo määritellyn tyyppiluokan jäseneksi liittyminen Ohjelmoija voi liittää oman Tyyppinsä jo määriteltyyn Luokkaaan päätason määritelmällä jonka instance (faktat) => Luokka Tyyppi where... 77
faktat rajoittavat niitä tyyppiparametreja jotka Tyyppi sisältää. (Tämä koko rajoite voidaan jättää pois, jos se on tarpeeton.) where-osa sisältää niiden metodien, jotka tämä Luokka vaatii, toteutukset tämäntyyppiselle syötteelle. Näissä totetuksissa saa hyödyntää niitä tyyppiluokkien jäsenyyksiä jotka faktat lupasivat. Intuitiivisesti: Jos nämä faktat ovat voimassa, niin tämä Tyyppi kuuluu tuohon Luokkaaan tällä tavalla.... Ne metodit, jotka jo määritely Luokka vaatii jäseniltään, on (toivottavasti...) selostettu sen dokumentaatiossa. 78