Luku 4.3: Olemattomia arvoja

../_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 voi 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ä, ettemme yritä 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 is out of bounds (min 0, max 3)
  [...]

Voisiko olla metodi, joka kertoo meille yhdellä kertaa 1) onko vektorissa alkiota tietyllä indeksillä vai ei, ja 2) jos on alkio, 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

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

Kun alkio löytyy, paluuarvo on "joku merkkijono, tarkemmin sanoen kolmas".

Kun alkiota ei löydy, paluuarvo 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.

Paluuarvot 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 paluuarvon 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

Tässä tyyppiparametri on toinen. Voit ajatella, että esimerkiksi Option[Int] tarkoittaa: "kääre, jonka sisältä löytyy Int-arvo tai tyhjää". Hakasulkeisiin merkityn tyyppiparametrin idea on tässä sama kuin vektorien ja puskurienkin kohdalla: 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 takaamaan 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.

  • Jokainen Option[X]-tyyppinen arvo on joko None tai sellainen Some, jonka sisällä on X-tyyppinen 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.

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" pantua 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-- Type Mismatch Error:
  |testi = 5
  |        ^
  |        Found:    (5 : Int)
  |        Required: Option[Int]

Vastaavasti Option[Int]-tyyppistä arvoa ei 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-- Type Mismatch Error:
  |lukuarvo = ehkaArvo
  |           ^^^^^^^^
  |           Found:    (ehkaArvo : Option[Int])
  |           Required: Int
ehkaArvo - 1-- Type Mismatch Error:
  |ehkaArvo - 1
  |^^^^^^^^^^
  |value - is not a member of Option[Int]

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 then Some(exp.name) else None

Mikä on nyt realGoodExperience-muuttujan tyyppi?

Tyyppipäättely, muuttujat ja Option


Opiskelija kysyy:

En ymmärrä tätä, kun sanotaan, että: "Kaikki Some-oliot ovat Option-olioita" ja "Jokainen Option-tyyppinen olio on joko jokin Some-olio tai None". Miten sitten kuitenkin voi tehdä näin:

val eka = Some(10)

Tuleeko tuossa eka-muuttujasta Option-olio?

On tärkeää erottaa toisistaan muuttujat ja oliot. Mikään muuttuja ei varsinaisesti ole mikään olio eikä sellaiseksi tule.

Jos muuttujan tyyppi on Option[X], se voi viitata joko Some[X]-tyyppiseen olioon tai None-yksittäisolioon.

Muotoillaan kysymys toisin: mikä tulee sellaisen muuttujan tyypiksi, joka pannaan noin viittaamaan Some-olioon?

var kokeilu1 = Some(10)kokeilu1: Some[Int] = Some(10)

Muuttujan arvoksi asetetaan Some(10). Scala päättelee muuttujan tyypiksi Some[Int]. Tähän muuttujaan ei voi sijoittaa mitään muuta kuin viittauksia Some[Int]-olioihin. Ei esimerkiksi Nonea. (Tällaisen muuttujan tekeminen ei yleensä ole perusteltua. Jos tiedetään, että kokonaislukuarvo on olemassa, pelkkä Int riittää.)

Kirjaamalla muuttujan määrittelyyn laajempi tyyppi Option[Int] saadaan muuttuja, joka voi viitata myös None-olioon, kuten alla.

var kokeilu2: Option[Int] = Some(10)kokeilu2: Option[Int] = Some(10)
kokeilu2 = Nonekokeilu2: Option[Int] = None

Tyyppipäättelyn kannalta ratkaisevaa on se, mitä muuttujaan sijoitetaan, kun muuttuja määritellään. Scala ei lähde "yleistämään" muuttujan tyyppiä (esim. Somesta yläkäsite Optioniksi), ellei ohjelmoija siihen erikseen kehota kuten kokeilu2-muuttujan kohdalla yllä. Tai ellei yhtäsuuruusmerkkiä seuraava lauseke vaadi yleistämistä, kuten tässä:

val luku = 100luku: Int = 100
var mahdollinenPositiivinenLuku = if luku > 0 then Some(luku) else NonemahdollinenPositiivinenLuku: Option[Int] = Some(100)

Vaihtoehtoisten lausekkeiden arvot ovat tyyppiä Some[Int] ja None.

Koko if-lausekkeen tyyppi on sellainen, että se kattaa molemmat mainitut tapaukset. Siis Option[Int].

Sijoitettavan lausekkeen tyyppi tulee myös muuttujan tyypiksi. Muuttujan tyyppi "lukitaan" sijoituksen yhteydessä, eikä se voi vaihtua.

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

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

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 Option-tyyppiseen muuttujaan muttei mihin tahansa muualle.

null-arvojen käyttäminen kuuluu ohjelmointitapaan, jossa mikä tahansa arvo on "valinnainen" ja saattaa puuttua. Tästä poiketen None-arvon ja Option-tyypin käyttäminen sopii ohjelmointitapaan, jossa ohjelmoija nimenomaisesti määrittelee ne kohdat ohjelmasta, joissa arvo on "valinnainen" ja saattaa puuttua. Jäljempänä tässä luvussa kerrotaan lisää siitä, miksi jälkimmäinen ohjelmointitapa on usein parempi ja nullia on syytä välttää — erityisesti jos ohjelmasi ratkaisevat mutkikkaita ja tärkeitä ongelmia ja ovat kooltaan suuria.

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 esiintyi ensi kertaa erikoisarvo Unit. Sitä käytetään tarkoittamaan "ei mitään merkityksellistä arvoa" ja erityisesti "funktio ei missään tilanteessa tuota mitään merkityksellistä paluuarvoa".

Unit-arvo on kokonaan omaa Unit-tietotyyppiään, eikä sitä voi sijoittaa mielivaltaiseen muuttujaan (kuten nullin voi) tai Option-tyyppiseen muuttujaan (kuten Nonen voi). Käytämme Unitia sellaisten vaikutuksellisten funktioiden paluuarvona, jotka "eivät palauta mitään".

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 rivinvaihto.

Käsiteltävänä on kaksi erilaista tapausta. Ne kirjataan case-sanaa ja "nuolta" käyttäen. "Nuoli" koostuu yhtäsuuruusmerkistä ja suurempi kuin -merkistä.

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 jälkeinen 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 * 1000tulos: 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 tuota arvoa 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.

Sisennä case-alkuiset rivit.

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, vaihda riviä ja sisennä syvemmälle.

Käskyjä voi olla peräkkäin useitakin.

Pikkutehtäviä: match ja Optionin metodeita

Ennen kuin korjataan Category-luokka, tee seuraavat pikkutreenit. Käytä 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 then "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 then 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 contains. Kokeile itse sen käyttöä sekä Some- että None-olioilla ja arvioi seuraavat väitteet. Väitteissä oletetaan, että seuraavat määrittelyt on jo tehty:

val ehkaLuku: Option[Int] = Some(123)
val eiLukua: Option[Int] = None

Eräs hyödyllinen metodi 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 getOrElse-metodin käyttöä sekä Some- että None-olioilla ja erilaisilla parametriarvoilla. Voit kokeilla esimerkiksi seuraavia ja tutustua hieman alempaa löytyvään 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 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.

end Category

Asetetaan 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ääritty 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 tallennetaan 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 ja on kenties nätimpi. (Makuasia.)

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-moduulin tiedostoista löytyy) käytetään hieman eri tavalla kuin edellisessä luvussa hahmottelimme.

Nythän nimittäin favorite-metodin paluuarvon 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 = Category("Wine", "bottle")
// ... (Mahdollisesti lisätään kokemusolioita kategoriaan.)

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 ehkä 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 then
        newExperience
      else
        newExperience.chooseBetter(this.fave)

end Category

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 seuraavaksi niistä eräitä.

Miljardin dollarin virhe

Kutsun null-viittauksen keksimistä vuonna 1965 miljardin dollarin virheekseni. 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 ottaa 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 neljänkymmenen viime 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 sitä vastaava toisen niminen 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 vaihtoehtoisiin ratkaisuihin, joista Option on yksi. null-arvon käyttö on (hyvissä) Scala-ohjelmissa harvinaista.

Kun lausekkeen tyyppinä on Option[X] eikä vain X, 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 pitäisi 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. Se saattaa kuulostaa oudolta.

Ohjelmoinnin aloittelija voi pitää virheilmoituksia puhtaasti ikävinä. Mutta ennen ohjelma-ajoa tuleva virheilmoitus on 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 Optionin ympäriltä

Seuraavat 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 then 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 APIn 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 lähinnä parantamassa Scalan yhdisteltävyyttä muihin kieliin. nullin sisällyttäminen kieleen tekee helpommaksi sen, että Scala-ohjelmasta voi käyttää Java-kielellä kirjoitettuja kirjastoja. (Siitä 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

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 hieman 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.

  • Scala on kiva kieli, mutta tämä ei ole vain Scala-kurssi tai edes ensisijaisesti Scala-kurssi. Tämä on ensisijaisesti ohjelmoinnin perusteiden kurssi.

  • Kun käsittelemme tämän aiheen, voimme vertailla eri ratkaisutapoja ja pohtia lyhyesti ohjelmointikielten suunnitteluperiaatteista. Tämä auttaa ymmärtämään, miksi ohjelmoimme niin kuin ohjelmoimme.

  • Vaikka välttäisitkin null-arvon käyttöä, voit päätyä vastatusten NullPointerExceptionin kanssa. Tunne vihollisesi.

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äisitkin nullin käyttöä. Aina jää.

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

Olit itse mitä mieltä tahansa, niin Optionia joka tapauksessa käytetään laajasti Scala-ohjelmoinnissa ja muillakin kielillä ohjelmoidessa. Tämä tekniikka on siis syytä hallita.

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

Yhteenvetoa

  • "Ehkä olemassa olevasta arvosta" voi pitää kirjaa Option-luokan avulla. Luokalle 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.3), jolloin luokan käyttö kätevöityy.

  • Ohjelmointikieli peruskirjastoineen voidaan 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!

Materiaalin luvut tehtävineen ja viikkokoosteineen on laatinut Juha Sorva.

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, Kaisa Ek, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Onni Komulainen, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Mikael Lenander, Ilona Ma, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, Joel Toppinen, Anna Valldeoriola Cardó ja Aleksi Vartiainen.

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

Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista suunnittelivat Juha Sorva ja Teemu Sirkiä. Teemu Sirkiä ja Riku Autio toteuttivat ne apunaan Teemun aiemmin rakentamat työkalut Jsvee ja Kelmu.

Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset laati Juha Sorva.

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

Tapa, jolla 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+ luotiin alun perin Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Nykyään tätä avoimen lähdekoodin projektia kehittää Tietotekniikan laitoksen opetusteknologiatiimi ja tarjoaa palveluna laitoksen IT-tuki; sitä ovat kehittäneet kymmenet Aallon opiskelijat ja muut.

A+ Courses -lisäosa, joka tukee A+:aa ja O1-kurssia IntelliJ-ohjelmointiympäristössä, on toinen avoin projekti. Sen suunnitteluun ja toteutukseen on osallistunut useita opiskelijoita yhteistyössä O1-kurssin opettajien kanssa.

Kurssin tämänhetkinen henkilökunta löytyy luvusta 1.1.

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

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