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

Kurssin viimeisimmän version löydät täältä: O1: 2024

Luku 4.2: Säiliöitä... ja kaatuva ohjelma

Tästä sivusta:

Pääkysymyksiä: Miten kuvaan suhdetta yhdestä oliosta useaan toiseen olioon? Onko muitakin kokoelmia kuin puskureita? Mitä kaikkea puskurilla tms. kokoelmalla voi tehdä? Miten kuvaan alkiokokoelmaa, jonka sisältöä ei ole tarkoitus muuttaa?

Mitä käsitellään? Puskurit olioina. Uusi kokoelmatyyppi Vector. Puskurien ja vektorien metodeita. Kokoelman käyttö osana luokan toteutusta. Uusia muuttujien rooleja: säiliö ja sopivimman säilyttäjä. Ajonaikaiset virheilmoitukset.

Mitä tehdään? Luetaan, testaillaan kirjastometodeita REPLissä ja ohjelmoidaan.

Suuntaa antava työläysarvio:? Kolme, neljä tuntia.

Pistearvo: A115.

Oheismoduulit: GoodStuff, Miscellaneous, Football2 (uusi).

../_images/sound_icon.png

Muuta: Yhdessä kohdassa on kaiuttimista tai kuulokkeista hyötyä. Aivan pakolliset ne eivät ole.

../_images/person08.png

Takaisin GoodStuffiin: Category-luokan käyttö

Tässä luvussa palaamme taas GoodStuff-ohjelman äärelle ja alamme toteuttaa Category-luokkaa. Tuota toteutusta laatiessa vastaan tulee useita uusia asioita.

Kertaus tarpeen?

Jos luvun 3.3 alun yhteenveto GoodStuff-ohjelman luokista ei ole enää tuoreessa muistissa, niin kertaa se nyt.

Tarkastellaan ensin, miten haluaisimme Category-luokan toimivan. Se on esitelty alla animaatiossa. Uusia käsitteitä animaatiossa ei muuten tule, ja voit käydä sen nopeasti läpi, jos asia tuntuu sinusta selvältä. Varmista kuitenkin, että ymmärrät esimerkin hyvin, ennen kuin jatkat.

Animaatiossa esiintyy pieni testiohjelma CategoryTest, joka vain koekäyttää eräitä Category-luokan metodeita.

Category-luokan versioista

Tiedoksi vain: Jos satut omin päin kokeilemaan GoodStuff-moduulista löytyvää Category-luokkaa (mikä ei ole tässä välttämätöntä), niin huomaat siinä pieniä eroja tässä esitettyyn. Se johtuu siitä, että GoodStuff-moduulissa on se lopullinen versio, johon päädytään erinäisten vaiheiden jälkeen luvun 4.3 lopussa.

Ensimmäinen luonnos Category-luokasta

Hahmotellaan luokalle toteutusta vähitellen tarkentaen ja parannellen. Ensimmäinen versio pseudokoodina:

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

  yksityiseen ilmentymämuuttujaan experiences luettelo lisätyistä kokemuksista, aluksi tyhjä
  yksityiseen ilmentymämuuttujaan fave viittaus arvosanaltaan korkeimpaan kokemukseen

  def favorite = this.fave

  def addExperience(newExperience: Experience) =
    Lisää annettu kokemus siihen luetteloon, johon this.experiences viittaa.
    Vaihda fave-muuttujan arvoa lisätyn kokemuksen ollessa kaikkein paras.

  def allExperiences = Palauta luettelo kaikista toistaiseksi lisätyistä kokemuksista.

end Category

Jotta päästään pseudokoodista Scala-koodiin, tarvitaan ratkaisut kahteen ongelmaan.

  1. Miten pidetään kirjaa useasta yhteen kategoriaolioon liittyvästä kokemusoliosta?

  2. Miten päivitetään suosikkia vain uuden kokemuksen ollessa parempi?

Aloitamme ratkaisemalla ensimmäisen ongelman. Siis: Miten saamme käytettyä muuttujaa experiences "säiliönä", jonka kautta pääsee käsiksi useisiin toisiin olioihin? Minkä tyyppinen experiences-ilmentymämuuttujan pitäisi olla ja miten lisätään uusi kokemus addExperience-metodissa?

Rooli: Säiliö

../_images/container.png

Säiliö on varastointipaikka, jossa on tilaa usealle tiedolle ("oma hylly" kullekin).

Muuttujaa, jonka avulla käsitellään useaa tietoalkiota voi sanoa säiliöksi (container; tämä on yleinen muuttujan rooli).

Yhdellä muuttujalla voi olla vain yksi arvo kerrallaan, joten säiliömuuttujan tulee olla jotakin sellaista tyyppiä, että sillä voi viitata alkiokokoelmaan. Olet jo kohdannut yhden tällaisen tyypin, Bufferin. Tätä puskurityyppiä voi käyttää osana Category-luokan toteutusta:

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

  private val experiences = Buffer[Experience]()
  // tästä puuttuu vielä fave-ilmentymämuuttuja

  def favorite = this.fave

  def addExperience(newExperience: Experience) =
    this.experiences += newExperience
    // tästä puuttuu vielä fave-ilmentymämuuttujan päivittäminen

  def allExperiences = this.experiences

end Category

Luodaan kullekin kategoriaoliolle avuksi puskuri. Kunkin kategoriaolion muuttuja experiences siis sisältää viittauksen tällaiseen puskuriin, ja kullakin kategoriaoliolla on oma puskurinsa, aluksi tyhjä. (Muista: tämä koodirivi suoritetaan aina silloin, kun luodaan uutta kategoriaoliota.)

Niin kuin luvussa 1.5 todettiin, tyhjää puskuria luodessa pitää ilmoittaa alkioiden tietotyyppi ns. tyyppiparametrina hakasulkeissa.

Kukin kategoriaolio käyttää experiences-muuttujaansa säiliönä, johon lisätään uusi kokemus aina, kun addExperience-metodia kutsutaan. (Kertaa puskuriin lisääminen +=-operaattorilla tarvittaessa luvusta 1.5.)

Tämä allExperiences-metodin toteutus yksinkertaisesti palauttaa viittauksen Category-olion käyttämään puskuriin, kun pyydetään luetteloa kaikista aiemmin lisätyistä kokemuksista. Tämä ratkaisu toimii muttei ole ihan tyydyttävä. Palataan asiaan kohta.

Voit tutustua luokan toimintaan myös seuraavan animaation avulla.

Pikkutehtäviä

Jos kategorialuokka on määritelty kuin äskeisessä koodissa, ja luodaan 10 kategoriaoliota, montako puskuria tulee samalla luoduksi?

Jos kategorialuokka on määritelty kuin äskeisessä koodissa, ja luodaan 10 kategoriaoliota, montako kokemusoliota tulee samalla luoduksi?

Koodinlukutehtävä: lampaiden kloonausta

Tutki seuraavaa koodia. Mieti huolellisesti, mitä tapahtuu, kun testiohjelma ajetaan.

import scala.collection.mutable.Buffer

// Lampaan virtuaaligeenit kuvataan tässä leluohjelmassa puskurillisena lukuja.
class Lammas(val nimi: String, val geenit: Buffer[Int]):

  // Aiheuttaa eräänlaisia muutoksia lampaan geeneissä.
  def mutatoi() =
    this.geenit(0) += 1
    this.geenit(1) += 5

  /* Luo kloonin lampaasta. Kloonilla on annettu nimi ja sen geenit
     ovat identtiset alkuperäisen lampaan kanssa. Myöhemmät mutaatiot
     alkuperäiseen eivät vaikuta klooniin ja toisin päin. */
  def kloonaa(klooninNimi: String) = Lammas(klooninNimi, this.geenit)

end Lammas


object Lammastesti extends App:
  val eka = Lammas("Puskuripässi", Buffer(2, 5))
  val toka = eka.kloonaa("Dolly")
  toka.mutatoi()

Vain yksi alla olevista kloonausmetodiin liittyvistä väitteistä on totta. Mikä?

Olion kloonaamisesta

Ei ole aivan yksinkertaista muodostaa oliosta kopiota, joka olisi alkuperäisestä täysin erillinen mutta alkutilaltaan samankaltainen. Ratkaisu riippuu siitä, millaisesta kopioitavasta oliosta on kyse ja millaista kopiointia täsmälleen tavoitellaan.

Tässä yksinkertainen ratkaisumalli yksinkertaisiin tilanteisiin:

class Juttu(var luku: Int, var toinen: Int):

  // Luo toisen samanlaisen mutta erillisen olion ja palauttaa viittauksen siihen.
  // Alkuperäisen ja kopion tietoja voi muuttaa toisistaan riippumattomasti.
  def kopio = Juttu(this.luku, this.toinen)

end Juttu

Tässä eräitä huomionarvioisia seikkoja:

  • Äskeinen metodi palauttaa pintakopion (shallow copy) alkuperäisestä: siinä yksinkertaisesti otetaan ilmentymämuuttujien arvot ja tehdään toinen olio, jonka ilmentymämuuttujilla on samat arvot. (Pintakopion palauttaa myös lammasluokan kloonausmetodi ylempänä.)

  • Mutta mitä jos lukujen sijaan ilmentymämuuttujissa onkin viittauksia muunlaisiin olioihin? Haluttaisiinko niistä luoda kopiot samalla? Entä niistä olioista, joihin nuo toiset oliot edelleen viittavat? Haluttaisiinko syväkopio (deep copy)?

  • Periytyminen, jota käsittelemme lisää seitsemännellä kierroksella, tuo omat kommervenkkinsä kopioimiseen.

  • Muuttumattomuus yksinkertaistaa tässäkin asioita. Jos käytämme tilaltaan muuttumattomia olioita, onko kopioimiselle edes tarvetta? Mieti vaikkapa String-, Pos- ja Odds-luokkia, joiden oliot ovat muuttumattomia.

Heikkous Category-toteutuksessamme

Seuraava pohdiskelu Category-luokan julkisesta rajapinnasta ei ole välttämätöntä luettavaa. Se voi silti herättää ajatuksia ja osin myös perustelee alempaa löytyvän Vector-kokoelmatyypin esittelyn. Kiireessä voit siirtyä suoraan sinne.

Puskuri Category-luokan rajapinnassa

Otetaan lähtökohdaksi, että haluamme ehkäistä luokkiemme virheellistä käyttöä ja mahdollisuuksien mukaan kitkeä luokan julkisesta rajapinnasta toimintoja, jotka eivät ole mielekkäitä tai johtavat virhetilanteisiin (luku 3.2).

Katso vielä uudelleen tätä toteutusta:

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

  private val experiences = Buffer[Experience]()
  // tästä puuttuu vielä fave-ilmentymämuuttuja

  def favorite = this.fave

  def addExperience(newExperience: Experience) =
    this.experiences += newExperience
    // tästä puuttuu vielä fave-ilmentymämuuttujan päivittäminen

  def allExperiences = this.experiences

end Category

allExperiences-metodin tehtävänä on palauttaa luettelo kaikista kategoriaan kuuluvista kokemuksista. (GoodStuff-ohjelman käyttöliittymä tarvitsee tätä metodia, jotta se saa kategoriaan kuuluvat kokemukset ruudulle näkyviin.)

Metodi on nyt toteutettu niin, että se jakaa kutsujalleen viittauksen yksityisesti käytettyyn puskuriin. Millaisia virhetilanteita mahdollistuu tämän vuoksi? Mitä ikävää olisi vaikkapa siinä, että kutsumme jokuKategoria.allExperiences, saamme palautusarvona puskuriviittauksen ja lisäämme jotakin tuohon puskuriin? Vaihtoehtoisesti: Millaisia virhetilanteita mahdollistuisi, jos experiences-ilmentymämuuttuja olisikin julkinen?

Vastaus: jos metodi on toteutettu noin, voi kutsuva koodi pyytää viittauksen kategorian sisäisesti käyttämään puskuriin ja lisätä tai poistaa sieltä alkioita (kokemuksia) kutsumatta kategorian addExperience-metodia ja päivittämättä kategorian fave-muuttujaa:

val wineCategory = Category("Wine", "bottle")wineCategory.addExperience(Experience("Il Barco 2001", "ookoo", 6.69, 6))
wineCategory.favorite.nameres0: String = Il Barco 2001
val bufferUsedByCategory = wineCategory.allExperiencesbufferUsedByCategory: Buffer[Experience] = ArrayBuffer(o1.goodstuff.Experience@2a863ca0)
val superkokemus = Experience("Supermahtava", "woohoo", 10, 10)superkokemus: Experience = o1.goodstuff.Experience@4985e5b6
bufferUsedByCategory += superkokemuswineCategory.favorite.nameres1: String = Il Barco 2001
wineCategory.allExperiencesres2: Buffer[Experience] = ArrayBuffer(o1.goodstuff.Experience@2a863ca0, o1.goodstuff.Experience@4985e5b6)

Esimerkin lopuksi suosikki on sama aluksi lisätty kokemus...

... vaikka kategoriaan tuli lisättyä toinenkin kokemus, joka on arvosanaltaan ensimmäistä parempi.

Ongelma on siis se, että puskuria voi muokata. Palauttamalla luokan käyttäjälle viittauksen tällaiseen muuttuvatilaiseen alkiokokoelmaan tarjoamme hänelle mahdollisuuden tehdä tyhmyyksiä ja saatamme houkutellakin sellaisiin: Buffer-tyyppinen palautusarvo ikään kuin antaa ymmärtää, että palautettua kokoelmaa olisi sopivaa tai järkevää muokata, vaikka todellisuudessa olemme suunnitelleet, että kategoriaan kuuluvien kokemusten luettelon päivittäminenhän on yksin Category-olion vastuulla.

Olisi parempi, jos voisimme palauttaa allExperiences-metodin kutsujalle kokoelman, jonka sisältö on juuri se kategorian senhetkinen kokemusluettelo ja sillä hyvä. Tuota kokoelmaa ei voisi muokata ja jo sen tietotyyppi tekisi tämän kutsujalle selväksi.

Asia on helppo korjata. Lisäksi sen korjatessa saamme syyn tutustua uuteen kokoelmatyyppiin Vector, joka tulee osoittautumaan muutoinkin hyödylliseksi ja esiintyy kurssilla jatkossa toistuvasti.

Uusi kokoelmatyyppi: vektori

"Vektori"-sanalla on erilaisia enemmän ja vähemmän toisiinsa liittyviä merkityksiä mm. matematiikassa ja ohjelmoinnissa. Ohjelmoinnin piirissäkin riippuu yhteydestä, mitä tällä sanalla tarkoitetaan.

Scalassa vektori (vector) on alkiokokoelmatyyppi samassa mielessä kuin puskurikin.

Vector-luokka

Luokka Vector on käytössä kaikissa Scala-ohjelmissa ilman erillistä import-käskyä samoin kuin vaikkapa String ja Int ovat. Kukin vektori on alkiokokoelma, joka sisältää keskenään samantyyppisiä alkioita. Vektorin luominen käy kuin puskurinkin:

val lukuvektori = Vector(4, 10, -100, 10, 15)lukuvektori: Vector[Int] = Vector(4, 10, -100, 10, 15)
val sanavektori = Vector("eka", "toka", "kolmas", "neljäs")sanavektori: Vector[String] = Vector(eka, toka, kolmas, neljäs)

Kuten puskurinkin tapauksessa, myös vektorin alkioilla on nollasta alkavat indeksit, joiden perusteella voi poimia yksittäisen alkion:

lukuvektori(3)res3: Int = 10
sanavektori(2)res4: String = kolmas

Miten vektori sitten eroaa puskurista?

Keskeisin ero luokkien Buffer ja Vector toiminnallisuudessa on se, että siinä missä puskuria voi muuttaa sen luomisen jälkeen, vektori on luomisen jälkeen täysin muuttumaton. Siihen ei voi lisätä alkioita, eikä alkioita voi poistaa tai vaihtaa toisiin:

lukuvektori += 999-- Not Found Error:
  |lukuvektori += 999
  |^^^^^^^^^^^^^^
  |value += is not a member of Vector[Int]
lukuvektori(2) = 999-- Not Found Error:
  |lukuvektori(2) = 999
  |^^^^^^^^^^^
  |value += is not a member of Vector[Int]

Vektorit — ja puskurit — olioina

Vektorin alkioiden lukumäärän saa selville size-metodilla:

sanavektori.sizeres5: Int = 4
lukuvektori.sizeres6: Int = 5

Ja siinä tulikin paljastettua jotakin: Scalan vektoreilla on metodeita eli ne ovat olioita, ja niin ovat muuten puskuritkin.

Vektorin koko on aina sama, mutta puskuriolion size-metodi voi palauttaa eri kutsukerroilla eri luvun:

val sanapuskuri = Buffer[String]("eka")sanapuskuri: Buffer[String] = ArrayBuffer(eka)
sanapuskuri.sizeres7: Int = 1
sanapuskuri += "toka"sanapuskuri += "kolmas"sanapuskuri.sizeres8: Int = 3

Muunnokset kokoelmatyyppien välillä: to, toVector, toBuffer

Ei ole harvinaista, että ohjelmassa halutaan viedä yhden alkiokokoelman sisältö toiseen, yleensä niin, että kohdekokoelma on eri tyyppiä kuin alkuperäinen. Tämä on helppoa, koska siihen on nimenomaisia metodeita. Tässä niistä ensin yleiskäyttöisin:

val ekaVektori = Vector(10, 50, 5)ekaVektori: Vector[Int] = Vector(10, 50, 5)
val sisaltoKopioitunaPuskuriin = ekaVektori.to(Buffer)sisaltoKopioitunaPuskuriin: Buffer[Int] = ArrayBuffer(10, 50, 5)

to-metodi siis loi uuden puskurin, kopioi vektorin sisällön sinne ja palautti viittauksen luomaansa puskuriin. Tuota puskuria voi muokata tutusti:

sisaltoKopioitunaPuskuriin += 100res9: Buffer[String] = ArrayBuffer(10, 50, 5, 100)

Puskurin sisällön kopioiminen uuteen vektoriin on yhtä helppoa:

val tokaVektori = sisaltoKopioitunaPuskuriin.to(Vector)tokaVektori: Vector[Int] = Vector(10, 50, 5, 100)

to-metodia kutsuessa annetaan parametri, joka määrää halutun kokoelman tyypin, mistä Buffer ja Vector yllä olivat esimerkkejä. Muutamalle usein käytetylle kokoelmatyypille on lisäksi erilliset metodinsa, kuten toBuffer ja toVector. Seuraavat koodinpätkät tekevät keskenään ihan saman:

val puskuriinKopioitu = jokuKokoelma.to(Buffer)
val vektoriinKopioitu = jokuKokoelma.to(Vector)
val puskuriinKopioitu = jokuKokoelma.toBuffer
val vektoriinKopioitu = jokuKokoelma.toVector

Näin voit siis jättää yhdet sulkeet pois koodista, jos haluat.

toVector avuksi Category-luokassa

Tilkitään Category-luokan vuotava rajapinta:

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

  private val experiences = Buffer[Experience]()
  // tästä puuttuu vielä fave-ilmentymämuuttuja

  def favorite = this.fave

  def addExperience(newExperience: Experience) =
    this.experiences += newExperience
    // tästä puuttuu vielä fave-ilmentymämuuttujan päivittäminen

  def allExperiences = this.experiences.toVector

end Category

allExperiences-metodi luo vektorin, joka sisältää kaikki ne kokemukset, jotka juuri kutsuhetkellä kuuluvat kyseiseen kategoriaan. Palautusarvon tyypiksi on valittu Vector[Experience] eikä Buffer[Experience], koska tämän palautusarvon muuttaminen ei ole sellainen operaatio, jota metodia kutsuneen tahon on mielekästä tehdä.

Silti: miksei aina Buffer?

Ihmettelet ehkä edelleen, onko erillinen vektorityyppi vaivan arvoinen, kun sillä kerran voi tehdä vähemmän asioita kuin puskurilla.

Aina ei ole hyvä voida tehdä kokoelmalla mitä tahansa. Muuttumaton alkiokokoelma takaa, että missään kohdassa ohjelmaa kyseisen kokoelman sisältö ei muutu. Tämä muun muassa auttaa ihmislukijaa päättelemään, miten ohjelma toimii ja missä virheet sijaitsevat. Vektorilla voi myös selkiyttää luokan rajapintaa ja osin ehkäistä sen virhekäyttöä, mistä Category-luokkamme toimi esimerkkinä.

Muuttumaton data on funktionaalisen ohjelmointiparadigman kulmakivi ja monen laadukkaan ohjelman taustalla vaikuttava periaate. Siitä lisää luvussa 11.2 ja jatkokursseilla.

Vektorien ja puskurien välillä on myös suoritustehokkuuseroja, jotka riippuvat tapauksesta ja joita emme käsittele tällä peruskurssilla.

Muuttumattoman ja muuttuvan kokoelman ero on samantapainen kuin val- ja var-muuttujan. Luvussa 1.4 annettiin tämä nyrkkisääntö:

Tee jokaisesta muuttujasta val, ellei sinulla ole juuri nyt selvää syytä tehdä siitä var.

Samaan tapaan voimme nyt todeta:

Tee jokaisesta kokoelmasta muuttumaton (kuten Vector), ellei sinulla ole juuri nyt selvää syytä tehdä siitä muuttuvatilaista (kuten Buffer).

Vektorien ja puskurien metodeita

Ennen luvun päättävää isompaa ohjelmointitehtävää tutkikaamme, millaisia metodeita kokoelmilta löytyy, kun ne kerran olioita ovat. Näiden metodien joukosta löytyy työkaluja myös siihen ohjelmointitehtävään.

Kaikkia seuraavia metodeita ei tarvitse heti opetella ulkoa, mutta voit pistää mieleen, että niitä on esitelty tässä luvussa (ja myös Scalaa kootusti -sivulla). Muitakin kokoelmien metodeita tulee vastaan kurssin mittaan.

Seuraavissa esimerkeissä käytämme enimmäkseen vektoreita, mutta esitellyt vektorien metodit löytyvät myös puskureilta (ja muiltakin kokoelmatyypeiltä).

Luodaan aluksi testivektori, jossa on vaikkapa merkkijonoja:

val vektori = Vector("eka", "toka", "kolmas", "neljäs")vektori: Vector[String] = Vector(eka, toka, kolmas, neljäs)

Kokoelman kuvaileminen: mkString

Ohjelman tuottamia tekstejä muotoillessa on usein iloa mkString-metodista, jota voi käyttää näin:

vektori.mkString(";")res10: String = eka;toka;kolmas;neljäs

Metodi siis muodostaa ja palauttaa merkkijonon, joka sisältää kuvauksen kustakin alkiosta. Erottimena käytetään parametriksi annettua merkkijonoa, yllä puolipistettä.

Tässä toinen esimerkki, jossa erottimena on käytetty rivinvaihtoa (joka merkitään \n):

val lukuja = Vector(10, 50, 30, 100, 0)lukuja: Vector[Int] = Vector(10, 50, 30, 100, 0)
println(lukuja.mkString("\n"))10
50
30
100
0

mkString-tehtävä

Ensimmäiseltä kierrokselta tuttu soittamisfunktio osaa soittaa useaa melodiaa yhtäaikaa. Yhtäaikaisten melodioiden väliin laitetaan &-merkki:

play("cccedddfeeddc---&cdefg-g-gfedc-c-")

Tässä merkkijonossa on yksi &-merkki. play-funktio soittaa sen vasemmanpuolisen melodian samaan aikaan kuin oikeanpuoleisen.

Tällä tavoin voi myös esimerkiksi koota bändin, joka soittaa useita instrumentteja.

Laadi funktio together, jonka avulla voi muodostaa moniäänisiä kappaleita:

val ukkoNooa = "cccedddfeeddc---"ukkoNooa: String = cccedddfeeddc---
val basso = "[33]<<c-c-d-d-e-d-cdc"basso: String = [33]<<c-c-d-d-e-d-cdc
val esitys = together(Vector(ukkoNooa, basso), 150)esitys: String = cccedddfeeddc---&[33]<<c-c-d-d-e-d-cdc/150
play(esitys)

Funktiolle annetaan ensimmäiseksi parametriksi ääniä kuvaavat merkkijonot vektorissa.

Toiseksi parametriksi annetaan koko kappaleen tempo kokonaislukuna.

Funktio palauttaa moniäänistä kappaletta edustavan merkkijonon, jossa välimerkit ovat paikoillaan play-funktiolle sopivalla tavalla.

Tässä toinen käyttöesimerkki, jossa syöte on mutkikkaampi ja kauniimpi mutta periaate ihan sama:

val aanet = Vector(
  "FGHbG(GHb>D<)--.(GHb>D<)--.(FAC)---- FGHbG(FAC)--.(FAC)--.(DGHb)--AG-FGHbG(FGHb)--->C-<(AF)--GF---F-(DF>C<)---.(DGHb)------",
  "<<    (<eb>eb)--.(<eb>eb)--.(<f>f)----     (<d>d)--.(<d>d)--.(<g>g)----     (<c>c)-----(<f>f)-----f---(da)---.(<g>g)------",
  "P:<<    c ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc cc"
)aanet: Vector[String] = Vector(FGHbG(GHb>D<)--.(GHb>D<)--.(FAC)---- FGHbG(FAC)--.(FAC)--.(DGHb)--AG-FGHbG(FGHb)--->C-<(AF)--
GF---F-(DF>C<)---.(DGHb)------, <<    (<eb>eb)--.(<eb>eb)--.(<f>f)----     (<d>d)--.(<d>d)--.(<g>g)----     (<c>c)-----(<f>f)----
-f---(da)---.(<g>g)------, P:<<    c ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc ccc cc)
play(together(aanet, 20))

Kirjoita funktio misc.scala-tiedostoon Miscellaneous-moduulissa.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Kokoelman sisällön tutkiminen: indexOf, contains ja isEmpty

indexOf-metodilla voi selvittää, missä päin kokoelmaa tietty alkio esiintyy:

val vektori = Vector("eka", "toka", "kolmas", "neljäs")vektori: Vector[String] = Vector(eka, toka, kolmas, neljäs)
vektori.indexOf("toka")res11: Int = 1
vektori.indexOf("eka")res12: Int = 0

Kokeile indexOf-metodia itse. Mikä seuraavista väitteistä kuvaa sitä, miten metodi käyttäytyy, jos etsitty alkio esiintyy kokoelmassa useasti?

Entä mitä indexOf palauttaa, jos esiintymiä ei ole lainkaan?

Kokoelman ominaisuuksia voi tutkia myös parametrittomalla metodilla isEmpty ja metodilla contains jota kutsutaan samaan tapaan kuin indexOf-metodia. Kokeile näitäkin metodeita jollakin vektorilla tai puskurilla.

Mitkä seuraavista pitävät paikkansa? Alla oletetaan, että muuttuja kokoelma viittaa johonkin vektoriin tai puskuriin.

Alkioiden poimiminen: apply; head ja tail; take ja drop

Kokoelmilla on metodi apply, jolle annetaan parametriksi indeksi, sekä parametrittomat metodit head ja tail.

Kokeile mainittuja metodeita itse REPLissä. Arvioi kokeilujesi perusteella, mitkä seuraavista väittämistä vaikuttavat pitävän paikkansa.

Alla oletetaan, että muuttuja kokoelma viittaa johonkin vektoriin tai puskuriin, jossa on useita alkioita.

Kokoelmilla on metodit take ja drop, joille annetaan parametriksi kokonaisluku.

Kokeile mainittuja metodeita itse REPLissä eri merkkijonoilla ja eri parametriarvoilla. Arvioi kokeilujesi perusteella, mitkä seuraavista väittämistä vaikuttavat pitävän paikkansa.

Alla oletetaan taas, että kokoelma viittaa kokoelmaan, jossa on alkioita.

Tutki näitäkin:

Vektoria "muuttavista" metodeista

On helppo kuvailla metodia kuten tail sanomalla, että "se ottaa vektorista kaikki alkiot paitsi ensimmäisen", mikä kuulostaa siltä että vektorin sisältö muuttuisi. Moni muukin metodi tässä mielessä näennäisesti — mutta vain näennäisesti — muuttaa vektorin sisältöä. Vaikkapa reverse "kääntää vektorin sisällön":

val lukuvektori = Vector(4, 10, -100, 10, 15)lukuvektori: Vector[Int] = Vector(4, 10, -100, 10, 15)
lukuvektori.reverseres13: Vector[Int] = Vector(15, 10, -100, 10, 4)

Kuitenkin kyseessä on kokonaan uusi kokoelma, jossa samat alkiot ovat eri järjestyksessä. Alkuperäinen on ennallaan.

lukuvektorires14: Vector[Int] = Vector(4, 10, -100, 10, 15)

Mikään esitellyistä — tai muistakaan — vektorien metodeista ei ole vaikutuksellinen eikä siis muuta alkuperäistä vektoria. Vektorin käyttäjä voi luottaa siihen, että kerran tietynlaiseksi luotu vektori pysyy aina sellaisena kuin se on.

Puskuria muuttavista metodeista

Puskureilla on samat yllä esitellyt vaikutuksettomat metodit kuin vektoreillakin. Lisäksi niille on määritelty joukko vaikutuksellisia metodeita, joilla puskurin sisältöä voi muokata. Tässä eräitä:

Kokeile alla mainittuja metodeita itse REPLissä eri puskureille. Arvioi kokeilujesi perusteella, mitkä seuraavista väittämistä vaikuttavat pitävän paikkansa.

Alla oletetaan, että lukupuskuri on Buffer[Int]-tyyppinen muuttuja.

Kokoelmien yhdistäminen: ++, +: ja diff

Alla on näytetty yksinkertainen tapa yhdistää kaksi kokoelmaa. Tarkemmin sanoen näin muodostetaan uusi kokoelma, jossa on samat alkiot kuin kahdessa olemassa olevassa kokoelmassa:

val ekat = Vector("eka", "toka")ekat: Vector[String] = Vector(eka, toka)
val tokat = Vector("a", "b", "c")tokat: Vector[String] = Vector(a, b, c)
val yhdistelma = ekat ++ tokatyhdistelma: Vector[String] = Vector(eka, toka, a, b, c)
val toisinPain = tokat ++ ekattoisinPain: Vector[String] = Vector(a, b, c, eka, toka)

Operaattorissa on kaksi plusmerkkiä.

Tämäkin toimii myös puskureille.

Operaattori ++ siis yhdistää kaksi kokoelmaa. Jos haluat yhdistää vain yhden alkion kokoelman alkuun, niin operaattori +: palvelee:

val ekat = Vector("eka", "toka")ekat: Vector[String] = Vector(eka, toka)
val isompi = "oikeesti eka" +: ekatisompi: Vector[String] = Vector(oikeesti eka, eka, toka)

diff puolestaan tuottaa kokoelman, jossa yhden kokoelman alkiot on "vähennetty" toisesta:

Vector("a", "b", "c", "d").diff(Vector("a", "c"))res15: Vector[String] = Vector(b, d)

Football2: uusi otteluluokka

Johdanto

Muutoksen maailmassa yksi asia on muuttumaton: taukoamaton muuttuminen.

—väitetty Herakleitoksen sanomaksi; alkuperä tuntematon

Successful software always gets changed.

Fred Brooks

Ohjelmankehitystyö on usein iteratiivista: laaditaan ensin yksinkertainen versio tai prototyyppi, kokeillaan ja arvioidaan sitä ja jatketaan sitten kehitystyötä eteenpäin.

On myös tyypillistä, että ohjelmalle asetetut vaatimukset muuttuvat ohjelman kehitystyön aikanakin ja uudet vaatimukset on huomioitava seuraavaa versiota kehitettäessä. Niin on jo käynyt esimerkiksi FlappyBug-ohjelmalle, ja nyt niin käy nyt luvussa 3.5 pohjustetulle jalkapallotilastoja käsittelevälle ohjelmalle. (Eikä viimeistä kertaa.)

Tehtävänanto

Nouda moduuli Football2 ja tutustu sen Scaladoc-dokumentaatioon. Tässä ensimmäisessä vaiheessa toteutetaan luokka Match. Luokat Club ja Player on annettu valmiina, eikä niihin tarvitse tehdä muutoksia. Dokumentaatiossa mainittu luokka Season toteutetaan myöhemmässä tehtävässä (luku 4.4).

../_images/module_football2.png

Kaavio keskeisistä yhteyksistä luokkien välillä.

Football2-spesifikaatio eroaa aiemmassa Football1-moduulin yksinkertaisemmasta spesifikaatiosta. Uuden Match-luokan tulee pitää kirjaa ei ainoastaan maaleista vaan myös siitä, ketkä nuo maalit ovat tehneet. Vanhat addHomeGoal- ja addAwayGoal-metodit on korvattu yhdellä addGoal-metodilla, jonka olisi tarkoitus päätellä maalintekijän mukaan, kummalle joukkueelle maali kirjataan. Lisäksi toivelistalla on uusia metodeita muun muassa ottelun voittomaalin tekijän määrittämiseen. Tämä kaikki edellyttää melko suurta remonttia luokkaan Match.

Tiedostosta Match.scala löytyy vaillinainen versio luokasta Match. Siinä on ihan hyvä alku uudelle toteutukselle: ilmentymämuuttujissa on kaksi puskuria, joihin voi tallentaa maalien tekijöitä. Uusi tapa lisätä maaleja on luonnosteltu metodiin addGoal. Myös uusi winnerName-metodi on valmiiksi tehty, ja se tuottaa virheilmoituksia vain, koska luokka on muilta osin puutteellinen. Tehtäväsi on täydentää luokka Match toimivaksi viimeistelemällä addGoal-metodi ja lisäämällä puuttuvat metodit.

Ohjeita ja vinkkejä

  • Pakkauksessa o1.football2.gui on pieni sovellusohjelma FootballApp, joka tarvitsee toimiakseen spesifikaatiota vastaavan Match-luokan. Kunhan saat luokkasi käyttökuntoon, voit kokeilla sen metodeita FootballApp-sovelluksen kautta käynnistämällä ohjelman ja painamalla maalintekijänappuloita tarjotussa ikkunassa:

    ../_images/football2_gui.png
  • Käytä ilmentymämuuttujissa puskureita ja Player-olioita, kuten annettu koodi jo ehdottaakin.

  • Poimi kokoelmankäsittelykonsteja tästä luvusta.

  • Älä tee muutoksia valmiisiin luokkiin Player ja Club.

  • Osa puuttuvista metodeista tehtiin jo luvussa 3.5. Voit poimia ne osat silloisesta ratkaisustasi Football2-moduuliin. Jos et tehnyt jo tuota aiempaa tehtävää, niin aloita siitä. (Voit myös katsoa sen esimerkkiratkaisun.)

  • FootballApp-sovellus vain lisäilee maaleja ja tutkii voittomaalin tekijöitä. Voit myös käyttää Match-luokkaasi erikseen REPLissä tai laatia oman pienen testiohjelman.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Suosikkikirjanpito (ensimmäinen yritys)

Category-luokkamme jäi kesken: miten kategoriaolion pitäisi huolehtia siitä, että se pystyy kertomaan, mikä aiemmin lisätyistä kokemuksista on käyttäjän suosikki?

Tässä kaksi erilaista ratkaisutapaa:

  1. Kirjataan kategoriaolioon tieto siitä, mikä on paras kategoriaan lisätty kokemus. Päivitetään tätä tietoa aina, kun kategoriaan lisätään aiempaa suosikkia parempi kokemus.

  2. Pidetään kategoriaoliossa tallessa ainoastaan luettelo kaikista kokemuksista. Kun oliolta kysytään suosikkikokemusta, se käy koko luettelon läpi ja palauttaa siellä olleista kokemuksista arvosanaltaan parhaan.

Valitsemme nyt ensimmäisen tavan, jolle meillä onkin jo alku. Yllä suunnittelimme käyttävämme fave-nimistä muuttujaa niin, että se viittaa aina juuri siihen lisätyistä kokemuksista, joka on arvosanaltaan paras.

Millainen fave-ilmentymämuuttujan pitäisi olla ja miten sitä käsittelisimme addExperience-metodissa?

Hahmotelma pseudokoodina

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

  private val experiences = Buffer[Experience]()
  private var fave = aluksi ei vielä ole suosikkia

  def favorite = this.fave

  def addExperience(newExperience: Experience) =
    this.experiences += newExperience
    Aseta this.fave-muuttujan arvoksi parempi näistä: newExperience tai entinen this.fave.

  def allExperiences = this.experiences.toVector

end Category

Kategoriassa, jossa ei ole vielä kokemuksia ei ole suosikkiakaan. Tarvitsemme jonkin tavan ilmoittaa, ettei muuttujalla ole aluksi arvoa tai että sen arvo tarkoittaa "ei suosikkia". Toistaiseksi tähän ei ole opittu hyviä välineitä.

Käytämme fave-muuttujaa vähän kuin tuoreimman säilyttäjänä mutta nirsosti: vain parempi kelpaa uudeksi arvoksi.

null-arvo

Ehkä fave-muuttujan voisi alustaa näin?

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

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

  def favorite = this.fave

Scalan sana null tarkoittaa "arvoa, joka ei ole mikään olio lainkaan" tai "viittausta ei-mihinkään". null-arvon voi sijoittaa muuttujaan tähän tapaan.

null-arvon voi sijoittaa monentyyppisiin muuttujiin, ja pelkästä sijoituksesta ei nyt tule ilmi, että haluamme fave-muuttujan tyypiksi Experience. Niinpä tässä tilanteessa täytyy tämän muuttujan tyyppi kirjata erikseen koodiin kaksoispisteen perään samaan tapaan kuin parametrimuuttujille aina tehdään. (Tyypinhän saa kirjoittaa muuttujan määrittelyyn vaikka aina; ks. luvut 1.8 ja 3.5.)

Ennakkovaroitus

null-arvo on ongelmallinen, ja sen käyttöä kannattaa yleensä välttää. Tähän palataan pian. Jatketaan nyt addExperience-metodia toteuttamalla.

Rooli: sopivimman säilyttäjä

../_images/most-wanted_holder.png

Kun arvot haluaa kalifiksi kalifin paikalle, niin kyseessä on sopivimman säilyttäjä. —Otto Seppälä

Sopivimman säilyttäjä (most-wanted holder) on muuttuja, joka pitää kirjaa toistaiseksi "parhaasta" löydetystä arvosta. Valintaperusteena voidaan käyttää mitä erilaisimpia kriteereitä: joskus pidetään kirjaa suurimmasta tai pienimmästä luvusta, joskus pisimmästä merkkijonosta, parhaasta urheilutuloksesta, nuorimmasta opiskelijaoliosta. Ohjelmassamme fave-ilmentymämuuttuja on sopivimman säilyttäjä, jonka sopivimmuuskriteerinä on kokemukselle annettu arvosana.

Kun muokkaamme sopivimman säilyttäjän arvoa, tarkastelemme, onko uusi kandidaattiarvo sopivampi kuin muuttujan vanha arvo. Tämän voi tehdä esimerkiksi if-käskyllä, jossa vertaillaan arvoja keskenään, tai tarkoitukseen sopivalla metodilla.

Tässä tapauksessa meillä on juuri tarkoitukseemme sopiva metodi tarjolla: luvussa 3.4 toteuttamamme chooseBetter:

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 = newExperience.chooseBetter(this.fave)

  def allExperiences = this.experiences.toVector

end Category

chooseBetter palauttaa kahdesta vertailemastaan kokemuksesta paremman. Käytetään sitä.

Tässä toteutuksessa perusidea on täysin kunnossa. Se toimii juuri niin kuin aiemmissa luvuissa on periaatteellisella tasolla nähty Category-luokan toimivan: kategoriaolio kysyy kokemusoliolta paremmuudesta ja käyttää vastausta päivittääkseen suosikkia.

Kuitenkin luokassa on eräs vakava puute. Tämä käy ilmi, kun sitä yrittää käyttää:

Ajonaikainen poikkeustilanne

Otetaan äskeinen versio Category-luokasta ja luvun alun käynnistysolio CategoryTest. Ajetaan CategoryTest. Ohjelma käynnistyy, mutta tekstikonsoliin ilmestyy heti virheilmoitus:

Exception in thread "main" java.lang.ExceptionInInitializerError
    at o1.goodstuff.gui.CategoryTest.main(CategoryTest.scala)
Caused by: java.lang.NullPointerException
    at o1.goodstuff.Experience.isBetterThan(Experience.scala:22)
    at o1.goodstuff.Experience.chooseBetter(Experience.scala:28)
    at o1.goodstuff.Category.addExperience(Category.scala:31)
    at o1.goodstuff.gui.CategoryTest$.<clinit>(CategoryTest.scala:7)

Kyseessä on poikkeus eli ajonaikainen virhe. Se ilmenee vasta, kun ohjelmaa käytetään (luku 1.8). Virheilmoituksen rivit luettelevat kutsupinon sillä hetkellä sisältämät kehykset, kun ongelmatilanne syntyi. Tällaista tulostetta kutsutaan stack traceksi. Se auttaa paikantamaan ongelman. Tarkastellaan virheilmoitusta:

Näemme, että CategoryTest-ohjelmaa ajaessa on syntynyt poikkeustilanne.

Poikkeustilanteen syyksi mainitaan NullPointerException. Tämä poikkeustyypin nimi mikä jo itsessään kertoo vähän siitä, millaisesta ongelmasta on kyse. Lisää siitä alempana.

Ongelma syntyi kokemusluokan rivillä 22 isBetterThan-metodissa, jota oli kutsuttu saman luokan chooseBetter-metodista riviltä 28, jota oli kutsuttu kategorialuokan riviltä 31 metodista addExperience, jota oli kutsuttu CategoryTest.scala-tiedostosta.

NullPointerException-virhetilanteet

NullPointerException-tyyppinen virhetilanne kertoo aina yrityksestä käyttää null-arvon (eikä olemassa olevan olion) ominaisuutta: yritetään kutsua olemattoman olion metodia tai käyttää olemattoman olion ilmentymämuuttujaa. Tällainen toimenpide ei ole mielekäs, eikä sitä pidä tietokoneohjelmassa tehdä (vrt. nollalla jakaminen aritmetiikassa).

NullPointerException kertoo yleensä ohjelmoijan tekemästä virheestä. Niin nytkin.

Tarkastele virhetilanteen syntymistä seuraavan animaation kautta. Huomaa varsinkin null-arvo muuttujassa ja se, mitä sillä tehdään.

Arvioi väite: Virhe liittyy siihen, että yritetään selvittää arvosanaa sellaiselle oliolle, jota ei ole olemassakaan, vaan on vain null-arvo.

Väite: Virhe on peräisin siitä arvosta, joka fave-muuttujaan aiemmin sijoitettiin kategoriaoliota luotaessa.

Väite: Virhe syntyy, kun käynnissä on metodikutsu, jota vastaavassa kehyksessä this-muuttujan arvona on null-viittaus eikä viittausta johonkin kokemusolioon.

Käskyssä newExperience.chooseBetter(this.fave) on osalausekkeena this.fave. Väite: kun tuo käsky suoritetaan on osalausekkeen this.fave arvo null.

Väite: Virhe syntyy kohdassa, jossa yritetään välittää null-arvo parametriksi kokemusolion metodille.

Miten tästä selvitään?

Yhteenvetoa

  • Voimme kuvata yhteyttä yhdestä oliosta useaan toiseen olioon käyttämällä ilmentymämuuttujaa, joka viittaa kokoelmaan olioita.

  • Alkiokokoelmia on erilaisia, esimerkiksi vektoreita ja puskureita.

    • Ne ovat Scalassa olioita ja tarjoavat monia metodeita.

    • Vektori on tilaltaan muuttumaton, puskuri muuttuvatilainen.

  • Muuttujaa voi käyttää sopivimman säilyttäjänä eli pitämään kirjaa siitä arvosta, joka toistaiseksi parhaiten täyttää tietyn kriteerin (esim. pienin luku, paras kokemus).

    • Tällaisen muuttujan alkuarvo ansaitsee erityishuomion.

  • Ajonaikaisten poikkeustilanteiden setvimisessä voivat kutsupinon tilasta kertovat tulosteet auttaa.

  • On olemassa null-niminen arvo, joka tarkoittaa "viittaus ei-mihinkään". Sen käyttö ei ole suositeltavaa muun muassa sen virhealttiuden vuoksi. Parempia välineitä on luvassa.

  • Lukuun liittyviä termejä sanastosivulla: kokoelma, puskuri, vektori; säiliö; muuttumaton, muuttuvatilainen; sopivimman säilyttäjä; null-viittaus; ajonaikainen virhe, stack trace.

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, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, 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 ja Juha Sorva. 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. Pääkehittäjänä on nyt Markku Riekkinen, jonka lisäksi A+:aa 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.

Lisäkiitokset tähän lukuun

Ahmed Ahne on René Goscinnyn and Jean Tabaryn luomus.

Luvussa tehdään vääryyttä Carl Bellmanin ja jonkun muun musiikille. Kiitos ja anteeksi.

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