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 tapaan Arrays.stream(arr).

    • Luodun streamin tyyppi riippuu taulukon alkioiden tyypistä luonnolliseen tapaan:

      • int tai Integer: IntStream

      • long tai Long: LongStream

      • double tai Double: doubleStream

      • Jokin muu tyyppi T: Stream<T>

  • Säiliön cont alkioita lukevan streamin voi luoda tapaan cont.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 tuloksena Stream<Integer> eikä IntStream.

  • BufferedReader-oliosta br (joka on alustettu lukemaan jotain syötevirtaa, kuten tiedostoa) rivejä lukevan streamin voi luoda tapaan br.lines(). Streamin tyyppi on Stream<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 funktio predicate palauttaa arvon true. Eli tämä karsii streamista 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 streamin, jossa kukin tämän streamin alkio t on korvattu funktiokutsun function.apply(t) tuloksella. Eli tämä muuntaa streamin 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 streamin, 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 streamin tyyppi yleisestä streamista lukustreamiksi. Tällainen muunnos on tarpeen, jos haluamme käyttää lukustreamien tarjoamia numeerisia funktioita.

  • sorted() ja sorted(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 rajapinnan Comparator 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 funktiokutsun consumer.accept(t) tämän streamin 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 streamin alkiot sisältävän taulukon.

    • Lukustreamien vastaavat funktiot palauttavat kyseisen lukustreamin alkioden tyyppisen taulukon. Esim. IntStream-streamin toArray() palauttaa int-taulukon.

  • T reduce(T identity, accumulator): laskee tämän streamin 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 streamin 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 streamin 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).

        • Esim. 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 streamin 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>. Streamin 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 streamia esim. prosessoidaan rinnakkain).

    • Esimerkiksi jotain tyyppiä Stream<T> olevan streamin 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 streamin 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 streamin 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 streamin alkioiden lukumäärän.

        • Esim. jos stream s lukisi 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 streamin 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ä).

        • Esim. jos stream s lukisi 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 streamin alkioiden summan, ja tuloksen tyyppi vastaa funktion nimen ilmaisemaa lukutyyppiä.

        • Esim. jos stream s lukisi taulukkoa {4, 7, 6, 3, 8}, palauttaisi s.collect(Collectors.summingInt(i -> i)) arvon 28.

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

        • Esim. jos stream s lukisi taulukkoa {"one", "two", "three"}, palauttaisi s.collect(Collectors.joining("-")) merkkijonon “one-two-three”.

      • Collectors.groupingBy(classifier): ryhmittelee streamin alkiot sanakirja-säiliöön parametrina saadun funktion classifier määrittämien avainten alaisuuteen. Streamin 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.

        • Esim. jos stream s lukisi 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 streamin 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).

        • Esim. jos stream s lukisi taulukkoa {"one", "two", "three", "four"}, palauttaisi s.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 alkioille reduce-operaation käyttäen funktiota accumulator`. 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ä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).

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)

Funktionaalisen ohjelmoinnin paradigman piirteisiin kuuluu