MeetUp – Programowanie w Javie 8

Wpis dotyczy wydarzenia z 28 września 2017. Odświeżając wiedzę o nowych ficzerach w Jawie 8, 9, 10,…. trafiłem na prawie skończony wpis i postanowiłem go skończyć

Z definicji staram się uczestniczyć we wszystkich wydarzeniach mających słowo “Java” w tytule. Tym razem trafiłem na meetup z cyklu “TechTalk with Capgemini” pod obiecującym tytułem “Programowanie funkcyjne w JAVIE“. Spotkanie zdecydowanie spełniło swój cel. Prelegent Andrzej Listowiecki czarował nas kodowaniem na żywo i robił to interesująco i sprawnie, jakby wywołał magicznego bota, który generował kod adekwatnie do wypowiadanych słów.

Zaczynając pisać wyobrażałem sobie, że zaprezentuję programowanie funkcyjne, materiał okazał się jednak zbyt bogaty. Dzisiaj jedynie o nowych ficzerach, a programowanie funkcyjne mam nadzieję zaprezentować w którymś z kolejnych wpisów.

Kompletny prezentowany niżej kod znajduje się na githubie: https://github.com/RobertPod/FunctionalProgrammingExercises/tree/BlogB czyli w branchu BlogB.

Kod powstawał zgodnie z metodologią TDD – najpierw definiujemy potrzebę biznesową, potem piszemy test a na końcu dopisujemy kod, który spełnia napisany test.

Przechodząc do rzeczy:

Domena

Wyobraźmy sobie amatorski klub biegowy. Na potrzeby dokumentowania osiągnięć zawodników zdefiniowano następującą strukturę danych:

public class K34TeamScore implements Serializable {
    private String name;
    private int distance;
    private int duration;
    private String competitionCity;
}

…/model/K34TeamScore.java

Czyli dla każdego startu, tworzymy rekord zawierający informacje kto ukończył, jaki dystans, w jakim czasie i gdzie rozgrywane były zawody

Uwaga: Potencjalne źródło błędów to wartości null pojawiające się jako dane Stringów. Dla uniknięcia tego zagrożenia, blokujemy konstruktor bezargumentowy,

    private K34TeamScore() {
    }

a w wypadku podania konstruktorowi kopiującemu wartości null, zamieniamy ją na pusty String,

    public K34TeamScore(String name, int distance, int duration, String competitionCity) {
        this.name = name == null ? "" : name;
        this.distance = distance;
        this.duration = duration;
        this.competitionCity = competitionCity;
    }

Zakładamy, że null w polu competitionCity nas nie boli.

Pamiętamy, że obecna implementacja kontenera Optional, nie może być serializowana, dlatego w definicji danych nie używamy tego kontenera.

Na potrzeby artykułu funkcjonalność ograniczono do jednej metody, która odpowiada na pytanie: którzy zawodnicy z teamu przebiegli zadany dystans? Żeby było ciekawiej przyjęto założenia:

  • Jeżeli zawodnik kilkakrotnie ukończył dystans, na liście znajdzie się tylko raz
  • Jeżeli zawodnik kilkakrotnie ukończył dystans, na liście wynikowej pojawi się rekord z zawodów, gdzie dany zawodnik uzyskał najlepszy czas na tym dystansie
  • Lista wynikowa zostanie uporządkowana alfabetycznie wg imion
Testy i dane testowe

Identyczne dane i identyczny zbiór testów przygotowano zarówno dla rozwiązania tradycyjnego jak i funkcyjnego. Testy podzielono na grupy: testowanie poprawności algorytmu dla poprawnego zestawu danych oraz testowanie zachowania dla niekompletnych danych.

Tablica z danymi testowymi wygląda następująco:

    private K34TeamScore k34TeamScore1 = new K34TeamScore("Robert", marathon_dist, duration1, "Berlin");
    private K34TeamScore k34TeamScore2 = new K34TeamScore("Robert", half_marathon, duration2, "Wrocław");
    private K34TeamScore k34TeamScore3 = new K34TeamScore("Piotrek", BUGT_distanc, duration3, "Zakopane");
    private K34TeamScore k34TeamScore4 = new K34TeamScore("Daniel", marathon_dist, duration4, "Kraków");
    private K34TeamScore k34TeamScore5 = new K34TeamScore("Daniel", BRzeznikaU_di, duration5, "Cisna");
    private K34TeamScore k34TeamScore6 = new K34TeamScore("Robert", marathon_dist, duration6, "NY");

…/imp/K34TeamResultsClassicApproachTest.java i
…/imp/K34TeamResultsLambdaTest.java (nazwy dystansów poskracano, żeby forma jak najbardziej przypominała tabelę)

Z danych tych utworzono testowe tablice danych private K34TeamScore[] z dwoma różnymi porządkami danych, żeby wyniki testów były niezależne od kolejności danych wejściowych.

Dla tak przygotowanych danych przygotowano osiem testów:

  1. ilu biegaczy przebiegło maraton? (2)
  2. ilu biegaczy przebiegło maraton? – dla drugiego porządku danych (2)
  3. ilu biegaczy przebiegło 10km? (0)
  4. ilu biegaczy przebiegło BUGT? (1)
  5. czy wyniki są sortowane rosnąco po imionach
  6. czy wyniki są sortowane rosnąco po imionach – dla drugiego porządku danych
  7. czy podawany jest najlepszy wynik na danym dystansie?
  8. czy podawany jest najlepszy wynik na danym dystansie? – dla drugiego porządku danych

Dla skrócenia zapisu wszystkie testy zgromadzono w jednej metodzie testowej runnersWhoRanTheDistanceTest().

Dodano metody runnersWhoRanTheDistanceTest_NullInput(), runnersWhoRanTheDistanceTest_EmptyInputData() i runnersWhoRanTheDistanceTest_NameEqNull() sprawdzające odpowiednio jak testowana funkcjonalność zachowuje się odpowiednio wywołana z: wartośćią null, pustymi danymi oraz z imieniem równym null.

Dla tak przygotowanych testów (…/imp/K34TeamResultsClassicApproachTest.java i
…/imp/K34TeamResultsLambdaTest.java) przystępujemy do implementacji funkcjonalności.

Podejście tradycyjne

kod: …/imp/K34TeamResultsClassicApproach.java.

Dla zapewnienia, że nie dostaniemy na wejściu wartości null widoczność konstruktora bezargumentowego zmieniamy na prywatną i w wypadku gdy wejściowy Optional opakowuje null, zamieniamy go na pustą tablicę danych wejściowych. Wygląda to jak poniżej:

public class K34TeamResultsClassicApproach {
    K34TeamScore[] k34TeamScores;

    public K34TeamResultsClassicApproach(Optional<K34TeamScore[]> k34TeamScores) {

        this.k34TeamScores = k34TeamScores.orElseGet(() -> new K34TeamScore[]{});
    }

    private K34TeamResultsClassicApproach() {

    }
    ...

Sam kod metody konstruującej tablicę z oczekiwanymi danymi wygląda następująco:

    ...
    public Optional<K34TeamScore[]> runnersWhoRanTheDistanceAndBestScore(int distance) {
      List<K34TeamScore> resultList = new ArrayList<>();
      Set<String> resultSet = new HashSet<>();

      for (var k34TeamScore : k34TeamScores) {
        if (k34TeamScore.getDistance() == distance) {
          if (resultSet.add(k34TeamScore.getName())) {
              resultList.add(k34TeamScore);
          } else {
            for (var k34TeamScoreElem : resultList) {
              if (k34TeamScoreElem.getName().equals(k34TeamScore.getName())
                    && k34TeamScoreElem.getDuration() > k34TeamScore.getDuration()) {
                resultList.remove(k34TeamScoreElem);
                resultList.add(k34TeamScore);
                break;
              }
            }
          }
        }
      }

      var returnTable = new K34TeamScore[resultList.size()];
      returnTable = resultList.toArray(returnTable);

      Arrays.sort(returnTable, new CompareTeamMemberName());
      return Optional.ofNullable(returnTable);
    }

Kod nie był jakoś specjalnie optymalizowany i gdyby szło to na produkcję, pewnie pomyślałbym nad możliwością likwidacji zagnieżdżonych for i if.

W tym miejscu jednak chodzi o ilustrację algorytmu. Dla uniknięcia dyskusji skomentuję tylko, że zdaję sobie sprawę, że można wstępnie posortować tablicę wejściową po wynikach (jak to ma miejsce w implementacji strumieniowej) i w ten sposób uniknąć wewnętrznej pętli, nie miej sortowanie ma podobną złożoność obliczeniową (wg. mojej opinii w kodzie produkcyjnym powinna decydować liczność danych wejściowych i testy wydajnościowe). Chciałem też pokazać wprost całość algorytmu.

A jak to możemy zrobić wykorzystując składnię Javy 8?

Podejście funkcyjne z użyciem strumieni

kod …/imp/K34TeamResultsFunctionsImpl.java.

Wykorzystamy tutaj klasę stream, która umożliwia nam wykonanie szeregu operacji na danych wejściowych traktowanych jako strumień. Na strumieniu danych, jedna po drugiej można wykonywać kolejne operacje pochodzące z repertuaru (tylko bardziej popularne metody):

  • filter – używana do filtrowania, wyjściowy stream zawiera wybrane elementy wejściowego
  • map -mówiąc prosto przepisanie, czyli konwersja elementów strumienia na inny format
  • flatMap – podobnie jak wyżej, z “wyciągnięciem” elementów zagnieżdżonych. Dla przykładu automatyczna konwersja tablicy tablic ciągów znaków na prostą tablicę ciągu znaków
  • distinct – “elininuje” ze strumienia powtarzające się elementy
  • sorted – sortuje, na podstawie metody compareTo, jeżeli nasza struktura danych implementuje interface Comparable,lub na podstawie bezpośrednio zdefiniowanego komparatora, jak w przykładach niżej.
  • peek – wykonuje operację na emencie, nie przekształcając go
  • forEach – identycznie jak peek, operacja kończąca
  • reduce – operacja redukcji. Inne przykłady redukcji to max(), min(), count(). Wszystkie te operacje są kończące.
  • collect – Grupuje wszystkie elementy pozostające w strumieniu i zwraca je w postaci definiowanej przez podany Collector.

Gdzie na czerwono zaznaczono tzw. terminal operation, których wywołanie jest niezbędne, żeby dany strem został uruchomiony. Jednocześnie terminal operation kończą działanie strumienia i musi to być ostatnia wywoływana na tym strumieniu operacja

Opisywana metoda przy tym podejściu wygląda następująco:

  public static Optional<K34TeamScore[]> function(Optional<K34TeamScore[]> k34, int distance) {
    K34TeamScore[] k34TeamScores = k34.orElse(new K34TeamScore[]{});
    var marathoners = new HashSet<String>();

    var resultList =
          Stream.of(k34TeamScores)
              .filter(k34t -> k34t.getDistance() == distance)
              .sorted(comparing(K34TeamScore::getDuration))
              .filter(k34t -> marathoners.add(k34t.getName()))
              .sorted(comparing(K34TeamScore::getName))
              .collect(toList());

    var returnTable = new K34TeamScore[resultList.size()];
    return Optional.ofNullable(resultList.toArray(returnTable));
  }

Po bezpiecznym rozpakowaniu kontenera wejściowego, inicjalizujemy strumień i cały algorytm realizujemy w pięciu dość prostych linijkach:

  1. Wybierzmy rekordy z interesującym nas wynikiem
                            .filter(k34t -> k34t.getDistance() == distance)
    

    Metoda .filter wybiera nam elementy spełniające zadane kryterium. Metodzie tej podajemy tzw predykat, czyli funkcję, która na podstawie zadanego kryterium sprawdza wartość boolean. Zapis wewnątrz metody .filter rozumieć można jako anonimową klasę

                            .filter(new Predicate<K34TeamScore>() {
                                @Override
                                public boolean test(K34TeamScore k34t) {
                                    return k34t.getDistance() == distance;
                                }
                            })
    
  2. Posortujmy interesująca nas elementy wg wynków od najlepszego do najgorszego wyniku. Dzięki temu będziemy pewni, że gdy pierwszy raz trafimy na danego zawodnika to jednocześnie trafimy na jego najlepszy wynik:
                            .sorted(comparing(K34TeamScore::getDuration))
    

    W tym wypadku zastosowaliśmy nowy pattern klasy Comparator, który pozwala nam bezpośrednio wywołać statyczną metodę comparing. Gdybyśmy chcieli zastąpić ‘lambdę’ K34TeamScore::getDuration kod wyglądałby następująco:

                            .sorted(comparing(new Function<K34TeamScore, Integer>() {
                                @Override
                                public Integer apply(K34TeamScore k34TeamScore) {
                                    return k34TeamScore.getDuration();
                                }
                            }))
    
  3. Kolejny krok, to filtrowanie tak by w tablicy wyjściowej był tylko jeden rekord dla danego zawodnika – ten z najlepszym wynikiem.
                            .filter(k34t -> marathoners.add(k34t.getName()))
    

    Rozwinięcie funkcji testującej jest podobne jak wyżej. Dla uproszczenia kodu posłużono się pomocniczą klasą HashSet.

  4. Tak otrzymane wyniki sortujemy affabetycznie wg imion zawodników
                            .sorted(comparing(K34TeamScore::getName))
    

    gdzie zamknięta w lambdę klasa abstrakcyjna jest dość oczywita.

  5. Na koniec wreszcie tworzymy listę wyników
                            .collect(toList());
    

    Zauważmy, że ta ostania operacja jest operacją terminalną i wymusza przetwarzanie strumienia.

Interfejs funkcyjny

Używane przy przetwarzaniu strumieni lambdy (czy bardziej tradycyjnie anonimowe klasy), nie wymagały dodatkowych definicji, ponieważ w deklaracji tych metod strumieniowych dokładnie zdefiniowano jaka funkcja (czyli mówiąc językiem tradycyjnej Javy – metoda) może się tam znaleźć. Dzieje się to w ten sposób, że mając interfejs, określany jako interfejs funkcyjny a w nim zadeklarowaną metodę, możemy metodę tą podawać jako parametr wywołania innej metody. Wywołując tą drugą metodę możemy podać dowolną implementację metody występującej jako parametr.

Interfejs funkcyjny, to każdy interfejs, który definiuje interfejs dokładnie jednej metody. W Javie 8 wprowadzono specjalnie adnotację @FunctionalInterface, która zapewnia, że w klasie jest interfejs tylko jednej metody. W takej klasie, poza interfejsem tej jedynej metody, nazywanej też funkcją, może się znajdować dowolna ilość metod domyślnych. Dla ilustracji pokazano jeden interfejs funkcyjny a w nim zdefiniowano funkcję funkction_:

@FunctionalInterface
public interface K34TeamResultsFunction {
    Optional<K34TeamScore[]> function_(Optional<K34TeamScore[]> k34TeamScores, int distance);
}

…/api/K34TeamResultsFunction.java.

Proste wywołanie omówionej w poprzednim rozdziale metody statycznej, jak i opisanej wyżej funkcji pokazano w kasie testowej: …\test\java\robert\trening\functional_programming_exercises\imp\K34TeamResultsLambdaTest.java. Poza prostą ilustracją, sposobu definiowania interfejsu funkcyjnego, niewiele więcej wnosimy w tym temacie niemniej więcej o czystem programowaniu funkcyjnym w Javie mam nadzieję napisać w którymś z kolejnych postów.

Podsumowanie

Powyżej, na dość prostym przykładzie postarałem się pokazać część nowej funkcjonalności. Mam nadzieję, że jasno pokazałem, że nowe ficzery wymagają przyswojenia pewnego aparatu pojęciowego jak strumienie i lambdy, w rezultacie pozwalają jednak na bardziej zwarty i przejrzysty zapis.

Przy pisaniu bardzo pomogło mi podejście TDD. Próbując różne możliwości implementacyjne i zmagając się ze składnią streamów, a przede wszystkim się ucząc, wykonywałem dziesiątki modyfikacji kodu. Testy pozwalały mi w każdym momencie mieć pewność, że kod wykonuje dokładnie to co powinien. Testy znajdują się w plikach: …/src/test/java/robert/trening/functional_programming_exercises/imp/K34TeamResultsClassicApproachTest.java dla podejścia klasycznego i …/src/test/java/robert/trening/functional_programming_exercises/imp/K34TeamResultsLambdaTest.java dla strumieni.

Kończac serialowo: … to be continued (I hope).

UWAGA: Team K34 istnieje w realu i oczywiście jest to najlepszy team na świecie (przecież to mój team). Poetykę grupy biegowej zastosowałem, żeby odejść od schematu order, customer, … Imiona są częściowo prawdziwe, reszta zupełnie fikcyjna. Biorę, wobec koleżanek i kolegów biegowych pełną odpowiedzialność za zaniżanie ich osiągnięć, niemniej proszę nie mieć do mnie pretensji, jakby co postawię piwo.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

This site uses Akismet to reduce spam. Learn how your comment data is processed.