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

9 Kontrolliabstraktiot

Muutettu viimeksi 9.3.2016 / 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(_))
Käyttöesimerkki:
for (f <- FileMatcher.filesEnding("txt"))
  println(f)
Näissä funktioliteraaleissa käytetään edellä opittuja paikanpitäjäparametreja: ensimmäinen alaviiva vastaa ensimmäistä parametria, toinen toista. Saman saa ilmaista toki pidemmästikin:
(fileName: String, query: String) => fileName.endsWith(query)
Tyyppipäättelyn ansiosta (vai syystä?) myös seuraava on mahdollinen muoto:
(fileName, query) => fileName.endsWith(query)

Koska kaikissa tapauksissa query on sama, rakaisua voidaan vielä yksinkertaistaa, "refaktoroida", hieman:

  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.

Huom: Kannattaa kiinnittää huomiota siihen, että tässä viimeisessä versiossa käytetään aitoja sulkeumia: kyselyfunktioiden parametri query suljetaan noihin funktioliteraaleihin!

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 Scalassa on predikaattifunktion parametrina saava exists-aksessori monille kokoelmatyypeille, sovellusohjelmoija voi hoidella homman vaivatta antamalla predikaattifunktion literaalina:
 def containsNeg(nums: List[Int]) = nums.exists(_ < 0)
Samaan tapaan, sen sijaan että kyseltäisiin esimerkiksi parittoman alkion olemassaoloa algoritmisesti, voidaan antaa parittomuuspredikaatti funktioliteraalina
  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ä "tavallinen" kaksiparametrinen funktio ja sen Curry-muunnettu versio:

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

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

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

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

Laskenta vastaa seuraavaa:

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

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

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: (x: Int)(y: Int)Int

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

scala> viisPlus(9)
res0: Int = 14

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

val seuraaja = (x: Int) => x + 1
val tuplattu = (x: Int) => 2*x
val nelioity = (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(nelioity, 14) )  // 196

// jne...
Funtio funktion paluuarvona:

scala> def f(m:Int) = {(x:Int) => x+m}  // tyyppi päätellään f:((Int)=>Int)
f: (m: Int)Int => Int

scala> f(4)(3)
res0: Int = 7

scala> f(10)(4)
res1: Int = 14

scala> val a = f(11)
a: Int => Int = <function1>

scala> a(8)
res2: Int = 19

Omia kontrolliabstraktioita

Ensin pieni johdanto tekstitiedostoon tulostamisesta, koska kirjan esimerkeissä juuri tämän kanssa puuhaillaan. Laaditaan sovellus, jolla voi kirjoittaa näppäimistöltä tekstiä tiedostoon gradu.txt
import java.io._

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

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

tulos.close()
println("Työt tehty!\n")

Määritellään sitten 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 nämä twice-funktion käyttöesimerkit tulostavat 7.0. Jokaisessa on annettu tavalla tai toisella funktio, joka palauttaa parametrinsa arvon yhdellä kasvatettuna. Tulkki osaa kauniisti kertoa twicen tyypin "scalaksi":
scala> def twice(op: Double => Double, x: Double) = op(op(x))
twice: (op: Double => Double, x: 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.

Kokeile välinettä:

  withPrintWriter(
    new File("tmp.txt"),
    writer => { writer.println("hölökyn kölökyn")
                writer.println("töttöröö")
              }
  )
Pitäisi syntyä 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 "kontrollirakenteelta", Scalan kalustosta kyllä keinoja löytyy 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öö")
               }
  }

Ehkäpä nuo aaltosulkeet vaikuttavat luontevilta parametrina annettavan funktioliteraalin ympärillä?

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 = scala.io.StdIn.readLine
                while (rivi != "") {
                  writer.println(rivi)
                  rivi = scala.io.StdIn.readLine
                }
                println("Työt tehty!\n")
              }
  }

Nimiparametreista

Edellä nähdyissä esimerkeissä (paitsi luvun 8 esimerkeissä "Funktioparametrin evaluointiaika" ja "Vielä pari esimerkkiä sulkeumista") 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

    => muodollinenFunktioparametri

ja 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 tarkistukset 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 sovellusohjelmoija voi alkaa valittaa: "Miks' mun pitää kirjoittaa tuo hölmön näköinen "() =>", miksen mä saa kirjoittaa vain tähän tyyliin:

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.

Ongelmia tulee esimerkiksi jos väittämän laskennalla on jokin sivuvaikutus, jonka ei toivota toteutuvan, kun assertionsEnabled on false. Tai seuraavan tyyppinen tilanne:

...
var j = 0
...
myAssert(100/j == 0)  // tulee nollalla jako ennen aikojaan...

Vaan otetaanpa avuksi tuo jo alussa paljastettu rakenne " => muodollinenFunktioparametri" 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 omaan toistolause-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
}