Helsingin yliopisto / tietojenkäsittelytieteen laitos / Ohjelmointitekniikka (Scala) / © Arto Wikla 2010

9 Kontrolliabstraktiot

Muutettu viimeksi 8.4.2010 / Sivu luotu 1.4.2010 / [oppikirjan esimerkit] / [Scala]

Sivun sisältöä:

Koodin tiivistämistä funktioparametrein

Perinteisesti ohjelmoiden "melkein samanlaista" koodia joutuu usein kertaamaan. Esimerkkinä tästä tiedostonimien luettelemista eri perustein:
  object FileMatcher {
    private def filesHere = (new java.io.File(".")).listFiles

    def filesEnding(query: String) =
      for (file <- filesHere; if file.getName.endsWith(query))
        yield file

    def filesContaining(query: String) =
      for (file <- filesHere; if file.getName.contains(query))
        yield file

    def filesRegex(query: String) =
      for (file <- filesHere; if file.getName.matches(query))
        yield file
  }
Ainoa ero noissa kolmessa tapauksessa siis on käytettävä merkkijonopredikaatti. Kaikilla on muuten sama hahmo:
    def filesMatching(query: String, predikaatti) =
      for (file <- filesHere; if file.getName.predikaatti(query))
        yield file
Tämä ei kuitenkaan vielä ole ihan sitä meidän vahvasti ja staattisesti tyypitettyä Scalaamme...

Siispä kirjoitetaan predikaatin tyyppi näkyviin:

    def filesMatching(query: String, matcher: (String, String) => Boolean) = {
      for (file <- filesHere; if matcher(file.getName, query))
        yield file
    }
Tämän avulla on sitten helppo toteuttaa nuo aloitusesimerkin operaatiot:
  def filesEnding(query: String) =
    filesMatching(query, _.endsWith(_))

  def filesContaining(query: String) =
    filesMatching(query, _.contains(_))

  def filesRegex(query: String) =
    filesMatching(query, _.matches(_))
Näissä käytetään edellä opittuja paikanpitäjäparametreja. Pidemmästikin saa toki kirjoittaa. Esimerkiksi tuo ensimmäinen voidaan ilmaista "pitkän kaavan kautta" näin:
(fileName: String, query: String) => fileName.endsWith(query)

Ja lopulta koko "refaktoroitu" versio:

  object FileMatcher {
    private def filesHere = (new java.io.File(".")).listFiles

    private def filesMatching(matcher: String => Boolean) =
      for (file <- filesHere; if matcher(file.getName))
        yield file
  
    def filesEnding(query: String) =
      filesMatching(_.endsWith(query))
  
    def filesContaining(query: String) =
      filesMatching(_.contains(query))
  
    def filesRegex(query: String) =
      filesMatching(_.matches(query))
  }
Eipä tuo uudempi versio kovin paljon lyhyempi ole, mutta ehkäpä se on hivenen selkeämpi. Uusien hakutapojen lisääminen olisi myös aika suoraviivaista.

Koodin yksinkertaistamista funktioparametrein

Edellinen esimerkki osoitti, miten ohjelmointivälineiden ("APIn") laadintaa voitiin selkeyttää. Tässä katsotaan pieni esimerkki, miten sovellusohjelmaa voidaan yksinkertaistaa funktioparametrein.

Perinteinen tapa tutkia, onko listassa (tms.) vaikkapa negatiivisia lukuja, menee esimerkiksi siten, että oletetaan, ettei ole ja katsotaan alkio kerrallaan, kumoutuuko oletus:

  def containsNeg(nums: List[Int]): Boolean = {
    var exists = false
    for (num <- nums)
      if (num < 0)
        exists = true
    exists
  }
Koska kiltti Scala-yhteisö on toteuttanut hienon -- predikaattifunktion parametrina saavan -- exists-aksessorin monille kokoelmatyypeille, sovellusohjelmoija voi hoidella homman vaivatta:
 def containsNeg(nums: List[Int]) = nums.exists(_ < 0)
Samaan tapaan sen sijaan että askarreltaisiin algoritmisesti
  def containsOdd(nums: List[Int]): Boolean = {
    var exists = false
    for (num <- nums)
      if (num % 2 == 1)
        exists = true
    exists
  }
voidaan kirjoittaa tyylikkään kompaktisti:
def containsOdd(nums: List[Int]) = nums.exists(_ % 2 == 1)

Curry-muunnos

"Curryttaminen", "Curry-muunnos", "kurittaminen"(?!(ei!)) on funktionaalisen ohjelmoinnin perusoperaatioita. Se kirjoitetaan isolla, koska kyseessä on Haskell Curry -nimisen loogikon sukunimi. Hänen etunimensäkin on ikuistettu(?) ohjelmoinnin maailmaan... ;-)

Kyse on funktiomuunnoksesta, jossa funktion argumenteista poistetaan yksi muodostamalla uusi "funktio funktiolle":

Eli funktion f: (X x Y) ---> Z Curry-muunnos on curry(f): X ---> (Y ---> Z).

Tämä esimerkkimuunnos siis kuvaa kaksiargumenttisen funktion uudeksi funktioksi, joka kuvaa alkuperäisen ensimmäisen argumentin kuvaukseksi toiselta alkuperäiseltä argumentilta alkuperäisen funktion arvolle. Voiko sen enää selvemmin sanoa...

Käytännössä (=teoriassa?) siis riittää, että käytössä on vain yksiparametrisia funktioita, koska kaikki muut funktiot voidaan Curry-muunnoksella palauttaa yksiparametrisiksi funktioiksi. Ajatelkaa mikä ilo tämä on matemaatikolle: Kun hän onnistuu todistamaan jotakin yksiargumenttisille funktioille, samalla tulee todistettua asia kaikille funktioille, joilla on äärellinen määrä argumentteja! Mahtaisiko iloa olla myös ohjelmointikielen toteuttajalle? Riittää toteuttaa vain yksiparametriset funktiot ja Curry? (Edellytys Curry-muunnoksen tekemiselle tietysti on, että funktioiden arvoina sallitaan funktioita.)

Ohjelmointikielillä, joissa funktiot ovat first-class-arvoja, voidaan esittää funktioarvoisia funktioita ja mm. tehdä Curry-muunnoksia. Esimerkkinä Scala-kielinen funktio ja sen Curry-muunnettu versio:

scala> def plainOldSum(x: Int, y: Int) = x + y
plainOldSum: (Int,Int)Int

scala> plainOldSum(9,5)
res0: Int = 14

scala> def curriedSum(x: Int)(y: Int) = x + y
curriedSum: (Int)(Int)Int

scala> curriedSum(9)(5)  // huom!
res1: Int = 14
Matematiikan merkinnöin plainOldSum: (Int x Int) ---> Int ja curriedSum: Int ---> (Int ---> Int).

Laskenta vastaa seuraavaa:

scala> def first(x: Int) = (y: Int) => x + y
first: (Int)(Int) => Int

scala> val second = first(5)
second: (Int) => Int = <function>

scala> second(9)
res0: Int = 14
Aiemmin jo nähtiin sellaisia osittain sovellettuja funktioita, joissa parametrien määrää supistettiin korvaamalla osa parametreista vakioarvoilla. Jos olen oikein ymmärtänyt, Curry-muunnos ei ole mitään muuta kuin osittain sovellettuja funktioita, joissa korvataan parametri funktiolla. Näin myös se aiemmin nähty "osittain sovellettujen funktioiden" -käsite on vain eräs erikoistapaus Curry-muunnoksesta: tavallaan tuolloin parametreja korvataan vakiofunktiolla.

"Curry-rakenteisista" funktioista saa helposti erikoistettua uusia funktioita:

scala> def curriedSum(x: Int)(y: Int) = x + y
curriedSum: (Int)(Int)Int

scala> val viisPlus = curriedSum(5)_    // huom. placeholder!
viisPlus: (Int) => Int = <function>

scala> viisPlus(9)
res0: Int = 14

Tyypitettyjä Lambda-lausekkeita Scalalla: (Tämä kohta kuuluu oikeastaan syventävien opintojen kurssille Ohjelmointikielten periaatteet!)

val seuraaja = (x: Int) => x + 1
val tuplattu = (x: Int) => 2*x
val neliöity = (x: Int) => x*x

val sovellus = (f:(Int) => Int, a:Int) => f(a)

println( sovellus(seuraaja,  1) )  // 2
println( sovellus(seuraaja, 20) )  // 21

println( sovellus(tuplattu, 33) )  // 66

println( sovellus(neliöity, 14) )  // 196

// jne...
Lisäesimerkkejä:

Funtio funktion paluuarvona:

def f(m:Int) = {(x:Int) => x+m}  // tyyppi päätelty f:((Int)=>Int)
println( f(4)(3) )   // 7
println( f(4)(10) )  // 14
...

Omia kontrolliabstraktioita

Ensin pieni asiaankuulumaton johdanto tekstitiedostoon tulostamisesta, koska kirjan esimerkeissä juuri tämän kanssa puuhaillaan:

Peruskurssien Java-esimerkki muokattuna Scalalle:

import java.io._

val tulos = new PrintWriter("gradu.txt")
println("Kirjoittamasi teksti menee tiedostoon gradu.txt")
println("(tyhjä rivi lopettaa!)\n")

var rivi = readLine
while (rivi != "") {
  tulos.println(rivi)
  rivi = readLine
}

tulos.close()
println("Työt tehty!\n")
Eli tuttua tavaraa ja tuttuun Scalamaiseen tapaan vaivattomampaa kirjoitttaa.

Määritellään ensin sormiharjoituksena uusi "kontrollirakenne" twice, joka soveltaa Double-->Double-funktiota kahteen kertaan:

def twice(op: Double => Double, x: Double) = op(op(x))

def kasvataYhdella(a: Double) = a + 1
println( twice(kasvataYhdella, 5) )

println( twice((x: Double) => {x + 1}, 5) )

println( twice((x: Double) => x + 1, 5) )

println( twice(x => x + 1, 5) )

println( twice(_ + 1, 5) )
Kaikkki twicen käyttöesimerkit tulostavat 7.0. Tulkki osaa kauniisti kertoa twicen tyypin "scalaksi":
scala> def twice(op: Double => Double, x: Double) = op(op(x))
twice: ((Double) => Double,Double)Double

Laaditaan sitten väline, jota käyttäen "tekstitiedoston sulkeminen ei pääse unohtumaan":

  import java.io._

  def withPrintWriter(file: File, op: PrintWriter => Unit) {
    val writer = new PrintWriter(file)
    try {
      op(writer)
    } finally {
      writer.close()
    }
  }
Funktiolle withPrintWriter siis annetaan parametrina "tiedostokahva" (Javan File-olio) ja funktio, joka kuvaa PrintWriter-olion algoritmille ("Unit-funktiolle"), joka tavalla tai toisella kirjoittelee tiedostoon. Jos tulee poikkeuksia, niistä huolehtii (halutessaan) withPrintWriter-funktion kutsuja. Mutta withPrintWriter itse avaa varsinaisen tiedoston ja varmistaa joka tapauksessa, että sekä onnistuneen että poikkeukseen päättyvän operaation jälkeen tiedosto varmasti suljetaan.

Kokeillaan välinettä:

  withPrintWriter(
    new File("tmp.txt"),
    writer => { writer.println("hölökyn kölökyn")
                writer.println("töttöröö")
              }
  )
Näkyy syntyvän haluttu tiedosto.

Huom: Tässäkin tyyppipäättelijä helpottaa (vai vaikeuttaa?) ohjelmoijan elämää! Tuon funktioliteraalin saa kirjoittaa pidemmässäkin muodossa:

    (writer: PrintWriter) => { writer.println("hölökyn kölökyn")
                               writer.println("töttöröö")
                             }

Jos tuo oman "kontrollirakenteen" kutsu withPrintWriter(file, algoritmi) ei vielä näytä tarpeeksi "kontrollirakenteiselta", Scalan kalustosta kyllä löytyy keinoja tähänkin lähtöön...

Ensinnäkin: yksiparametrisen funktion kutsussa todellinen parametri voidaan kaarisulkeiden sijaan ympäröidä myös aaltosulkeilla:

scala> println{"Onpa hienoa!"}
Onpa hienoa!

Toiseksi: vaikka withPrintWriter olikin kaksiparametrinen, ei hätää, tehdään Curry-muunnos:

  def withPrintWriter(file: File)(op: PrintWriter => Unit) {
    val writer = new PrintWriter(file)
    try {
      op(writer)
    } finally {
      writer.close()
    }
  }
Ja nyt withPrintWriterin käyttö näyttää seuraavalta:
  withPrintWriter( new File("tmp.txt") ) {
    writer => { writer.println("hölökyn kölökyn")
                writer.println("töttöröö")
              }
  }
Toteutetaan lopuksi alun tulostustiedostoesimerkki tällä uudella, hienolla ja ikiomalla kontrollirakenteella:
  import java.io._

  def withPrintWriter(file: File)(op: PrintWriter => Unit) {
    val writer = new PrintWriter(file)
    try {
      op(writer)
    } finally {
      writer.close()
    }
  }


  withPrintWriter( new File("gradu.txt") ) {
    writer => {
                println("Kirjoittamasi teksti menee tiedostoon gradu.txt")
                println("(tyhjä rivi lopettaa!)\n")

                var rivi = readLine
                while (rivi != "") {
                  writer.println(rivi)
                  rivi = readLine
                }
                println("Työt tehty!\n")
              }
  }

Nimiparametreista

Edellä nähdyissä esimerkeissä (paitsi luvun 8 lopun "ohjelmointikielten periaatteet" -esimerkeissä) todellinen funktioparametri on annettu aina muodossa, jossa parametrifunktion omat muodolliset parametrit tavalla tai toisella on annettu - siis tyyliin:
  withPrintWriter(
    new File("tmp.txt"),
    writer => { writer.println("hölökyn kölökyn")
                writer.println("töttöröö")
              }
  )
Tai pidemmässä muodossa:
    (writer: PrintWriter) => { writer.println("hölökyn kölökyn")
                               writer.println("töttöröö")
                             }

Jos haluaa tehdä "enemmän oikean näköisiä" kontrollirakenteita eikä parametreja tarvita, voi tehdä toisinkin. Muodollisen funktioparametrin parametrilistan voi jättää tyhjäksi

    => muodollisenFunktioparametrinKutsu
ja a'vot tai voilà, jo alkavat omat välineet muistuttaa oikeita kontrollirakenteita...

Ensin kirjan esimerkki lyhyesti ja muokattuna: Laaditaan oma väittämien tarkistusväline. Ideana on että ohjelman testausvaiheessa väittämät tarkistetaan, mutta tuotantoversiosta nämä mahdollisesti raskaat tarkistuslaskennat jätetään pois. Ja senhän tietää, mitä siitä sitten seuraa...;-)

Ohjelmoidaan väittämän tarkistusfunktio:

var assertionsEnabled = true

def myAssert(predicate: () => Boolean) =
  if (assertionsEnabled && !predicate())
     throw new AssertionError
Nyt erilaisiin kriittisiin ohjelmakohtiin voi kirjoitella väittämiä tyyliin:
myAssert(() => jokin väittämä muuttujien arvosta)  // esim. x!=666
Homma toimii hienosti: Kun assertionsEnabled==true, predikaatti lasketaan. Ja koska todellinen predikaatti on sulkeuma, se todellakin lasketaan kutsukohdan ympäristössä!

Kun assertionsEnabled==false, predikaattia ei lasketa. Muista että && on ehdollinen and: jos ensimmäinen operandi on epätosi, toista ei evaluoida!

Hienoa muuten, mutta (marinaa ja murinaa:) "miks' mun pitää kirjoittaa tuo hölmön näköinen () =>, miksen mä saa kirjoittaa":

myAssert(jokin väittämä muuttujien arvosta) //  ei toimi, koska () "=>" puuttuu...

No, auttavainen systeemiohjelmoija yrittää tyydyttää sovellusohjelmoijan toiveen:

def myAssert(predicate: Boolean) =
  if (assertionsEnabled && !predicate)
    throw new AssertionError
Jo kelpaa kääntäjälle ja tulkille tuo toivottu muoto:
myAssert(jokin väittämä muuttujien arvosta)
Tässä kuitenkin jouduttiin ojasta allikkoon: Tavallisen arvoparametrin tapaan tuo todellinen parametri "jokin väittämä muuttujien arvosta" evaluodaan nyt aina ennen myAssert-funktion kutsua! Riippumatta assertionsEnabled-muuttujan arvosta.

Vaan otetaanpa avuksi tuo jo alussa paljastettu rakenne " => muodollisenFunktioparametrinKutsu" ja kirjoitetaan

def myAssert(predicate: => Boolean) =
  if (assertionsEnabled && !predicate)
    throw new AssertionError
Nyt tuo toivottu muoto
myAssert(jokin väittämä muuttujien arvosta)
sekä kelpaa että myös tarkoittaa sitä, mitä haluttiin!

Palataan samassa hengessä vielä edellisen luvun 8 lopun esimerkkiin:

def toista(algoritmi: =>Unit, kertaa:Int) {
  for (i <- 1 to kertaa) algoritmi
}

var x = 0; val k = 2
toista(x+=k, 5)
println(x)                      // 10
Jos tuon " =>":n jättää pois, kaikki ns. "toimii", mutta x:ää kasvatetaa vain kerran k:n arvolla! Parametri evaluoidaan arvoparametrina ennen kutsutun funktion käynnistämistä.

Jospa nyt lopuksi vähän diivailtaisiin...

Muokataan edellistä toistorakennetta vähän ja erityisesti tehdään Curry-muunnos, niin jopa alkaa näyttää omalta kontrollirakenteelta:

def toistokertoja(kertaa:Int)(algoritmi: =>Unit) {
  for (i <- 1 to kertaa) algoritmi
}

var x = 0; val k = 2

toistokertoja(5) {
 x+=k
}

println(x)

toistokertoja(2) {
  println("Scala on kivaa?")
  println("vai...")
}

toistokertoja(3) {
  toistokertoja(7) {
    print("*")
  }
  println
}