- COMP.CS.140
- 8. Geneeriset tyyppiparametrit
- 8.4 Funktionaalinen ohjelmointi Javassa
Funktionaalinen ohjelmointi Javassa¶
Lambda-funktiot, funktioviittaukset ja funktionaalisen rajapinnan käsite lisättiin Javaan sen kahdeksannessa versiossa osana yleisemmin ottaen funktionaalista ohjelmointia tukevia ominaisuuksia. Emme pyri tällä kurssilla perehtymään funktionaaliseen ohjelmointiin missään mielessä syvällisesti. Aiheesta on tarjolla erillinen kurssi, joka ainakin ohjelmoinnista kiinnostuneiden kannattaa suorittaa.
Tässä suppeassa tarkastelussamme on esillä seuraavat funktionaalisen ohjelmoinnin paradigmaan liittyvät piirteet:
Datajoukkoa käsitellään funktioilla eikä eskplisiittisellä iteroinnilla kuten silmukoilla.
Javassa: käsitellään alkioita iteroitavien säiliöiden sijaan
Stream
-rajapintojen avulla.
Funktioparametrien käyttö.
Dataa käsitellään yleisluontoisilla funktioilla, jotka itsessään ottavat parametrikseen niiden toimintatapaa tarkentavan funktion.
Edellä esimerkkinä toteutettu
filter
-funktio noudatti tätä periaatetta, kuten myös jo aiemminkin runsaasti käyttämämme Javan lajittelufunktiot, jotka sallivat järjestyskriteerin määrittämisen erillisen vertailufunktion avulla.
Javassa: funktiot voivat ottaa parametrina funktionaalisen rajapinnan toteuttavia olioita, ja sellaisia voidaan välittää helposti lambda-funktioiden tai funktioviitteiden avulla.
Data on muuttumatonta, ja funktiot eivät aiheuta sivuvaikutuksia.
Funktiot eivät muuta olemassaolevaa dataa vaan luovat esimerkiksi vanhan datan pohjalta uutta dataa.
Funktionaalisessa ohjelmointityylissä esimerkiksi lajittelufunktio ei lajittelisi parametrikseen saaamaansa listaa itsessään vaan palauttaisi uuden listan, jossa on alkuperäisen listan alkiot järjestyksessä.
Javassa:
Stream
-rajapintojen yhteydessä suositellaan noudattamaan tätä periaatetta, mutta se ei ole pakollista.
Dataan kohdistuvat operaatiot suoritetaan “laiskasti” (lazy evaluation).
Konkreettinen datan prosessointi aloitetaan vasta silloin, kun prosessoinnin lopputulosta pyydetään eksplisiittisesti. Tätä selvennetään alempana.
Javassa:
Stream
-rajapinnat noudattavat tätä periaatetta.
Keskitymme jatkossa Javan Stream
-rajapintojen käyttöön, koska ne ovat Javan keskeisin apuväline
funktionaalista ohjelmointityyliä mukailevaan datan käsittelyyn.
Javan Stream-rajapinnat¶
Tekstin sujuvoittamiseksi käytämme tästä eteenpäin sanaa virta (joskus myös “stream”) tarkoittamaan Javan
Stream
-rajapintaa tai sellaisen toteutusta.
Javan virrat ovat tyyppejä, jotka tarjoavat melko monipuolisen joukon operaatioita virroille
välitetyn alkiojoukon käsittelyyn. Javan luokkakirjastossa on tarkemmin ottaen neljä erilaista
virtatyyppiä: Stream<T>
, IntStream
, LongStream
ja DoubleStream
. Näiden keskeisin
ero on käsiteltävien alkioiden tyyppi. Geneerinen virta Stream<T>
sopii yleisesti ottaen
kaikkien viitetyyppien käsittelyyn, ja jälkimmäiset kolme on erikoistettu niiden nimissä
mainittujen lukutyyppien käsittelyyn. Lukutyyppejä käsittelevät virrat tarjoavat operaatioita
mainittujen lukutyyppien käsittelyyn. Lukutyyppejä käsittelevät virrat tarjoavat operaatioita
myös alkioiden numeeriseen käsittelyyn, kuten esimerkiksi niiden summan laskemiseen. Yleiskäyttöinen
Stream<T>
ei tarjoa tällaisia operaatioita, koska laskutoimituksia ei voi soveltaa kuin
lukutyyppeihin.
Virta itsessään ei sinänsä talleta käsiteltäviä alkioita, koska virta ei ole säiliö, se vaan lukee niitä luontinsa yhteydessä määritetystä lähteestä. Jos tekstissä puhutaan virran alkioista, sillä tarkoitetaan virran lukemia alkioita. Javan luokkakirjasto tarjoaa esimerkiksi seuraavat valmiit funktiot virran luontiin:
Taulukon
arr
alkioita lukevan virran voi luoda tapaanArrays.stream(arr)
.Luodun virran tyyppi riippuu taulukon alkioiden tyypistä luonnolliseen tapaan:
int
:IntStream
long
:LongStream
double
:doubleStream
Jokin muu tyyppi
T
:Stream<T>
Säiliön
cont
alkioita lukevan virran voi luoda tapaancont.stream()
. Luotu virta on aina tyyppiäStream<T>
, missäT
on säiliön alkioiden tyyppi.Jos säiliön alkioiden tyyppi olisi esimerkiksi
Integer
, on tuloksenaStream<Integer>
eikäIntStream
.
BufferedReader
-oliostabr
(joka on alustettu lukemaan jotain syötevirtaa, kuten tiedostoa) rivejä lukevan virran voi luoda tapaanbr.lines()
. Virran tyyppi onStream<String>
, koska luettavat alkiot ovat rivejä eli merkkijonoja.
Kun virta on luotu, voi siihen, eli sen lukemaan alkiojoukkoon, kohdistaa kahdenlaisia virtaoperaatioita: välioperaatioita (intermediate operations) tai loppuoperaatioita (terminal operations). Välioperaatiolla tarkoitetaan operaatiota, jonka tuloksena on jälleen virta. Tämä mahdollistaa virtaan kohdistettavien operaatioiden ketjutuksen: välioperaation tulokseen voidaan välittömästi kohdistaa uusi virtaoperaatio tarvitsematta alustaa erikseen uutta virtaa. Loppuoperaatio tuottaa konkreettisen tuloksen, esimerkiksi arvon tai alkioita sisältävän säiliön, minkä jälkeen kyseinen virtaoperaatioiden ketju päättyy. Jos loppuoperaation tulokseen haluaa soveltaa vielä lisää virtaoperaatioita, pitää luoda uusi tulosta lukeva virta.
Operaatiot on toteutettu virran jäsenfunktioina eli jos olisi luotu virta s
, suoritettaisiin
esimerkiksi ensimmäinen alempana mainittu operaatio tapaan s.distinct()
. Operaatioita voi suorittaa
peräkkäin esimerkiksi ketjuttamalla jäsenfunktiokutsuja: s.distinct().sorted()
kohdistaisi
virtaan s
operaation distinct
ja sen tulokseen operaation sorted
.
Alla on esitelty muutamia yleisen virran Stream<T>
tarjoamia välioperaatioita. Tässä on
yksinkertaisuuden vuoksi jätetty pois funktionaalisia rajapintoja vastaavien parametrien tyypit,
mutta sellaiset parametrit kuvataan tarkemmin alakohdissa.
distinct()
: tuottaa uuden virran, jossa on tämän virran yksikäsitteiset alkiot eli tämä operaatio poistaa duplikaatit.filter(predicate)
: tuottaa uuden virran, jossa on vain ne tämän virran alkiot, joille parametrina annettu funktiopredicate
palauttaa arvontrue
. Eli tämä karsii virrasta pois kaikki ne alkiot, jotka eivät täytä parametrinpredicate
määrittämää kriteeriä.Parametrin
predicate
tulee täyttää funktionaalinen rajapintaPredicate
, jonka funktiotest
tässä määrittää, sisällytetäänkö alkio tulokseen vai ei.Toimintaperiaate vastaa aiemmin funktionaalisten rajapintojen yhteydessä esimerkkinä esitettyä funktiota
filter
.
map(function)
: tuottaa uuden virran, jossa kukin tämän virran alkiot
on korvattu funktiokutsunfunction.apply(t)
tuloksella. Eli tämä muuntaa virran kaikki alkiot parametrina annetulla muunnosfunktiolla.Parametrin
function
tulee täyttää funktionaalinen rajapintaFunction
.
mapToInt(function)
,mapToLong(function)
jamapToDouble(function)
: toimivat muuten kuinmap
, mutta tuottavat uuden virran, jonka tyyppi onIntStream
,LongStream
taiDoubleStream
. Tyyppi määräytyy luonnollisesti asianomaisen map-funktion nimessä mainitun tyypin mukaisesti, ja muunnosfunktionfunction
tulee palauttaa sitä vastaavaa lukutyyppiä oleva arvo.Nämä ovat siinä mielessä tärkeitä, että näiden avulla voidaan vaihtaa virran tyyppi yleisestä virrasta lukuvirraksi. Tällainen muunnos on tarpeen, jos haluamme käyttää lukuvirtojen tarjoamia numeerisia funktioita.
sorted()
jasorted(comparator)
: tuottavat uuden virran, jossa on tämän virran alkiot lajitellussa järjestyksessä. Ensimmäinen käyttää alkioiden luonnollista järjestystä ja jälkimmäinen ottaa parametrina rajapinnanComparator
toteuttavan vertailuolion.
Kuten aiemmin todettiin, virrat suorittavat operaationsa laiskasti. Virtoja koskevia operaatioita aletaan suorittaa vasta, kun virtaan kohdistetaan loppuoperaatio, joka palauttaa konkreettisen tuloksen tai ei mitään, eikä enää uutta virtaa. Näin ollen virtaa käyttäessä tulee aina viimeisenä suorittaa loppuoperaatio. Välioperaatioita voi olla nollakin, jos haluttu tulos saadaan aikaiseksi pelkällä loppuoperaatiolla. Alla on esitelty muutamia virtojen loppuoperaatioita:
void forEach(consumer)
: Suorittaa funktiokutsunconsumer.accept(t)
tämän virran jokaiselle alkiollet
. Huomaa, että tämä funktio ei palauta tulosta (ainoa vaikutus on ne mahdolliset sivuvaikutukset, mitä edellämainitut funktiokutsut tuottavat).Parametrin
consumer
tulee täyttää funktionaalinen rajapintaConsumer
.
Object[] toArray()
: Palauttaa tämän virran alkiot sisältävän taulukon.Lukuvirtojenn vastaavat funktiot palauttavat kyseisen lukuvirran alkioiden tyyppisen taulukon. Esimerkiksi
IntStream
-virrantoArray()
palauttaaint
-taulukon.
T reduce(T identity, accumulator)
: laskee tämän virran alkioiden pohjalta eräässä mielessä kumulatiivisen lopputuloksen.Parametrin
accumulator
tulee täyttää funktionaalinen rajapintaBinaryOperator
.Tulos alustetaan tapaan
T result = identity
ja sen jälkeen tämän virran alkiot käydään läpi päivittäen tulos kunkin alkiont
kohdalla tapaanresult = accumulator.apply(result, t)
.Esimerkiksi tyyppiä
Stream<Integer>
olevan virrans
lukujen summan voisi laskea kutsullas.reduce(0, (a, b) -> a + b)
tai vaihtoehtoisesti funktioviittausta käyttäen kutsullas.reduce(0, Integer::sum)
.Esimerkiksi lukujen 7, 2, 6 summa muodostuisi askeleittain tapaan
result
= 0,result
= 0 + 7 = 7,result
= 7 + 2 = 9 jaresult
= 9 + 6 = 15.
R collect(supplier, accumulator, combiner)
: kerää tämän viraan alkiot. Yleensä joko palauttaa alkiot säiliössä tai palauttaa niiden pohjalta muodostetun muunlaisen tuloksen.Parametrin
supplier
tulee täyttää funktionaalinen rajapintaSupplier<R>
. Lopputulos alustetaan tämän avulla tapaanR result = supplier.get()
.Parametrin
accumulator
tulee täyttää funktionaalinen rajapintaBiConsumer<R,? super T>
. Virran alkiot käydään läpi päivittäen tulos kunkin alkiont
kohdalla tapaanaccumulator.accept(result, element)
.Parametrin
combiner
tulee täyttää funktionaalinen rajapintaBiConsumer<R, R>
. Stream voi halutessaan käyttää tätä kahden (osittaisen) tuloksen yhdistämiseen, joka voi olla tarpeen, jos virtaa esimerkiksi prosessoidaan rinnakkain.Esimerkiksi jotain tyyppiä
Stream<T>
olevan virrans
alkiot voisi kerätäArrayList
-listaan kutsullas.collect(() -> new ArrayList<>(), (r, t) -> r.add(t), (r1, r2) -> r1.addAll(r2))
tai vaihtoehtoisesti funktioviittauksia käyttäen kutsullas.collect(ArrayList::new, ArrayList::add, ArrayList::addAll)
.Huomaa, että minkä tahansa luokan olion luovaan
new
operaatioon voi viitata tapaanclassName::new
.
R collect(collector)
: kerää tämän virran alkiot. Toimii sinänsä kuin ylempicollect
, mutta parametrina saatavacollector
toteuttaa yhtäaikaisesti kaikkien yllä erillisinä saatujen kolmen eri parametrin operaatiot.Parametrin
collector
tulee täyttää funktionaalinen rajapintaCollector
. Emme ole esitelleet sitä aiemmin (emmekä esitä sen yksityiskohtia tässäkään tarkemmin). Todettakoon vain, että Javan luokkaCollectors
tarjoaa joukon staattisia jäsenfunktioita, jotka luovat erilaisia valmiitaCollector
-rajapinnan toteuttavia olioita, joita voi hyödyntääcollect
-funktion kanssa. Alla on kuvattu muutaman tällaisen jäsenfunktion osalta millaisenCollector
-olion ne palauttavat.Collectors.toList()
: kerää alkiot listaan.Esimerkiksi tyyppiä
Stream<T>
olevan virrans
alkiot voisi kerätäList<T>
-listaan kutsullas.collect(Collectors.toList())
. Lopputuloksen tyyppi on jokin rajapinnanList<T>
täyttävä lista.
Collectors.counting()
: palauttaa virran alkioiden lukumäärän.Jos virta
s
lukisi esimerkiksi taulukkoa{4, 7, 6, 3, 8}
, palauttaisis.collect(Collectors.counting())
arvon 5.Huomaa, että
collect
voi alkioden “keräämisen” sijaan myös palauttaa tällaisen muunlaisen tuloksen.
Collectors.averagingInt(mapper)
,Collectors.averagingLong(mapper)
jaCollectors.averagingDouble(mapper)
: palauttavat virran alkioiden keskiarvonDouble
-arvona. Parametrinmapper
tulee olla funktio, joka muuntaa alkion funktion nimen kuvaamaan lukutyyppiin. Jos alkiot ovat jo valmiiksi oikeantyyppisiä lukuja, voi muunnos säilyttää arvon sellaisenaan (mutta muunnos on silloinkin määritettävä).Jos virta
s
lukisi esimerkiksi taulukkoa{4, 7, 6, 3, 8}
, palauttaisis.collect(Collectors.averagingInt(i -> i))
arvon 5.6.
Collectors.summingInt(mapper)
,Collectors.summingLong(mapper)
jaCollectors.SummingDouble(mapper)
: samankaltaisia kuin edelliset keskiarvofunktiot, mutta palauttavat virran alkioiden summan, ja tuloksen tyyppi vastaa funktion nimen ilmaisemaa lukutyyppiä.Jos virta
s
lukisi esimerkiksi taulukkoa{4, 7, 6, 3, 8}
, palauttaisis.collect(Collectors.summingInt(i -> i))
arvon 28.
Collectors.joining(delimiter)
: palauttaaString
-merkkijonon, jossa on virran alkiot liitettynä yhteen parametriksi saadulla merkkijonolladelimiter
eroteltuina.Jos virta
s
lukisi esimerkiksi taulukkoa{"one", "two", "three"}
, palauttaisis.collect(Collectors.joining("-"))
merkkijonon “one-two-three”.
Collectors.groupingBy(classifier)
: ryhmittelee virran alkiot sanakirja-säiliöön parametrina saadun funktionclassifier
määrittämien avainten alaisuuteen. Virran kullekin alkiollet
määritetään avain tapaankey = classifier(t)
, ja alkiot
lisätään sanakirjassa kyseisen avaimenkey
alaisuuteen tallennettuun listaan.Parametrin
classifier
tulee täyttää funktionaalinen rajapintaFunction
.Lopputuloksen tyyppi on jokin rajapinnan
Map
toteuttava sanakirja, jonka arvot ovat jonkintyyppisiä rajapinnanList
toteuttavia listoja.Jos virta
s
lukisi esimerkiksi taulukkoa{"one", "two", "three", "four"}
, ryhmittelisis.collect(Collectors.groupingBy(String::length))
merkkijonot niiden pituuksien mukaan: tuloksena olisi sanakirja, jossa on avaimen 3 alla merkkijonot “one” ja “two”, avaimen 4 alla merkkijonon “four” ja avaimen 5 alla merkkijonon “three” sisältävä lista.Huomaa jälleen edellä käytettu funktioviittaus. Sama tulos saataisiin lambda-funktiolla
s -> s.length()
.
Collectors.groupingBy(classifier, collector2)
: ryhmittelee virran alkiot parametrina saadun funktionclassifier
mukaisesti muuten samaan tapaan kuin edellä, mutta sanakirjaan tallennetaan ryhmän sijaan parametrincollector2
kyseisestä ryhmästä tuottama tulos.Parametrin
collector2
tulee täyttää funktionaalinen rajapintaCollector
. Eli tässä tehdään hierarkkisesti kaksi sisäkkäistäCollector
-operaatiota: ulompi on tämängroupingBy
:n suorittama ryhmitys, ja sisempi on parametrina saaduncollector2
:n suorittama operaatio (joka voi sinänsä olla millainenCollector
tahansa).Jos virta
s
lukisi esimerkiksi taulukkoa{"one", "two", "three", "four"}
, palauttaisis.collect(Collectors.groupingBy(String::length, Collectors.counting()))
sanakirjan, jossa on virran eri pituisten merkkijonojen lukumäärät pituuksia vastaavien avainten alla. Eli tässä avaimen 3 alla arvo 2, avaimen 4 alla 1 ja avaimen 5 alla 1.
Collectors.reducing(accumulator)
: suorittaa virran alkioillereduce
-operaation käyttäen funktiotaaccumulator`. Tulos kääritään ``Optional
-olioon, joka on tyhjä jos ja vain jos virta on tyhjä.Optional<T>
on javan geneerinen luokka, joka on tarkoitettu kuvaamaan arvoja, joita ei välttämättä ole olemassa. Luokalla on esimerkiksi jäsenfunktiotboolean isEmpty()
jaboolean isPresent()
sen tutkimiseen, onko kyseinenOptional
-olio tyhjä tai onko siinä arvo, ja funktioT get()
, joka palauttaa olion arvon (jos se on olemassa).
Lukuvirroilla on pitkälti edellämainittuja vastaavat operaatiot ja lisäksi esimerkiksi numeeriset
operaatiot min()
, max()
ja sum()
, jotka laskevat virran lukujen minimin, maksimin
ja summan, sekä summaryStatistics()
, joka laskee yhdellä kertaa minimin, maksimin, summan sekä
keskiarvon.
Tässä esiteltiin vain pieni osa Javan luokkakirjaston virtaoperaatioista ja apuvälineistä.
Tarkempaa ja kattavampaa tietoa saa esimerkiksi tutustumalla Javan luokkakirjaston pakkauksen
java.util.stream
dokumentaatioon.
Alla on vielä yhtenä esimerkkinä ohjelma, joka käsittelee tiedoston sisältöä virtojen avulla.
Ohjelma olettaa, että luettava tiedosto transactions.csv
koostuu muotoa
“bankAccount;transferAmount
” olevista riveistä, missä bankAccount
on tilinumeron kuvaava
merkkijono ja transferAmount
tilitapahtuman summaa kuvaava luku; negatiivinen arvo kuvaa
tililtä lähtevää maksua ja positiivinen arvo tilille saapuvaa summaa. Ohjelma luo tietojen pohjalta
Map
-säiliön, jonka avaimet ovet tilinumeroita ja arvot niitä vastaavia tilejä kuvaavia
Optional<Account>
-olioita. Account
-oliot esittävät tilin tilinumeron sekä saldon (tiliin
kohdistuneiden tilitapahtumien summan). Luokkien tulisi olla eri tiedostoissa.
public class Account {
private String number;
private double balance;
public Account(String number, double balance) {
this.number = number;
this.balance = balance;
}
public String getNumber() {
return number;
}
public double getBalance() {
return balance;
}
public void addAmount(double amount) {
this.balance += amount;
}
@Override
public String toString() {
return String.format("%s: %.1f", number, balance);
}
}
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
public class ReadAccounts {
public static void main(String[] args)
throws IOException {
try(var br = new BufferedReader(new FileReader("transactions.csv"))) {
Map<String, Optional<Account>> accs = br.lines() // Luetaan tiedoston rivejä virtana.
.map(line -> line.split(";")) // Paloitellaan rivi.
.map(acc -> new Account(acc[0], Double.parseDouble(acc[1]))) // Rivin osat -> Account-olio.
.collect(Collectors.groupingBy(Account::getNumber, // Tilinumerolla ryhmittely.
Collectors.reducing((a, b) -> {
a.addAmount(b.getBalance()); // Summataan tilitapahtumat.
return a;
})
)
);
System.out.println(accs);
}
}
Jos syötetiedoston transactions.csv
sisältö olisi
46262;7200
26736;2500
78291;3900
46262;-1825.4
26736;-50.9
26736;-220.5
78291;-31.9
46262;-125
78291;-180.3
46262;-449.1
26736;115
78291;-1390
46262;-899
78291;-49.9
46262;25
edellinen ohjelma tulostaisi jokseenkin seuraavaa:
{26736=Optional[26736: 2343.6], 46262=Optional[46262: 3926.5], 78291=Optional[78291: 2247.9]}
Ohjelmointidemo (kesto 1:32:45)