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

Luku 7.2: Laiskoja kokoelmia

Tästä sivusta:

Pääkysymyksiä: Miten teen ohjelman, joka lukee etukäteen tuntemattoman määrän syötettä tai toistaa muuta toimenpidettä tuntemattoman määrän kertoja? Miten käsittelen liudan tietoalkioita yksi kerrallaan varastoimatta niitä kaikkia ensin muistiin?

Mitä käsitellään? Laiskalistat. Äärettömät listat. Evaluoimattomat eli by name -parametrit. Muodostamme myös hyvänpäiväntuttavuuden koneoppimiseen.

Mitä tehdään? Luetaan ja ohjelmoidaan.

Suuntaa antava työläysarvio:? Pari, kolme tuntia? Luku on pitkähkö, mutta iso osa siitä on vapaaehtoista lukemista.

Pistearvo: A210 + C5. (Pistearvo on korkea vaivaan nähden, koska ohjelmointitehtävien on tarkoitus olla "melkein pakollisia".)

Oheismoduulit: Sentiments (uusi), HigherOrder.

../_images/person04.png

Johdanto

Lämmitellään luvun pääaihetta toisella aiheella, joka tukee tuon pääaiheen ymmärtämistä.

Lähdetään ajatuksesta, että haluamme luoda funktion fiveTimes. Funktio palauttaa vektorin, jossa on viisi kertaa parametriksi annetun Int-lausekkeen arvo:

fiveTimes(100)res0: Vector[Int] = Vector(100, 100, 100, 100, 100)
fiveTimes(1 + 1)res1: Vector[Int] = Vector(2, 2, 2, 2, 2)

Lauseke tulee evaluoida viidesti. Jos evaluointi tuottaa eri arvoja eri kerroilla, tulee vektoriin eri lukuja:

import scala.util.RandomfiveTimes(Random.nextInt(100))res2: Vector[Int] = Vector(43, 55, 21, 46, 87)
fiveTimes(Random.nextInt(100))res3: Vector[Int] = Vector(33, 65, 62, 31, 73)

Tässä on toteutus fiveTimes-funktiolle. Arvioi sitä.

def fiveTimes(numberExpression: Int) =
  Vector.tabulate(5)( anyIndex => numberExpression )

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

Luvussa 4.3 esiteltiin Option-luokka ja sen metodi getOrElse.

Some(100).getOrElse(0)res4: Int = 100
None.getOrElse(0)res5: Int = 0
sanavektori.lift(-123).getOrElse("ei sanaa tuolla indeksillä")res6: String = "ei sanaa tuolla indeksillä"

Yllä getOrElsen parametrin määrittää literaali, mutta parametrilauseke voi olla mutkikkaampi:

None.getOrElse(1 + 1)res7: Int = 2
lukuvektori.lift(-5).getOrElse(Random.nextInt(10))res8: Int = 8

Tässä vielä pari esimerkkikäskyä. Päättele ja kokeile miten ne käyttäytyvät.

Some(123).getOrElse(1 / 0)
Some(123).getOrElse(Random.nextInt(100))

Mikä seuraavista pitää paikkansa?

Evaluoimattomat eli by name -parametrit

Tässä on halutusti toimiva versio fiveTimes-funktiosta. Se on lähes identtinen alkuperäisen yrityksen kanssa:

def fiveTimes(numberExpression: =>Int) =
  Vector.tabulate(5)( anyIndex => numberExpression )

Parametrin tyypin alussa on (pelkkä) nuoli. Se tarkoittaa, että kyseessä on ns. evaluoimaton parametri eli by name -parametri.

Tavalliset parametrit eli ns. by value -parametrit evaluoidaan ennen kuin funktiokutsu alkaa, kuten äskeisissä tehtävissä kerrattiin. Tällöin parametriksi välittyy evaluoidun lausekkeen arvo. Tästä poiketen by name -parametria ei evaluoida ennen funktiokutsun aloittamista. Funktiolle välitetään evaluoimaton lauseke. Tuo parametrilauseke evaluoidaan vasta silloin, kun sitä funktion rungossa käytetään (jos käytetään). Siis kesken funktion suorituksen.

Sikäli kun by name -parametria käytetään funktion rungossa useita kertoja, niin lauseke myös evaluoidaan useita kertoja:

def fiveTimes(numberExpression: =>Int) =
  Vector.tabulate(5)( anyIndex => numberExpression )

tabulate muodostaa viisialkioisen vektorin ja laittaa kullekin indekseistä 0–4 Int-arvon, joka saadaan evaluoimalla numberExpression-parametri. Jos numberExpression on esimerkiksi Random.nextInt(100), niin vektoriin tulee viisi erikseen arvottua kokonaislukua.

Jos haluat, voit ajatella evaluoimattomia parametreja eräänlaisina parametrittomina funktioina, jotka välitetään toiselle funktiolle parametriksi.

Tässä on toinen, kuvitettu esimerkki evaluoimattomasta parametrista:

Tällä kurssilla sinun ei tarvitse itse määritellä metodeita, jotka vastaanottavat evaluoimattomia parametreja. Tulet kuitenkin käyttämään sellaisia. Siinä ei ole mitään erityisen hankalaa, ja itse asiassa olet jo niin tehnytkin, koskapa Option-olioiden getOrElse-metodin parametri on juuri tällainen evaluoimaton parametri. getOrElse-metodi on määritelty joko a) palauttamaan kääreen sisältö ja jättämään parametrinsa evaluoimatta, tai b) sisällön puuttuessa evaluoimaan parametrilauseke ja palauttamaan sen arvo. Siksi tuo metodi toimii kuten äskeinen tehtävä osoitti.

Toinen tuttu esimerkki aiheesta

Luvussa 5.1 totesimme, että logiikkaoperaattorien && tai || perään kirjoitettu osalauseke evaluoidaan vain jos operaattoria edeltävä osalauseke ei jo määrää koko lausekkeen arvoa. Nuo operaattorithan ovat Boolean-olioiden metodeita (luku 5.2); operaattoria seuraava osalauseke on käytännössä by name -parametri.

Lisää termejä

Evaluoimattomia parametreja sanotaan siis usein by name -parametreiksi ja "tavallisia" parametreja by value -parametreiksi.

Jos funktion parametri evaluoidaan vähintään kerran niin parametria voi sanoa tiukasti evaluoiduksi (strict). fiveTimes-funktion parametrit ja kaikki tavalliset by value -parametrit ovat siis tiukasti evaluoituja. Jos parametria ei välttämättä evaluoida kertaakaan, sitä voi sanoa väljästi evaluoiduksi (non-strict); esimerkki tästä on getOrElse-metodin parametri.

By value -parametrien evaluointi on paitsi tiukkaa, myös hanakkaa (eager) eli evaluointi tapahtuu jo ennen funktiokutsun alkua.

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

Nyt luvun pääaiheeseen.

Haaste: määräämätön määrä toistoja

Tähän mennessä olemme tavanneet toistaa käskyjä keräämällä datan alkioiksi kokoelmaan ja käymällä kokoelman läpi alkio kerrallaan. Kokoelman koko on rajannut toistojen määrän etukäteen, eikä tuo raja määrity tai muutu vasta alkioita läpi käydessä. Esimerkiksi vektoriin säilötty data on aluksi kokonaan muistissa, ja käymme sen läpi for-silmukalla tai korkeamman asteen metodilla, joka toistaa toimenpidettä (korkeintaan) yhtä monta kertaa kuin kokoelmassa on alkioita.

Mutta entäpä jos tilanne onkin vaikka jokin näistä, kuten usein on?

  • Haluamme pyytää käyttäjältä syötteitä ohjelman käsiteltäväksi, kunnes käyttäjä ilmaisee haluavansa lopettaa. Syötteiden määrä ei ole tiedossa etukäteen.

  • Haluamme käydä läpi dataa jostakin lähteestä (tiedostosta, verkko-osoitteesta, ohjelman ohjaaman laitteen antureista tms.) käsitellen kunkin data-alkion kerrallaan. Alkioita on etukäteen tuntematon määrä, ja niitä saattaa olla niin paljon, ettei kaikkia voi ensin kerätä kokoelmaan tietokoneen muistiin kerralla.

  • Käytössämme on laskutoimitus, joka tuottaa kullakin kerralla tarkemman likiarvon halutusta tuloksesta, vaikkapa Newtonin menetelmä matemaattisen funktion nollakohtien etsimiseen. Haluamme toistaa tätä toimenpidettä iteratiivisesti eli edellistä tulosta askelittain parantaen, kunnes saavutamme hyväksyttävän tarkan vastauksen. Tarvittavien tarkennusaskelten lukumäärä ei ole etukäteen tiedossa.

Vielä yleisemmin sanoen: haluamme toistaa jotakin toimenpidettä etukäteen tuntemattoman määrän kertoja. Toistojen määrä on rajoittamaton, periaatteessa ääretönkin, esimerkiksi jos käsiteltävää dataa syntyy alati lisää.

Konkreettisempi esimerkki

Laaditaan ensin leluohjelma, joka raportoi neljän merkkijonon pituudet. Tässä ei ole vielä mitään uutta.

@main def reportLengths() =
  def report(input: String) = "The input is " + input.length + " characters long."
  val lines = Vector("a line of text", "another", "not written by the user", "but hardcoded into the program")
  lines.map(report).foreach(println)

Apufunktio muodostaa raportin yksittäisen syötteen pituudesta.

Käsiteltävät alkiot ovat kokoelmassa. Tässä ensimmäisessä ohjelmaversiossamme tuo kokoelma on vektori ja se sisältää koodiin literaaleina kirjatut viisi merkkijonoa.

Viimeinen rivi käyttää noita työkaluja: otetaan kaikki merkkijonot, muodostetaan kustakin raportti, ja tulostetaan kukin raporteista.

Otetaan tavoitteeksi muokata ohjelmaa niin, että se toimiikin näin:

Enter some text: hello
The input is 5 characters long.
Enter some text: hello again
The input is 11 characters long.
Enter some text: third input
The input is 11 characters long.
Enter some text: fourth
The input is 6 characters long.
Enter some text: stop already
The input is 12 characters long.
Enter some text: stop
The input is 4 characters long.
Enter some text: please

Ohjelma ei käytä mitään vakiovalikoimaa merkkijonoja vaan kyselee käyttämänsä tekstirivit käyttäjältä.

Hommaa jatketaan, kunnes käyttäjä sanoo lopetussanan "please". Sitä ennen syötteitä saatetaan antaa mikä tahansa pieni tai suuri määrä.

Miten voimme määritellä, milloin syötteiden kysely ja raportointi loppuvat, kun emme tiedä määrää etukäteen?

Yksi tapa selviää, kunhan opit käyttämään laiskaa kokoelmaa.

Laiskalistat

Laiskalistat (lazy-list) ovat eräänlaisia alkiokokoelmia.

Laiskalistan voi luoda alkiot luettelemalla muista kokoelmista tuttuun tapaan:

val dataaLaiskasti = LazyList(10.2, 32.1, 3.14159)dataaLaiskasti: LazyList[Double] = LazyList(<not computed>)

Laiskalistojen toString-metodi, jota REPL käyttää, ei näytä kokoelman sisältöä. Tämä johtuu siitä tavasta, jolla laiskalistat toimivat — tai pikemminkin jättävät toimimatta. Siitä kerrotaan kohta lisää.

Laiskalistan voi luoda myös olemassa olevasta kokoelmasta tutulla to-metodilla (luku 4.2):

val sanavektori = Vector("eka", "toka", "kolmas", "neljäs")sanavektori: Vector[String] = Vector(eka, toka, kolmas, neljäs)
val sanatLaiskasti = sanavektori.to(LazyList)sanatLaiskasti: LazyList[String] = LazyList(<not computed>)

Näin muodostettu laiskalista kattaa alkuperäisen vektorin alkiot. Niitä ei tosin tässä näy.

LazyList-olioilla on tutut kokoelmien perustoiminnot. Tässä esimerkiksi ohitetaan listan pari ensimmäistä alkiota ja poimitaan jäljelle jääneistä ensimmäinen.

sanatLaiskasti.drop(2).headres9: String = kolmas

Silmukkakin toimii:

for sana <- sanatLaiskasti do
  println("listan sisältöä: " + sana)listan sisältöä: eka
listan sisältöä: toka
listan sisältöä: kolmas
listan sisältöä: neljäs

Samoin tutut korkeamman asteen metodit:

sanatLaiskasti.filter( _.length > 4 ).map( _ + "!" ).foreach(println)kolmas!
neljäs!

Mitään mullistavaa ei vielä nähty. Miten laiskalistat eroavat tutuista kokoelmista?

Loppumaton lista

Laiskalistojen erikoisuuksiin kuuluu se, että kokoelmalla ei tarvitse olla äärellistä kokoa, vaan se voi olla päättymätön. (Vrt. ääretön lukujono matematiikassa.)

Eräs tapa luoda LazyList-olio` on kutsua continually-metodia. Se tuottaa loputtoman listan:

val laiskalista = LazyList.continually("Elio")laiskalista: LazyList[String] = LazyList(<not computed>)

Näin loimme siis kokoelman, jonka jokaisena alkiona on merkkijono "Elio" ja jossa on loputtomasti keskenään samanlaisia alkioita. Otetaan take-metodilla viisi ensimmäistä alkiota ja käydään ne läpi:

laiskalista.take(5).foreach(println)Elio
Elio
Elio
Elio
Elio

Alkuperäinen kokoelma on ääretön, mutta take palauttaa parametrinsa mittaisen LazyListin, joka on pätkä alkuperäisestä.

foreachin työ ei ole loputon, koska se käy läpi vain take-metodin palauttaman viisialkioisen kokoelman.

Alla on toinen esimerkki loputtomasta kokoelmasta. (Siinä käytetään operaattoria ++, joka yhdistää kokoelmat peräkkäin; luku 4.2.)

val sanoja = LazyList("Today I don't feel like doing anything", "I just wanna lay in my bed",
                      "Don't feel like picking up my phone", "So leave a message at the tone")sanoja: LazyList[String] = LazyList(<not computed>)
val sanatJaLoppu = sanoja ++ LazyList.continually("LOPPUI JO")sanatJaLoppu: LazyList[String] = LazyList(<not computed>)
sanatJaLoppu.take(7).foreach(println)Today I don't feel like doing anything
I just wanna lay in my bed
Don't feel like picking up my phone
So leave a message at the tone
LOPPUI JO
LOPPUI JO
LOPPUI JO

Muodostetaan LazyList, jonka alkioina on ensin neljä tiettyä merkkijonoa ja sitten viidettä merkkijonoa toistuvasti mielivaltaisen monta kertaa.

Jos kokoelmasta otetaan seitsemän ensimmäistä alkiota, saadaan neljä erilaista alkiota ja sitten loppuviestiä kolme kertaa.

Miten laiskalistat käyttäytyvät?

Yllä listojen päättymättömät osat sisälsivät vain kopioita yhdestä ja samasta alkiosta. Seuraavassa esimerkissä näin ei ole. Muodostetaan loputon näennäissatunnaislukujen kokoelma:

val satunnaisia = LazyList.continually( Random.nextInt(10) )satunnaisia: LazyList[Int] = LazyList(<not computed>)

Äskeinen käsky ei vielä arponut ensimmäistäkään lukua. Se vain määritteli kokoelman, jonka alkiot saadaan arpomalla tarpeen mukaan.

Otetaan tuosta kokoelmasta muutama luku:

satunnaisia.take(5).foreach(println)8
9
5
6
8

Määräsimme koneen tulostamaan alkiot laiskalistasta, joka on viiteen alkioon rajattu versio loputtomasta kokoelmastamme. Jotta tulostaminen onnistuisi, on laiskalistan muodostettava nuo alkiot eli arvottava lukuja.

Tutkitaan nyt kokoelman kuvausta:

satunnaisiares10: LazyList[Int] = LazyList(8, 9, 5, 6, 8, <not computed>)

Laiskalista on muodostanut viisi ensimmäistä alkiotaan, koska oli niiden tulostamiseksi pakko. Ne ovat nyt tallessa satunnaisia-muuttujan osoittamassa kokoelmassa. Myöhempiä alkioita ei ole muodostettu, kun ei ole tarvinnut.

Jos poimimme uudestaan tuon laiskalistan alusta korkeintaan viisi alkiota, saamme aiemmin arvottuja ja listan osaksi tallennettuja lukuja:

satunnaisia.take(3).foreach(println)8
9
5
satunnaisia.take(5).foreach(println)8
9
5
6
8

Kokoelmamme on siis laittanut luvut muistiin eikä evaluoi arvontalauseketta uudelleen, kun samat vanhat alkiot kysytään taas.

Jos otamme vähän pidemmän pätkän ja katsomme sen alkiot, muodostaa laiskalista alkioita lisää tarpeen mukaan. Tämän laiskalistan tapauksessa alkioiden muodostaminen tarkoittaa, että arvotaan perään lisää lukuja:

satunnaisia.take(7).foreach(println)8
9
5
6
8
4
1
satunnaisiares11: LazyList[Int] = LazyList(8, 9, 5, 6, 8, 4, 1, <not computed>)

Mitä on laiskuus?

Tietokone ei tietenkään voi varastoida ääretöntä määrää alkioita. Laiskalistojen keskeinen toimintaperiaate onkin, että koko kokoelmaa kaikkine alkioineen ei muodosteta saman tien, kun LazyList-olio syntyy. Sen sijaan alkioita muodostetaan vain sitä mukaa, kun niitä tarvitaan.

Esimerkiksi äskeisen foreach(println)-käskyn suorittaminen edellytti, että arvottiin muutama satunnaisluku käyttäen lauseketta Random.nextInt(10) muutaman kerran. Kokoelman sisältö tuli tällöin siltä osin luoduksi, mutta muita lukuja ei arvottu.

Ylempänä tässä luvussa esiteltiin evaluoimattomat eli by name -parametrit, joiden evaluoinnin hoitaa kutsuttu funktio eikä kutsuja. continually-metodi saa juuri tällaisen parametrin. continually muodostaa laiskalistan, joka evaluoi tuon parametrin uudestaan ja uudestaan mutta vain silloin, kun tarvitaan uusia alkioita.

Kokoelmaa, joka evaluoi alkionsa vain tarvittaessa ja pitää ne vasta siitä eteenpäin tallessa, sanotaan laiskaksi (lazy).

Kaikissa seuraavissa oletetaan, että ensin on annettu (vain) nämä käskyt:

import scala.util.Randomdef lukuja(raja: Int) = LazyList.continually( Random.nextInt(raja) )lukuja(raja: Int): LazyList[Int]

Käytössä on siis lukuja-niminen funktio, jolla voi luoda satunnaisia lukuja sisältävän laiskalistan.

Voit kokeilla koodinpätkiä REPLissä. Punainen Stop-nappi vasemmassa reunassa katkaisee koko REPL-session tarvittaessa.

println(lukuja(1000))

Mitä tämä koodi tekee?

println(lukuja(1000).take(6))

Mitä tämä koodi tekee?

println(lukuja(1000).take(6).mkString(" "))

Mitä tämä koodi tekee?

println(lukuja(1000).mkString(" "))

Mitä tämä koodi tekee?

for luku <- lukuja(1000).take(6) do
  println(luku)

Mitä tämä koodi tekee?

for luku <- lukuja(1000) do
  println(luku)

Mitä tämä koodi tekee?

println(lukuja(1000).take(6).sum)

Mitä tämä koodi tekee?

println(lukuja(1000).size)

Mitä tämä koodi tekee?

val lista = lukuja(1000)
lista.take(6).foreach(println)
println(lista.size)

Mitä tämä koodi tekee?

Tuntemattoman kokoisia kokoelmia

On mahdollista — ja hyödyllistä — käsitellä kokoelmia, joiden koko määräytyy vasta kokoelmaa käsiteltäessä.

Luodaan satunnaisia lukuja vaikkapa väliltä 0–99. Tässä esimerkissä emme kuitenkaan poimi lukuja mitään tiettyä määrää, vaan luomme ja tulostamme niitä jonkin määrän, kunnes sattuu tulemaan luku, joka on yli 90:

LazyList.continually( Random.nextInt(100) ).takeWhile( _ <= 90 ).mkString(",")res12: String = 31,84,16,45,72,81,41,36,87,19,79,62,13,60,47,45,66,58,85,15,8,9,7,30,68,41,48,80,21,78,72,27
LazyList.continually( Random.nextInt(100) ).takeWhile( _ <= 90 ).mkString(",")res13: String = 0,65,83,38,75,33,11,18,75,51,3

takeWhile palauttaa laiskalistan, joka päättyy juuri ennen ensimmäistä sellaista alkiota, joka ei toteuta ehtoa. Vaikka alkuperäinen kokoelma oli ääretön, tässä rajatussa kokoelmassa ei ole loputtomasti alkioita. Samoin kuin continually ja take eivät arvo mitään lukuja, ei myöskään takeWhile tee niin; se ainoastaan palauttaa LazyList-olion, joka osaa tarvittaessa arpoa lukuja kunnes kohdataan kokoelman päättävä alkio.

mkString tuottaa merkkijonon, jossa on kuvattu kaikki kokoelman alkiot; tämä pakottaa muodostamaan kunkin alkion. Äärelliselle laiskalistalle mkString toimii kuin mille tahansa muullekin kokoelmalle. Esimerkkimme käyttää satunnaisuutta, ja eri kokeilukerroilla syntyy erikokoisia kokoelmia, jotka päättyvät sen mukaan, milloin niitä läpikäydessä tulee vastaan riittävän iso luku.

Lisäesimerkki laiskasta kokoelmasta

Seuraava esimerkki osoittaa laiskuuden vielä konkreettisemmin.

Luodaan ensin apufunktio, joka tuottaa uusia alkioita ja tulostaa ilmoituksen asiasta:

var laskuri = 0laskuri: Int = 0
def tuotaAlkio() =
  laskuri += 1
  println("Jaaha, täytyi vaivautua tuottamaan uusi alkio: " + laskuri)
  laskurituotaAlkio(): Int

Muodostetaan laiskalista, jonka alkiot luodaan tuolla funktiolla. Se ei tosin vielä "viitsi" tuottaa yhtään alkiota, koska ei tarvitse.

val laiskatLuvut = LazyList.continually( tuotaAlkio() )laiskatLuvut: LazyList[Int] = LazyList(<not computed>)

Kolmen ensimmäisen alkion tutkiminen pakottaa laiskalistan kutsumaan tuotaAlkiota kuten raporteista näkyy:

laiskatLuvut.take(3).mkString(",")Jaaha, täytyi vaivautua tuottamaan uusi alkio: 1
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 2
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 3
res14: String = 1,2,3

Niiden tutkiminen uudestaan ei vaadi, joten kokoelma laiskottelee:

laiskatLuvut.take(3).mkString(",")res15: String = 1,2,3

Pidemmälle tutkiessa on taas muodostettava lisää alkioita:

laiskatLuvut.take(5).mkString(",")Jaaha, täytyi vaivautua tuottamaan uusi alkio: 4
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 5
res16: String = 1,2,3,4,5

Sanastoa: LazyListit ovat väljiä ja laiskoja

Yllä totesimme, että laiskalistaan muodostuu uusia alkioita vain sitä mukaa, kun kyseistä alkiota tarvitaan johonkin toimenpiteeseen. Ja sikäli kun jotakin alkiota ei tarvita, ei sitä muodosteta lainkaan. Laiskalista on siis *väljästi* evaluoitu kokoelma samassa mielessä kuin by-name -parametri on väljä.

Käytimme myös termiä laiska. Kokoelman laiskuus tarkoittaa, että:

  • kokoelman alkiot evaluoidaan väljästi; ja

  • kokoelma pitää jo evaluoidut alkiot tallessa, jolloin sen ei tarvitse evaluoida niitä uudestaan.

Esimerkiksi satunnaislukuja arpova laiskalista siis sekä välttää turhaa arpomista että säästää itseään tulevalta työltä (arpomiselta) pitämällä jo arvotut luvut tallessa.

Uusittu versio ohjelmastamme

Seuraavaksi sovellamme laiskalistaa syötteen käsittelyyn. Periaate alkioiden muodostamisesta laiskasti vain tarpeen mukaan on tässä ratkaisevan tärkeä.

Yllä hahmottelimme tämän ohjelman, joka raportoi neljän tietyn merkkijonon pituudet.

@main def reportLengths() =
  def report(input: String) = "The input is " + input.length + " characters long."
  val lines = Vector("a line of text", "another", "not written by the user", "but hardcoded into the program")
  lines.map(report).foreach(println)

Halusimme laatia tämän sijaan ohjelman, jossa kysyttyjen syötteiden määrä on etukäteen rajaamaton ja päättyy "please"-syötteeseen. Nyt kun laiskalistat ovat tuttuja, sellainen versio ohjelmasta on helposti laadittavissa. Tämä on jo melkein valmis:

@main def sayPlease() =
  def report(input: String) = "The input is " + input.length + " characters long."
  val inputs = LazyList.continually( readLine("Enter some text: ") )
  inputs.map(report).foreach(println)

Ainoa tässä tehty muutos on: emme palautakaan vektoria, jossa on jo merkkijonoja, vaan laiskalistan, joka "tuo" syötteitä ohjelman käsiteltäväksi. Se on päättymätön merkkijonojen lista, jonka kukin alkio saadaan kysymällä sitä käyttäjältä. Tämän käskyn suorittaminen ei vielä kysy käyttäjältä mitään! Käsky vasta määrittelee kokoelman, jonka alkiot synnytetään kutsumalla readLinea aina, kun tarvitaan uusi alkio.

map palauttaa laiskalistallisen raportteja. Tuon kokoelman kukin alkio muodostetaan (tarvittaessa) evaluoimalla readLine-käsky ja soveltamalla sen palauttamaan arvoon report-funktiota. Tämäkään käsky ei vielä kysy käyttäjältä mitään eikä kutsu report-funktiota, vaan vain valmistautuu tekemään näin, kun tai jos kokoelmasta myöhemmin pyydetään alkioita.

foreach määrää raporttilistan alkiot tulostettavaksi. Jotta laiskalista voi näin käsitellä alkion, sen on ensin määritettävä tuo alkio kysymällä syötettä käyttäjältä ja kutsumalla reportia.

Tuloksena syntyy ohjelma, joka toistuvasti kyselee käyttäjältä syötteitä ja raportoi niiden mitat.

Tuo siis jo pitkälti toimii, mutta lopetusehto jäi toteuttamatta: äskeinen ohjelma kyselee käyttäjältä syötteitä vaikka tuomiopäivään saakka.

Lopetusehto on helppo lisätä:

@main def sayPlease() =
  def report(input: String) = "The input is " + input.length + " characters long."
  val inputs = LazyList.continually( readLine("Enter some text: ") )
  inputs.takeWhile( _ != "please" ).map(report).foreach(println)

takeWhile-käsky tuottaa lopetussanaan "please" rajatun laiskalistan, jonka alkioita — eli käyttäjän syötteitä — ei tuoteta yhtään enempää kuin tarvitaan. Kun lopetussana on kohdattu, lista päättyy. (Vrt. takeWhile-esimerkki satunnaisluvuille ylempänä.)

Tästä REPL-esimerkistä puuttuu yksi kohta:

import scala.util.Randomval kuudenNopanheitonSumma = LazyList.continually( Random.nextInt(6) ).???.sumkuudenNopanheitonSumma: Int = 19

Mitä kysymysmerkkien tilalle voi kirjoittaa, jotta koodi laskisi yhteen kuusi lukua väliltä 1–6 (rajat mukaan lukien)?

import scala.io.StdIn.*

@main def tuntematonOhjelma() =
  val rivejaKayttajalta = LazyList.continually( readLine("Kirjoita jotain: ") )
  val tulos = rivejaKayttajalta.dropWhile( _.length < 5 ).head
  println(tulos)

Mitä tuo ohjelma tulostaa?

import scala.io.StdIn.*

@main def program1() =
  val userInputs = LazyList.continually( readLine("Enter a number: ") )
  println(userInputs.take(4).mkString(","))
  println(userInputs.take(4).map( _.toInt ).product)

Mitä kaikkea tuo ohjelma tekee? (Oletetaan, että käyttäjä syöttää kokonaislukuja eikä katkaise ohjelman suoritusta.)

import scala.io.StdIn.*

@main def program2() =
  val firstInputs  = LazyList.continually( readLine("Enter a number: ") )
  val secondInputs = LazyList.continually( readLine("Enter a number: ") )
  println(firstInputs.take(4).mkString(","))
  println(secondInputs.take(4).map( _.toInt ).product)

Tämä ohjelma on muuten samanlainen kuin edellinen, paitsi että laiskalistoja on kaksi.

Mikä seuraavista kuvaa tämän ohjelmaversion toimintaa?

import scala.io.StdIn.*

@main def program3() =
  def userInputs = LazyList.continually( readLine("Enter a number: ") )
  println(userInputs.take(4).mkString(","))
  println(userInputs.take(4).map( _.toInt ).product)

Tämä ohjelma on muuten ihan samanlainen kuin program1 yllä, paitsi että userInputs on määritelty def-sanalla funktioksi. Tuota funktiota käytetään kahdesti.

Mikä seuraavista kuvaa tämän ohjelman toimintaa?

Pohdi, mitä seuraava koodi tekee. Voit myös kopioida sen tiedostoon ja kokeilla sitä.

Jos koodi on vaikea hahmottaa, kertaa luvun 7.1 averageRainfallFromStrings-tehtävä.

import scala.io.StdIn.*

@main def program4() =
  def inputs = LazyList.continually( readLine("Enter a number: ") )
  val result = inputs.flatMap( _.toIntOption ).head
  println(result)

Mitkä seuraavista väitteistä pitävät paikkansa?

Tehtävä: tunteita leffa-arvosteluissa

Laatikaamme ohjelma, johon käyttäjä voi syöttää lyhyitä sanallisia kommentteja elokuvasta ja joka pyrkii arvioimaan, onko kommentti positiivinen vai negatiivinen. Arvio perustuu tuhansiin ohjelmalle tiedoston kautta syötettyihin aitoihin elokuva-arvioihin, jotka ihminen on aiemmin käsityönä sijoittanut tunneskaalalle.

Haastavammat ja työläämmät osat on tosin jo puolestasi tehty. Tässä pienessä tehtävässä vain kunnostat ohjelman käyttöliittymän.

Tehtävänanto

Tehtäväsi on noutaa Sentiments-moduuli ja täydentää sen käyttöliittymä tiedostoon MovieSentimentApp.scala. Käyttöliittymän tulee toimia tekstikonsolissa näin:

Please comment on a movie or hit Enter to quit: This is a masterpiece, a truly fantastic work of cinema art.
I think this sentiment is positive. (Average word sentiment: 0.36.)

Please comment on a movie or hit Enter to quit: I hated it.
I think this sentiment is negative. (Average word sentiment: -0.41.)

Please comment on a movie or hit Enter to quit: The plot had holes in it.
I think this sentiment is negative. (Average word sentiment: -0.28.)

Please comment on a movie or hit Enter to quit: Adam Sandler
I think this sentiment is negative. (Average word sentiment: -0.80.)

Please comment on a movie or hit Enter to quit: It was great.
I think this sentiment is positive. (Average word sentiment: 0.04.)

Please comment on a movie or hit Enter to quit: It wasn't great.
I think this sentiment is negative. (Average word sentiment: -0.16.)

Please comment on a movie or hit Enter to quit: It was "great".
I think this sentiment is positive. (Average word sentiment: 0.04.)

Please comment on a movie, or hit Enter to quit: 
Bye.

Sovellus ei yritäkään sen syvempää tunneanalyysiä kuin luokitella käyttäjän syötteet positiivisiin ja negatiivisiin.

Se kertoo myös laskemansa luvun, johon luokittelu perustuu. Positiivinen luku tarkoittaa, että ohjelmalle opetettujen tietojen valossa syötteen sanat esiintyvät keskimäärin positiivissävytteisissä elokuva-arvioissa.

Ilmeisten syötteiden positiivisuuden tai negatiivisuuden analysaattorimme tunnistaa varsin mukavasti, Välimerkeistä se ei kuitenkaan välitä, ja muutenkin monet ihmiskielen nyanssit jäävät siltä havaitsematta.

Suoritus päättyy, kun käyttäjä antaa tyhjän syötteen. (Siis aivan tyhjän merkkijonon, jossa ei ole edes välilyöntiä.)

Aineisto, jolla analysaattorillemme opetetaan, millaiset sanat esiintyvät positiivisissa ja mitkä negatiivisissa elokuva-arvioissa, on tiedostossa sample_reviews_from_rotten_tomatoes.txt, joka löytyy moduulin sisältä. Siihen ei ole aihetta nyt koskea, mutta on sivistävää vilkaista, millaisella datalla analysaattori koulutetaan.

Itse analysaattori on valmiiksi ohjelmoitu tiedostoon SentimentAnalyzer.scala. Jos haluat, voit silmäillä sen dokumentaatiota ja koodiakin, mutta älä muuta nyt sitäkään.

Laadi käyttöliittymä pakkauksen o1.sentiment.ui tiedostoon MovieSentimentApp.scala. Tiedosto sisältää jo käyttöliittymän palasia, mutta sinun täytyy kytkeä ne yhteen laiskalistaa ja sen metodeita käyttäen.

Ohjeita ja vinkkejä

  • Haluttu käyttöliittymä toimii pitkälti samoin kuin aiempi sanojen pituuksia raportoiva esimerkkiohjelmakin.

    • Tässä tosin kyseessä on käynnistysolio (extends App; luku 2.7). Sen voi ajaa; se on annettu täydennettäväksi. Kirjoita koodisi suoraan tuon olion sisään merkittyyn kohdaan. Älä määrittele käynnistysfunktiota (@main).

  • Tekemistä on itse asiassa varsin vähän. Koko tehtävän voi ratkaista parilla koodirivillä, miksei yhdelläkin.

  • Tehtävässä valmiina annettu analysaattori on erittäin alkeellinen esimerkki tunteiden automaattisesta tunnistamisesta (sentiment analysis) ja — määritelmästä riippuen — koneoppimisesta (machine learning). Voit lukea niistä vähän lisää tehtävän alta.

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

Tunneanalyysistä

Esimerkkiohjelmamme analysoi tunteita niin yksinkertaisella algoritmilla, että on vähän yllättävääkin, miten siedettävästi se suoriutuu. Se vain tutkii sille annetusta opetusdatasta, kuinka usein mikäkin sana esiintyy positiivisissa ja negatiivisissa arvioissa ja antaa näin kullekin opetusaineiston sanalle positiivisuutta kuvaavan lukuarvon. Kun käyttäjä syöttää tekstin, ohjelma analysoi sen poimimalla syötteestä kaikki tuntemansa sanat ja laskemalla noiden sanojen positiivisuusarvoista keskiarvon.

Jos haluat, voit tutustua myös luokan SentimentAnalyzer koodiin, joka tuon tekee. Siitä varmaan saa jo osin selvää, ja kymppikierroksen jälkeen enemmänkin.

Myös paljon monimutkaisempia ja parempia algoritmeja on vastaaviin tarkoituksiin laadittu. Aiheesta on järjestetty kilpailujakin; itse asiassa tehtävässämme käytetty esimerkki onkin Kaggle-ohjelmointikilpailusivustolta.

Automaattista tunnetunnistusta voidaan soveltaa esimerkiksi suositusten tuottamisessa kuluttajille ja erilaisten diagnoosien tukena. Elokuva-arvioiden arviointi ja tekstien arviointi yleensäkin on vain yksi tunnetunnistuksen haara; lisää kertoo esimerkiksi Wikipedia.

Koneoppimisesta

Koneoppiminen (machine learning) on rajussa nosteessa oleva tietojenkäsittelyn osa-alue. Siitä on tullut jatkuva puheenaihe yliopistoihin, yritysmaailmaan ja yhteiskuntaan muutenkin. Saamme lukea, miten koneet oppivat ajamaan autoa, ennustamaan säätä ja osakekursseja ja tuotteiden suosittuutta, seulomaan roskapostia, pelaamaan lautapelejä, tunnistamaan kuvista esineitä ja kasvoja ja kasvojen pienenpienistä värimuutoksista tunteita, kääntämään kieltä, diagnosoimaan tauteja ja tietoturvauhkia, arvioimaan vakuutuskorvauspyyntöjä, lajittelemaan jätteitä ja vaikka mitä muuta.

Äskeisen esimerkkiohjelmamme voi tulkita alkeelliseksi esimerkiksi koneoppimisesta, ainakin jos valitsemme riittävän laajan määritelmän tälle termille, jolle määritelmiä riittää.

Ainakin ohjelmallamme on useita koneoppimiselle tyypillisiä piirteitä:

  • Ohjelma vastaanottaa opetusdataa, josta se "oppii" tietystä tarkasteltavasta ilmiöstä, joka on ohjelman erityisala. Tässä tapauksessa datana on tiedostollinen elokuva-arvioita, joista ohjelma oppii millaisia positiiviset ja negatiiviset tunteenilmaisut ovat. Yleisemmin ottaen koneoppimisen datana voi olla muunkinlaista tekstiä, kuvia, numeroita, tms.

  • Ohjelma käyttää opetusdataa määrittääkseen parametrit käyttämälleen algoritmille: eri sanojen "positiivisuuslukemat". Keskeistä on, että nämä lukemat lasketaan datasta; itse ohjelmakoodi ei ota kantaa siihen, onko jokin ilmaisu positiivinen tai negatiivinen.

  • Oppimaansa nojaten ohjelma kykenee arvioimaan aiemmin kohtaamattomia tilanteita, jotka vastaavat opetusdataa riittävässä määrin, tässä tapauksessa uusia elokuva-arvioita.

Yksi piirre esimerkistämme puuttuu: emme järjestelmällisesti arvioineet sitä, kuinka laadukkaasti ohjelmamme elokuva-arvioita luokittelee emmekä parantaneet analyysiä tällaisen arvion perusteella. Siitä nyt puhumattakaan, että ohjelmamme tekisi automaattisesti tällaista itsearviointia ja tehtävässään kehittymistä. Monet edistyneemmät koneoppimissovellukset keräävät lisää dataa käyttönsä aikana ja kehittyvät sen perusteella (kuten vaikkapa Googlen Quick, Draw!-peli).

Koneoppimista käsitellään monilla Aallon kursseilla. Ohjelmointi 2 tarjoaa aiheeseen johdannon, ja myös useita nimenomaisesti koneoppimista käsitteleviä kursseja on tarjolla, alkaen kurssista CS-C3240 Machine Learning. Aallossa on useitakin tutkimusryhmiä, jotka soveltavat koneoppimisen menetelmiä ja kehittävät uusia.

Oliko tuo nyt sitä tekoälyä?

Päätä itse.

Tekoäly eli keinoäly (artificial intelligence, AI) on moniselitteinen ja kiistelty muotisana.

Perinteisessä merkityksessään tekoäly viittaa ihmisajattelun tarkoitukselliseen jäljittelemiseen: pyritään mallintamaan ihmismielen toimintaa ja toistamaan sen prosesseja keinotekoisesti.

Nykyisin termillä tarkoitetaan usein monenmoista ihan muutakin.

Arki- ja markkinointikielessä tekoälyksi sanotaan milloin mitäkin sovellusta, jonka aikaansaannokset ovat riittävän hienoja, että sovellusta kelpaa kutsua älykkääksi. Kyseessä on kenties jotakin sellaista, josta tulee mieleen, että "tuota tekevät yleensä ihmiset, eivät koneet" tai "enpä ole ajatellut, että tuohonkin koneet jo pystyvät". Tunneanalyysiohjelmaamme voi arkisesti sanoa tekoälyksi, jos sen arviointikyky tekee vaikutuksen.

Nykyään kun tekoälystä kirjoitetaan, on kyseessä monesti koneoppimiseen perustuva uutuussovellus, jommoisista lueteltiin yllä esimerkkejä. Ihmisen ajattelutavasta nämä sovellukset ovat usein hyvin kaukana, eikä sellaiseen pyritäkään. Pikemminkin päinvastoin: ohjelmat vain muodostavat syötedatan perusteella matemaattisia malleja ja hyödyntävät tietokoneen vahvuuksia eli nopeutta, täsmällisyyttä ja väsymättömyyttä. Koneoppimisen arkipäiväistyessä saattaa käydä niin, että se menettää ”tekoälystatuksensa”.

Kun tekoälynä markkinoidaan esimerkiksi pelien pelaamista hyvin tuloksin tai IBM:n Watsonin kaltaista kysymys ja vastaus -teknologiaa, jotkut perinteisen tekoälyn uranuurtajat tulevat vihaisiksi. (Väitteet ihmiskunnan lopusta tekoälyn armoilla voivat saada heidät hyvin vihaisiksi.) Mutta sanan merkitys taitaa olla jo pysyvästi laventunut perinteisestä. Ehkä tekoäly vielä joskus sovittelee sanaan liittyvät skismat?

Joitakin tekoälyn muotoja käsitellään Aallon kurssilla CS-C1000 Introduction to Artificial Intelligence. Helsingin yliopistolla on aiheesta lyhyt, avoin johdantokurssi verkossa.

Tekoälytermistöä

Tarkkasanaiset puhuvat erikseen suppeasta (narrow) ja yleisestä (general) tekoälystä.

Suppealla tekoälyllä tarkoitetaan järjestelmiä, jotka on laadittu olemaan hyviä (tai oppimaan hyviksi) nimenomaisella rajatulla saralla. Paljon uutisoidut koneoppimisen sovellukset edustavat lähes poikkeuksetta juuri suppeaa tekoälyä.

Jos me opetamme sen tunnistamaan autoja [kuvista], niin se ei tee mitään muuta kuin tunnista autoja. Ei ole mitään mahdollisuutta, että se päätyisi tekemään jotain muuta.

—koneoppimistutkija Timo Aila (Ylen artikkelissa)

Yleinen tekoäly on vielä paljon haastavampi tavoite: älyn ei tulisi olla sidottu tiettyyn aihepiiriin, vaan sen pitäisi kyetä hahmottamaan laajoja kokonaisuuksia, oppimaan niistä ja yhdistelemään niitä. Kenties tällainen järjestelmä olisi ohjelmoitu kehittämään avukseen eri aihepiireihin sopivaa suppeaa tekoälyä.

Käytetään myös termejä heikko (weak) ja vahva (strong) tekoäly, jotka tarkoittavat suunnilleen samaa kuin suppea ja yleinen. Lisäksi niihin voi liittyä ajatus siitä, että vahvalla tekoälyllä — toisin kuin heikolla — on jonkinasteinen tietoisuus ja ehkä tunteitakin.

Tehtävä: mittausdata vuorovaikutteisesti

Treenataan vielä hieman laiskalistan käyttöä näppäimistösyötteen käsittelyyn.

Tehtävänanto

Luvussa 7.1 teit averageRainfall-funktion, joka laski vektorissa annettujen sademäärien (kokonaislukujen) keskiarvon, lopettaen kun syötevektorissa osutaan mittausjakson päättävään lukuun 999999. Negatiiviset syötearvot yksinkertaisesti ohitetaan.

Toteuta sama toiminnallisuus vuorovaikutteisena ohjelmana tiedostoon RainfallApp.scala moduulissa HigherOrder. Käytä laiskalistaa.

Ohjelman tulee toimia täsmälleen seuraavien ajoesimerkkien mukaisesti.

Enter rainfall (or 999999 to stop): 10
Enter rainfall (or 999999 to stop): 5
Enter rainfall (or 999999 to stop): 100
Enter rainfall (or 999999 to stop): 10
Enter rainfall (or 999999 to stop): 5
Enter rainfall (or 999999 to stop): -100
Enter rainfall (or 999999 to stop): -100
Enter rainfall (or 999999 to stop): 110
Enter rainfall (or 999999 to stop): 999999
The average is 40.
Enter rainfall (or 999999 to stop): 999999
No valid data. Cannot compute average.
Enter rainfall (or 999999 to stop): -123
Enter rainfall (or 999999 to stop): -1
Enter rainfall (or 999999 to stop): 999999
No valid data. Cannot compute average.

Ohjeita ja vinkkejä

  • Sinun ei tarvitse vaivata mieltäsi mahdollisuudella, että syöte on epäkelpo, esimerkiksi tyhjä "", "laama", "3.141" tms. Voit olettaa, että syötetty merkkijono on muunnettavissa kokonaisluvuksi toInt-metodilla (luku 5.2).

  • Tässäkin käynnistysolio on annettu. Älä määrittele @main-metodia.

  • Yksi tapa ratkaista tehtävä on lukea kaikki syötteet laiskalistalla, kopioida ne vektoriin (toVector) ja laskea tulos vektorista kuten aiemmassa sadamäärätehtävässä. Toisaalta syytä vektorin käyttöön ei ole, koska myös laiskalistaa voi käsitellä vektoreista tutuilla metodeilla.

    • Esimerkiksi metodeita size ja sum voi mainiosti kutsua laiskalistalle, kunhan lista on äärellinen.

    • Jos teet niin, käytä muuttujaa, joka viittaa tuohon laiskalistaan. (Varo tekemästä vahingossa useita erillisiä syötettä näppäimistöltä lukevia laiskalistoja kuten aiemmassa monivalintatehtävässä.)

  • Huolehdi oikeinkirjoituksesta, jotta saat pisteet automaattitarkastimelta.

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

Pieni jatkotehtävä

Muokkaa äskeistä ratkaisuasi niin, ettei RainfallApp kaadu, vaikka sille antaisi syötteeksi jotakin muuta kuin kokonaisluvun. Kaikki kelvottomat syötteet yksinkertaisesti ohitetaan (ihan kuin negatiiviset luvutkin).

Vinkki: kokeile käyttää metodeita toIntOption ja flatten.

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

Valinnaista: laiskalistoihin viittaaminen ja tehokkuus

Aiemminkin on tullut esille, että pienen näköinen valinta def- ja val-sanojen välillä voi olla hyvinkin merkityksellinen. Tässä valinnaisessa kappaleessa käsitellään sitä, miten tuo valinta vaikuttaa laiskalistojen käyttöön.

val, def ja LazyListien muistintarpeet

Johdantoesimerkki

Niin kauan kuin meillä on tallessa viittaus LazyList-olioon, roskankerääjä ei siivoa sen alkioita pois muistista. Tämä toteutui niissä aiemmissa esimerkeissämme, joissa meillä oli muuttuja (val), joka tallensi viittauksen LazyListiin, kuten tässäkin:

val satunnaisia = LazyList.continually( Random.nextInt(100) )satunnaisia: LazyList[Int] = LazyList(<not computed>)
satunnaisia.take(5).mkString(",")res17: String = 11,70,28,1,93
satunnaisia.take(5).mkString(",")res18: String = 11,70,28,1,93

Saimme samat luvut, koska kyseessä oli yksi ja sama LazyList-olio. Entä jos olisimme käyttäneet muuttujan sijaan funktiota, joka palauttaa viittauksen satunnaislukujen listaan? Näin:

def satunnaisia = LazyList.continually( Random.nextInt(100) )satunnaisia: LazyList[Int]
satunnaisia.take(5).mkString(",")res19: String = 42,72,7,86,30
satunnaisia.take(5).mkString(",")res20: String = 2,2,64,87,54

Lauseke satunnaisia on nyt parametrittoman funktion kutsu. Aina, kun evaluoimme sen, saamme kokonaan uuden LazyListin. Noiden listojen alkiot ovat toisistaan aivan riippumattomat.

Emme pistäneet talteen viittausta, joka osoittaisi kumpaankaan luomistamme satunnaislukujen listoista. Niinpä listaoliot eivät jää muistiin. Roskankerääjä vapauttaa niiden viemän muistitilan ripeästi muuhun käyttöön.

Tehokkuudesta

Vertaillaan vielä kahta koodinpätkää. Tässä ensimmäinen, jossa val-muuttuja viittaa laiskalistaan:

val longListOfInputs = LazyList.continually( readSomeDataFromSomewhere() )
longListOfInputs.map(doStuffWithDataElement).foreach(println)
longListOfInputs.map(doSomethingElseWithElement).foreach(println)

Tallennamme muuttujaan viittauksen syötteisiin. Tarkoituksena on lukea suuri mutta äärellinen määrä alkioita jostakin lähteestä (tiedostosta, verkosta, näppäimistöltä; jostain).

Kun määräämme laiskalistan tulostamaan syötteiden perusteella muodostetut tulokset, koko lista käydään läpi ja kaikki sen alkiot muodostetaan kutsumalla funktiota, joka lukee uuden alkion tietokoneen muistiin. Muuttujaan longListOfInputs jää viittaus kaikki alkiot sisältävään kokoelmaan.

Myöhemmin voimme käsitellä muistiin jo tallennettuja syötteitä jollakin toisella tavalla. Jälkimmäinen läpikäynti ei enää nouda syötettä mistään, vaan käyttää LazyListin varastoimia alkioita.

Tässä identtinen koodi def-sanalla valin sijaan:

def longListOfInputs = LazyList.continually( readSomeDataFromSomewhere() )
longListOfInputs.map(doStuffWithDataElement).foreach(println)
longListOfInputs.map(doSomethingElseWithElement).foreach(println)

Tämä ohjelma ei pistä laiskalistaa sisältöineen talteen. Itse asiassa jo samalla, kun ohjelma käsittelee listan alkioita yksi kerrallaan, tulee kustakin käsitellystä alkiosta saman tien vapaata riistaa roskankerääjälle, joka toimii ohjelma-ajon taustalla. Varsinkin jos lista on pitkä, voi käydä niin, että alkupään alkiot on käsitelty ja poistettu muistista ennen kuin loppupää on edes muodostettu.

Kun syötedata ei jää mihinkään talteen, jälkimmäinen läpikäynti lukee syötettä uudestaan sitä mukaa kun käsittelee syötealkioita.

Riippuu tilanteesta, kumpi on parempi ratkaisu:

  • Jos alkioita on suhteessa käytettävissä olevaan muistiin vähän — ja varsinkin jos samoja alkioita on tarkoitus käydä läpi useasti — niin laiskalistan tallentaminen muuttujaan on hyvä ajatus.

  • Jos alkiot käydään läpi vain kerran ja kunkin niistä voi heittää menemään heti käytön jälkeen (kuten vaikkapa SentimentAnalyzer-esimerkissämme), niin def on parempi vaihtoehto. Tällä tavoin on myös mahdollista käydä läpi suuri määrä dataa ilman, että kaikkien data-alkioiden on mahduttava tietokoneen muistiin yhtaikaa.

Kun datamäärät ovat pieniä, tällä seikalla ei yleensä ole käytännön merkitystä.

Listojen sisäisestä toteutuksesta

Listojen sisäinen toteutus perustuu ajatukseen, että kuhunkin alkioon liitetään eli linkitetään viittaus seuraavaan alkioon.

Linkitetty rakenne mahdollistaa muistin vapautumisen kuvatusti. Toisaalta se tarkoittaa sitä, että listaa on tehokasta käsitellä vain järjestyksessä, ei alkioita sieltä täältä poimien (mikä taas toimii mainiosti esimerkiksi puskureille ja vektoreille).

Linkitetyistä rakenteista ja tehokkuudesta puhutaan enemmän mm. kurssilla Ohjelmointi 2.

Lukusarjoja kätevästi

Onko mahdollista luoda ääretön luonnollisten lukujen vektori tai muu vastaava?

Vektori (tai puskuri tms. tiukasti evaluoitu kokoelma) ei tuohon sovi, koska kaikki vektorin alkiot pidetään muistissa ja äärettömän kokoinen vektori veisi äärettömästi muistia.

Mutta laiskalista sopii:

val positiiviset = LazyList.from(1)positiiviset: LazyList[Int] = LazyList(<not computed>)
positiiviset.take(3).foreach(println)1
2
3

Scala APIsta sattuu löytymään metodi from, jolla voi helposti luoda peräkkäisiä lukuja muodostavan kokoelman. Parametriksi annetaan luku, josta aloitetaan.

Tässä edetään kymmenen kerrallaan:

val kympit = LazyList.from(0, 10)kympit: LazyList[Int] = LazyList(<not computed>)
kympit.take(3).foreach(println)0
10
20

Ja tässä miinus yksi kerrallaan:

val negatiiviset = LazyList.from(-1, -1)negatiiviset: LazyList[Int] = LazyList(<not computed>)
negatiiviset.take(3).foreach(println)-1
-2
-3

Metodien yhdistelyä:

val ekaIsoNelio = LazyList.from(0).map( n => n * n ).dropWhile( _ <= 1234567 ).headekaIsoNelio: Int = 1236544
val sanoja = Vector("eka", "toka", "kolmas")sanoja: Vector[String] = Vector(eka, toka, kolmas)
val lista = LazyList.from(0).map( n => sanoja(n % sanoja.size) )lista: LazyList[String] = LazyList(<not computed>)

Mikä seuraavista kuvaa lista-muuttujan osoittamaa laiskalistaa oikein?

Lisämateriaalia: erilaisia LazyListejä

Iteroiva lista

Mainitsemisen arvoinen on myös iterate-metodi. Se luo kokoelman, jossa seuraava alkio saadaan edellisestä soveltamalla tiettyä funktiota aina vain uudelleen:

val vaihteleva = LazyList.iterate(1)( x => -2 * x )vaihteleva: LazyList[Int] = LazyList(<not computed>)
vaihteleva.take(4).foreach(println)1
-2
4
-8
val lallatus = LazyList.iterate("")( "la" + _ )lallatus: LazyList[String] = LazyList(<not computed>)
lallatus.take(4).foreach(println)
la
lala
lalala

Seuraavassa esimerkissä tuotetaan iterate-metodilla laiskalista ja toteutetaan sen avulla algoritmi, joka määrittää neliöjuuren likiarvon käyttämättä sqrt-kirjastofunktiota. (Koodi soveltaa Newtonin menetelmää positiivisen luvun neliöjuuren arvioimiseen.)

def squareRoot(n: Double) =
  def isTooFar(approx: Double) = (approx * approx - n).abs > 0.0001
  def nextApprox(prev: Double) = (prev + n / prev) / 2
  def approximations = LazyList.iterate(1.0)(nextApprox)
  approximations.dropWhile(isTooFar).headsquareRoot(n: Double): Double
squareRoot(9)res21: Double = 3.000000001396984
squareRoot(654321)res22: Double = 808.9011064400888

Ensimmäinen kolmesta apufunktiosta tutkii, onko annettu likiarvo liian huono eli liian kaukana oikeasta ratkaisusta.

Toinen apufunktio tuottaa edellisen likiarvon perusteella seuraavan likiarvon käyttäen Newtonin menetelmää.

Kolmas apufunktio tuottaa laiskalistan, jossa on mielivaltainen määrä likiarvoja. Ensimmäisenä likiarvona käytetään "arvausta" 1.0 ja seuraavat saadaan aina nextApprox-apufunktiota kutsumalla.

Riittävän hyvä likiarvo saadaan tiputtamalla listan alusta pois liian kaukaiset arvot ja poimimalla sitten ensimmäinen jäljelle jäänyt arvo.

Mielivaltainen laiskalista rekursiolla

continually-, from- ja iterate-metodeilla voi tehdä tietynlaisia kokoelmia kätevästi. Jos niitä ei olisi tarjolla tai jos haluamme määritellä LazyList-olion, joka ei sovi noiden metodien muottiin?

Voimme räätälöidä haluamamme kokoelman rekursion avulla.

Alla on yksinkertainen esimerkki rekursiivisesta eli itseensä viittaavasta laiskalistan määrittelystä. Tämä koodi tuottaa positiivisten lukujen sarjan (samanlaisen kuin LazyList.from(1)).

def positiiviset(eka: Int): LazyList[Int] = eka #:: positiiviset(eka + 1)positiiviset(eka: Int): LazyList[Int]
positiiviset(1).take(3).foreach(println)1
2
3

Operaattori #:: muodostaa LazyListin yhdistelmänä: alkuun tulee vasemmalla mainittu yksittäinen arvo ja perään laitetaan oikealla mainittu laiskalista.

Määritelmä on rekursiivinen eli itseensä viittaava: positiivisten lukujen sarja muodostetaan laittamalla alkuarvon perään kaikkien sitä suurempien positiivisten lukujen sarja.

Samalla perusidealla voi määritellä aivan minkälaisen tahansa LazyListin; kokeile. Myös esimerkiksi kirjastometodit from ja continually on määritelty juuri rekursiivisesti.

Rekursio on monipuolinen ohjelmointitekniikka, joka ei liity vain laiskalistoihin. Lisää siitä luvussa 12.2.

Tulkintatehtävä

Seuraava esimerkki on mutkikkaampi. Osaatko selvittää, mitä tämä funktio tekee ja miten se sen tekee?

Funktio ja sen osat on tarkoituksella nimetty epämääräisesti muttei muuten tarkoituksellisen harhaanjohtavasti.

Tehtävä on matemaattisluonteinen.

def mystery(limit: Int): Vector[Int] =
  import scala.math.sqrt

  val odd = LazyList.from(3, 2)
  def candidates = odd.takeWhile( _ <= limit )

  def initialVals = odd.takeWhile( _ <= sqrt(limit).toInt )
  def multiples(n: Int) = LazyList.from(n * n, 2 * n).takeWhile( _ <= limit )
  def rejected = initialVals.flatMap(multiples)

  val result = candidates.diff(rejected)
  result.toVector
end mystery

Lisämateriaalia: laiskat muuttujat ja "tavalliset listat"

Laiskat muuttujat

Mitä eroa on näillä?

val tulos = "laama".length
println(tulos)
println(tulos)
def tulos = "laama".length
println(tulos)
println(tulos)

Tähän mennessä opitun perusteella ero on selvä. Ensimmäinen, val-sanalla määritelty muuttuja, hoitaa homman (laskee merkkijonon pituuden) heti täsmälleen yhden kerran ja pitää tuloksen muistissa. Toinen, def-sanalla määritelty funktio, jättää homman aluksi tekemättä eikä tee sitä kertaakaan, ellei funktiota kutsuta; toisaalta se tekee saman työn useasti, jos funktiota kutsutaan useasti.

Noista koodinpätkistä jälkimmäinen siis laskee pituuden kahdesti, ensimmäinen käyttää muistia välttääkseen laskemasta samaa uudestaan.

LazyList-olion totesimme olevan vähän kuin tästä väliltä: se hoitaa hommansa (alkioiden muodostamisen) vasta pyydettäessä, mutta pistää sitten tuloksen muistiin ja välttää näin tekemästä samaa uudelleen. Tätä kutsuimme laiskaksi.

Myös muuttuja voi olla laiska. Scalassa tällainen muuttuja määritellään sanoilla lazy val:

lazy val tulos = "laama".lengthtulos: Int = <lazy>
println(tulos)5
println(tulos)5

Vaikka muuttujaan on sijoitettu "laama".length", tuota lauseketta ei vielä evaluoida. Laiskaan muuttujaan kytketään evaluoimaton lauseke.

REPL raportoi merkinnällä <lazy>, että kyseiselle muuttujalle ei ole vielä muodostettu arvoa lainkaan.

Kun muuttujan arvolla todella tehdään jotakin (tässä: tulostetaan), on lauseke evaluoitava. Merkkijonon pituus selvitetään vasta tässä vaiheessa ja tulos tallentuu muuttujaan.

Jälkimmäinen käsky näyttää ulospäin samalta. Kone ei kuitenkaan tässä laske merkkijonon pituutta uudelleen, vaan välttää vaivan käyttämällä muuttujaan jo tallennettua arvoa.

Tässä toinen esimerkki. Määritellään funktio ja kaksi laiskaa muuttujaa.

def printtaaJaPalauta(luku: Int) =
  println("Palautan parametrini " + luku)
  lukuprinttaaJaPalauta(luku: Int): Int
lazy val eka = printtaaJaPalauta(1)eka: Int = <lazy>
lazy val toka = printtaaJaPalauta(2)toka: Int = <lazy>

Kumpikaan muuttujamäärittelyistä ei vielä kutsunut funktiota, minkä voi todeta siitä, ettei funktion sisältämää tulostuskäskyä vielä suoritettu. Jatketaan:

if eka > 0 then eka * 10 else toka * 10Palautan parametrini 1
res23: Int = 10
if eka > 0 then eka * 10 else toka * 10res24: Int = 10

if-käskyn ehdon evaluoiminen vaatii eka-muuttujalle arvon, joten tämän laiskan muuttujan arvo määritetään printtaaJaPalauta-funktiota kutsumalla. Tuloste ilmestyy näkyviin.

Valituksi tulee ensimmäinen haara, jossa if-lausekkeen arvoksi saadaan eka * 10. eka-muuttujalle on jo laskettu arvo, joten sitä ei lasketa uudestaan. Funktiomme ei tulosta toista riviä, kuten olisi käynyt, jos eka olisi def eikä lazy val.

Käskyn uusiminenkaan ei tuota printtaaJaPalauta-funktion lisätulosteita, koska eka-muuttujalla on jo arvo.

Koska jälkimmäistä haaraa ei valittu, ei toka-muuttujan arvoa tarvittu eikä sen arvoa ole edes määritetty.

Joissakin ohjelmointikielissä, kuuluisimmin Haskellissä, kaikki muuttujat ovat laiskoja ja kaikki lausekkeet evaluoidaan väljästi. Useimmissa ohjelmointikielissä muuttujat eivät kuitenkaan ole laiskoja ja lausekkeiden evaluointi on tavallisesti tiukkaa.

Myös Scalassa tiukka evaluointi on lähtökohtana. Mutta kuten tässä luvussa olet nähnyt, ohjelmoija voi valikoidusti määritellä väljästi evaluoitavia parametreja sekä laiskoja kokoelmia ja muuttujia.

Vähän List-luokasta eli "tavallisista listoista"

Tässä luvussa on käsitelty laiskalistoja ja Scalan luokkaa LazyList. On myös olemassa listoja, jotka eivät ole laiskoja.

Listat (list) ovat muuttumattomia alkiokokoelmia ja muistuttavat sikäli muun muassa vektoreita. Ne ovat yleisiä funktionaalisessa ohjelmoinnissa, ja niitä käytetään monissa Scala-ohjelmissa. (Mm. kurssilla Ohjelmointi 2 listat ovat merkittävässä roolissa.)

Listaa käytetään pitkälti niin kuin ennestään tuttuja kokoelmia. Uuden listan luominen käy esimerkiksi näin:

val tyhjaLista = List[Int]()tyhjaLista: List[Int] = List()
val sanalista = List("eka", "toka", "kolmas")sanalista: List[String] = List(eka, toka, kolmas)

Tutut metodit ovat tarjolla:

sanalista.sizeres25: Int = 3
sanalista.tailres26: List[String] = List(toka, kolmas)
sanalista.map( _.length )res27: List[Int] = List(3, 4, 6)
val pidempi = "alkuun" :: sanalistapidempi: List[String] = List(alkuun, eka, toka, kolmas)

Operaattoria :: käytetään nimenomaan listoja käsiteltäessä. Se muodostaa uuden listan, jonka alussa on lisäalkio, ja vastaa siis kokoelmien yleistä operaattoria +: (luku 4.2) ja laiskalistojen operaattoria #::.

Toteutustekniikastaan johtuen listat ovat tehokkaita tilanteissa, joissa joko käsitellään nimenomaan kokoelman alkupään alkioita tai käydään kokoelmaa läpi järjestyksessä. Toisaalta lista ei yleensä ole hyvä valinta, jos tarkoitus on poimia mielivaltaisia alkioita jostakin päin kokoelmaa esimerkiksi indeksin perusteella.

Yhteenvetoa

  • Laiskalista (LazyList) on kokoelma, jonka alkioita ei muodosteta etukäteen vaan vain tarvittaessa.

    • Laiskalista sopii käytäväksi läpi järjestyksessä.

    • Laiskalistan alkiot voi käsitellä yksi kerrallaan varastoimatta niitä kaikkia yhtaikaisesti muistiin tai edes tietämättä etukäteen, paljonko alkioita tulee olemaan.

    • Tutummista kokoelmatyypeistä poiketen laiskalista voi olla äärellinen tai ääretön. Äärettömästä laiskalistasta voi rajata tarkasteltavaksi oleellisen osan esimerkiksi take- tai takeWhile-metodilla.

    • Laiskalistan voi muodostaa esimerkiksi toistamalla tiettyä toimenpidettä (continually), tuottamalla lukuja järjestyksessä (from), soveltamalla toimenpidettä toistuvasti aiempaan tulokseen (iterate) ja muillakin tavoilla.

  • Evaluoimattomat parametrit eli by name -parametrit välitetään kutsutulle funktiolle evaluoimattomina lausekkeina toisin kuin tavalliset (by value) parametrit, jotka evaluoidaan ensin.

    • Evaluoimattoman parametrilausekkeen vastaanottaja voi evaluoida sen kerran, ei kertaakaan tai useita kertoja sen mukaan kuin on tarkoituksenmukaista.

    • Tällaiset parametrit ovat apunamme mm. laiskalistoja muodostaessamme.

  • Lukuun liittyviä termejä sanastosivulla: laiskalista (eli virta); evaluoimaton parametri eli by name -parametri; tiukka, väljä ja laiska evaluointi. Nämäkin mainittiin: koneoppiminen, tekoäly.

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

Sademäärätehtävä on muunnelma Elliot Solowayn klassikkotehtävästä.

Tunneanalyysitehtävä on muunnelma Eric D. Manleyn and Timothy M. Urnessin muotoilemasta ohjelmointitehtävästä, joka perustuu alun perin Kaggle-sivuston ohjelmointikilpailuun.

Laulun sanat ovat Bruno Marsin biisistä.

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