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

6 Funktionaalisia olioita ja vähän muutakin

Muutettu viimeksi 5.4.2016 / Sivu luotu 19.3.2010 / [oppikirjan esimerkit] / [Scala]

Sivun sisältöä:

Pääkonstruktori

Muuttumaton eli immutaabeli olio on kerran synnyttyään aina saman sisältöinen. Sillä (=sen kentillä) on siis aina sama arvo. Muutettavalla eli mutaabelilla oliolla on vastaavasti tila, joka voi ajassa muuttua. Oppikirjan hengessä aletaan rakennella immutaabelia rationaalilukuluokkaa Rational. Välillä katsotaan muitakin esimerkkejä periaatteista ja tekniikoista.

Muuttumaton rationaaliluku voidaan määritellä lyhyesti:

class Rational(os: Int, nim: Int)
Tunnukset os ja nim tarkoittavat luokkaparametreja. (Älä sekoita näitä Javan "luokkamuuttujiin"! Muista ettei Scalassa ole mitään "staticcia".)

Tällä tavoin kirjoitetusta luokkaparametreista synytetään jokaiselle luotavalle oliolle omat salaiset vakiokentät. Luokkamäärittelyn otsake siis määrittelee pääkonstruktorin:

val x = new Rational(1,2)
println(x);                      // Main$$anon$1$Rational@7ba4f24f
Aaltosulkeitakaan ei tarvita, ellei luokkamäärittelyltä muuta haluta kuin keino luoda oliota luokkaparametrikentin. Tyhjän määrittelylohkon { } saa toki kirjoittaa, jos jostain syystä haluaa. Olion oletusarvoinen tulostustaitokin näkyy luokkaan Javan tapaan periytyvän "jostain ylempää".

Usein luokkaan kuitenkin halutaan muitakin kenttiä ja monenmoisia metodeita. Myös saatetaan haluta suorittaa joitakin toimenpiteitä aina, kun olio luodaan. Tällaiset toimenpiteet ohjelmoidaan sellaisinaan luokkamäärittelyn runkoon:

class Rational(os: Int, nim: Int) {
  println("Hip, hei, syntyi luku: "+ os +"/"+ nim)
}

val x = new Rational(1, 2)       // Hip, hei, syntyi luku: 1/2
val y = new Rational(764, 321);  // Hip, hei, syntyi luku: 764/321

Oliota luotaessa siis määritellään kenttiä ja metodeita ja suoritetaan luokkamäärittelyn mahdollisesti sisältämät lauseet (tms.).

Korvataan peritty toString-metodi omalla:

class Rational(os: Int, nim: Int) {
  override def toString = os +"/"+ nim
}

var x = new Rational(1,2)
println(x);                      // 1/2
x =  new Rational(1234,5678)
println(x);                      // 1234/5678

Kenttiä, kenttiä, kenttiä

Kerrataan taas kyllästymiseen asti, mutta myös täydennetään:

Luokkaan voidaan määritellä kenttiä erilaisin tavoin:

Sarjanumeroita

Ainokaista käyttäen on helppo ohjelmoida esimerkiksi "sarjanumeron generointi" (mikä tunnettuun tapaan Javalla tehdään staticeilla):
class Rational(os: Int, nim: Int) {
  Rational.lkm += 1
  println("Hip, hei, syntyi " +  Rational.lkm +". luku: "+ os +"/"+ nim)
}
object Rational {             // oliokumppani kätkee lukumäärälaskurin
  private var lkm = 0
}

val x = new Rational(1, 2)       // Hip, hei, syntyi 1. luku: 1/2
val y = new Rational(764, 321);  // Hip, hei, syntyi 2. luku: 764/321

// Rational.lkm = -666  **** error: variable lkm cannot be accessed in object this

Laskuri saatiin private-määreellä turvaan ulkopuolisilta! Ja luokaan siis näkyvät oliokumppanin yksityiset kentät. Koska toisaalta oliokumppanin sisältä ei näe luokkakumppanin kenttiä ilman luokan ilmentymää, mieleen tulee elävästi Javan sääntö "staticista ei näe ei-staticciin, mutta ei-staticista näkee staticciin".

Hyvä peruste on tietenkin se, että luokasta Rational voi olla useita ilmentymiä ja siten myös kentistä useita versioita. Rational-ainokainen sen sijaan on ainoa lajissaan ja sen kentät ovat aina yksikäsitteiset.

Ehto, vaatimus ja väittämä

Ilmauksella require(väittämä) voidaan ohjelmoida väittämiä, joiden ohjelmoijan mielestä pitäisi olla aina tosia. Voidaan esimerkiksi ajatella, että luokan Rational dokumentaatiossa on sanottu, että käyttäjän on aina itse varmistettava, ettei luotavan olion nimittäjä ole nolla. Ja jos on, huonosti käyköön...

Ohjelmoidaan rationaaliluvun konstruoinnin alkuun ennakkoehtona (pre-condition) nimittäjän ei-nolluuden tarkistus:

class Rational(os: Int, nim: Int) {
  require(nim != 0)
  override def toString = os +"/"+ nim
}

val x = new Rational(666, 0)
println(x);
Ja huonostihan siinä käy:
java.lang.IllegalArgumentException: requirement failed
  ...
  ...
      ja rivikaupalla muita virheilmoituksia ...
  ...

Kenttiä ja metodeja

Hienojahan nuo Rational-oliot ovat, mutta pitäisihän niillä päästä laskemaankin!

Kuten edellä todettiin, määreettömät luokkaparametrit näkyvät vain oliolle itselleen; kyseessä on siis olioprivaattius. Niinpä luokkaan Rational ei ole mahdollista ohjelmoida metodia, jossa käsiteltäisiin parametrina saadun Rational-olion luokkaparametrien tarkoittamia kenttiä. Ja niihinhän pitää päästä käsiksi, jotta pääsisi aritmetiikkaa harrastamaan.

Ratkaisu löytyy määrittelemällä val-kentät, joihin kopioidaan luokkaparametrien arvot. Ne voitaisiin tehdä toki privaateiksi, mutta mikseipä noita rationaaliluvun muuttumattomia osia voi näyttää ulkomaailmallekin? Saadaan ikäänkuin ilmaiset "getterit". Muuttamaanhan val-kenttiä ei kukaan pääse.

Ohjelmoidaan rationaalilukujen yhteen- ja kertolasku:

class Rational(os: Int, nim: Int) {
  require(nim != 0)

  val osoittaja = os
  val nimittaja = nim

  def +(that: Rational): Rational =
    new Rational(
      osoittaja * that.nimittaja + that.osoittaja * nimittaja,
      nimittaja * that.nimittaja
    )

  def *(that: Rational): Rational = 
    new Rational(osoittaja * that.osoittaja, nimittaja * that.nimittaja)

  override def toString = osoittaja +"/"+ nimittaja
}

val a = new Rational(2, 3)
val b = new Rational(4, 5)

val c = a + b
println(c)              // 22/15
println(a*a)            // 4/9
println(b.osoittaja)    // 4   ** jos osoittaja ja nimittaja olisivat private,
                        //     ** tällainen viittaus ei olisi luvallinen

Lisäkonstruktorit

Kokonaisluvutkin ovat rationaalilukuja: 1/1, 2/1, 3/1, ... Tyhmälle tietokoneelle (= luokka Rational) tuollaisetkin rationaaliluvut pitää kuitenkin antaa muodossa new Rational(3, 1).

Mikä neuvoksi? No tietenkin pääkonstruktoria kuormittava yksiparametrinen lisäkonstruktori:

class Rational(os: Int, nim: Int) {
  require(nim != 0)

  val osoittaja = os
  val nimittaja = nim

  def this(n: Int) = this(n, 1) // lisäkonstruktori kutsuu pääkonstruktoria

  def +(that: Rational): Rational =       //   (itseviittauksessa voisi Javan tapaan
   new Rational(                          //    käyttää ilmausta this)
      osoittaja * that.nimittaja + that.osoittaja * nimittaja,
      nimittaja * that.nimittaja
   )

  def *(that: Rational): Rational =
    new Rational(osoittaja * that.osoittaja, nimittaja * that.nimittaja)

  override def toString = osoittaja +"/"+ nimittaja
}

val a = new Rational(5, 6)
val kolmonen = new Rational(3)

println(kolmonen)     // 3/1
println(a * kolmonen) // 15/6
Lisäkonstruktorit voivat pääkonstruktorin sijasta toki kutsua myös toisia lisäkonstruktoreita. Mutta vain luokan kirjoitusasussa aiemmin määriteltyjä konstruktoreita on luvallista kutsua.

Huom: Myös lisäkonstruktorissa voi olla suoritettava lohko. Mutta tämän konstruktorin kutsuman toisen konstruktorin kutsun on oltava tuon lohkon ensimmäinen operaatio!

Huom: Tietokone (= luokka Rational) jää vieläkin niin tyhmäksi, ettei se osaa seuraavaa:

val a = new Rational(5, 6)
println(a * 2)
println(2 * a)  // tästä nyt puhumattakaan!
Tähänkin ongelmaan löytyy pian apu!

Yksityiset kentät ja metodit

Käytetään vielä tuota edellistä Rational-määrittelyä:
val a = new Rational(2, 3)
val b = new Rational(4, 5)

println(a + a * b + b)      // 450/225
Voidaan huomata sellainen myönteinen seikka, että laskutoimitukset sitovat oikein. Mutta kielteistäkin näkyy: Rational-luokka ei osaa sieventää rationaalilukuja. Opetetaan se sille ohjelmoimalla suurimman yhteisen tekijän (syt) etsintä ja sitten supistaminen sen avulla:
class Rational(os: Int, nim: Int) {
  require(nim != 0)

  private val sytinArvo = syt(os.abs, nim.abs)

  val osoittaja = os  / sytinArvo     // supistetaan aina, kun luodaan
  val nimittaja = nim / sytinArvo     // uusi olio! "normeerataan"

  def this(n: Int) = this(n, 1) // lisäkonstruktori kutsuu pääkonstruktoria

  def +(that: Rational): Rational =       //   (itseviittauksessa voisi Javan t$
   new Rational(                          //    käyttää ilmausta this)
      osoittaja * that.nimittaja + that.osoittaja * nimittaja,
      nimittaja * that.nimittaja
   )

  def *(that: Rational): Rational =
    new Rational(osoittaja * that.osoittaja, nimittaja * that.nimittaja)

  override def toString = osoittaja +"/"+ nimittaja

  private def syt(a: Int, b: Int): Int =    // Rekursiiviselle metodille
    if (b == 0) a else syt(b, a % b)        // on annettava tyyppi!
}

println(new Rational(7,49)) // 1/7

val a = new Rational(2, 3)
val b = new Rational(4, 5)
println(a + a * b + b)      // 2/1

Tyylikästä vai mitä!

Tunnuksista ja niiden tyylistä

Metodien kuormittamisesta ja automaattisista tyyppimuunnoksista

Edellä todettiin eräs puute Rational-luokassa: kokonaislukujen ja rationaalien aritmetiikan sekoittaminen ei onnistu.

No, ei tässä mitään hätää, kuormitetaan yhteenlasku ja kertolasku:

class Rational(os: Int, nim: Int) {
  require(nim != 0)

  private val sytinArvo = syt(os.abs, nim.abs)

  val osoittaja = os  / sytinArvo     // supistetaan aina, kun luodaan
  val nimittaja = nim / sytinArvo     // uusi olio! "normeerataan"

  def this(n: Int) = this(n, 1) // lisäkonstruktori kutsuu pääkonstruktoria

  def +(that: Rational): Rational =       
   new Rational(                          
      osoittaja * that.nimittaja + that.osoittaja * nimittaja,
      nimittaja * that.nimittaja
   )

  def +(i: Int): Rational =
   new Rational(osoittaja + i * nimittaja, nimittaja)

  def *(that: Rational): Rational =
    new Rational(osoittaja * that.osoittaja, nimittaja * that.nimittaja)

  def *(i: Int): Rational =
    new Rational(osoittaja * i, nimittaja)

  override def toString = osoittaja +"/"+ nimittaja

  private def syt(a: Int, b: Int): Int =    // Rekursiiviselle metodille
    if (b == 0) a else syt(b, a % b)        // on annettava tyyppi!
}

val a = new Rational(2, 3)
val b = new Rational(4, 5)

println(a + 1)  // 5/3
println(b * 2)  // 8/5

Nyt voi siis rationaalilukuun lisätä kokonaisluvun ja sen voi kertoa kokonaisluvulla. Mutta toisin päin ei vielä onnistu:

Yritys println(1 + a) johtaa ilmoitukseen:

error: overloaded method value + with alternatives:
  (x: Double)Double 
  (x: Float)Float 
  (x: Long)Long 
  (x: Int)Int 
  (x: Char)Int 
  (x: Short)Int 
  (x: Byte)Int 
  (x: String)String
 cannot be applied to (this.Rational)
println(1 + a)
          ^
one error found

Jotenkin olisi kuitenkin kivaa, että yhteenlasku ja kertolasku olisivat vaihdannaisia...

Kuitenkaan esim. ykköselle ei ole operaatioita 1.+(a), missä a olisi ikiomaa Rational-tyyppiä.

Ratkaisu saadaan implisiittisestä tyyppimuunnoksesta ("implicit conversion"), jolla Int-arvo saadaan muunnettua automaattisesti Rational-tyyppiseksi. Siihen näkyvyysalueeseen, jossa tuota edellä kiellettyä operaatiota suoritetaan, kirjoitetaan määrittely:

implicit def intToRational(x: Int) = new Rational(x)
Esimerkiksi juuri tuohon yllä nähtyyn ohjelmanpätkään:
val a = new Rational(2, 3)
val b = new Rational(4, 5)

println(a + 1)  // 5/3
println(b * 2)  // 8/5

import scala.language.implicitConversions // ilman tätä tulee varoitus
implicit def intToRational(x: Int) = new Rational(x)

println(1 + a)  // 5/3
println(2 * b)  // 8/5

Johan alkoi Lyyti kirjoittaa...

Tuota "implisiittimääritelmää" ei voi kirjoittaa tietenkään Rational-luokan sisälle, koska silloin se ei näkyisi muualle. Ainakin toistaiseksi tyydymme siihen, että määritelmä löytyy samasta käännösyksiköstä, jossa muunnosta tarvitaan.