Tämä kurssi on jo päättynyt.

Luku 4.3: Olemattomia arvoja

Tästä sivusta:

Pääkysymyksiä: Miten ilmaisen ohjelmassa, että jotakin tietoa ei välttämättä ole? Mitä voin käyttää null-arvojen sijaan?

Mitä käsitellään? null-arvon ongelma. Option-tietotyypin peruskäyttö. match-valintakäsky.

Mitä tehdään? Luetaan. Pieniä tehtäviä. (Isompia ohjelmointitehtäviä tämän luvun aiheesta on seuraavassa luvussa.)

Suuntaa antava työläysarvio:? Tunti tai puolitoista. Riippuu ihmettelyn määrästä. Luvun pääaihe eli Option-tyyppi on aluksi monelle haastava, ja match-käskyn muotoilussa on vähän totuttelemista.

Pistearvo: A15.

Oheisprojektit: GoodStuff.

../_images/person08.png

Jatkoa viime numerosta

Jäimme tilanteeseen, jossa ohjelma kaatui, koska yritimme katsoa viittauksen kautta kokemusolion arvosanaa — another.rating — mutta another-muuttujan arvona olikin null eli ei viittausta mihinkään. Tämä siksi, että ensimmäistä kokemusoliota lisättäessä kategorialla ei vielä ole suosikkia, minkä merkitsemiseen käytimme null-arvoa.

Tietokone ei osaa itse päätellä, miten sen pitäisi suoriutua tapauksesta, jossa edellistä suosikkikokemusta ei ole. Ohjelmoijan pitää tämäkin erikseen kirjata koodiin. Viimelukuisessa algoritmissamme emme huomioineet lainkaan tätä mahdollisuutta.

Tässä kaksi erilaista mahdollisuutta korjata tilanne:

  1. Käytetään jotakin toista tietotyyppiä, jonka avulla voidaan kuvata sitä, että "suosikilla on nolla tai yksi arvoa".
  2. Käytetään olemattoman olion merkkinä null-arvoa, jonka voi sijoittaa (melkein) minkä vain tyyppiseen muuttujaan. Kuitenkin muistetaan varmistaa muilla käskyillä, ettei yritetä käsitellä olemattoman olion piirteitä.

On perusteltavissa, että ensimmäinen vaihtoehto on usein parempi. (Perustelut alempana.) Kohta tarkastelemme erästä tällaista ratkaisutapaa Category-ongelmaamme. Aloitetaan kuitenkin tutustumalla yleisemmin ratkaisun välineeseen.

Inspiraatiota lift-metodista

On hyvin yleistä, että jokin asia voi "olla ehkä olemassa". Tällaisia tilanteita löytyy vaikkapa Scalan peruskirjastoista: vektorin alkio indeksillä sata voi joko olla olemassa (riittävän suuressa vektorissa) tai olla olematta (pienemmässä).

Onhan meillä tämä tuttu tyyli tutkia kokoelman alkioita, mutta monesti on ikävää, että se kaatuu liian suureen tai negatiiviseen indeksiin:

val sanoja = Vector("eka", "toka", "kolmas", "neljäs")sanoja: Vector[String] = Vector(eka, toka, kolmas, neljäs)
sanoja(2)res0: String = kolmas
sanoja(100)java.lang.IndexOutOfBoundsException: 100
  [...]

Voisiko olla metodi, joka kertoo meille yhdellä kertaa 1) onko vektorissa alkiota tietyllä indeksillä vai ei, ja 2) jos on, niin mikä?

Voisi olla ja on. Sen nimi on lift.

sanoja.lift(2)res1: Option[String] = Some(kolmas)
sanoja.lift(100)res2: Option[String] = None
sanoja.lift(-1)res3: Option[String] = None

Palautusarvot näyttävät aika järkeviltä:

Kun alkio löytyy, palautusarvo on "joku merkkijono, tarkemmin sanoen kolmas".
Kun alkiota ei löydy, palautusarvo on "ei arvoa lainkaan". Huom. Tämä ei ole sama kuin null; palataan siihen vielä.
Vaikka indeksi olisi epäkelpo, liftaaminen ei keskeydy ajonaikaiseen poikkeustilanteeseen. Metodilla voi poimia alkion indeksin osoittamasta kohdasta turvallisesti.
Palautusarvot eivät kuitenkaan ole tavallisia merkkijonoja vaan jotakin muuta.

lift-metodi nojautuu Option-nimiseen tietotyyppiin. Selvitetään, mikä se on ja miten se voi auttaa meitä korjaamaan Category-luokan.

Option-tietotyyppi

Option-luokka on määritelty pakkaukseen scala ja on siis aina käytettävissä kaikissa Scala-ohjelmissa. Yksi Option-olio toimii ikään kuin kääreenä, jonka sisällä on joko yksi tietyntyyppinen arvo tai vaihtoehtoisesti ei mitään.

Some ja None

Katsotaan äskeistä esimerkkiä uudestaan:

val sanoja = Vector("eka", "toka", "kolmas", "neljäs")sanoja: Vector[String] = Vector(eka, toka, kolmas, neljäs)
sanoja.lift(2)res4: Option[String] = Some(kolmas)
sanoja.lift(100)res5: Option[String] = None
Some-olio on "täysi kääre" eli sellainen Option-olio, joka pitää sisällään yhden arvon, tässä tapauksessa merkkijonon.
None on Scala-kieleen kuuluva yksittäisolio, joka edustaa "tyhjää käärettä". Se on eräänlainen Option-olio sekin.
lift-metodin palautusarvon tyyppi kertoo meille, että metodi palauttaa sellaisen Option-olion, johon on kääritty merkkijono tai ei mitään. Siis Some-olion, jossa on merkkijono, tai None.

Otetaan mukaan lukuja sisältävä toinen vektori:

val lukuja = Vector(10, 0, 100, 10, 5, 123)lukuja: Vector[Int] = Vector(10, 0, 100, 10, 5, 123)
lukuja.lift(0)res6: Option[Int] = Some(10)
lukuja.lift(-1)res7: Option[Int] = None
Huomaa, että tässä tyyppiparametri on toinen. Voit ajatella, että esimerkiksi Option[Int] tarkoittaa: "kääre, jonka sisältä löytyy Int-arvo tai tyhjää". Hakasulkuihin merkityn tyyppiparametrin idea on juuri sama kuin vektorien ja puskurienkin yhteydessä: sillä ilmoitetaan, minkä tyyppisestä "kääreen" sisällä kenties olevasta arvosta on kyse.
../_images/inheritance_option.png

Option on Somen ja Nonen yläkäsite.

Option-tyyppi on määritelty siten, että se takaa meille erinäisiä asioita:

  • Kaikki Some-oliot ovat Option-olioita. Vrt. "Kaikki laamat ovat eläimiä." None on myös Option-olio.
  • Option-olioita on täsmälleen kahdenlaisia. Jokainen Option-tyyppinen olio on joko jokin Some-olio tai None. Vrt. "Jokainen ihminen on joko joku kuolevainen tai Chuck Norris."
  • Jokainen Option[X]-tyyppinen arvo on joko None tai sellainen Some, jonka sisällä on tyyppiä X oleva arvo.

Option-arvoja muuttujassa

Äskeisissä esimerkissä saimme Option-tyyppisiä arvoja metodin palauttamina. Voimme myös itse luoda näitä arvoja sekä määritellä muuttujia, joiden tyyppi on Option.

var testi: Option[Int] = Nonetesti: Option[Int] = None
Tässä muuttujan arvoksi on sijoitettu aluksi None tai tarkemmin sanoen viittaus None-yksittäisolioon.
None on määritelty tyypiltään yhteensopivaksi Option-luokan kanssa. Viittauksen None-olioon voi sijoittaa arvoksi nimenomaan Option-tyyppiselle muuttujalle, ei esimerkiksi Experience- tai Int-tyyppiselle.

Näin siis määrittelimme muuttujan, jonka avulla voisi tallentaa kokonaisluvun, mutta johon nyt ei ole kuitenkaan tallennettuna kokonaislukua vaan None. Option-tyyppisen muuttujan arvoksi voi sijoittaa myös "täyden kääreen" eli Some-olion:

testi = Some(5)testi: Option[Int] = Some(5)
Käärityn arvon saa luotua ilmaisulla Some(...). Kun kyseessä on Option[Int], täytyy sisällä olevan arvon olla kokonaisluku. Ilmaisulla Some(5) siis luodaan "kääre", jonka sisällä on lukuarvo 5.
(Voisi kirjoittaa myös new Some(5), mutta new-sana on tässä yhteydessä luvallista jättää pois, ja yleensä jätetäänkin.)

Option[Int]-olioon voi myös kääriä laskutoimituksen lopputuloksen:

testi = Some(10 + 50)testi: Option[Int] = Some(60)

Kukin Option-olio on tilaltaan muuttumaton. Esimerkiksi kun kerran on luotu olio ilmaisulla Some(5), ei "kääreeseen" laitettua arvoa voi enää vaihtaa toiseksi eikä käärettä tyhjentää. Sen sijaan jos on olemassa var-muuttuja, joka viittaa Option-olioon, niin muuttujan arvoksi voi kyllä sijoittaa viittauksen johonkin muuhun Option-olioon, kuten yllä tehtiinkin.

Kuvia tai sitä ei tapahtunut

Kääritty vs. käärimätön arvo

Int-tyyppinen arvo on aina kokonaisluku. Option[Int]-tyyppisessä arvossa on ehkä sisällä kokonaisluku. Nämä tyypit ovat erilliset eikä niitä voi sijoittaa ristiin. Esimerkiksi Int-arvoa ei sellaisenaan voi sijoittaa Option[Int]-tyyppiseen muuttujaan:

testi = 5<console>:11: error: type mismatch;
found   : Int(5)
required: Option[Int]
      testi = 5
              ^

Option[Int]-tyyppistä arvoa ei vastaavasti voi sijoittaa Int-tyyppiseen muuttujaan tai käyttää laskutoimituksessa:

var lukuarvo = 10lukuarvo: Int = 10
var ehkaArvo: Option[Int] = Some(10)ehkaArvo: Int = Some(10)
lukuarvo = ehkaArvo<console>:13: error: type mismatch;
found   : Option[Int]
required: Int
      lukuarvo = ehkaArvo
                 ^
ehkaArvo - 1<console>:13: error: value - is not a member of Option[Int]
      ehkaArvo - 1
               ^

Samaan tapaan jos metodi ottaa parametrikseen Int-arvon, ei Option[Int] kelpaa ja toisin päin.

Tämä on erittäin hyvä asia. Se tarkoittaa muun muassa sitä, että jos tietyssä kohtaa ohjelmaa kaivataan kokonaislukua, ei riitä, että on "mahdollisesti olemassa oleva luku". Virheilmoitukset muistuttavat jo ennen ohjelma-ajoa, että jos luku tarvitaan, niin sellainen on myös toimitettava.

Option, kas siinä pulma

Tarkastele seuraavia lausekkeita. Millä kaikilla niistä on arvo, jonka voi sijoittaa Option[String]-tyyppiseen muuttujaan?

Oletetaan, että muuttujassa exp on viittaus johonkin Experience-olioon. Oletetaan lisäksi suoritetuksi tämä rivi:

val realGoodExperience = if (exp.rating >= 10) Some(exp.name) else None

Mikä on nyt realGoodExperience-muuttujan tyyppi?

Voisiko selventää eroa null vs. None, kiitos?

null on arvo, joka tarkoittaa "viittaus ei mihinkään olioon" ja jota on teknisesti mahdollista käyttää varsin vapaasti erilaisissa yhteyksissä, vaikkapa Experience- tai String-tyyppisen muuttujan arvona.

null-arvojen käyttäminen kuuluu siis ohjelmointitapaan, jossa mikä tahansa arvo on periaatteessa "valinnainen" ja voi puuttua.

None on yksittäisolio, joka on Option-tyyppinen. Se on siis tarkoitettu käytettäväksi nimenomaan yhteyksissä, joissa tarvitaan Option-tyyppistä arvoa. Viittauksen Noneen voi sijoittaa sellaiseen muuttujaan, jonka tyyppinä on Option[jotain] mutta ei mihin tahansa muualle.

None-arvon ja Option-tyypin käyttäminen sopii ohjelmointitapaan, jossa ohjelmoija nimenomaisesti määrittelee ne kohdat ohjelmasta, joissa arvo on "valinnainen" ja voi puuttua. Jäljempänä luvussa kerrotaan lisää siitä, miksi tämä ohjelmointitapa on hyvin usein parempi, ja nullin käyttöä on syytä välttää.

Ero null-viittauksen ja None-olion välillä voi selkiytyä vertaamalla tämän luvun animaatioita edellisen luvun lopussa olleeseen.

Ja Unit vielä?

Luvussa 1.6 ensi kertaa esiintynyt Unit on erikoisarvo, jota käytetään tarkoittamaan "ei mitään merkityksellistä arvoa" ja erityisesti: "funktio ei tuota missään tilanteessa mitään merkityksellistä palautusarvoa". Unit-arvo on kokonaan omaa Unit-tietotyyppiään, eikä sitä voi sijoittaa mielivaltaiseen muuttujaan (kuten nullin voi) tai Option-tyyppiseen muuttujaan (kuten Nonen). Tällä kurssilla emme käytä Unitia missään muussa yhteydessä kuin eräiden vaikutuksellisten funktioiden palautusarvona.

Option-kääreen avaaminen ja match-käsky

Jotta voimme tehdä Option-olion sisällöllä jotain, tarvitsemme keinon "avata kääre" ja katsoa, mitä siellä on, jos mitään.

Otamme nyt työkaluksemme Scalan match-käskyn. Se on if-käskyn sukulainen sikäli, että silläkin voi tehdä valintoja.

Optionin sisältöön pääsee käsiksi muillakin konstein, mutta käytämme nyt aluksi nimenomaan match-käskyä. match-käsky puolestaan sopii moneen muuhunkin kuin Optionin käsittelyyn, mutta käytämme sitä nyt aluksi nimenomaan siihen.

match-valintakäsky

Pohjustetaan tutunoloisesti:

val lukuja = Vector(10, 0, 100, 10, 5, 123)lukuja: Vector[Int] = Vector(10, 0, 100, 10, 5, 123)
val ehkaLuku = lukuja.lift(5)ehkaLuku: Option[Int] = Some(123)
val ehkaTuhannes = lukuja.lift(999)ehkaTuhannes: Option[Int] = None

Valitaan toimenpide sen perusteella, kumpaan tapaukseen ehkaLuku-muuttujan arvo "mätchää", täyteen vai tyhjään Option-kääreeseen:

ehkaLuku match {
  case Some(kaarittyLuku) => "kääreessä on joku luku"
  case None => "kääreessä ei ole mitään lukua"
}res8: String = kääreessä on joku luku
Tutkimme tässä lausekkeen ehkaLuku arvoa. Sen perään tulevat avainsana match ja aaltosulut, jotka ovat pakolliset.
Käsiteltävänä on kaksi erilaista tapausta. Ne kirjataan case-sanaa sekä yhtäsuuruusmerkistä ja suurempi kuin -merkistä koostuvaa "nuolta" käyttäen.
Siinä välissä määritellään tapaukset. Kun käytämme match-käskyä juuri Option-olioiden käsittelyyn, käytämme tyypillisesti yhtä Some-tapausta ja yhtä None-tapausta.
Koska esimerkkilausekkeemme arvo sattui olemaan eräs Some-olio, niin valituksi tuli ensimmäinen tapaus.
Nuolen perässä oleva lauseke määrää koko match-käskyllä muodostetun lausekkeen arvon. Esimerkissämme kyseessä on merkkijono.
Esimerkkikäskyssämme kaarittyLuku on ohjelmoijan valitsema nimi, jota emme käyttäneet mihinkään ihan vielä.

Vastaavasti valituksi voi tulla myös toinen tapaus:

ehkaTuhannes match {
  case Some(kaarittyLuku) => "kääreessä on joku luku"
  case None => "kääreessä ei ole mitään lukua"
}res9: String = kääreessä ei ole mitään lukua
ehkaTuhannes-muuttujan arvo sopii yhteen jälkimmäisen tapauksen kanssa, joten päädytään toiseen haaraan ja saadaan match-lausekkeelle arvo sieltä.

Äskeisissä esimerkeissä oli väliä vain sillä, oliko kyseisellä Option-oliolla sisältöä, ei sillä, mitä tuo mahdollinen sisältö oli. Seuraava esimerkki huomioi myös tämän.

ehkaLuku match {
  case Some(kaarittyLuku) => "kääreessä on: " + kaarittyLuku
  case None => "kääreessä ei ole mitään lukua"
}res10: String = kääreessä on: 123
Some-tapauksen määrittelyyn voi kirjata muuttujan nimen tähän tapaan. Tällöin Option-olion sisältämä arvo kopioituu uuteen tuonnimiseen paikalliseen muuttujaan, kun tapauksen koodia aletaan suorittaa. Niinpä...
... muuttujan nimeä voi käyttää viittaamaan siihen arvoon, joka Option-olion sisältä "löytyi", tässä esimerkissämme kokonaislukuun.

Seuraava lisäesimerkki korostaa eräitä match-käskyn ominaisuuksia:

val lukuja = Vector(10, 0, 100, 10, 5, 123)lukuja: Vector[Int] = Vector(10, 0, 100, 10, 5, 123)
val tulos = lukuja.lift(4) match {
  case None => 0
  case Some(luku) => luku * 1000
}tulos: Int = 5000
match-lauseke on lauseke siinä missä muutkin, ja sitä voi käyttää osana muita käskyjä. Sen arvon voi esimerkiksi sijoittaa muuttujaan kuten tässä.
match-sanan eteen kirjoitetaan lauseke, jonka arvoa tarkastellaan. Aiemmissa esimerkeissä tuo lauseke oli muuttujan nimi, mutta se voi hyvin olla jokin muukin. Tässä kutsutaan lift-metodia ja tarkastellaan saman tien sen palauttamaa arvoa (sijoittamatta sitä välissä muuttujaan).
Tapauksilla ei ole mitään ennalta määrättyä järjestystä; None-tapauksen voi kirjata ennen Some-tapausta. Scala käy tapauksia läpi koodiin kirjatussa järjestyksessä kunnes osuva löytyy.
Aiemmissa esimerkeissä match-lausekkeen arvo oli merkkijono, mutta muutkin tyypit ovat yhtä mahdollisia. Tässä kumpikin tapaus tuottaa kokonaisluvun, ja tuloksen tyyppi on Int.
case-alkuiset rivit on tapana sisentää. Tämäkään sisennys ei kuitenkaan vaikuta ohjelman toimintaan.

Voit tutkia esimerkkejä myös animaationa.

Seuraava pseudokoodi kiteyttää, miten olemme soveltaneet match-käskyä Option-olioihin:

tutkittava lauseke match {
  case Some(muuttujaSisallolle) => lauseke evaluoitavaksi "kääreen ollessa täysi", ehkä oheista muuttujaa hyödyntäen
  case None => lauseke evaluoitavaksi "kääreen ollessa tyhjä"
}

Tapauksiin voi liittää myös vaikutuksellisia käskyjä kuten println. Tässä esimerkki:

lukuja.lift(7) match {
  case None => 
    println("Ei ollut lukua seiskaindeksillä.")
    println("Ei voi mitään.")
  case Some(luku) => 
    println("Seiskaindeksillä oli " + luku + ".")
}Ei ollut lukua seiskaindeksillä.
Ei voi mitään.
Kun käskyt ovat vaikutuksellisia, on tapana vaihtaa riviä ja sisentää syvemmälle.
Käskyjä voi olla peräkkäin useitakin.

Pikkutehtäviä: match plus Option

Ennen kuin korjataan Category-luokka, tee vielä seuraavat pikkutreenit käyttäen REPLiä apuna tarpeen mukaan. Option-tyypistä muodostuu meille tärkeä työväline, joten on hyvä treenata nämä perusasiat kuntoon.

Oletetaan annetuiksi seuraavat käskyt:

var eka: Option[Int] = None
var toka: Option[Int] = Some(10)

Mikä on nyt seuraavan match-lausekkeen arvo?

eka match {
  case Some(arvo) => arvo
  case None       => -1
}

Entä seuraavan lausekkeen?

toka match {
  case Some(arvo) => arvo
  case None       => -1
}

Entä tämän?

toka match {
  case Some(arvo) => arvo + 100
}

Vielä yksi.

Vector(15, 18, 50, 12).lift(3) match {
  case Some(alkio) => if (alkio >= 18) "aikuinen" else "lapsi"
  case None        => "puuttuva ikätieto"
}

Mitä seuraavan koodinpätkän match-käsky tekee muuttujan kokeilu arvolle?

var kokeilu = 100
val mahdollinenTulos = if (kokeilu > 50) Some(2 * kokeilu) else None
mahdollinenTulos match {
  case Some(tulos) =>
    kokeilu += tulos
  case None =>
    kokeilu = 0
}

Option-olioilla on metodeita. Yksi niistä on nimeltään getOrElse. Some-olio vastaa getOrElse-metodikutsuun palauttamalla sisältämänsä arvon. None-olion getOrElse-metodi puolestaan evaluoi getOrElse-metodille parametriksi välitetyn lausekkeen ja palauttaa sen arvon.

Kokeile itse getOrElse-metodin käyttöä sekä Some- että None-olioilla ja erilaisilla parametriarvoilla. Voit kokeilla esimerkiksi seuraavia ja tutustua hieman alempana olevaan animaatioon niistä.

val sanoja = Vector("eka", "toka", "kolmas", "neljäs")
println(sanoja.lift(100).getOrElse("ei sanaa"))
println(sanoja.lift(2).getOrElse("ei sanaa"))

Mitkä kaikki seuraavista väittämistä pitävät paikkansa?

Option-olioilla on myös parametrittomat metodit isDefined ja isEmpty. Kokeile näidenkin metodien käyttöä REPLissä.

Tutustu lisäksi seuraavaan match-lausekkeeseen:

x match {
  case Some(thing) => true
  case None        => false
}

Mitkä seuraavista väittämistä pitävät paikkansa?

Suosikkikirjanpito: uusi toteutus

Option-tyyppinen ilmentymämuuttuja

Palataan Category-luokkaan. Sinne voi määritellä ilmentymämuuttujan fave Option-luokkaa käyttäen näin:

class Category(val name: String, val unit: String) {

  private val experiences = Buffer[Experience]()
  private var fave: Option[Experience] = None

  def favorite = this.fave

  def addExperience(newExperience: Experience) = {
    // Toteutus tänne.
  }

}
Laitetaan fave-muuttujan tyypiksi Option[Experience] eli "mahdollisesti yksi kokemus tai sitten ei yhtään". Tämä määrittely vastaa täsmälleen mallinnustarvettamme.
Muuttujan arvo voi siis olla joko None tai Some(...), missä "kääreen" sisällä on viittaus Experience-olioon. Aluksi se on None.

Uusi toteutus addExperience-metodille

Toteutetaan addExperience-metodi ensin pseudokoodina:

  def addExperience(newExperience: Experience) = {
    this.experiences += newExperience
    Suosikin päivittämistapa riippuu siitä, mitä suosikkikääreestä nyt löytyy:
    1) Mikäli ei löydy mitään, merkitään suosikiksi Some, jossa on lisätty uusi kokemus.
    2) Mikäli kääreessä on vanha suosikki, valitaan uudesta kokemuksesta ja vanhasta suosikista parempi ja kääritään se.
  }
Kaksi tapausta: this.faven vanhana arvona on vain tyhjä kääre None tai jokin Some-kääreessä oleva vanha suosikkikokemus.
Option[Experience]-tyyppisiä arvoja ei voi vertailla arvosanan perusteella, Experience-tyyppisiä voi. Jotta vertailu onnistuisi, on vanha suosikki otettava Option-kääreestä ulos.

Tässä sama Scalalla:

def addExperience(newExperience: Experience) = {
  this.experiences += newExperience
  this.fave match {
    case None =>
      this.fave = Some(newExperience)
    case Some(oldFave) =>
      val newFave = newExperience.chooseBetter(oldFave)
      this.fave = Some(newFave)
  }
}
Lisäyksen lopuksi kategoriaolioon liittyy väistämättä jokin suosikkikokemus. Se laitetaan Some-kääreeseen ennen ilmentymämuuttujaan tallentamista (koska uusittuun fave-muuttujaan pitää tallentaa Option[Experience]-tyyppinen eikä Experience-tyyppinen arvo).
Kuten näet, match-käskyn sisällä voi myös määritellä apumuuttujia tavalliseen tapaan. Tässä on käytetty tilapäissäilöä newFave välivaiheiden selkiyttämiseksi, mutta koko homman olisi voinut hoitaa myös yhdellä käskyllä this.fave = Some(newExperience.chooseBetter(oldFave))

Sama toisin jäsennettynä

Voit tutkia myös tätä toista toteutusta, joka toimii yhtä lailla.

def addExperience(newExperience: Experience) = {
  this.experiences += newExperience
  val newFave = this.fave match {
    case Some(oldFave) => newExperience.chooseBetter(oldFave)
    case None          => newExperience
  }
  this.fave = Some(newFave)
}

Muutos luokan rajapinnassa

Äsken tehtyä Category-toteutusta (joka myös GoodStuff-projektin tiedostoista löytyy) käytetään hieman eri tavalla kuin edellisessä luvussa hahmottelimme.

Nythän nimittäin favorite-metodin palautusarvon tyyppi on Experiencen sijaan Option[Experience], ja metodi palauttaa joko Nonen tai kokemusolioon viittavan Somen. Luokkaa käyttäessä on huomioitava tämä muutos. Uutta Category-luokkaamme voi koekäyttää vaikkapa näin:

val wineCategory = new Category("Wine", "bottle")
// ... (Mahdollisesti lisätään kokemusolioita.)

wineCategory.favorite match {
  case Some(bestWine) =>
    println("Suosikki on: " + bestWine.name)
  case None =>
    println("Ei suosikkia vielä.")
}

Toisaalta esimerkiksi lauseke wineCategory.favorite.name ei enää ole kelvollinen kuten ei sovi ollakaan. Jos yritämme kirjoittaa tuon käskyn ohjelmaamme, saamme virheilmoituksen jo ennen ohjelma-ajoa: työkalustomme toteaa, että wineCategory.favorite tuottaa Optionin, jolla ei ole name-muuttujaa (vaan sen mahdollisesti sisältämällä Experience-oliolla on).

Vaihtoehtoinen toteutustapa ilman Optionia

Kuten luvun alussa tuli todettua, on toinenkin tapa lähestyä poikkeustilanteen aiheuttanutta null-ongelmaa: jätetään null-alkuarvo suosikille, mutta käytetään käskyjä huolellisesti niin, ettei olemattoman olion piirteisiin koskaan yritetä päästä käsiksi. Esimerkiksi addExperience-metodin olisi voinut saada toimimaan myös ilman Option-luokkaa:

class Category(val name: String, val unit: String) {

  private val experiences = Buffer[Experience]()
  private var fave: Experience = null

  def favorite = this.fave

  def addExperience(newExperience: Experience) = {
    this.experiences += newExperience
    this.fave =
      if (this.fave == null)
        newExperience
      else
        newExperience.chooseBetter(this.fave)
  }

}

Kun ensin tarkastetaan vertailuoperaattorilla, onko olion fave-muuttujan arvo null, ja sijoitetaan paremmuusvertailu else-olioon, ei metodimme tee sopimatonta paremmuusvertailua olemattoman arvosanan kanssa. Tämäkin metoditoteutus toimii moitteettomasti.

Et sitten aikaisemmin kertonut!?

Eikö tuo null-arvoa käyttävä versio ole yhtä toimiva ja yksinkertaisempi kuin Option-versio? Monimutkaistiko Option Category-luokkaamme aivan turhaan? Kannattiko tätä uutta tekniikkaa edes opetella?

Option-toteutuksessa on erittäin hyviä puolia null-toteutukseen verrattuna. Käsittelemme niistä eräitä seuraavaksi.

Miljardin dollarin virhe

Kutsun sitä miljardin dollarin virheekseni, null-viittauksen keksimistä vuonna 1965. Suunnittelin silloin ensimmäistä kattavaa tyyppijärjestelmää viittauksille olio-ohjelmointikielessä (ALGOL W). Tavoitteeni oli varmistaa, että kaikki viittausten käyttö olisi ehdottoman turvallista. — — Mutta en pystynyt vastustamaan kiusausta laittaa null-viittaus mukaan pelkästään siksi, että se oli niin helppo toteuttaa. Tämä on johtanut lukemattomiin virheisiin, haavoittuvuuksiin ja järjestelmien kaatumisiin, jotka ovat luultavasti aiheuttaneet miljardin dollarin verran tuskaa ja vahinkoa viimeisten neljänkymmenen vuoden aikana.

—C. A. R. Hoare (käännetty englannista)

Yllä on lainaus puheesta vuodelta 2009, jossa Sir Charles Antony Richard Hoare pyytää anteeksi null-arvon keksimistä alun perin ALGOL-ohjelmointikieleen. ALGOLista on otettu valtavasti (enimmäkseen hyviä) vaikutteita myöhempiin ohjelmointikieliin, nykyisiinkin. Myös null-arvo tai jokin sitä vastaava toisenniminen käsite on ohjelmointikielissä yleinen. Kuitenkin null-viittausten käyttö aiheuttaa joka päivä jotakuinkin vastaavia ajonaikaisia virhetilanteita kuin se, jonka näit edellisen luvun lopussa. Usein nämä virheet ovat monimutkaisemmissa ohjelmissa ja hankalammin paikannettavia kuin esimerkissämme.

Eräät ohjelmoijien piirissä tunnetuimmat ja pahamaineisimmat virheilmoitukset (esim. segmentation fault ja edellisen luvun null pointer exception) liittyvät ohjelmoijan tekemiin null-arvojen käyttövirheisiin.

Miksi Option?

Kuten olet jo nähnyt, Scala-kielikin mahdollistaa null-arvojen käytön. Kieli on kuitenkin laadittu kannustamaan vaihtoehtoisten ratkaisutapojen käyttöön, joista Option on yksi. null-arvon käyttö on (hyvissä) Scala-ohjelmissa harvinaista.

Kun lausekkeen tyyppinä on Option[X] eikä vain X, niin emme voi yksinkertaisesti käyttää sitä kohdassa, jossa kaivataan X-tyyppistä arvoa. Yritys tehdä näin aiheuttaa virheilmoituksen jo ennen ohjelman ajamista. Niinpä ohjelmoijan on huolehdittava "kääreen avaamisesta". Tämä muistuttaa häntä käsittelemään myös None-tapauksen, jossa "kääreessä" ei ole arvoa lainkaan.

Esimerkiksi kun Categoryn favorite-metodi palauttaa Option[Experience]-tyyppisen arvon, on tämä selvä signaali luokkaa käyttävälle ohjelmoijalle: suosikkia ei välttämättä ole, ja molemmat tapaukset on syytä jotenkin huomioida metodia käyttäessä. Jos ohjelmoija asian silti jättää huomioimatta, hän saa pikaisen virheilmoituksen eikä ongelma jää bugiksi ohjelmaan ja ilmene kenties vasta epäonnisen loppukäyttäjän kärsiessä siitä joskus myöhemmin.

Voi olla, että saamme luokan kuten Category toteutettua null-arvoja käyttäen siten, että tuon luokan metodit huolellisesti valvovat ettei virhetilanteita synny. Silti ongelma ei poistu, jos luokan rajapintaan kuuluvat metodit (kuten favorite) palauttavat null-arvoja. Luokan käyttäjän, joka voimme olla me itse tai joku muu, pitää muistaa olla varpaillaan niiden varalta.

Jos olet ohjelmoinnin aloittelija tai paatunut null-arvoilla ohjelmoija, et ehkä aavistakaan, kuinka monelta ongelmalta vältyt käyttämällä "ehkä olemattomien arvojen" kuvaamiseen Optionia.

Virheilmoituksista

Option-ratkaisumallin hyväksi puoleksi mainittiin se, että saa virheilmoituksia, mikä saattaa kuulostaa oudolta.

Ohjelmoinnin aloittelija voi pitää virheilmoituksia puhtaasti ikävinä. Ennen ohjelma-ajoa tuleva virheilmoitus on kuitenkin ystäväsi! Se tarkoittaa, että tietokone on kyennyt automaattisesti havaitsemaan ohjelmassa jonkin ongelman. On hienoa, että saat viestin — vaikka sitten valituksenkin — välittömänä palautteena. Ajonaikaisten virheiden syiden etsiminen on usein paljon hankalampaa, ja jo niiden havaitseminen on kaikkea muuta kuin itsestään selvää.

Hyvä osa siitä Tony Hoaren miljardista olisi säästetty aikaisemmilla virheilmoituksilla.

Lisämateriaalia Option in ympäriltä

Alla olevat laatikot täydentävät äskeistä tekstiä. Lue, jos ei ole liian kiire.

Onko Option vain Scalan ja sen lähisukulaisten erikoisuus?

Ei. Sama tai samantapainen rakenne on käytettävissä monessa muussakin kielessä. On yleistymään päin.

Useissa yleisissä kielissä (esim. C, Java) kyllä käytetään usein null-arvoja, joskus syystä (ks. seuraava lisätietolaatikko alla) ja joskus syyttä. Tuoreimmissa Java-kielen versioissakin on eräänlainen Optional-luokka. Nähtäväksi jää, millaisen käyttäjäkunnan se saavuttaa.

Tämä ei ole puhtaasti kielikysymys. Jos Option-luokkaa tai vastaavaa työkalua ei ole, mutta sellaista haluaa käyttää, niin sellaisen voi tehdä itsekin. Scalassakaan Option ei varsinaisesti ole muuta kuin peruskirjastoon kuuluva luokka. Alla on Optionin, Somen ja Nonen lähdekoodi karsitussa muodossa niiltä osin, joita kurssilla on tähän mennessä käsitelty.

Seuraavaa koodia ei ole pakko kokonaan ymmärtää, mutta se toimii näyttönä siitä, että jos tämä tyyppi Scalasta puuttuisi, niin sen toteuttaminen ei olisi mikään taikatemppu. Itse asiassa iso osa tästä koodista on jo tämän kurssin alkupään perusteella ymmärrettävissä.

abstract class Option[InnerType] {

  def getOrElse(default: =>InnerType) = if (this.isEmpty) default else this.get
  def get: InnerType   // toteutettu erikseen alakäsitteille Some ja None tuossa alempana

  def isEmpty: Boolean // toteutettu erikseen alakäsitteille Some ja None tuossa alempana
  def isDefined = !this.isEmpty

}

class Some[InnerType](val content: InnerType) extends Option[InnerType] {
  def isEmpty = false
  def get = this.content
}

object None extends Option[Nothing] {
  def isEmpty = true
  def get = throw new NoSuchElementException
}

// Yksinkertaistettu Scala API:n Option-toteutuksesta, joka on täällä:
// https://github.com/scala/scala/blob/v2.11.8/src/library/scala/Option.scala

Yleisempi opetus tästä on, että ohjelmoija voi toteuttaa itselleen ja muille työkaluja, jotka muovaavat tapaa, jolla ohjelmoidaan.

Toki se, että tietty rakenne — vaikkapa Option — on osa kielen valmista työkalupakkia ja sitä käytetään kielen peruskirjastoissa, vaikuttaa tuon rakenteen suosioon kieltä käyttävien ohjelmoijien keskuudessa. Osittain asia on siis kyllä kielikysymyskin.

Miksi Scalassa sitten edes on null? Miksi se esiteltiin kurssilla?

Osa ohjelmoijista onkin sitä mieltä, että esimerkiksi Scalassa ei null-arvoa pitäisi ollakaan.

Tilanteesta riippuen null-arvon käyttäminen saattaa olla perusteltua. Scalassa se on mukana erityisesti parantamassa Scalan yhdisteltävyyttä muiden kielten kanssa. nullin sisällyttäminen kieleen tekee helpommaksi eritoten sen, että Scala-ohjelmasta voi käyttää Java-kielellä kirjoitettuja kirjastoja (mistä vähän lisää luvussa 5.4 ja myöhemmin).

../_images/martin_odersky.png

Martin Odersky, Scalan johtohahmo

We want to move away from null. Null is a source of many, many errors. We could have come out with a language that just disallows null as a possible value of any type, because Scala has a perfectly good replacement called an option type. But of course a lot of Java libraries return nulls and we have to treat it in some way.

Martin Odersky haastattelussa vuonna 2009

Tulevissa Scala-versioissa kuulemma pyritään kitkemään null-arvoja uusilla tavoilla.

Muissa kielissä nullia saatetaan käyttää:

  • kätevien vaihtoehtojen puutteen vuoksi;
  • tapauskohtaisista suoritustehokkuus- tai kätevyyssyistä;
  • ohjelmointikulttuurillisista syistä; tai joskus vain
  • koska ei muutakaan osata.

Option vaatii lisätoimenpiteitä tietokoneelta ohjelman suorituksen aikana (Some-olioiden luominen, arvojen noutaminen niiden sisältä). Usein tällä ei ole mitään käytännön merkitystä, mutta joskus on.

Kuten todettu, emme jatkossa suosi nulliin perustuvia ratkaisuja. Silti Scala-kieliselläkin ohjelmoinnin alkeiskurssilla nullin esittely on perusteltua, koska:

  • null-arvo on monessa muussa yhteydessä yleinen ja kuuluu ohjelmoijan yleissivistykseen. Kun etsit itse tietoa netistä tai tutustut muiden tekemään koodiin, törmäät varmasti null-arvoon ennemmin tai myöhemmin.
  • Tämä ei ole vain Scala-kurssi tai edes ensisijaisesti Scala-kurssi.
  • Aiheen käsitteleminen mahdollisti eri ratkaisutapojen vertailun sekä lyhyen pohdinnan ohjelmointikielten suunnitteluperiaatteista, joka auttaa ymmärtämään, miksi teemme niin kuin teemme.
  • Vaikka välttäisitkin nullin käyttöä, voit päätyä vastatusten NullPointerExceptionin kanssa, ja on syytä tuntea vihollisensa.

Lisää jorinaa Optionista

On toki totta, että Optionin hyödyllisyys riippuu tilanteesta, tarkoituksesta ja osin ohjelmoijastakin. On toki myös totta, että tilaa virheille jää, vaikka välttäisikin nullin käyttöä. Aina jää.

Optionilla on puolensa. Sen hyviä puolia on helpompi aliarvoida kuin huonoja.

Olit itse mitä mieltä tahansa, niin Optionia käytetään laajasti mm. Scala-ohjelmoinnissa ja siksi tämä tekniikka on syytä hallita.

Tällä kurssilla käytämme Option-luokkaa jatkossa runsaasti.

Yhteenvetoa

  • "Ehkä olemassa olevasta arvosta" voi pitää kirjaa Option-luokan avulla. Sille määrätään käyttöyhteyteen sopiva tyyppiparametri: esimerkiksi Option[Int] tarkoittaa "nolla tai yksi kokonaislukuarvoa".
    • Option-tyyppinen arvo on joko viittaus None-olioon ("tyhjään kääreeseen") tai sellaiseen Some-olioon, jonka sisälle on "kääritty" yksi arvo.
    • On lähes aina parempi käyttää Optionia kuin null-viittauksia.
  • Scalan match-käsky sopii mm. Option-olioiden käsittelyyn. Sillä voi valita toimenpiteen tapauskohtaisesti sillä perusteella, onko Option-oliolla sisältöä vai ei.
  • Monesti hyödyllisiä Option-luokan metodeita ovat mm. getOrElse, isDefined ja isEmpty. Lisää Option-olioiden metodeita kohdataan myöhemmin kurssilla (luku 8.2), jolloin luokan käyttö kätevöityy.
  • Ohjelmointikieli peruskirjastoineen on mahdollista suunnitella vähentämään ohjelmavirheitä ja edesauttamaan virheiden nopeaa havaitsemista. Option-luokka on esimerkki tästä.
  • Lukuun liittyviä termejä sanastosivulla: Option, null-viittaus; käännösaikainen virhe, ajonaikainen virhe.

Palaute

Huomaathan, että tämä on henkilökohtainen osio! Vaikka olisit tehnyt lukuun liittyvät tehtävät parin kanssa, täytä palautelomake itse.

Tekijät

Tämän oppimateriaalin kehitystyössä on käytetty apuna tuhansilta opiskelijoilta kerättyä palautetta. Kiitos!

Kierrokset 1–13 ja niihin liittyvät tehtävät ja viikkokoosteet on laatinut Juha Sorva.

Kierrokset 14–20 on laatinut Otto Seppälä. Ne eivät ole julki syksyllä, mutta julkaistaan ennen kuin määräajat lähestyvät.

Liitesivut (sanasto, Scala-kooste, usein kysytyt kysymykset jne.) on kirjoittanut Juha Sorva sikäli kuin sivulla ei ole toisin mainittu.

Tehtävien automaattisen arvioinnin ovat toteuttaneet: (aakkosjärjestyksessä) Riku Autio, Nikolas Drosdek, Joonatan Honkamaa, Jaakko Kantojärvi, Niklas Kröger, Teemu Lehtinen, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä ja Aleksi Vartiainen.

Lukujen alkuja koristavat kuvat ja muut vastaavat kuvituskuvat on piirtänyt Christina Lassheikki.

Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista ovat suunnitelleet Juha Sorva ja Teemu Sirkiä. Niiden teknisen toteutuksen ovat tehneet Teemu Sirkiä ja Riku Autio käyttäen Teemun toteuttamia Jsvee- ja Kelmu-työkaluja.

Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset on laatinut Juha Sorva.

O1Library-ohjelmakirjaston ovat kehittäneet Aleksi Lukkarinen ja Juha Sorva. Useat sen keskeisistä osista tukeutuvat Aleksin SMCL-kirjastoon.

Opetustapa, jossa käytämme O1Libraryn työkaluja (kuten Pic) yksinkertaiseen graafiseen ohjelmointiin on saanut vaikutteita tekijöiden Flatt, Felleisen, Findler ja Krishnamurthi oppikirjasta How to Design Programs sekä Stephen Blochin oppikirjasta Picturing Programs.

Oppimisalusta A+ on luotu Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Pääkehittäjänä toimii tällä hetkellä Jaakko Kantojärvi, jonka lisäksi järjestelmää kehittävät useat tietotekniikan ja informaatioverkostojen opiskelijat.

Kurssin tämänhetkinen henkilökunta on kerrottu luvussa 1.1.

Joidenkin lukujen lopuissa on lukukohtaisia lisäyksiä tähän tekijäluetteloon.

a drop of ink
Palautusta lähetetään...