Skip to content

Latest commit

 

History

History
457 lines (324 loc) · 19.7 KB

luento10.md

File metadata and controls

457 lines (324 loc) · 19.7 KB

Pelaajastatistiikkaa Java 8:lla

Muokataan hieman [viikon 2 laskareissa] (https://github.com/mluukkai/ohtu2014/tree/master/viikko2) työn alla ollutta NHL-pelaajastatistiikka-ohjelmaa.

forEach

Pystymme tulostamaan 10 parasta pistemiestä metodin public List topScorers(int howMany) avulla seuraavasti:

public static void main(String[] args) {
    Statistics stats = new Statistics();
    
    for (Player player : stats.topScorers(10)) {
        System.out.println(player);
    }
}

Java 8:ssa kaikille rajapinnan Interable toteuttaville olioille kuten kokoelmille on lisätty metodi forEach, jonka avulla kokoelma on helppo käydä läpi. Metodille voidaan antaa parametriksi lambda-lauseke jota metodi kutsuu jokaiselle kokoelman alkiolle:

    Statistics stats = new Statistics();

    stats.topScorers(10).forEach(s->{
        System.out.println(s);
    });

Nyt parametrina on lambda-lauseke s->{ System.out.println(s); }. Lausekkeen parametrina on nuolen vasemmalla puolella oleva s. Nuolen oikealla puolella on lausekkeen koodilohko, joka tulostaa parametrina olevan pelaajan. Metodi forEach siis kutsuu jokaiselle kokoelman pelaajalle lambda-lauseketta.

Lambda-lauseke olisi voitu kirjoittaa myös kokonaan yhdelle riville. Tällöin koodilohkoa ei ole välttämätöntä laittaa aaltosulkeisiin:

    stats.topScorers(3).forEach( s->System.out.println(s) );

Teknisesti ottaen metodi forEach saa parametrikseen rajapinnan Consumer<T> toteuttavan olion. Consumer on käytännössä luokka, joka toteuttaa metodin void accept(T param). Consumer-rajapinnan toteuttavia olioita on helppo generoida edellä olevan esimerkin tapaan lambda-lausekkeiden avulla.

Java täydentääkin edellä määritellyn lambda-lausekkeen anonyymiksi sisäluokaksi:

    stats.topScorers(10).forEach(new Consumer<Player>() {
        @Override
        public void accept(Player s) {
            System.out.println(s);
        }
    });

Java 8:ssa on mahdollista viitata myös luokkien yksittäisiin metodeihin. Metodi joka ottaa parametrikseen merkkijonon ja ei palauta mitään onkin tyyppiä Consumer<String>

Voimmekin antaa metodille forEach parametriksi viittauksen metodiin. Java 8:ssa metodeihin viitataan syntaksilla Luokka::metodi.

Voimmekin muuttaa parhaiden pistemiesten tulostuksen seuraavaan muotoon

    stats.topScorers(10).forEach(System.out::println);

Nyt forEach kutsuu metodia System.out.println jokaiselle pelaajalle antaen vuorossa olevan pelaajan metodin parametriksi.

Tulostuskomento System.out.println on hieman ikävä kirjoittaa kokonaisuudessaan. Importataan System.out staattisesti jolloin pystymme viittaamaan olioon System.out suoraan kirjoittamalla koodiin out:

import static java.lang.System.out;

public class Main {

    public static void main(String[] args) {
        Statistics stats = new Statistics();

        stats.topScorers(3).forEach(out::println);
    }
    
}

Staattisen importtauksen jälkeen voimme siis tulostaa ruudulle helpommin, kirjoittamalla out.println("tekstiä").

filter

Luokan Statistics metodit toimivat hyvin samaan tyyliin, ne käyvät läpi pelaajien listan ja palauttavat joko yksittäisen tai useampia pelaajia metodin määrittelemästä kriteeristä riippuen. Jos lisäisimme luokalle samalla periaatteella muita hakutoiminnallisuuksia (esim. kaikkien yli 10 maalia tehneiden pelaajien lista), joutuisimme "copypasteamaan" pelaajat läpikäyvää koodia vielä useampiin metodeihin.

Parempi ratkaisu olisikin ohjelmoida luokalle geneerinen etsintämetodi, joka saa hakukriteerin parametrina. Edelliseltä viikolta tutut Java 8:n oliovirrat eli streamit tarjoavat sopivan välineen erilaisten hakujen toteuttamiseen. Streamien API-kuvaus.

Muutetaan ensin metodi List<Player> team(String teamName) käyttämään stream-apia:

    public List<Player> team(String teamName) {
        return players
                .stream()
                .filter(p->p.getTeam().contains(teamName))
                .collect(Collectors.toList());
    }

Sensijaan että pelaajien lista käytäisiin eksplisiittisesti läpi käsitelläänkin metodilla stream listasta saatavaa oliovirtaa. Virrasta filtteröidään ne jotka toteuttavat lambda-lausekkeella määritellyn ehdon. Tuloksena olevasta filtteröidystä streamista sitten "kerätään" metodin collect avulla oliot palautettavaksi listaksi.

Metodi filter saa parametrikseen rajapinnan Predicate<T> toteuttavan olion. Päätämmekin poistaa metodin team ja sensijaan lisätä luokalle geneerisemmän hakumetodin find joka saa etsintäehdon parametriksi:

    public List<Player> find(Predicate<Player> condition) {
        return players
                .stream()
                .filter(condition)
                .collect(Collectors.toList());
    }

Yleistetyn metodin avulla on nyt helppo tehdä mielivaltaisen monimutkaisia hakuja. Hakuehdon muodostaminen lambda-lausekkeiden avulla on suhteellisen helppoa:

    public static void main(String[] args) {
        Statistics stats = new Statistics();
        
        // tulostetaan vähintään 21 maalia ja syöttöä tehneet pelaajat
        stats.find(p->p.getGoals()>20 && p.getAssists()>20).forEach(out::println);
    }        

Java 8:ssa rajapinnoilla voi olla oletustoteutuksen omaavia metodeja. Rajapinnalla Predicate löytyykin mukavasti valmiiksi toteutetut metodit and, or ja negate. Näiden avulla on helppo muodostaa yksittäisten esim. lambda-lausekkeen avulla muodostettujen ehtojen avulla mielivaltaisen monimutkaisia ehtoja. Seuraavassa edellisen esimerkin tuloksen tuottava haku de Morganin lakia hyväksikäyttäen muodostettuna:

    Statistics stats = new Statistics();
    
    Predicate<Player> cond1 = p->p.getGoals()<=20;
    Predicate<Player> cond2 = p->p.getAssists()<=20;
    Predicate<Player> cond = cond1.or(cond2);
            
    stats.find(cond.negate()).forEach(out::println);

Eli ensin muodostettiin ehto "korkeintaan 20 maalia tai syöttöä tehneet pelaajat." Tästä otettiin sitten negaatio jolloin tuloksena on de morganin sääntöjen nojalla ehto "vähintään 21 maalia ja 21 syöttöä tehneet pelaajat".

järjestäminen

Metodin topScorers(int howMany) avulla on mahdollista tulostaa haluttu määrä pelaajia tehtyjen pisteiden mukaan järjestettynä. Metodin toteutus on hieman ikävä, sillä palautettavat pelaajat on kerättävä yksi kerrallaan erilliselle listalle:

    public List<Player> topScorers(int howMany) {
        Collections.sort(players);
        ArrayList<Player> topScorers = new ArrayList<>();
        Iterator<Player> playerIterator = players.iterator();
        
        while (howMany>=0) {
            topScorers.add( playerIterator.next() );            
            howMany--;
        }
        
        return topScorers;
    }

Metodista on helppo tehtä Java 8:a hyödyntävä versio:

    public List<Player> topScorers(int howMany) {
        return players
                .stream()
                .sorted()
                .limit(howMany)
                .collect(Collectors.toList());
    }

Eli otamme jälleen pelaajista muodostuvat streamin. Stream muutetaan luonnollisen järjestyksen (eli luokan Player metodin compareTo määrittelemän järjestyksen) mukaisesti järjestetyksi streamiksi metodilla sorted. Medodilla limit rajoitetaan streamin koko haluttuun määrään pelaajia, ja näistä muodostettu lista palautetaan.

Jotta myös muunlaiset järjestykset olisivat mahdollisia, generalisoidaan metodi muotoon, joka ottaa parametriksi halutun järjestyksen määrittelevän Comparator<Player>-rajapinnan määrittelevän olion:

    public List<Player> sorted(Comparator<Player> compare, int number) {
        return players
                .stream()
                .sorted(compare)
                .limit(number)
                .collect(Collectors.toList());
    }

Metodin tarvitsema vertailijaolio on helppo luoda lambda-lausekkeena:

    Comparator<Player> byPoints = (p1, p2)->p2.getPoints()-p1.getPoints();
    
    System.out.println("sorted by points");
    stats.sorted(byPoints, 10).forEach(out::println);

Comparator-olioiden luominen on hieman ikävää, varsinkin jos joutuisimme luomaan useiden eri kriteerien avulla tapahtuvia vertailijoita:

    Comparator<Player> byPoints = (p1, p2)->p2.getPoints()-p1.getPoints();
    Comparator<Player> byGoals = (p1, p2)->p2.getGoals()-p1.getGoals();
    Comparator<Player> byAssists = (p1, p2)->p2.getAssists()-p1.getAssists();
    Comparator<Player> byPenalties = (p1, p2)->p2.getPenalties()-p1.getPenalties();

Koodi sisältää ikävästi copy-pastea.

Voimme siistiä koodia Comparatoreja rakentavan tehdasmetodin avulla. Periaatteena on, että tehtaalle annetaan viitteenä metodi, jonka perusteella Player-olioiden vertailu tehdään. Esim. pisteiden perusteella tapahtuvan vertailun tekevä vertailija luotaisiin seuraavasti:

    Comparator<Player> byPoints = by(Player::getPoints);

Tehdasmetodin nimi on siis by.

Koska Player-olioiden getterimetodit ovat parametrittomia ja palauttavat kokonaislukuarvon, ne toteuttavat rajapinnan Function<Player, Integer>. Tehtaan koodi on seuraavassa:

    public static Comparator<Player> by(Function<Player, Integer> getter){
        return (p1, p2)->getter.apply(p2)-getter.apply(p1);
    }

Tehtaan parametrina saaman getterimetodin kutsumistapa on hiukan erikoinen, esim. getter.apply(p1) siis tarkoittaa samaa kuin p1.getPoints() jos tehdasta on kutsuttu ylläolevalla tavalla eli antamalla parametriksi Player::getPoints.

Järjestäminen esim. maalien perusteella onnistuu nyt tehtaan avulla seuraavasti:

    Comparator<Player> byGoals = by(Player::getGoals);
    stats.sorted(byGoals, 10).forEach(out::println);

Vertailijaa ei ole oikeastaan edes tarvetta tallettaa muuttujaan, sillä tehdasmetodi on nimetty siten, että sen kutsuminen on sujuvaa suoraan sorted-metodin parametrina:

    stats.sorted(by(Player::getGoals), 10).forEach(out::println);

Comparator-rajapinnalle on lisätty pari kätevää oletustoteutuksen omaavaa metodia thenComparing ja reversed. Ensimmäinen näistä mahdollistaa toissijaisen järjestämiskriteerin määrittelemisen erillisen vertailijan avulla. Eli jos ensin sovellettu vertailija ei erottele järjestettäviä oliota, sovelletaan niihin toissijaista vertailijaa. Metodi reversed toimii kuten nimi antaa olettaa, eli se muodostaa vertailijasta käänteisesti toimivan vertailijan.

Seuraavassa pelaajat listattuna ensisijaisesti tehtyjen maalien ja toissijaisesti syöttöjen "vähyyden" perusteella:

    Comparator<Player> order = by(Player::getPoints)
                               .thenComparing(by(Player::getAssists).reversed());

    stats.sorted(order, 20).forEach(out::println);

numeerinen statistiikka

Haluaisimme laskea erilaisia numeerisia tilastoja pelaajista. Esim. yksittäisen joukkueen yhteenlasketun maalimäärän.

Ensimmäinen yrityksemme toteuttaa asia Java 8:lla on seuraava:

    int maalit = 0;
    stats.find(p->p.getTeam().equals("PHI")).forEach(p->{
        maalit += p.getGoals();
    });
    System.out.println(maalit);

Koodi ei kuitenkaan käänny. Syynä tälle on se, että lambda-lausekkeen sisältä ei pystytä muuttamaan metodin paikallista muuttujaa maalit. Asia korjaantuisi määrittelemällä muuttuja luokkatasolla. Emme kuitenkaan tee näin, vaan pyrimme hyödyntämään vielä radikaalimmalla tavalla Java 8:n tarjoamia uusia ominaisuuksia.

Talletetaan ensin käsiteltävien olioiden stream muuttujaan:

    Stream<Player> playerStream = stats.find(p->p.getTeam().equals("PHI")).stream();

Streameille on määritelty metodi map, jonka avulla voimme muodostaa streamista uuden streamin, jonka jokainen alkio on muodostettu alkuperäisen streamin alkiosta suorittamalla tälle metodin map parametrina määritelty metodi tai lambda-lauseke.

Saamme muutettua pelaajien streamin pelaajien maalimääristä koostuvaksi streamiksi seuraavasti:

   playerStream.map(p->p.getGoals())

eli uusi streami muodostetaan siten, että jokaiselle alkuperäisen streamin alkiolle suoritetaan lambda-lauseke p->p.getGoals().

Sama käyttäen metodireferenssejä olisi:

   playerStream.map(Player::getGoals)

Metodin map avulla muunnettu oliovirta voidaan tarvittaessa "kerätä" listaksi:

    List<Integer> maalit = playerStream.map(Player::getGoals).collect(Collectors.toList() );

Näin saisimme siis generoitua listan tietyn joukkueen pelaajien maalimääristä.

Streamille voidaan myös suorittaa metodi reduce, joka "laskee" streamin alkioiden perusteella määritellyn arvon. Määritellään seuraavassa maalien summan laskeva operaatio:

    int maalienSumma = playerStream.map(Player::getGoals).reduce((item1,item2)->item1+item2).get();

Metodi reduce saa parametrikseen lambda-lausekkeen joka saa ensimmäisellä kutsukerralla parametrikseen streamin 2 ensimmäistä alkiota. Nämä lasketaan yhteen ja toisella kutsukerralla lambda-lauseke saa parametrikseen streamin kolmannen alkion ja edellisen reducen laskeman summan. Näin reduce "kuljettaa" mukanaan streamin alkioiden summaa ja kasvataa sitä aina seuraavalla vastaantulevalla alkiolla. Kun koko stream on käyty läpi, palauttaa reduce kaikkien alkioiden summan, joka muutetaan kokonaislukutyyppiseksi metodin get avulla.

Builder revisited

Luennolla 9 toteutettiin monimutkaisen olion luomista helpottava rakentaja. Rakentajan toteutuksessa kiinnitettiin erityisesti huomiota rajapinnan sujuvuuteen:

Pinorakentaja rakenna = new Pinorakentaja();

Pino pino = rakenna.kryptattu().prepaid(10).pino();

Toteutustekniikkana käytetiin viimeaikoina yleistynyttä metodien ketjutusta.

Java 8 avaa mielenkiintoisen uuden mahdollisuuden rakentaja-suunnittelumallin toteuttamiseen.

Tarkastellaan ensin yksinkertaisempaa tapausta, NHL-tulospalveluohjelmasta löytyvää luokkaa Player. Tavoitteenamme on, että pelaajaolioita pystyttäisiin luomaan normaalin konstruktorikutsun sijaan seuraavaa syntaksia käyttäen:

    Player pekka = Player.create(p->{
        p.setName("Pekka");
        p.setTeam("Sapko");
        p.setGoals(10);
        p.setAssists(20);
    });

    Player arto = Player.create(p->{
        p.setName("Arto");
        p.setTeam("Blues");
        p.setPenalties(200);
    });

Nyt siis luotava olio "konfiguroidaan" antamalle pelaajan luovalle metodille create lambda-lausekkeena määriteltävä koodilohko, joka asettaa pelaajan kentille sopivat alkuarvot.

Metodin toteutus näyttää seuraavalta:

public class Player implements Comparable<Player> {

    String name;
    private String team;
    private int goals;
    private int assists;

    public Player() {
    }
    
    public static Player create(Consumer<Player> init){
        Player p = new Player();
        init.accept(p);
        return p;
    }

    // setterit ja getterit
}

Metodin parametrina on siis Consumer<Player>-tyyppinen olio. Käytännössä kyseessä on rajapinta, joka määrittelee että sen toteuttajalla on metodi void accept(Player p). Rajapinnan toteuttava olio on helppo luoda lambda-lausekkeen avulla. Käytännössä siis rakentajametodi toimii siten, että se luo ensin pelaaja-olion ja kutsuu sen jälkeen metodin parametrina olevaa lambda-lausekkeen avulla määriteltyä koodilohkoa antaen luodun pelaaja-olion parametriksi. Näin koodilohkoon määritellyt setterikutsut suoritetaan luodulle pelaajaoliolle. Rakentajametodi palauttaa lopuksi luodun ja määritellyllä tavalla "konfiguroidun" olion kutsujalle.

Eli käytännössä jos rakentajaa kutsutaan seuraavasti:

    Player pekka = Player.create(p->{
        p.setName("Pekka");
        p.setTeam("Sapko");
        p.setGoals(10);
        p.setAssists(20);
    });

rakentajan sisällä suoritettava toiminnallisuus vastaa seuraavaa:

    public static Player create(Consumer<Player> init){
        Player p = new Player();
        // komento init.accept(p);
        // saa aikaan seuraavat
        p.setName("Pekka");
        p.setTeam("Sapko");
        p.setGoals(10);
        p.setAssists(20);
        return p;
    }

Rakentajan pelaaja-oliolle suorittamat komennot voidaan siis antaa lambda-lausekkeen muodossa parametrina.

Näin saadaan erittäin joustava tapa luoda olioita, toisin kuin normaalia konstruktoria käytettäessä, riittää että oliolle kutsutaan ainoastaan niitä settereitä, joiden avulla tietty kenttä halutaan alustuvan, muut kentät saavat oletusarvoisen alkuarvon.

Koska pelaaja-olion muodostaminen on suhteellisen suoraviivaista, rakentajasta ei tällä kertaa oltu tehty erillistä luokkaa ja rakentaminen hoitui staattisen metodin create sekä olion settereiden avulla.

Dekoroitujen pinojen rakentaminen on hieman monimutkaisempi operaatio, joten rakentajasta kannattaa tehdä oma luokkansa.

Seuraavassa esimerkki siitä, miten pinoja on tarkoitus rakentaa:

    PinonRakentaja builder = new PinonRakentaja();
    
    Pino pino1 = builder.create(p->{
        p.kryptaava();
        p.prepaid(10);
        p.logaava(tiedostoon);
    });
       

    Pino pino2 = builder.create(p->{
        p.kryptaava();
    });    

Periaate on siis sama kuin pelaajaolioiden rakentamisessa. Pinolle liitettävät ominaisuudet määritellään lambda-lausekkeen avulla.

Pinolle ominaisuuksia dekoroivat metodit eivät tällä kertaa ole luokan Pino metodeja, vaan pinonrakentajan metodeja, joten rakentajan metodin create parametri onkin nyt tyypiltään pinonrakentaja:

public class PinonRakentaja {
    Pino pino;

    Pino create(Consumer<PinonRakentaja> init){
        pino = new Pino();
        init.accept(this);
        return pino;
    }
    
    void kryptaava() {
        this.pino = new KryptattuPino(pino);
    }  
    
    void prepaid(int crediitit) {
        this.pino = new PrepaidPino(pino, crediitit);
    }
    
    void logaava(PrintWriter loki) {
        this.pino = new LokiPino(pino, loki);
    } 
}

Eli kun kutsutaan esim:

    Pino pino1 = builder.create(p->{
        p.kryptaava();
        p.prepaid(10);
    });

rakentajan sisällä suoritettava toiminnallisuus vastaa seuraavaa:

    Pino create(Consumer<PinonRakentaja> init){
        pino = new Pino();
        // komento init.accept(this);
        // saa aikaan seuraavat:
        this.kryptaava();
        this.prepaid(10);
        return pino;
    }

Eli jälleen rakentajan suorittavat komennot annetaan lambda-lausekkeen muodossa parametrina. Rakentajametodien suoritusjärjestys on sama kuin komentojen järjestys lambda-lausekeessa.