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 tapaan Arrays.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 tapaan cont.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 tuloksena Stream<Integer> eikä IntStream.

  • BufferedReader-oliosta br (joka on alustettu lukemaan jotain syötevirtaa, kuten tiedostoa) rivejä lukevan virran voi luoda tapaan br.lines(). Virran tyyppi on Stream<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 funktio predicate palauttaa arvon true. Eli tämä karsii virrasta pois kaikki ne alkiot, jotka eivät täytä parametrin predicate määrittämää kriteeriä.

    • Parametrin predicate tulee täyttää funktionaalinen rajapinta Predicate, jonka funktio test 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 alkio t on korvattu funktiokutsun function.apply(t) tuloksella. Eli tämä muuntaa virran kaikki alkiot parametrina annetulla muunnosfunktiolla.

    • Parametrin function tulee täyttää funktionaalinen rajapinta Function.

  • mapToInt(function), mapToLong(function) ja mapToDouble(function): toimivat muuten kuin map, mutta tuottavat uuden virran, jonka tyyppi on IntStream, LongStream tai DoubleStream. Tyyppi määräytyy luonnollisesti asianomaisen map-funktion nimessä mainitun tyypin mukaisesti, ja muunnosfunktion function 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() ja sorted(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 rajapinnan Comparator 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 funktiokutsun consumer.accept(t) tämän virran jokaiselle alkiolle t. 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 rajapinta Consumer.

  • Object[] toArray(): Palauttaa tämän virran alkiot sisältävän taulukon.

    • Lukuvirtojenn vastaavat funktiot palauttavat kyseisen lukuvirran alkioiden tyyppisen taulukon. Esimerkiksi IntStream-virran toArray() palauttaa int-taulukon.

  • T reduce(T identity, accumulator): laskee tämän virran alkioiden pohjalta eräässä mielessä kumulatiivisen lopputuloksen.

    • Parametrin accumulator tulee täyttää funktionaalinen rajapinta BinaryOperator.

    • 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 alkion t kohdalla tapaan result = accumulator.apply(result, t).

      • Esimerkiksi tyyppiä Stream<Integer> olevan virran s lukujen summan voisi laskea kutsulla s.reduce(0, (a, b) -> a + b) tai vaihtoehtoisesti funktioviittausta käyttäen kutsulla s.reduce(0, Integer::sum).

        • Esimerkiksi lukujen 7, 2, 6 summa muodostuisi askeleittain tapaan result = 0, result = 0 + 7 = 7, result = 7 + 2 = 9 ja result = 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 rajapinta Supplier<R>. Lopputulos alustetaan tämän avulla tapaan R result = supplier.get().

    • Parametrin accumulator tulee täyttää funktionaalinen rajapinta BiConsumer<R,? super T>. Virran alkiot käydään läpi päivittäen tulos kunkin alkion t kohdalla tapaan accumulator.accept(result, element).

    • Parametrin combiner tulee täyttää funktionaalinen rajapinta BiConsumer<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 virran s alkiot voisi kerätä ArrayList-listaan kutsulla s.collect(() -> new ArrayList<>(), (r, t) -> r.add(t), (r1, r2) -> r1.addAll(r2)) tai vaihtoehtoisesti funktioviittauksia käyttäen kutsulla s.collect(ArrayList::new, ArrayList::add, ArrayList::addAll).

      • Huomaa, että minkä tahansa luokan olion luovaan new operaatioon voi viitata tapaan className::new.

  • R collect(collector): kerää tämän virran alkiot. Toimii sinänsä kuin ylempi collect, mutta parametrina saatava collector toteuttaa yhtäaikaisesti kaikkien yllä erillisinä saatujen kolmen eri parametrin operaatiot.

    • Parametrin collector tulee täyttää funktionaalinen rajapinta Collector. Emme ole esitelleet sitä aiemmin (emmekä esitä sen yksityiskohtia tässäkään tarkemmin). Todettakoon vain, että Javan luokka Collectors tarjoaa joukon staattisia jäsenfunktioita, jotka luovat erilaisia valmiita Collector-rajapinnan toteuttavia olioita, joita voi hyödyntää collect-funktion kanssa. Alla on kuvattu muutaman tällaisen jäsenfunktion osalta millaisen Collector-olion ne palauttavat.

      • Collectors.toList(): kerää alkiot listaan.

        • Esimerkiksi tyyppiä Stream<T> olevan virran s alkiot voisi kerätä List<T>-listaan kutsulla s.collect(Collectors.toList()). Lopputuloksen tyyppi on jokin rajapinnan List<T> täyttävä lista.

      • Collectors.counting(): palauttaa virran alkioiden lukumäärän.

        • Jos virta s lukisi esimerkiksi taulukkoa {4, 7, 6, 3, 8}, palauttaisi s.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) ja Collectors.averagingDouble(mapper): palauttavat virran alkioiden keskiarvon Double-arvona. Parametrin mapper 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}, palauttaisi s.collect(Collectors.averagingInt(i -> i)) arvon 5.6.

      • Collectors.summingInt(mapper), Collectors.summingLong(mapper) ja Collectors.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}, palauttaisi s.collect(Collectors.summingInt(i -> i)) arvon 28.

      • Collectors.joining(delimiter): palauttaa String-merkkijonon, jossa on virran alkiot liitettynä yhteen parametriksi saadulla merkkijonolla delimiter eroteltuina.

        • Jos virta s lukisi esimerkiksi taulukkoa {"one", "two", "three"}, palauttaisi s.collect(Collectors.joining("-")) merkkijonon “one-two-three”.

      • Collectors.groupingBy(classifier): ryhmittelee virran alkiot sanakirja-säiliöön parametrina saadun funktion classifier määrittämien avainten alaisuuteen. Virran kullekin alkiolle t määritetään avain tapaan key = classifier(t), ja alkio t lisätään sanakirjassa kyseisen avaimen key alaisuuteen tallennettuun listaan.

        • Parametrin classifier tulee täyttää funktionaalinen rajapinta Function.

        • Lopputuloksen tyyppi on jokin rajapinnan Map toteuttava sanakirja, jonka arvot ovat jonkintyyppisiä rajapinnan List toteuttavia listoja.

        • Jos virta s lukisi esimerkiksi taulukkoa {"one", "two", "three", "four"}, ryhmittelisi s.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 funktion classifier mukaisesti muuten samaan tapaan kuin edellä, mutta sanakirjaan tallennetaan ryhmän sijaan parametrin collector2 kyseisestä ryhmästä tuottama tulos.

        • Parametrin collector2 tulee täyttää funktionaalinen rajapinta Collector. Eli tässä tehdään hierarkkisesti kaksi sisäkkäistä Collector-operaatiota: ulompi on tämän groupingBy:n suorittama ryhmitys, ja sisempi on parametrina saadun collector2:n suorittama operaatio (joka voi sinänsä olla millainen Collector tahansa).

        • Jos virta s lukisi esimerkiksi taulukkoa {"one", "two", "three", "four"}, palauttaisi s.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 alkioille reduce-operaation käyttäen funktiota accumulator`. 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äsenfunktiot boolean isEmpty() ja boolean isPresent() sen tutkimiseen, onko kyseinen Optional-olio tyhjä tai onko siinä arvo, ja funktio T 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)

Funktionaalisen ohjelmoinnin paradigman piirteisiin kuuluu

Palautusta lähetetään...