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
arralkioita lukevan streamin voi luoda tapaanArrays.stream(arr).Luodun streamin tyyppi riippuu taulukon alkioiden tyypistä luonnolliseen tapaan:
inttaiInteger:IntStreamlongtaiLong:LongStreamdoubletaiDouble:doubleStreamJokin muu tyyppi
T:Stream<T>
Säiliön
contalkioita lukevan streamin voi luoda tapaancont.stream(). Luotu stream on aina tyyppiäStream<T>, missäTon 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 funktiopredicatepalauttaa arvontrue. Eli tämä karsii streamista pois kaikki ne alkiot, jotka eivät täytä parametrinpredicatemäärittämää kriteeriä.Parametrin
predicatetulee täyttää funktionaalinen rajapintaPredicate, jonka funktiotesttä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 alkioton korvattu funktiokutsunfunction.apply(t)tuloksella. Eli tämä muuntaa streamin kaikki alkiot parametrina annetulla muunnosfunktiolla.Parametrin
functiontulee täyttää funktionaalinen rajapintaFunction.
mapToInt(function),mapToLong(function)jamapToDouble(function): toimivat muuten kuinmap, mutta tuottavat uuden streamin, jonka tyyppi onIntStream,LongStreamtaiDoubleStream. Tyyppi määräytyy luonnollisesti asianomaisen map-funktion nimessä mainitun tyypin mukaisesti, ja muunnosfunktionfunctiontulee 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 rajapinnanComparatortoteuttavan 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
consumertulee 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
accumulatortulee täyttää funktionaalinen rajapintaBinaryOperator.Tulos alustetaan tapaan
T result = identityja sen jälkeen tämän streamin alkiot käydään läpi päivittäen tulos kunkin alkiontkohdalla tapaanresult = accumulator.apply(result, t).Esimerkiksi tyyppiä
Stream<Integer>olevan streaminslukujen 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
suppliertulee täyttää funktionaalinen rajapintaSupplier<R>. Lopputulos alustetaan tämän avulla tapaanR result = supplier.get().Parametrin
accumulatortulee täyttää funktionaalinen rajapintaBiConsumer<R,? super T>. Streamin alkiot käydään läpi päivittäen tulos kunkin alkiontkohdalla tapaanaccumulator.accept(result, element).Parametrin
combinertulee 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 streaminsalkiot 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
newoperaatioon voi viitata tapaanclassName::new.
R collect(collector): kerää tämän streamin alkiot. Toimii sinänsä kuin ylempicollect, mutta parametrina saatavacollectortoteuttaa yhtäaikaisesti kaikkien yllä erillisinä saatujen kolmen eri parametrin operaatiot.Parametrin
collectortulee täyttää funktionaalinen rajapintaCollector. Emme ole esitelleet sitä aiemmin (emmekä esitä sen yksityiskohtia tässäkään tarkemmin). Todettakoon vain, että Javan luokkaCollectorstarjoaa 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 streaminsalkiot 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
slukisi taulukkoa{4, 7, 6, 3, 8}, palauttaisis.collect(Collectors.counting())arvon 5.Huomaa, että
collectvoi 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. Parametrinmappertulee 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
slukisi 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
slukisi 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 merkkijonolladelimitereroteltuina.Esim. jos stream
slukisi taulukkoa{"one", "two", "three"}, palauttaisis.collect(Collectors.joining("-"))merkkijonon “one-two-three”.
Collectors.groupingBy(classifier): ryhmittelee streamin alkiot sanakirja-säiliöön parametrina saadun funktionclassifiermäärittämien avainten alaisuuteen. Streamin kullekin alkiolletmääritetään avain tapaankey = classifier(t), ja alkiotlisätään sanakirjassa kyseisen avaimenkeyalaisuuteen tallennettuun listaan.Parametrin
classifiertulee täyttää funktionaalinen rajapintaFunction.Lopputuloksen tyyppi on jokin rajapinnan
Maptoteuttava sanakirja, jonka arvot ovat jonkintyyppisiä rajapinnanListtoteuttavia listoja.Esim. jos stream
slukisi 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 funktionclassifiermukaisesti muuten samaan tapaan kuin edellä, mutta sanakirjaan tallennetaan ryhmän sijaan parametrincollector2kyseisestä ryhmästä tuottama tulos.Parametrin
collector2tulee 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 millainenCollectortahansa).Esim. jos stream
slukisi 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]}