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

Luku 7.1: Virtoja

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? Äärelliset ja äärettömät virrat. Evaluoimattomat eli by name -paramerit. 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. (Pistearvo on korkea vaivaan nähden, koska ohjelmointitehtävien on tarkoitus olla "melkein pakollisia".)

Oheisprojektit: Sentiments (uusi), HigherOrder.

../_images/person04.png

Johdanto

Lämmitellään luvun pääaihetta, virtoja, toisella aiheella, joka tukee sen 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.Randomimport scala.util.Random
fiveTimes(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, mikäli 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.

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ä siten, että keräämme käsiteltävän datan alkioiksi kokoelmaan, jota sitten käymme 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.

object LengthReport extends App {
  def report(input: String) = "The input is " + input.length + " characters long."
  def lines = Vector("a line of text", "another", "not written by the user", "but hardcoded into the program")
  lines.map(report).foreach(println)
}
Yksi apufunktio muodostaa raportin yksittäisen syötteen pituudesta.
Toinen tuottaa meille kokoelman, jossa on käsiteltävät alkiot. Tässä ensimmäisessä ohjelmaversiossamme tuo kokoelma on vektori ja se sisältää koodiin literaaleina kirjatut viisi merkkijonoa.
Viimeinen rivi käytää näitä funktioita: 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 virtoja.

Alkioita virrassa

Virrat (stream) ovat eräänlaisia alkiokokoelmia. Sana tulee ajatuksesta, että virta vähitellen "tuo" alkioita käsiteltäväksi.

Virtoja voi luoda alkiot luettelemalla kuten muitakin kokoelmia:

val datavirta = Stream(10.2, 32.1, 3.14159)datavirta: Stream[Double] = Stream(10.2, ?)
Virtojen toString-metodi, jota REPL käyttää, ei näytä koko virtaa. Tämä on seuraus siitä tavasta, jolla virrat toimivat ja josta kerrotaan kohta lisää.

Toinen tapa on metodi toStream, jolla virta luodaan olemassa olevan kokoelman perusteella (vrt. toVector, toBuffer; luku 4.2):

val sanavektori = Vector("eka", "toka", "kolmas", "neljäs")sanavektori: Vector[String] = Vector(eka, toka, kolmas, neljäs)
val sanavirta = sanavektori.toStreamsanavirta: Stream[String] = Stream(eka, ?)
Näin muodostettu virta kattaa alkuperäisen vektorin alkiot.

Virroilla on tutut kokoelmien perustoiminnot. Tässä esimerkiksi ohitetaan virran pari ensimmäistä alkiota ja poimitaan jäljelle jääneistä ensimmäinen.

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

Silmukkakin toimii:

for (sana <- sanavirta) {
  println("virran sisältöä: " + sana)
}virran sisältöä: eka
virran sisältöä: toka
virran sisältöä: kolmas
virran sisältöä: neljäs

Samoin tutut korkeamman asteen metodit:

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

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

Loppumaton virta

Virtojen erikoisuuksiin kuuluu se, että virralla ei tarvitse olla äärellistä kokoa, vaan virta voi olla päättymätön (vrt. ääretön lukujono matematiikassa).

Eräs tapa luoda virta on continually-tehdasmetodi. Se tuottaa loputtoman virran:

val virta = Stream.continually("Olavi")virta: Stream[String] = Stream(Olavi, ?)

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

virta.take(5).foreach(println)Olavi
Olavi
Olavi
Olavi
Olavi
Alkuperäinen virta on ääretön, mutta take palauttaa parametrinsa mittaisen virran, joka on pätkä alkuperäisestä virrasta.
foreachin työ ei ole loputon, koska se käy läpi vain take-metodin palauttaman viisialkioisen virran.

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

val sanoja = Vector("Sinutkin mukaani haluaisin", "sen virran oikkuihin suostumaan",
                    "Satamat kanssasi unohtaisin", "ja jäisin aalloille asumaan")sanoja: Vector[String] = Vector(Sinutkin mukaani haluaisin, sen virran oikkuihin suostumaan,
Satamat kanssasi unohtaisin, ja jäisin aalloille asumaan)
def sanatJaLoppu = sanoja.toStream ++ Stream.continually("LOPPUI JO")sanatJaLoppu: Stream[String]
sanatJaLoppu.take(7).foreach(println)Sinutkin mukaani haluaisin
sen virran oikkuihin suostumaan
Satamat kanssasi unohtaisin
ja jäisin aalloille asumaan
LOPPUI JO
LOPPUI JO
LOPPUI JO
Muodostetaan virta, jossa on ensin vektorin sisältö ja sitten tiettyä merkkijonoa mielivaltaisen monta kertaa.
Jos virrasta otetaan seitsemän ensimmäistä alkiota, saadaan neljä erilaista alkiota ja sitten loppuviestiä kolme kertaa.
Jos tästä puuttuisi toStream-kutsu, esimerkkikoodimme ei toimisi. Mitä silloin tapahtuisi? Voit selvittää kokeilemalla, mutta tallenna keskeneräiset työt Eclipsessä ensin.

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

val satunnaisia = Stream.continually( Random.nextInt(10) )satunnaisia: Stream[Int] = Stream(8, ?)

Otetaan virrasta muutama luku:

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

Satunnaisia lukuja väliltä 0–99 kunnes sattuu tulemaan yli 90:

Stream.continually( Random.nextInt(100) ).takeWhile( _ <= 90 ).mkString(",")res10: 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
Stream.continually( Random.nextInt(100) ).takeWhile( _ <= 90 ).mkString(",")res11: String = 0,65,83,38,75,33,11,18,75,51,3
takeWhile palauttaa virran, joka päättyy juuri ennen ensimmäistä alkiota, joka ei toteuta tiettyä ehtoa. Vaikka alkuperäinen virta oli ääretön, tässä rajatussa virrassa ei ole loputtomasti alkioita. Samoin kuin continually ja take eivät vielä arponeet virtaa täyteen satunnaislukuja, ei myöskään takeWhile tee niin; se ainoastaan palauttaa virran, joka osaa arpoa lukuja kunnes kohdataan virran päättävä alkio.
mkString tuottaa merkkijonon, jossa ovat kaikki virran alkiot. Äärettömälle virralle kutsuttuna se kuluttaisi tietokoneen muistiresurssin ja katkeaisi virheeseen, mutta äärelliselle virralle se toimii kuin mille tahansa kokoelmalle. Esimerkkimme käyttää satunnaisuutta, ja eri kokeilukerroilla syntyy erilaisia virtoja, jotka päättyvät sen mukaan, milloin niitä läpikäydessä tulee vastaan riittävän iso luku.

Virtojen toimintaperiaate

Tietokone ei tietenkään voi varastoida ääretöntä määrää alkioita. Virtojen keskeinen toimintaperiaate onkin, että koko virtaa kaikkine alkioineen ei muodosteta saman tien, kun virtaolio syntyy. Sen sijaan alkioita muodostetaan vain sitä mukaa, kun niitä tarvitaan. Esimerkiksi äskeisen mkString-käskyn suorittaminen edellytti, että arvottiin muutama satunnaisluku käyttäen lauseketta Random.nextInt(100) muutaman kerran. Virran 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 ja muodostaa kokoelman, joka evaluoi tuon parametrin uudestaan ja uudestaan mutta vain silloin, kun virrasta pyydetään uusia alkioita.

Periaate virran sisällön määrittelemisestä vain tarpeen mukaan eli laiskasti on ratkaisevan tärkeä, kun seuraavaksi sovellamme virtaa syötteen käsittelyyn.

Uusittu versio ohjelmastamme

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

object LengthReport extends App {
  def report(input: String) = "The input is " + input.length + " characters long."
  def 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 virrat ovat tuttuja, sellainen versio ohjelmasta on helposti laadittavissa. Tämä on jo melkein valmis:

object SayPlease extends App {
  def report(input: String) = "The input is " + input.length + " characters long."
  def inputs = Stream.continually( readLine("Enter some text: ") )
  inputs.map(report).foreach(println)
}
Ainoa tässä tehty muutos on: emme palautakaan vektoria, jossa on jo merkkijonoja, vaan virran joka "tuo" syötteitä ohjelman käsiteltäväksi. Se on päättymätön merkkijonojen virta, jonka kukin alkio saadaan kysymällä sitä käyttäjältä. Tämän käskyn suorittaminen ei vielä rupea toistamaan kysymyksiä käyttäjälle! Käsky vasta määrittelee virran, jonka alkiot synnytetään kutsumalla readLinea aina, kun tarvitaan uusi alkio.
map tuottaa "raporttivirran", jonka 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 virrasta myöhemmin pyydetään alkioita.
foreach määrää raporttivirran alkiot tulostettavaksi. Jotta alkion voi tulostaa, se on ensin määritettävä 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. Kuitenkin lopetusehto jäi vielä toteuttamatta: yllä oleva ohjelma kyselee käyttäjältä syötteitä vaikka tuomiopäivään saakka.

Lopetusehto on helppo lisätä:

object SayPlease extends App {
  def report(input: String) = "The input is " + input.length + " characters long."
  def inputs = Stream.continually( readLine("Enter some text: ") )
  inputs.takeWhile( _ != "please" ).map(report).foreach(println)
}
takeWhile-käsky tuottaa lopetussanaan "please" rajatun virran. Tuon virran alkioita — eli käyttäjän syötteitä — ei tuoteta yhtään enempää kuin tarvitaan. Kun lopetussana on kohdattu, virta päättyy. (Vrt. takeWhile-esimerkki satunnaislukujen virralle ylempänä.)

Pieniä virtaharjoituksia

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

import scala.util.Randomimport scala.util.Random
val kuudenNopanheitonSumma = Stream.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._

object Ohjelma extends App {
  def syotevirta = Stream.continually( readLine("Kirjoita jotain: ") )
  val tulos = syotevirta.dropWhile( _.length < 5 ).head
  println(tulos)
}

Mitä tuo ohjelma tulostaa?

import scala.io.StdIn._

object ExampleApp1 extends App {
  val userInputs = Stream.continually( readLine("Enter a number: ") )
  println(userInputs.take(4).mkString(","))
  println(userInputs.take(4).map( _.toInt ).product)
}

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

import scala.io.StdIn._

object ExampleApp2 extends App {
  def userInputs = Stream.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 edellinen, paitsi että userInputs on määritelty def-sanalla funktioksi.
Syntyy kaksi Stream-oliota, koska userInputsia käytetään kahdesti.

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

Tehtävä: tunteita leffa-arvosteluissa

Laatikaamme ohjelma, johon käyttäjä voi syöttää lyhyitä sanallisia kommentteja elokuvasta ja joka pyrkii arvioimaan, onko kyseinen kommentti positiivinen vai negatiivinen. Arvio perustuu tuhansiin ohjelmalle aiemmin tiedostossa syötettyihin aitoihin elokuva-arvioihin, jotka ihminen on 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 projekti Sentiments 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 projektin 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 virtaa ja sen metodeita käyttäen.

Ohjeita ja vinkkejä

  • Haluttu käyttöliittymä toimii pitkälti samoin kuin sanojen pituuksia raportoiva esimerkkiohjelmakin.
  • Tekemistä on itse asiassa varsin vähän. Koko tehtävä on mahdollista 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.

Palauttaminen

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 numeroarvon. Kun käyttäjä syöttää tekstin, ohjelma analysoi sen poimimalla kaikki tuntemansa sanat ja laskemalla noiden sanojen positiivisuusarvoista keskiarvon.

Jos haluat, voit tutustua myös luokan SentimentAnalyzer koodiin, joka tuon tekee. Tutustuminen tosin onnistuu helpommin ysikierroksen paikkeilla; muistutamme asiasta sitten.

Myös paljon monimutkaisempia ja parempia algoritmeja on vastaavaan tarkoitukseen 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 Machine Learning: Basic Principles. 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, jollaisista yllä lueteltiin esimerkkejä. Ihmisen ajattelutavasta näissä sovelluksissa ollaan 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ä.

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 kurssilla CS-E4800 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 virran käyttöä näppäimistösyötteen käsittelyyn.

Tehtävänanto

Luvussa 6.4 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 projektissa HigherOrder.

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).
  • Yksi tapa ratkaista tehtävä on lukea kaikki syötteet virralla, laittaa virran sisältö vektoriin (toVector) ja laskea tulos siitä kuten aiemmassa sadamäärätehtävässä. Toisaalta syytä vektorin käyttöön ei ole, koska myös virtaa voi käsitellä vektoreista tutuilla metodeilla.
    • Esimerkiksi metodeita size ja sum voi mainiosti kutsua virralle, kunhan virta on äärellinen.
    • Jos kutsut noita metodeita virralle, käytä muuttujaa, joka viittaa tuohon virtaan. (Varo tekemästä vahingossa useita syötettä näppäimistöltä lukevia virtoja.)
  • Huolehdi oikeinkirjoituksesta, jotta saat pisteet automaattitarkastimelta.

Palauttaminen

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

Lukuvirtoja 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 virta sopii:

def positiiviset = Stream.from(1)positiiviset: Stream[Int]
positiiviset.take(3).foreach(println)1
2
3
Scala API:sta sattuu löytymään metodi from, jolla voi helposti luoda peräkkäisiä lukuja "kuljettavan" virran. Parametriksi annetaan luku, josta aloitetaan.

Tässä edetään kymmenen kerrallaan:

def kympit = Stream.from(0, 10)kympit: Stream[Int]
kympit.take(3).foreach(println)0
10
20

Ja tässä miinus yksi kerrallaan:

def negatiiviset = Stream.from(-1, -1)negatiiviset: Stream[Int]
negatiiviset.take(3).foreach(println)-1
-2
-3

Metodien yhdistelyä:

val ekaIsoNelio = Stream.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 virta = Stream.from(0).map( n => sanoja(n % sanoja.size) )virta: Stream[String] = Stream(eka, ?)

Mikä seuraavista kuvaa virta-muuttujan osoittamaa virtaa oikein?

Lisämateriaalia: erilaisia virtoja

Iteratiivinen virta

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

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

Alla olevassa esimerkissä tuotetaan iterate-metodilla virta ja toteutetaan tuon virran 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 streamOfApproximations   = Stream.iterate(1.0)(nextApprox)
  streamOfApproximations.dropWhile(isTooFar).head
}squareRoot: (n: Double)Double
squareRoot(9)res12: Double = 3.000000001396984
squareRoot(654321)res13: 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 virran, 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 virran alusta pois liian kaukaiset arvot ja poimimalla sitten ensimmäinen jäljelle jäänyt arvo.

Mielivaltainen virta rekursiolla

continually-, from- ja iterate-metodeilla voi tehdä tietynlaisia virtoja kätevästi. Jos niitä ei olisi tarjolla tai jos haluamme määritellä virran, joka ei sovi noiden tehdasmetodien muottiin, niin voimme räätälöidä haluamamme virran rekursion avulla.

Alla on yksinkertainen esimerkki rekursiivisesta eli itseensä viittaavasta virran määrittelystä, joka tuottaa positiivisten lukujen sarjan (samanlaisen kuin Stream.from(1)).

def positiiviset(eka: Int): Stream[Int] = eka #:: positiiviset(eka + 1)positiiviset: (eka: Int)Stream[Int]
positiiviset(1).take(3).foreach(println)1
2
3
Operaattori #:: muodostaa virran yhdistelmänä: alkuun tulee vasemmalla mainittu yksittäinen arvo ja perään laitetaan oikealla mainittu virta.
Määritelmä on rekursiivinen eli itseensä viittaava: positiivisten virta muodostetaan laittamalla alkuarvon perään kaikkien sitä suurempien positiivisten lukujen virta.

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

Rekursio on monipuolinen ohjelmointitekniikka, joka ei liity vain virtoihin. Lisää siitä luvussa 12.1.

Tulkintatehtävä

Seuraava virtaesimerkki 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 = Stream.from(3, 2)
  def candidates = odd.takeWhile( _ <= limit )

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

  val result = candidates.diff(rejected)
  result.toVector
}

Lisämateriaalia: virtojen ominaisuuksia

Virrat ovat väljiä ja laiskoja

Ylempänä totesimme, että virran kaikkia alkioita ei välttämättä evaluoida lainkaan: virta on väljästi evaluoitu kokoelma. Totesimme myös, että virtaan muodostuu uusia alkioita vain sitä mukaa, kun kyseistä alkiota tarvitaan johonkin toimenpiteeseen.

Palataan tähän esimerkkiin:

val satunnaisia = Stream.continually( Random.nextInt(100) )satunnaisia: Stream[Int] = Stream(12, ?)
satunnaisia.take(5).mkString(",")res14: String = 12,62,28,14,31
Virta evaluoi määrittävän lausekkeensa Random.nextInt(100) arpoakseen uuden alkion vasta kun tai jos tuota alkiota tarvitaan. Tässä esimerkissä se tapahtuu viidesti, kun mkStringillä määrätään muodostettavaksi merkkijono virran viidestä alkiosta, mikä pakottaa virtaolion evaluoimaan alkioita muodostavan arpomiskäskyn toistuvasti.

Ja nyt tarkkana: virran jatkoksi muodostuu siis uusia alkioita vain tarvittaessa. Entä jos jo aiemmin muodostettua alkiota käytetään uudestaan?

satunnaisia.take(5).mkString(",")res15: String = 12,62,28,14,31
satunnaisia.take(5).mkString(",")res16: String = 12,62,28,14,31
satunnaisia.take(10).mkString(",")res17: String = 12,62,28,14,31,27,79,18,78,43
Kun katsomme päättymättömän lukuvirran viisi ensimmäistä lukua uudelleen, saamme samat luvut kuin ennenkin. Virtaolio on laittanut ne muistiin eikä evaluoi arvontalauseketta uudelleen, kun samat vanhat alkiot kysytään taas.
Kun otamme virrasta vähän pidemmän pätkän ja katsomme sen alkiot, saamme viisi aiemmin tallennettua ja siihen lisää päälle. Uudet alkiot muodoistuivat juuri arpomalla ja jäivät talteen virtaolioon.

Sanomme, että virta ei ole vain väljästi evaluoitu kokoelma vaan lisäksi laiska (lazy): se ei ainoastaan jätä muodostamatta sellaisia alkioita, joita ei ole tarvittu, vaan myös säästää itseään tulevalta työltä (tässä: arpomiselta) pitämällä jo muodostetut alkiot tallessa.

Seuraava esimerkki osoittaa laiskuuden vielä konkreettisemmin.

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

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

Muodostetaan virta, jonka alkiot luodaan tuolla funktiolla:

val lukuvirta = Stream.continually( tuotaAlkio() )Jaaha, täytyi vaivautua tuottamaan uusi alkio: 0
lukuvirta: Stream[Int] = Stream(0, ?)
Ensimmäisen alkion virta tuottaa jo aluksi. REPL kutsui virran toString-metodia, joka laittaa ensimmäisen alkion näkyviin. (Myöhemmissä Scala-versioissa tämä tulee muuttumaan, eikä virta tuota ensimmäistäkään alkiota ennen kuin pyydetään.)

Viiden ensimmäisen alkion tutkiminen vaatii uusia tuotaAlkio-kutsuja:

lukuvirta.take(5).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
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 4
res18: String = 0,1,2,3,4

Niiden tutkiminen uudestaan ei vaadi, joten virta laiskottelee:

lukuvirta.take(5).mkString(",")res19: String = 0,1,2,3,4

Virta pidemmälle tutkiessa on taas muodostettava lisää alkioita:

lukuvirta.take(10).mkString(",")Jaaha, täytyi vaivautua tuottamaan uusi alkio: 5
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 6
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 7
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 8
Jaaha, täytyi vaivautua tuottamaan uusi alkio: 9
res20: String = 0,1,2,3,4,5,6,7,8,9

val, def ja virtojen muistintarpeet

Niin kauan kuin meillä on tallessa viittaus virtaan, roskankerääjä ei siivoa virran alkioita pois muistista. Äskeisissä esimerkeissä tämä toteutui, koska meillä oli muuttuja (val), joka tallensi viittauksen luotuun virtaolioon. Entä jos ei olisi ollut?

def satunnaisia = Stream.continually( Random.nextInt(100) )satunnaisia: Stream[Int]
satunnaisia.take(5).mkString(",")res21: String = 42,72,7,86,30
satunnaisia.take(5).mkString(",")res22: String = 2,2,64,87,54
Määritellään muuttujan sijaan funktio, joka palauttaa viittauksen virtaan. Määrittely on muuten identtinen edellisen kanssa.
Lauseke satunnaisia on nyt parametrittoman funktion kutsu. Aina, kun evaluoimme sen, saamme kokonaan uuden virran.
Noiden erillisten virtojen alkiot ovat toisistaan aivan riippumattomat.

Koska emme pistäneet talteen viittausta, joka osoittaisi kumpaankaan luomistamme satunnaislukujen virroista, eivät virtaoliot jää muistiin. Roskankerääjä vapauttaa ripeästi niiden viemän muistitilan muuhun käyttöön.

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

val longStreamOfInputs = Stream.continually( readSomeDataFromSomewhere() )
longStreamOfInputs.map(doStuffWithDataElement).foreach(println)
longStreamOfInputs.map(doSomethingElseWithElement).foreach(println)
Viittaus virtaan tallennetaan muuttujaan. Tarkoituksenamme on lukea suuri mutta äärellinen määrä alkioita jostakin lähteestä (tiedostosta, verkosta, näppäimistöltä; jostain).
Kun virran perusteella muodostetut tulokset sitten määrätään tulostettavaksi, koko virta käydään läpi ja kaikki sen alkiot muodostetaan kutsumalla funktiota, joka lukee uuden alkion tietokoneen muistiin. Muuttujaan longStreamOfInputs jää viittaus kaikki alkiot sisältävään virtaan.
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ää aiemmin luettuja alkioita.

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

def longStreamOfInputs = Stream.continually( readSomeDataFromSomewhere() )
longStreamOfInputs.map(doStuffWithDataElement).foreach(println)
longStreamOfInputs.map(doSomethingElseWithElement).foreach(println)

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

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

Voi siis olla paljonkin merkitystä sillä, tallennatko virran muuttujaan vai et. 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 virran 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ä.

Virtojen sisäinen toteutus perustuu ajatukseen, että kuhunkin alkioon liitetään viittaus seuraavaan alkioon. Linkitetty rakenne mahdollistaa muistin vapautumisen kuvatusti; toisaalta se tarkoittaa sitä, että virtaa 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.

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 laskee pituuden kahdesti, ensimmäinen käyttää muistia välttääkseen laskemasta samaa uudestaan.

Virran 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ä sanoimme 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)
  luku
}printtaaJaPalauta: (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) eka * 10 else toka * 10Palautan parametrini 1
res23: Int = 10
if (eka > 0) 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 ainakin pääsääntöisesti 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.

Yhteenvetoa

  • Virta (Stream) on kokoelma, jonka kaikkia alkioita ei muodosteta etukäteen vaan vain tarvittaessa.
    • Virta sopii käytäväksi läpi järjestyksessä.
    • Virran 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 virta voi olla äärellinen tai ääretön. Äärettömästä virrasta voi rajata tarkasteltavaksi oleellisen osan esimerkiksi take- tai takeWhile-metodilla.
    • Virran voi muodostaa esimerkiksi toistamalla tiettyä toimenpidettä (continually), tuottamalla virtaan 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. virtoja muodostaessamme.
  • Lukuun liittyviä termejä sanastosivulla: virta; evaluoimaton parametri eli by name -parametri; tiukka, väljä ja laiska evaluointi. (Myös: koneoppiminen, tekoäly.)

Pian LazyList

Käytämme kurssilla Scala-versiota 2.12. Uunituoreesta versiosta 2.13 alkaen Stream-luokan korvaa luokka nimeltä LazyList. Sen toimintaperiaate on oleellisesti sama kuin tässä luvussa kuvatun Stream-luokan.

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.

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.

Virtalaulun sanat ovat Marja Mattlarin.

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