- 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 osana yleisemmin ottaen ns. ns. 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 ns.
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 (esim. 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 “stream” tarkoittamaan Javan
Stream
-rajapintaa tai (ehkä pääsääntöisesti) sellaisen konkreettista ilmentymää.
Javan streamit ovat tyyppejä, jotka tarjoavat melko monipuolisen joukon operaatioita streamille
välitetyn alkiojoukon käsittelyyn. Javan luokkakirjastossa on tarkemmin ottaen neljä erilaista
stream-tyyppiä: Stream<T>
, IntStream
, LongStream
ja DoubleStream
. Näiden keskeisin
ero on käsiteltävien alkioiden tyyppi. Geneerinen stream 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 streamit tarjoavat operaatioita
myös alkioiden numeeriseen käsittelyyn, kuten esim. niiden summan laskemiseen. Yleiskäyttöinen
Stream<T>
ei tarjoa tällaisia operaatioita, koska laskutoimituksia ei voi soveltaa kuin
lukutyyppeihin.
Stream itsessään ei sinänsä talleta käsiteltäviä alkioita (stream ei ole säiliö) vaan lukee niitä streamin luonnin yhteydessä määritetystä lähteestä. Jos tekstissä puhutaan streamin alkioista, sillä tarkoitetaan streamin lukemia alkioita. Javan luokkakirjasto tarjoaa esimerkiksi seuraavat valmiit funktiot streamin luontiin:
Taulukon
arr
alkioita lukevan streamin voi luoda tapaanArrays.stream(arr)
.Luodun streamin tyyppi riippuu taulukon alkioiden tyypistä luonnolliseen tapaan:
int
taiInteger
:IntStream
long
taiLong
:LongStream
double
taiDouble
:doubleStream
Jokin muu tyyppi
T
:Stream<T>
Säiliön
cont
alkioita lukevan streamin voi luoda tapaancont.stream()
. Luotu stream on aina tyyppiäStream<T>
, missäT
on säiliön alkioiden tyyppi.Esim. vaikka säiliön alkioiden tyyppi olisi
Integer
, on tuloksenaStream<Integer>
eikäIntStream
.
BufferedReader
-oliostabr
(joka on alustettu lukemaan jotain syötevirtaa, kuten tiedostoa) rivejä lukevan streamin voi luoda tapaanbr.lines()
. Streamin tyyppi onStream<String>
, koska luettavat alkiot ovat rivejä eli merkkijonoja.
Kun stream on luotu, voi siihen (eli sen lukemaan alkiojoukkoon) kohdistaa kahdenlaisia stream-operaatioita: ns. välioperaatioita (intermediate operations) tai loppuoperaatioita (terminal operations). Välioperaatiolla tarkoitetaan operaatiota, jonka tuloksena on jälleen stream. Tämä mahdollistaa streamiin kohdistettavien operaatioiden ketjutuksen: välioperaation tulokseen voidaan välittömästi kohdistaa uusi stream-operaatio tarvitsematta alustaa erikseen uutta streamia. Loppuoperaatio tuottaa konkreettisen tuloksen (esim. arvon tai alkioita sisältävän säiliön) ja sen jälkeen kyseinen stream-operaatioiden ketju päättyy. Jos loppuoperaation tulokseen haluaa soveltaa vielä lisää stream-operaatioita, pitää luoda uusi tulosta lukeva stream.
Operaatiot on toteutettu streamin jäsenfunktioina eli jos olisi luotu stream s
, suoritettaisiin
esim. ensimmäinen alempana mainittu operaatio tapaan s.distinct()
. Operaatioita voi suorittaa
peräkkäin esim. ketjuttamalla jäsenfunktiokutsuja: esim. s.distinct().sorted()
kohdistaisi
streamiin s
operaation distinct
ja sen tulokseen operaation sorted
.
Alla on esitelty muutamia yleisen streamin 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 streamin, jossa on tämän streamin yksikäsitteiset alkiot (eli tämä operaatio poistaa duplikaatit).filter(predicate)
: tuottaa uuden streamin, jossa on vain ne tämän streamin alkiot, joille parametrina annettu funktiopredicate
palauttaa arvontrue
. Eli tämä karsii streamista 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 streamin, jossa kukin tämän streamin alkiot
on korvattu funktiokutsunfunction.apply(t)
tuloksella. Eli tämä muuntaa streamin kaikki alkiot parametrina annetulla muunnosfunktiolla.Parametrin
function
tulee täyttää funktionaalinen rajapintaFunction
.
mapToInt(function)
,mapToLong(function)
jamapToDouble(function)
: toimivat muuten kuinmap
, mutta tuottavat uuden streamin, 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 streamin tyyppi yleisestä streamista lukustreamiksi. Tällainen muunnos on tarpeen, jos haluamme käyttää lukustreamien tarjoamia numeerisia funktioita.
sorted()
jasorted(comparator)
: tuottavat uuden streamin, jossa on tämän streamin alkiot lajitellussa järjestyksessä. Ensimmäinen käyttää alkioiden luonnollista järjestystä ja jälkimmäinen ottaa parametrina rajapinnanComparator
toteuttavan vertailuolion.
Kuten aiemmin todettiin, streamit suorittavat operaationsa laiskasti. Streameja koskevia operaatioita aletaan suorittaa vasta, kun streamiin kohdistetaan loppuoperaatio (joka palauttaa konkreettisen tuloksen, tai ei mitään, eikä enää uutta streamia). Näin ollen streamia 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 streamin loppuoperaatioita:
void forEach(consumer)
: Suorittaa funktiokutsunconsumer.accept(t)
tämän streamin 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 streamin alkiot sisältävän taulukon.Lukustreamien vastaavat funktiot palauttavat kyseisen lukustreamin alkioden tyyppisen taulukon. Esim.
IntStream
-streamintoArray()
palauttaaint
-taulukon.
T reduce(T identity, accumulator)
: laskee tämän streamin 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 streamin alkiot käydään läpi päivittäen tulos kunkin alkiont
kohdalla tapaanresult = accumulator.apply(result, t)
.Esimerkiksi tyyppiä
Stream<Integer>
olevan streamins
lukujen summan voisi laskea kutsullas.reduce(0, (a, b) -> a + b)
tai vaihtoehtoisesti funktioviittausta käyttäen kutsullas.reduce(0, Integer::sum)
.Esim. 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 streamin 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>
. Streamin 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 streamia esim. prosessoidaan rinnakkain).Esimerkiksi jotain tyyppiä
Stream<T>
olevan streamins
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 streamin 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 streamins
alkiot voisi kerätäList<T>
-listaan kutsullas.collect(Collectors.toList())
. Lopputuloksen tyyppi on jokin rajapinnanList<T>
täyttävä lista.
Collectors.counting()
: palauttaa streamin alkioiden lukumäärän.Esim. jos stream
s
lukisi 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 streamin 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ä).Esim. jos stream
s
lukisi 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 streamin alkioiden summan, ja tuloksen tyyppi vastaa funktion nimen ilmaisemaa lukutyyppiä.Esim. jos stream
s
lukisi taulukkoa{4, 7, 6, 3, 8}
, palauttaisis.collect(Collectors.summingInt(i -> i))
arvon 28.
Collectors.joining(delimiter)
: palauttaaString
-merkkijonon, jossa on streamin alkiot liitettynä yhteen parametriksi saadulla merkkijonolladelimiter
eroteltuina.Esim. jos stream
s
lukisi taulukkoa{"one", "two", "three"}
, palauttaisis.collect(Collectors.joining("-"))
merkkijonon “one-two-three”.
Collectors.groupingBy(classifier)
: ryhmittelee streamin alkiot sanakirja-säiliöön parametrina saadun funktionclassifier
määrittämien avainten alaisuuteen. Streamin 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.Esim. jos stream
s
lukisi 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 streamin 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).Esim. jos stream
s
lukisi taulukkoa{"one", "two", "three", "four"}
, palauttaisis.collect(Collectors.groupingBy(String::length, Collectors.counting()))
sanakirjan, jossa on streamin 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 streamin alkioillereduce
-operaation käyttäen funktiotaaccumulator`. Tulos kääritään ``Optional
-olioon, joka on tyhjä jos ja vain jos stream 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).
Lukustreameilla on pitkälti edellämainittuja vastaavat operaatiot ja lisäksi esimerkiksi numeeriset
operaatiot min()
, max()
ja sum()
, jotka laskevat streamin lukujen minimin, maksimin
ja summan, sekä summaryStatistics()
, joka laskee yhdellä kertaa minimin, maksimin, summan sekä
keskiarvon.
Tässä esiteltiin vain pieni osa Javan luokkakirjaston streamien operaatioista ja apuvälineistä.
Tarkempaa ja kattavampaa tietoa saa esim. tutustumalla Javan luokkakirjaston pakkauksen
java.util.stream
dokumentaatioon.
Alla on vielä yhtenä esimerkkinä ohjelma, joka käsittelee tiedoston sisältöä streamien 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ä streamina.
.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)