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

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

Luku 8.4: Pareja ja hakuja

Tästä sivusta:

Pääkysymyksiä: Voiko funktio palauttaa kaksi arvoa? Miten voin helposti poimia kokoelmasta olion, joka vastaa toista oliota (esim. suomen sanaa vastaava englanninnos)? Miten voin tehdä kokoelman, josta on kätevää hakea alkioita tietyn hakuavaimen perusteella (esim. opiskelijaolio opiskelijanumeron perusteella)?

Mitä käsitellään? Parit. Avain–arvo-parit ja niiden muodostamat hakurakenteet.

Mitä tehdään? Luetaan ja ohjelmoidaan pieniä tehtäviä.

Suuntaa antava työläysarvio:? Kaksi tuntia.

Pistearvo: B25.

Oheisprojektit: Miscellaneous.

../_images/person10.png

Johdanto: kaksi palautusarvoa?

Usein kysytty kysymys

Voiko funktio palauttaa kaksi arvoa? Voitaisiinko vaikkapa määritellä funktio toMinsAndSecs, jolle annettaisiin sekuntimäärä ja joka palauttaisi kerralla kaksi kokonaislukua: kokonaiset minuutit ja yli jääneet sekunnit? Jotenkin näin:

toMinsAndSecs(187)res0: (Int, Int) = (3,7)

Funktiota kutsuessa voisi sitten esimerkiksi sijoittaa palautetut arvot muuttujiin nimeltä min ja s ja tehdä niillä jotakin.

Ratkaisuvaihtoehtoja

Varsinaisesti funktio ei voi palauttaa kuin yhden arvon, mutta tätä rajoitusta voi kiertää, joten tavallaan se sittenkin voi.

Yksi kiertotapa on palauttaa kaksialkioinen kokoelma, vaikkapa Vector[Int]-tyyppinen. Tämä on siedettävä ratkaisu, joskin palautetusta kokoelmasta joutuu sitten erikseen noukkimaan halutut arvot, esimerkiksi indeksien perusteella näin:

val palautettu = toMinsAndSecs(187)palautettu: Vector[Int] = Vector(3,7)
val min = palautettu(0)min: Int = 3
val s = palautettu(1)s: Int = 7

Tämä vaihtoehto on myös epäkäytännöllinen tilanteissa, joissa halutaan palauttaa kaksi keskenään erityyppistä arvoa.

Toinen kiertotapa on laatia oma luokka, joka kuvaa palautettavien tietojen yhdistelmää. Voitaisiin esimerkiksi laatia luokka Time, jolla olisi muuttujat täysiä minuutteja ja ylijäämäsekunteja varten, ja hoitaa asia luomalla tällainen aikaolio. Tämä tapa on parhaimmillaan silloin, jos palautettava arvopari edustaa jotakin luokkana luontevasti mallintuvaa käsitettä.

Kolmas tapa hyödyntää pareja (pair). Tämä tapa on kätevä, ja sen oppimisesta on laajemminkin iloa. Tarkastellaan pareja ensin yleisesti ja palataan sitten minuutteihin ja sekunteihin.

Parit

Parien peruskäyttö Scalalla

Parin voi muodostaa käyttämällä sulkuja ja pilkkua, kuten tässä:

("reppu", "reissumies")res1: (String, String) = (reppu,reissumies)
Sulut löytyvät myös palautusarvon kuvauksesta. Merkintä (String, String) tarkoittaa, että siinä on sellainen pari, jossa on kaksi merkkijonoa.

Parista voi poimia jommankumman jäsenen näin:

var pari = ("reppu", "reissumies")pari: (String, String) = (reppu,reissumies)
pari._1res2: String = reppu
pari._2res3: String = reissumies
Parin ensimmäinen jäsen on nimeltään _1. (Huom. ei nolla vaan yksi.) Toinen on vastaavasti _2.
Alaviivaa ei tässä käytetä missään erikoismerkityksessä vaan ihan vain osana metodin nimeä. Scalassa kuten monessa muussakin ohjelmointikielessä metodinimet eivät saa alkaa numerolla, mitä on kierretty nimeämällä metodit näin. Kyseessä on siis sama alaviivamerkki kuin lyhennetyissä funktioliteraaleissa, mutta aivan eri käyttötarkoituksessa.

Merkittävä ero parin ja vaikkapa kaksialkioisen vektorin välillä on, että parin jäsenillä voi olla eri staattiset tyypit:

val nimiJaKengannumero = ("Juha", 43)nimiJaKengannumero: (String, Int) = (Juha,43)

Parin osat voi sijoittaa kahteen eri muuttujaan näin helposti, jälleen sulkuja käyttämällä:

val (nimi, kengannumero) = nimiJaKengannumeronimi: String = Juha
kengannumero: Int = 43

Pikkutehtävä: minuuteiksi ja sekunneiksi

Toteuta luvun alussa haviteltu funktio toMinsAndSecs sellaiseksi, että sitä voi käyttää näin:

import o1.misc._import o1.misc._
toMinsAndSecs(187)res4: (Int, Int) = (3,7)
val (min, s) = toMinsAndSecs(200)min: Int = 3
s: Int = 20

Kirjoita ratkaisu Miscellaneous-projektin kansion o1 tiedostoon misc.scala.

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

Tämä on uusintaversio luvun 1.4 pikkukysymyksestä.

Olipa kerran muuttujat nimeltä hannu ja kerttu, jotka olivat keskenään samantyyppisiä. Tarkastellaan seuraavaa koodinpätkää:

val (hannu2, kerttu2) = (kerttu, hannu)

Mikä seuraavista väittämistä kuvaa parhaiten sitä, mitä muuttujien arvoille käy, kun muuttujat on ensin luotu ja alustettu joillakin arvoilla ja äskeinen koodirivi sitten suoritetaan?

Pari funktion parametrina

Luonnollisesti voimme myös määritellä funktion, joka ottaa parametriksi parin. Tämä funktio tutkii, onko pari "järjestyksessä" eli onko ensimmäinen jäsen korkeintaan toisen suuruinen:

def isInOrder(pairOfNumbers: (Int, Int)) = pairOfNumbers._1 <= pairOfNumbers._2

Käyttöesimerkki:

val kokeilu = (10, 20)kokeilu: (Int, Int) = (10,20)
isInOrder(kokeilu)res5: Boolean = true

Kokoelman jaottelu pariksi

Pareja on käytetty myös useassa Scala API:n valmiissa metodissa, mistä on alla pari esimerkkiä.

Kokoelmien metodi splitAt palauttaa parin, jonka jäseninä on kaksi kokoelmaa:

val lampotilat = Vector(3, 6, -1, -5, 3, -1, 2)lampotilat: Vector[Int] = Vector(3, 6, -1, -5, 3, -1, 2)
lampotilat.splitAt(3)res6: (Vector[Int], Vector[Int]) = (Vector(3, 6, -1),Vector(-5, 3, -1, 2))
val (arki, viikonloppu) = lampotilat.splitAt(5)arki: Vector[Int] = Vector(3, 6, -1, -5, 3)
viikonloppu: Vector[Int] = Vector(-1, 2)
splitAtille annetaan parametri, joka osoittaa sijainnin kokoelmassa.
Palautetun parin ensimmäisessä jäsenessä ovat kaikki alkuperäisen kokoelman alkiot osoitettua järjestysnumeroa edeltävät alkiot ja toisessa kaikki loput.

Myös metodi partition jaottelee alkuperäisen kokoelman alkiot kahteen kokoelmaan ja tuottaa parin, jossa on kaksi kokoelmaa:

val (pakkaset, lauhat) = lampotilat.partition( _ < 0 )pakkaset: Vector[Int] = Vector(-1, -5, -1)
lauhat: Vector[Int] = Vector(3, 6, 3, 2)

Kuten esimerkistä voi arvata, tämä metodi jakaa alkiot soveltamalla niihin parametriksi annettua funktiota ja katsomalla, tuottaako se true vai false.

Pikkutehtävä: kokoelman jaottelu

Projektin Miscellaneous pakkauksesta o1.people löytyy yksittäisolio YoungAndOldApp. Tämän ohjelman toivottu toiminta on kuvattu sen ohjelmakoodin kommentissa. Täydennä ohjelma toiveiden mukaiseksi käyttäen äsken esiteltyä metodia.

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

Kokoelmat vetoketjuina

Äsken parissa oli jäseninä kokoelmia. Kokoelmassa voi myös olla alkioina pareja.

Eräs näppärä kokoelmien metodi on zipWithIndex. Se tuottaa alkuperäisen kokoelman perusteella sellaisen kokoelman, joka sisältää pareja, joissa kussakin on alkio ja sen indeksi:

val lajit = Vector("laama", "alpakka", "vikunja")lajit: Vector[String] = Vector(laama, alpakka, vikunja)
lajit.zipWithIndexres7: Vector[(String, Int)] = Vector((laama,0), (alpakka,1), (vikunja,2))
Palautusarvona saadaan vektori, jossa on sisällä pareja, joissa on jäseninä merkkijono ja kokonaisluku kussakin.

Kun pareja sisältävän kokoelman voi käydä läpi for-silmukalla, voi sulkumerkintää jälleen hyödyntää. Tässä kaksi tapaa tuottaa sama tuloste:

for (pari <- lajit.zipWithIndex) {
  println(pari._1 + " on numero " + pari._2)
}laama on numero 0
alpakka on numero 1
vikunja on numero 2
for ((alkio, indeksi) <- lajit.zipWithIndex) {
  println(alkio + " on numero " + indeksi)
}laama on numero 0
alpakka on numero 1
vikunja on numero 2

Metodi zipWithIndex on erikoistapaus yleisemmästä metodista zip, joka vetää yhteen kaksi kokoelmaa alkiopareittain:

val korkeudet = Vector(180, 80, 60)korkeudet: Vector[Int] = Vector(180, 80, 60)
val korkeudetJaLajit = korkeudet.zip(lajit)korkeudetJaLajit: Vector[(Int, String)] = Vector((180,laama), (80,alpakka), (60,vikunja))

Jos kokoelmat ovat eri mittaisia, jätetään pidemmästä loppuosa käyttämättä:

val iso = Vector("laama", "alpakka", "vikunja", "guanako", "wooly")iso: Vector[String] = Vector(laama, alpakka, vikunja, guanako, wooly)
val vainKolmeParia = korkeudet.zip(iso)vainKolmeParia: Vector[(Int, String)] = Vector((180,laama), (80,alpakka), (60,vikunja))

Vetoketjun voi myös avata:

korkeudetJaLajit.unzipres8: (Vector[Int], Vector[String]) = (Vector(180, 80, 60),Vector(laama, alpakka, vikunja))

Tässä siis syntyi pari, jonka jäsenet ovat vektoreita.

Pikkutehtävä: onko kokoelma järjestyksessä?

Laadi funktio isAscending, jolle annetaan parametriksi vektorillinen kokonaislukuja ja joka kertoo, ovatko luvut nousevassa järjestyksessä. Tässä esimerkkejä:

isAscending(Vector(-10, 1, 5, 10))res9: Boolean = true
isAscending(Vector(-10, 1, 5, 5, 10))res10: Boolean = true
isAscending(Vector(-10, 1, 5, 9, 5, 10))res11: Boolean = false
isAscending(Vector(10, 5, 1, -10))res12: Boolean = false

Hyödynnä tässä ja aiemmissa luvuissa opittuja kokoelmametodeita ovelasti, niin metodin rungoksi riittää yksi lyhyt rivi.

Kirjoita koodi jälleen tiedostoon misc.scala projektissa Miscellaneous.

Voit olettaa, että vektorissa on ainakin yksi alkio.

Tarkempi vinkki hyödyllisistä metodeista

NäytäPiilota

Metodit zip, tail ja forall ovat käyttökelpoisia. Voit myös hyödyntää aiemmin tässä luvussa laatimaamme funktiota isInOrder, jolle on toteutus valmiina tiedostossa.

Vaihtoehtoisesti voit kokeilla toteuttaa funktion sliding-metodin avulla. Se esiteltiin kuutoskierroksen drySpell-tehtävän yhteydessä.

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

Parit ovat monikkoja

Pari on erikoistapaus monikon (tuple) käsitteestä. Monikossa voi olla useampiakin jäseniä kuin kaksi:

("Tässä monikossa on neljä erilaista jäsentä.", 100, 3.14159, false)res13: (String, Int, Double, Boolean) = (Tässä monikossa on neljä erilaista jäsentä.,100,3.14159,false)

Onko monikko siis kokoelma samassa mielessä kuin puskurit ja vektorit? Ei ihan.

Monikot ja kokoelmat eroavat toisistaan jo käyttötarkoituksessaan. Monikot ovat edukseen lähinnä silloin, kun 1) jäseniä on pieni vakiomäärä (esim. vain kaksi) ja 2) kullakin jäsenellä on oma erillinen merkityksensä (esim. yksi on minuutit, toinen sekunnit; yksi on nimi, toinen kengännumero), mutta 3) erillisen luokan laatiminen tuntuu silti tarkoitukseen nähden liialliselta.

Tässä keskeisimpiä teknisiä eroja monikoiden ja kokoelmien välillä:

  • Monikossa kunkin jäsenen staattinen tyyppi määritellään erikseen, kokoelmassa kaikille alkioille yhteisesti.
  • Monikot ovat muuttumattomia kooltaan ja sisällöltään, kokoelmia on sekä muuttuvatilaisia että muuttumattomia.
  • Monikoilla ei ole kokoelmien metodeita (size, map, indexOf, tms.) eikä monikkoa voi käydä läpi esimerkiksi for-silmukalla (ilman kommervenkkejä).

Hyödyllinen ja yleinen käyttötarkoitus monikoille ja nimenomaan pareille löytyy hakurakenteista. Siitä kohta lisää.

Toinen johdanto: avaimia ja arvoja

Miten toteuttaisit seuraavat? Mitä yhteistä niillä on?

  • Sanakirja: halutaan etsiä annettua suomenkielistä sanaa vastaava englanninkielinen sana.
  • Opiskelijahakemisto: halutaan valita opiskelijaolio opiskelijanumeron perusteella.
  • Osoitekirja: halutaan, että henkilön nimen perusteella voi löytää henkilö- ja yhteystietoja kuvaavan olion.
  • Tietosanakirja: halutaan, että tietyn hakusanamerkkijonon kautta pääsee käsiksi tiettyyn artikkeliolioon.
  • Esiintymälaskuri: on olemassa olioita, joilla on tietty ominaisuus, ja halutaan laskea kuinka yleisiä tuon ominaisuuden eri arvot ovat olioiden joukossa.
    • Esim. luvussa 1.6 esitelty teema: on joukko elokuvia listalta, kullakin elokuvalla on ohjaaj(i)a ja halutaan laskea kunkin ohjaajan yleisyys listalla.

Tutuistakin työkaluista on mahdollista löytää ratkaisuja. Tehdään vaikkapa alkeellinen sanakirja puskureilla:

val hakusanoja = Buffer("kissa", "laama", "tapiiri", "koira")hakusanoja: Buffer[String] = ArrayBuffer(kissa, laama, tapiiri, koira)
val englanniksi = Buffer("cat", "llama", "tapir", "puppy")englanniksi: Buffer[String] = ArrayBuffer(cat, llama, tapir, puppy)

Näiden kahden puskurin perusteella voimme hakea sanakirjasta (muista indexOf-metodi luvusta 4.2):

val indeksi = hakusanoja.indexOf("tapiiri")indeksi: Int = 2
englanniksi(indeksi)res14: String = tapir
englanniksi(hakusanoja.indexOf("koira"))res15: String = puppy

Voimme myös päivittää sanakirjaa vaihtamalla puskurin alkion toiseksi:

englanniksi(hakusanoja.indexOf("koira")) = "dog"englanniksi(hakusanoja.indexOf("koira"))res16: String = dog

Mietitään vielä esimerkkimme luonnetta. Sanakirjassa on suomenkielisiä sanoja, joiden perusteella haetaan englanninkielisiä vastineita. Voidaan sanoa: suomenkieliset sanat toimivat avaimina (key), joiden perusteella haetaan englanniksi-puskurin sisältämiä arvoja (value).

Sama perusajatus toistuu muissakin yllä luetelluissa tilanteissa: avaimina on opiskelijanumeroita, arvoina opiskelijaolioita; avaimina on ohjaajien nimiä, arvoina heidän elokuviensa lukumääriä; jne. Alla käytämme sanoja "avain" ja "arvo" tässä merkityksessä.

Puskuriratkaisumme perustuu ajatukseen, että puskurien sisältö on tietyssä järjestyksessä ja avainten indeksit (hakusanoja-puskurissa) ovat samat kuin niitä vastaavien arvojen (englanniksi-puskurissa). Haluttu toiminnallisuus saadaan kyllä toteutettua näinkin, mutta ratkaisussamme on useita haittapuolia:

  • Käytimme erillisiä puskureita avaimille ja arvoille, vaikka miellämme sanakirjan yhdeksi kokonaisuudeksi.
  • Indekseillä räplääminen tuntuu vähän turhalta välivaiheelta. Eihän ketään varsinaisesti tässä kiinnosta, monentenako tietty arvo tai avain puskurissa on.
  • Mitä jos avainta ei löydy?
    • Esim. englanniksi(hakusanoja.indexOf("Juhan af Grann"))?
    • Tällöin indexOf palauttaa -1, ja negatiivisen luvun käyttö englanniksi-puskuriin indeksinä aiheuttaa ajonaikaisen virhetilanteen.
    • Ohjelmassa pitäisi siis aina muistaa tutkia esimerkiksi if-käskyllä, onko indeksi negatiivinen vai ei. Näin tarjotaan ohjelmointivirheille mahdollisuus siitä, vaikka ehkäisymenetelmiäkin olisi käytettävissä.
  • (Myös tehokkuudessa on parantamisen varaa: indexOf-metodi käy puskurin indeksejä läpi järjestyksessä yksitellen, kunnes haettu alkio löytyy. Tämä ei ole lainkaan optimaalista.)

Hakurakenteet

Käytetään siis sellaista kokoelmaa, johon tallennetaan sekä avaimet että arvot. Hakurakenne (map) on erittäin yleisesti käytetty ja kätevä kokoelmatyyppi, joka poikkeaa tähän mennessä kurssilla käsitellyistä indekseihin perustuvista kokoelmista kuten puskureista.

Hakurakenne sisältää avain–arvo-pareja (key–value pairs). Toisin kuin esimerkiksi puskurissa, joissa arvojen hakeminen perustuu järjestysnumeroihin, hakurakenteesta arvoja poimitaan avaimien perusteella.

Avainarvoina voi käyttää mitä tahansa olioita. Ne muodostavat joukon (set) sanan matemaattisessa mielessä, mikä käytännössä tarkoittaa sitä, että avainten on oltava erilaiset (ei kahta samaa suomenkielistä hakusanaa, ei kahta samaa opiskelijanumeroa, jne.).

Scalassa hakurakenteita kuvaa luokka nimeltä Map.

Seis! maphän on kokoelmien metodi eikä eräänlainen alkiokokoelma?

Juurihan aiemmissa luvuissa on käytetty metodia nimeltä map? Kyllä vain. Ihan sattumasta ei nimeämisessä ole kyse muttei samastakaan asiasta.

Tuttu kokoelmien metodi map synnyttää olemassa olevan kokoelman perusteella toisen kokoelman. Metodin parametrifunktio kuvaa riippuvuuden alkuperäisten alkioiden ja tulosalkioiden välillä.

Hakurakenne — Scalassa Map-olio — kokoaa yhteen avaimia ja arvoja; se pitää tallessa näiden välisiä riippuvuuksia.

map-metodi on (myös) ihan muilla kokoelmilla kuin hakurakenteilla.

Ensin: avain–arvo-parit

Ennen kuin käytämme Map-luokkaa, on hyvä tietää vähän avain–arvo-parien kuvaamisesta Scalalla. Ne ovat ihan tavallisia pareja. Sellaisen voi muodostaa vaikkapa näin:

("koira", "dog")res17: (String, String) = (koira,dog)

Kuitenkin on tapana käyttää toista merkintätapaa, joka korostaa sitä, että avain "osoittaa" arvoon. Seuraava käsky tuottaa tismalleen saman lopputuloksen kuin edellinen:

"koira" -> "dog"res18: (String, String) = (koira,dog)
Nuolta -> käytetään parin määrittelemiseen. (Huom. -> eikä =>, jota käytetään mm. tyyppimäärittelyissä.)
Sen vasemmalle puolelle kirjoitetaan avain ja oikealle puolelle se arvo, johon kyseinen avain osoittaa.
Tulos on pari toisiinsa liittyvää tietoa.

Minkä tahansa parin voi luoda kummalla tahansa tavalla. Hakurakenteiden avain–arvo -pareja luodessamme mekin tulemme käyttämään nuolimerkintää.

Sekä avaimena että arvona voi käyttää mitä vain Scala-oliota. Yllä sekä avain että arvo olivat merkkijonoja, kuten sanakirjallemme on sopivaa. Tarvittaessa voi myös vaikkapa laittaa avaimeksi luvun ja arvoksi merkkijonon tai toisinpäin:

233524 -> "Terhi Teekkari"res19: (Int, String) = (233524,Terhi Teekkari)
"Sergio Leone" -> 5res20: (String, Int) = (Sergio Leone,5)

Lisää nuolesta

-> on ihan tavallinen metodi.

"seikkailija" -> "adventurer"res133: (String, String) = (seikkailija,adventurer)
Kohdeolio, jolle metodia kutsutaan.
Viivalla ja väkäsellä nimetty metodi on tarjolla kaikille Scala-olioille. Tässä on käytetty operaattorinotaatiota (luku 5.2), jossa ei ole pistettä kohdeolion ja metodin nimen välissä.
Jälkimmäinen merkkijono on metodin parametri. Operaattorinotaatiossa sen ympäriltä voi jättää sulut pois.
Metodi palauttaa parin, joka muodostuu kohdeoliosta ja parametrioliosta.

Pistenotaatiotakin voisi käyttää. Seuraavat kaksi tekevät ihan saman:

"polvi".->("knee")res21: (String, String) = (polvi,knee)
"polvi" -> "knee"res22: (String, String) = (polvi,knee)

Koska kyseessä on "nuoli", on operaattorinotaatio ymmärrettävästi tässä yhteydessä paljon yleisempi.

Hakurakenteet Scalalla: Map-luokka

Map-luokan avulla äskeinen pieni suomi–englanti-sanakirja toteutuu mutkattomasti. Poimitaan ensinnäkin se Map-luokka, jota haluamme nyt käyttää:

import scala.collection.mutable.Mapimport scala.collection.mutable.Map

Muista: oliot voivat olla muuttuvatilaisia (mutable) tai muuttumattomia (immutable). Koska Scala-kieli tukee erilaisia ohjelmointityylejä, niin sen peruskirjastoissa on kaksi eri Map-luokkaa, joista toinen kuvaa muuttuvia hakurakenteita ja toinen muuttumattomia. Tällä kurssilla käytämme varsinkin nyt aluksi muuttuvia hakurakenteita (ts. hakurakenneoliota voi muuttaa sen luomisen jälkeen). Niitä kuvaava luokka löytyy pakkauksesta scala.collection.mutable, ja se on erikseen otettava käyttöön import-käskyllä, kuten yllä tehtiin ja kuten olemme tehneet saman pakkauksen Buffer-luokan kanssa.

Map-olion voi luoda tehdasmetodilla (ilman new-sanaa):

val suomestaEnglanniksi = Map("kissa" -> "cat", "laama" -> "llama", "tapiiri" -> "tapir", "koira" -> "puppy")suomestaEnglanniksi: Map[String,String] = Map(koira -> puppy, tapiiri -> tapir, kissa -> cat, laama -> llama)
Erona tutunlaisiin kokoelmatyyppeihin on se, että hakurakenteen alkioiksi laitetaan aina avain–arvo-pareja.
Hakurakenteella on kaksi tyyppiparametria: avainten tyyppi ja arvojen tyyppi. Tässä olemme luoneet hakurakenteen, jossa sekä avaimet että arvot ovat merkkijonoja.
REPLin tulosteestakin huomaa, että kyseessä ei ole indeksoitu rakenne. Alkiot ovat hakurakenteessa mielivaltaisen näköisessä järjestyksessä. Niillä ei ole järjestysnumeroita. Se, missä järjestyksessä hakurakenneolio pitää sisältönsä, riippuu sen sisäisestä toteutuksesta eikä ole tässä yhteydessä oleellista.

Seuraavissa esimerkeissä näet valikoiman Map-olioiden metodeita. Tälläkään kertaa oleellista ei ole opetella niitä kaikkia ulkoa, vaan se, että saat yleiskuvan tästä kokoelmatyypistä.

Seuraavaa lukiessasi huomaat myös, että hakurakenteilla on monta toisilleen vaihtoehtoista metodia, ja samaan tarpeeseen voi löytyä erilaisia ratkaisuja. Heti aluksi tuskin syntyy käsitystä siitä, mikä metodeista on milloinkin paras, mutta ei tarvitsekaan; tilanne paranee kokemuksen kasvaessa. On kuitenkin hyvä pystyä jo tunnistamaan erilaisia ratkaisuja.

Arvon hakeminen

Hakurakenteesta on helppo hakea arvo avaimen perusteella:

suomestaEnglanniksi.get("kissa")res23: Option[String] = Some(cat)
suomestaEnglanniksi.get("Juhan af Grann")res24: Option[String] = None
get-metodille annetaan parametriksi se avain, jota vastaava arvo halutaan hakea.
get-metodilta saadaan Option[ArvojenTyyppi]-tyyppinen palautusarvo.
Jos haettu avain oli kokoelmassa, saadaan sitä vastaava arvo Some-kääreessä. Jos taas ei, saadaan None.

Arvon vaihtaminen

Kun kyseessä on muuttuvatilainen hakurakenne, voi olemassa olevaa sanakirjaa päivittää tähän tapaan:

suomestaEnglanniksi.get("koira")res25: Option[String] = Some(puppy)
suomestaEnglanniksi("koira") = "dog"suomestaEnglanniksi.get("koira")res26: Option[String] = Some(dog)

Muista: Hakurakenteen avaimet ovat kaikki erilaisia. Hakurakenteeseen ei voi kuulua useita avain–arvo-pareja, joissa on sama avain. Yllä siis korvataan pari "koira" -> "puppy" parilla "koira" -> "dog".

Avain–arvo-parin lisäys

On olemassa useita tapoja lisätä uusi avain–arvo-pari hakurakenteeseen. Yksi tapa on käyttää samanlaista käskyä kuin yllä ja yksinkertaisesti sijoittaa arvo sellaiselle avaimelle, joka ei aiemmin ollut käytössä:

suomestaEnglanniksi("hiiri") = "mouse"

Toinen tapa lisätä tai vaihtaa uusi avain–arvo-pari on käyttää +=-operaattoria samaan tapaan kuin olet jo tottunut tekemään puskureille. Tällöin operaattorin oikealle puolelle kirjoitetaan lisättävä pari:

suomestaEnglanniksi += "sika" -> "pig"res27: Map[String, String] = Map(koira -> dog, tapiiri -> tapir, kissa -> cat, sika -> pig, hiiri -> mouse,
laama -> llama)

Tuo käsky korvaa vanhan avain–arvo-parin, jos avain on jo käytössä.

Kurssimateriaalissa käytetään jatkossa enimmäkseen tätä jälkimmäistä tapaa. Itse voit käyttää kumpaa vain.

Avain–arvo-parin poistaminen

Poistaminen onnistuu esimerkiksi näin:

suomestaEnglanniksi -= "laama"res28: Map[String, String] = Map(koira -> dog, tapiiri -> tapir, kissa -> cat, sika -> pig, hiiri -> mouse)

Saman olisi voinut tehdä myös remove-metodilla, joka palauttaa poistamansa arvon Option-kääreessä:

suomestaEnglanniksi.remove("laama")res29: Option[String] = Some(llama)

Löytyykö avainta?

contains-metodilla voi tutkia, onko tietty avain käytössä:

suomestaEnglanniksi.contains("tapiiri")res30: Boolean = true
suomestaEnglanniksi.contains("laama")res31: Boolean = false

Esimerkkitapauksessamme "tapiiri" löytyy sanakirjasta, kun taas "laama" jo poistettiin eikä löydy enää.

Entä löytyykö sanakirjasta tällä tavoin esimerkiksi sana "tapir"? Eli etsiikö contains paitsi avainten myös arvojen joukosta? Kokeile.

Kaikki avaimet tai kaikki arvot

Metodeilla keys ja values saa selville kaikki hakurakenteen avaimet tai arvot. Metodien palautusarvot ovat alkiokokoelmia, joita voi käsitellä tutuilla metodeilla. Esimerkiksi tässä tulostetaan ensin kukin avaimista ja sitten kukin arvoista:

suomestaEnglanniksi.keys.foreach(println)koira
tapiiri
kissa
sika
hiiri
suomestaEnglanniksi.values.foreach(println)dog
tapir
cat
pig
mouse

mapValues-metodi

mapValues toimii kuten muista kokoelmatyypeistä tutuksi tullut map-metodi, mutta nimensä mukaisesti se kohdistuu nimenomaan hakurakenteen sisältämiin arvoihin, ei avaimiin.

Käytetään tätä tutunlaista hakurakennetta:

val englanniksi = Map("kissa" -> "cat", "tapiiri" -> "tapir", "koira" -> "dog")englanniksi: Map[String,String] = Map(koira -> dog, tapiiri -> tapir, kissa -> cat)

Sovelletaan mapValues-metodia yllä määriteltyyn sanakirjaan. Tämän metodin parametriksi annetaan funktio, joka suoritetaan jokaiselle hakurakenteen sisältämälle arvolle (muttei avaimelle):

englanniksi.mapValues( _.toUpperCase )res32: Map[String,String] = Map(kissa -> CAT, tapiiri -> TAPIR, koira -> DOG)

mapValues-metodin palautusarvona saatiin uusi hakurakenne, jossa alkuperäisten arvojen tilalla on parametrifunktion palautusarvot. Tässä esimerkissä arvot on korvattu isokirjaimisilla versioilla. Avaimet ovat entisenlaiset.

Alla olevassa toisessa esimerkissä taas korvataan arvoina käytetyt merkkijonot niiden pituuksilla:

englanniksi.mapValues( _.length )res33: Map[String,Int] = Map(kissa -> 3, tapiiri -> 5, koira -> 3)

Tuttuja metodeita hakurakenteillakin

Hakurakenteilla on suuri joukko muista kokoelmista tuttuja metodeita. Tässä muutama esimerkki lukujen 4.2 ja 6.3 esittelemistä kokoelmametodeista:

val englanniksi = Map("kissa" -> "cat", "tapiiri" -> "tapir", "koira" -> "dog")englanniksi: Map[String,String] = Map(koira -> dog, tapiiri -> tapir, kissa -> cat)
englanniksi.isEmptyres34: Boolean = false
englanniksi.sizeres35: Int = 3
englanniksi.foreach(println)(koira,dog)
(tapiiri,tapir)
(kissa,cat)

Hakurakenteen kääntämisestä

Usein kysyttyä: miten saadaan muodostettua hakurakenne, jossa on alkuperäisen rakenteet avaimet arvoina ja arvot avaimina?

Yksinkertaisessa tapauksessa tuo onnistuu kätevästi, kun hyödynnämme parien swap-metodia, joka toimii näin:

val pari = "avain" -> "arvo"pari: (String, String) = (avain,arvo)
pari.swapres36: (String, String) = (arvo,avain)

Sama koko hakurakenteelle:

val suomestaEnglanniksi = Map("kissa" -> "cat", "koira" -> "dog", "laama" -> "llama")suomestaEnglanniksi: Map[String,String] = Map(kissa -> cat, koira -> dog, laama -> llama)
val englannistaSuomeksi = suomestaEnglanniksi.map( _.swap )englannistaSuomeksi: Map[String,String] = Map(cat -> kissa, dog -> koira, llama -> laama)
Sovelletaan swap-metodia kuhunkin avain–arvo-pariin. Huomaa, että koska hakurakenteen alkiot ovat pareja, niin map-metodille välitetään parametriksi parin käsittelemiseen sopiva funktio.

Tässä pitää olla tarkkana. Jos sama englanninnos olisi esiintynyt useita kertoja, olisimme menettäneet suunnankäännöksessä informaatiota, koska avaimien on oltava ainutlaatuisia. (Kokeile.)

Jos halutaan, että samaa avainta vastaa useita arvoja, on käytettävä arvona kokoelmaa, jossa ovat tallessa kaikki tiettyä avainta vastaavat arvot. Tässä voi myös olla apua tarkoitukseen erikseen laadituista työkaluista (hakusanoja: scala multimap).

Entä haku molempiin suuntiin?

Jos halutaan, että yhdestä hakurakenteesta voisi hakea samalla tavalla sekä arvolla että avaimella, niin arvotkin täytyy jotenkin saada avaimiksi.

Sanakirjaesimerkissämme avaimet ja arvot olivat molemmat keskenään samantyyppisiä, merkkijonoja. Tällöinhän samat parit voisi periaatteessa lisätä rakenteeseen "molemmin päin". Mutta voidaanko tällöin varmistaa, että avaimet ovat edelleen ainutlaatuisia? Muillakin tavoin ratkaisu voi olla epäkäytännöllinen.

Parempi ratkaisu on voi olla käyttää kahta eri hakurakennetta, joista yksi on "käännetty" versio toisesta (ks. yltä). Voidaan myös toteuttaa tai ottaa jostakin kirjastosta käyttöön kaksisuuntaista hakurakennetta (bidirectional map) kuvaava luokka. Luokan voi toteuttaa esimerkiksi niin, että se pitää sisällään kaksi erillistä ja erisuuntaista hakurakennetta.

Mitä, jos haettu avain puuttuu?

Hakurakenteita käytettäessä on yleensä huomioitava erikseen sellaiset tilanteet, joissa rakenteesta haetaan avainta, mutta sitä ei löydykään. Scalan kirjastot tukevat tällaisten tilanteiden käsittelyä useilla toisilleen vaihtoehtoisilla tavoilla, jotka on hyvä tuntea.

Tuttua yltä on jo se, että get-metodi on Option-palautusarvoinen ja palauttaa None, kun avainta ei löydy:

englanniksi.get("Juhan af Grann")res37: String = None

Monesti onkin hyvä ratkaisu kutsua get-metodia ja käyttää sen palauttamaa Option-oliota. Mutta on myös tilanteita, joissa tämä ei ole näppärin vaihtoehto.

getOrElse-metodi

getOrElse-metodi tarjoaa tavan käsitellä puuttuvia tietoja. Se muistuttaa luvusta 4.3 tuttua Option-olioiden samannimistä metodia. Erona on, että siinä missä Option-oliolle sanottiin "anna sisältösi tai sen puuttuessa käytä tätä arvoa", niin hakurakenteelle sanotaan "anna tätä avainta vastaava arvo tai sen puuttuessa käytä tätä arvoa".

englanniksi.getOrElse("kissa", "tuntematon hakusana")res38: String = cat
englanniksi.getOrElse("Juhan af Grann", "tuntematon hakusana")res39: String = tuntematon hakusana
getOrElse-metodille annetaan parametriksi haettava avain sekä "vara-arvo", jonka on syytä olla samaa tyyppiä kuin hakurakenteen arvot.
Jos avain löytyy hakurakenteesta, getOrElse-palauttaa sitä vastaavan arvon.
Jos avainta ei löydy, metodi palauttaa toisen parametrinsa.
Huomaa: Metodin palautusarvo on String eikä Option[String]. Option-käärettä ei tarvita, koska kummassakin tapauksessa pystytään tuottamaan mielekäs String-tyyppinen palautusarvo. Kätevää!

apply-oletusmetodi (ja sen vaarat)

Olet jo tottunut katsomaan indeksoidun kokoelman alkion käskyllä kokoelma(indeksi), jonka luku 5.3 kertoi olevan lyhennysmerkintä metodikutsulle kokoelma.apply(indeksi). apply on siis eräänlainen "olion oletusmetodi", jonka nimeä ei ole pakko kutsuessa erikseen kirjoittaa.

Myös hakurakenteilla on apply-metodi. Se toimii samansuuntaisesti kuin get, mutta oleellinen erokin näillä metodeilla on. Vertaillaan:

val englanniksi = Map("kissa" -> "cat", "tapiiri" -> "tapir", "koira" -> "dog")englanniksi: Map[String,String] = Map(koira -> dog, tapiiri -> tapir, kissa -> cat)
englanniksi("tapiiri")res40: String = tapir
englanniksi("Juhan af Grann")java.util.NoSuchElementException: key not found: Juhan af Grann
   ...
   at scala.collection.mutable.HashMap.apply(HashMap.scala:64)
   ...
englanniksi.get("kissa")res41: Option[String] = Some(cat)
englanniksi.get("Juhan af Grann")res42: Option[String] = None

apply-metodin hyvänä puolena verrattuna getiin on se, että arvon löytyessä saadaan tuo arvo suoraan ilman Option-käärettä, jolloin palautusarvon käyttö on pykälän verran kätevämpää. Kuitenkin apply-metodi siis tuottaa ajonaikaisen virhetilanteen, jos sille annetaan parametriksi olematon avain, ja siksi sitä käyttäessä pitää olla hyvin valppaana.

Yleensä get on turvallisempi ja parempi vaihtoehto.

get — hyvä vai huono?

Onko nyt tosiaan niin, että hakurakenteiden get-nimistä metodia on usein syytä suosia? Siis vaikka Option-olioiden samannimistä metodia on syytä kaihtaa (ks. lukujen 4.4 ja 8.2 vapaaehtoinen lukemisto)?

On.

Pikkutehtävä: get, apply ja getOrElse

Arvioi, mitkä seuraavista väitteistä pitävät paikkansa, kun on luotu hakurakenneolio ja siihen viittaava muuttuja näin:

val scrabble = Map('A'->10, 'I'->10, 'N'->9, 'S'->7, 'T'->9, 'E'->8, 'K'->5, 'L'->5, 'O'->5,
                   'Ä'->5,  'U'->4,  'M'->3, 'H'->2, 'J'->2, 'P'->2, 'R'->2, 'U'->2, 'Y'->2)

Hakurakenne luokan ilmentymämuuttujassa

Kuten muitakin kokoelmia myös hakurakennetta voi tietysti käyttää oman luokan rakennusosana. Alla on yksi alkeellinen esimerkki tästä, ja muita esimerkkejä on tulossa myöhemmissä luvuissa.

Jäsenesimerkki

Otetaan taas käyttöön yhteisöjen jäseniä kuvaava luokka Member (luvusta 4.4). Sen koodi on oleellisin osin toistettu tässä.

class Member(val id: Int, val name: String, val yearOfBirth: Int, val yearOfDeath: Option[Int]) {
  // etc.
}

Laaditaan kokeeksi yhteisöä kuvaava luokka Society, jossa käytämme hakurakennetta ilmentymämuuttujan kautta.

class Society(val name: String) {

  private val members = Map[Int, Member]()

  def add(newMember: Member) = {
    this.members(newMember.id) = newMember
  }

  def getByID(memberID: Int) = this.members.get(memberID)

}
Yhteisöoliolla on ominaisuuksina paitsi nimi myös hakurakenne, johon tallennetaan jäsenolioita, jäsennumeroita avaimina käyttäen. Kun yhteisöolio luodaan, tämä hakurakenne on vielä tyhjä.
Yhteisöoliolla on metodeita, joilla siihen voi lisätä jäseniä ja joilla sen jäsenkuntaa voi tutkia. Näiden toteutuksessa käytetään apuna Map-olion metodeita.

Pikkutehtävä: hakurakenteet ja Option

Lisää Miscellaneous-projektista löytyvään Society-luokkaan metodi yearOfDeath, joka toimii näin:

  • Sille annetaan parametriksi jäsennumero.
  • Se palauttaa Option[Int]-tyyppisen arvon, johon on kääritty sen jäsenen kuolinvuosi, jolla on annettu jäsennumero.
  • Kuitenkin jos kuolinvuotta ei ole, koska kyseistä jäsentä ei ole tai hän on elossa, palautetaan None.

Älä tee muita muutoksia Society-luokkaan äläkä muuta Member-luokkaa.

Kannattaa kutsua erästä aiemmista luvuista tuttua kokoelmien metodia, niin tehtävän ratkaisu on pitkälti siinä.

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

Lisäluettavaa

Hakurakenteen "vara-arvo"

Vaikka getOrElse-metodi on sinänsä kätevä, on melko tyypillistä, että sen toiseksi parametriksi annetaan tietylle hakurakenteelle aina sama "vara-arvo". Sanakirjaesimerkissämme voitaisiin vaikkapa haluta tuottaa teksti "ähäkutti" aina, jos rakenteesta haetaan olemattomalla avaimella.

Voit välttyä välittämästä samaa parametriarvoa toistuvasti getOrElse-metodille käyttämällä withDefaultValue-metodia. Tätä metodia käytetään yleensä juuri luodulle hakurakenteelle, kuten tässä:

val englanniksi = Map("kissa" -> "cat", "tapiiri" -> "tapir", "koira" -> "dog").withDefaultValue("ähäkutti")englanniksi: Map[String,String] = Map(koira -> dog, tapiiri -> tapir, kissa -> cat)
englanniksi("kissa")res43: String = cat
englanniksi("Juhan af Grann")res44: String = ähäkutti
withDefaultValue-metodille ilmoitetaan, mitä haluamme käyttää "vara-arvona" silloin, kun haku on huti.
Nyt kun haemme apply-metodilla olematonta avainta, samme tämän vara-arvon.

Tämä on oikein hyvä tapa käyttää hakurakennetta. Siispä seuraava suositus:

  • Kun hakurakenteelle on määriteltävissä mielekäs "vara-arvo", määrittele sellainen. Tällöin on kätevää ja turvallista käyttää apply-metodia arvon hakemiseen.
  • Kun hakurakenteelle ei ole tarkoituksenmukaista "vara-arvoa" (ohjelman luonteesta johtuen), niin käytä hakemiseen getiä tai getOrElseä.

Luodaan scrabble kuten aiemmassa tehtävässä ja lisäksi scrabble2 kuten alla:

val scrabble1 = Map('A'->10, 'I'->10, 'N'->9, 'S'->7, 'T'->9, 'E'->8, 'K'->5, 'L'->5, 'O'->5,
                   'Ä'->5,  'U'->4,  'M'->3, 'H'->2, 'J'->2, 'P'->2, 'R'->2, 'U'->2, 'Y'->2)
val scrabble2 = scrabble1.withDefaultValue(1)

Uskontoni kieltää häshmäpin käytön?

Tuolla ylempänä esiintyi virheilmoituksessa luokan nimi HashMap. Mikä se on?

Sana hash ei tässä liity Twitteriin eikä muihinkaan ajanvietteisiin vaan erääseen hakurakenteiden toteutustekniikkaan.

Scala API:n Map on abstrakteja metodeita sisältävä piirreluokka, joka kuvaa hakurakenteiden toimintoja yleisesti määrittelemättä sisäisesti käytettävää toteutustapaa. HashMap on konkreettinen luokka, joka toteuttaa Map-piirreluokan abstraktit metodit käyttäen hajautukseksi (hashing) kutsuttua toteutustapaa. Luomamme hakurakenne on siis sekä yleisempää tyyppiä Map että sen alatyyppiä HashMap.

Tämän luvun johdannossa mainittiin ohimennen, että kahteen puskuriin perustuva sanakirjatoteutus ei ollut kovin tehokas, koska indeksejä käytiin läpi järjestyksessä yksi kerrallaan ja mikä tahansa avain–arvo-pari voi löytyä mistä tahansa puskurien kohdasta. Hajautustoteutus on tyypillisissä käyttötilanteissa tehokkaampi, koska siinä avain–arvo-parit sijoitellaan hakurakenteeseen harkitusti avaimen perusteella.

Hajautusta ei käsitellä tällä kurssilla kuten ei tehokkuuden lisäämiseen tarkoitettuja algoritmeja yleensäkään, mutta voit lukea lisää vaikkapa Wikipediasta tai käydä Aallon kurssin Tietorakenteet ja algoritmit.

Yhteenvetoa

  • Useita tietoja, keskenään erityyppisiäkin, voi koota yhteen monikoksi. Pari on kaksipaikkainen monikko.
  • Hakurakenne on erittäin yleisesti käytetty kokoelmatyyppi. Se sisältää avain–arvo-pareja ja poikkeaa indekseihin perustuvista kokoelmista muun muassa siten, että:
    • alkioilla ei ole vastaavaa numerojärjestystä
    • eikä alkioita poimita järjestysnumeron (indeksin) perusteella, vaan
    • useimmiten annetaan avain ja poimitaan sitä vastaava arvo.
  • Scalassa hakurakenteita kuvaa luokka Map, jolla on runsaasti hyödyllisiä metodeita.
  • Lukuun liittyviä termejä sanastosivulla: monikko, pari; hakurakenne, avain–arvo-pari.

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