Elementy programowania funkcyjnego w Javie 8. Pragmatyczny przegląd. (c) Krzysztof Barteczko 2014
Istota programowania funkcyjnego Przede wszystkim – brak stanów, są tylko funkcje, zwracające wyniki na podstawie przekazanych argumentów, co jest ważne i dla separowania kodu i dla jego niezawdoności w srodowiskach rozproszonych. Ale również możliwość pisania programów w kategoriach "co ma być osiągnięte", a nie - jak w programowaniu imperatywnym - poprzez specyfikowanie kolejnych kroków algorytmu do wykonania. Kod imperatywny: List src = Arrays.asList(5, 72, 10, 11, 9); List target = new ArrayList<>(); for (Integer n : src) { if (n < 10) { target.add(n * n); } Schemat stylu „funkcyjnego”: List target = create( src, tu podać warunek wyboru elementów z listy src, tu podać operację na wybranych elementach); Programujemy w kategoriach interfejsów Metoda zwraca listę elementów- argumentów
(c) Krzysztof Barteczko 2014 Interfejsy na pomoc Interfejs Filter, z metodą test() odpowiedzialną za wybór elementów listy (ogólniej - zwraca true, jeśli przekazana jej wartość spełnia zapisane w kodzie tej metody warunki, false - w przeciwnym razie): public interface Filter { boolean test(V v); } Interfejs Transformer, którego metoda transform() ma wykonać operację na przekazanej wartości i zwrócić jej wynik: public interface Transformer { T transform(S v); } Napisanie metody create() w kategoriach tych interfejsów jest całkiem łatwe: static List create(List src, Filter f, Transformer t) { List target = new ArrayList<>(); for (S e : src) if (f.test(e)) target.add(t.transform(e)); return target; }
(c) Krzysztof Barteczko 2014 Przed Javą 8 Trzeba było pisać tak: List src = Arrays.asList(5, 72, 10, 11, 9); List target = create(src, new Filter () { public boolean test(Integer n) { return n < 10; } }, new Transformer () { public Integer transform(Integer n) { return n*n; } }); „Boiler-plate” code. A ważne są tu tylko dwie linijki: return n < 10; i return n*n;
(c) Krzysztof Barteczko 2014 Lambda-wyrażenia – pierwsze spotkanie W Javie 8 mamy lamda-wyrażenie = kod, funkcja „bez nazwy”, traktowana jak obiekt. List target = create(src, n -> n < 10, n -> n*n ); Lambda z parametrem n zwraca wynik wyrażenia n <10. Kompilator "dopasowuje" to lambda- wyrażenie do drugiego parametru (Filter ) metody create(...), w wyniku czego użyta tam metoda test(...) interfejsu Filter "pod spodem" uzyskuje odpowiednią implementację (Boolean test(Integer n) { return n < 10; }). Lambda z parametrem n zwraca wynik wyrażenia n*n. Kompilator "dopasowuje" to lambda- wyrażenie do trzeciego parametru (Transformer ) metody create(...), w wyniku czego użyta tam metoda transform(...) interfejsu Transformer "pod spodem" uzyskuje odpowiednią implementację (Integer transform(Integer n) { return n * n }). Lambda ogólnie: ( parametry -> kod) w szczególności: parametry mogą być listą deklaracji zmiennych, kod – wyrażenie lub instrukcja ujeta w nawiasy klamrowe.
(c) Krzysztof Barteczko 2014 W pokazanym dalej kodzie wykorzystano prostą klasę Employee o następującej postaci. public class Employee { private String lname; private String fname; private Integer age; private Double salary; public Employee(String lname, String fname, Integer age, Double salary) { this.lname = lname; this.fname = fname; this.age = age; this.salary = salary; } public String getLname() { return lname; } public String getFname() { return fname; } public Integer getAge() { return age; } public Double getSalary() { return salary; } public void setSalary(Double salary) { this.salary = salary; public String toString() { return lname + " " + fname; }
(c) Krzysztof Barteczko 2014 Elastyczność List num = Arrays.asList( 1, 3, 5, 10, 9, 12, 7); List txt = Arrays.asList("ala", "ma", "kota", "aleksandra", "psa", "azora" ); List emp = Arrays.asList( new Employee("Kowal", "Jan", 34, ), new Employee("As", "Ala", 27, ), new Employee("Kot", "Zofia", 33, ), new Employee("Puchacz", "Jan", 41, ) ); System.out.println( create(num, n-> n%2!=0, n->n*100) ); System.out.println( create(txt, s -> s.startsWith("a"), s -> s.toUpperCase() + " " + s.length()) ); List doPodwyzki = create(emp, e -> e.getAge() > 30 && e.getSalary() < 4000, e -> e ); System.out.println("Podwyzki powinni uzyskac:"); System.out.println(doPodwyzki); [100, 300, 500, 900, 700] [ALA 3, ALEKSANDRA 10, AZORA 5] Podwyzki powinni uzyskac: [Kowal Jan, Kot Zofia, Puchacz Jan] różne typy danych rózne warunki różne transformacje
(c) Krzysztof Barteczko 2014 Referencje do metod Metoda create jest tak napisana, że wynikowa lista wcale nie musi zawierać elementów tego samego typu, co lista zródłowa. Możemy np. łatwo uzyskać listę pensji wszystkich pracowników: List sal = create(emp, e -> true, e -> e.getSalary() ); System.out.println(sal); Przy tej okazji warto wspomnieć o referencjach do metod (zapisywanych z użyciem podwójnego dwukropka), które też są lambda-wyrażeniami. Zapis e -> e.getSalary() jest równoważny następującej referencji do metody getSalary z klasy Employee: Employee::getSalary i kod możemy zapisać nieco prościej: create(emp, e -> true, Employee::getSalary ); [3400.0, , , ]
(c) Krzysztof Barteczko 2014 A może chcemy zmieniać elementy listy ? Możemy stworzyć interfesj Modifier z metodą modify(): public interface Modifier { void modify(S v); } i metodę change, która będzie otrzymywac listę źródłową i wykonywać zmiany tych jej elementów, które spelniają podany warunek static void change(List list, Filter f, Modifier mod) { for (S e : list) { if (f.test(e)) { mod.modify(e); }
(c) Krzysztof Barteczko 2014 Modyfikacja elementów listy za pomocą lambda List emp = Arrays.asList( new Employee("Kowal", "Jan", 34, ), new Employee("As", "Ala", 27, ), new Employee("Kot", "Zofia", 33, ), new Employee("Puchacz", "Jan", 41, ) ); change(emp, e -> e.getAge() > 30 && e.getSalary() < 4000, e -> e.setSalary(e.getSalary()+200) ); for (Employee e : emp) System.out.println(e + " " + e.getSalary()); Kowal Jan As Ala Kot Zofia Puchacz Jan
(c) Krzysztof Barteczko 2014 Czy trzeba definiować własne interfejsy? Nie. Są dostęne gotowe dla częstych przypadków przetwarzania (użycia lambda). static List create(List src, Predicate filter, Function func) { List target = new ArrayList<>(); for (S e : src) { if (filter.test(e)) { target.add(func.apply(e)); } return target; } Gotowe interfejsy z pakietu java.util. function Metoda test() interfejsu Predicate przyjmuje argument typu S i zwraca wynik typu boolean. Metoda apply() interfejsu Function przyjmuje argument typu S i zwraca wynik typu T. Konieczny import java.util.function.*
(c) Krzysztof Barteczko i Consumer static void change(List list, Filter f, Consumer mod) { for (S e : list) { if (f.test(e)) { mod.accept(e); } } Metoda accept() interfejsu Consumer przyjmuje argument typu S i nie zwraca żadnego wyniku.
(c) Krzysztof Barteczko 2014 Ku przetwarzaniu strumieniowemu Pozbyliśmy się konieczności definiowania własnych interfejsów! Czy nie możemy pozbyć się konieczności pisania metod create() i change()? Tym bardziej, że wciąż nie są dostatecznie uniwersalne i elastyczne. Czy zawsze potrzebujemy filtrować dane i czy zawsze je przetwarzać? Konstrukcje typu: create(emp, e -> true, // e.getSalary() ); czy: List doPodwyzki = create(emp, e -> e.getAge() > 30 && e.getSalary() e // <--- sztuczne ); są nieco sztuczne. A może będziemy chcieli najpierw przetwarzać, później filtrować, a później wyniki filtrowania znowu przetwarzać - tego już nasze ograniczone metody nie zapewnią. Nie tylko nie musimy definiowac własnych interfejsów. Nie musimy też pisać metod w rodzaju create() i change(). Możemy zastosować dużo bardziej uniwersalne przetwarzanie strumieniowe.
(c) Krzysztof Barteczko 2014 O przetwarzaniu strumieniowym Zanim zapoznamy się z nim dokładniej, tu - na zachętę - pokazane zostaną szczególne przypadki jego użycia, dobrze zastępujące i znacznie poszerzające funkcjonalność, stworzonych wcześniej metod create(...) i change() ListStream.map.filter Stream.filter.map Stream...Stream.collect.reduce.forEach wartość albo kontener albo zmiana
(c) Krzysztof Barteczko 2014 Objaśnienia W kontekście wcześniejszych przykładowych kodów, sekwencja działań jest następująca. Od listy metodą stream() uzyskujemy tzw. strumień (inaczej zwany sekwencją), a na danych strumienia możemy wykonywać m.in. operacje przetwarzania (metoda map), filtrowania (metody filter), uzyskiwać nowe kolekcje wyników tych operacji (metoda collect) lub zagregowane wartości (metoda reduce), albo modyfikować elementy strumienia (np. metodą forEach) Metodzie map podajemy lambda-wyrażenie, którego wynikiem jest przetworzenie jego parametru (działa to tak jak apply z interfejsu Function). Metoda zwraca strumień, na ktorym można wykonywać dalsze operacje. Metodzie filter podajemy lambdę, przekształcająca parametr w wartość boolowską (tak jak test() interfejsu Predicate). Filtrowanie zwraca strumień, który pozwala wykonywać ew. dalsze operacje tylko na tych danych, dla których wynik lambdy jest true. Możemy zatem wywoływać sekwencje map-filter, realizujac kolejne etapy przetwarzania strumienia danych. Metoda collect natomiast kończy przetwarzanie strumienia i ma za parametr obiekt klasy Collector, który tworzy nową kolekcję i dodaje do niej dane ze strumienia, na rzecz którego została wywołana. W szczególności taki kolektor, budujący listę, można uzyskać za pomocą statycznej metody Collectors.toList(). Również metody reduce(...) i forEach(...) kończą przetwarzanie strumienia. Pierwsza pozwala zwrócić zagregowane wartości, druga robi coś z elementami strumienia (modyfikuje, wypisuje etc.)
(c) Krzysztof Barteczko 2014 Przetwarzanie strumieniowe – filter, map, collect Uzyskanie listy kwadratów tych elementów listy src, które są mniejsze od 10: import static java.util.stream.Collectors.toList; //... List src = Arrays.asList(5, 72, 10, 11, 9); List target = src.stream().filter(n -> n < 10).map(n -> n * n).collect(toList()); Wynik: lista [25, 81] Możemy filtrować kwadraty liczb, czyli najpierw wykonać map(), a później filter(): List num = Arrays.asList(1, 3, 5, 10, 9, 12, 7); List out = num.stream().map(n -> n*n).filter(n -> n > 80).collect(toList()); Wynik: lista [100, 81, 144]
(c) Krzysztof Barteczko 2014 Przetwarzanie strumieniowe – wielokrotne map/filter Możemy wykonywać map i filter wielokrotnie i w dowolnej kolejności. Z listy napisów wybrać trzycyfrowe reprezentacje liczb całkowitych, przekształcić je w liczby, wybrać parzyste i utworzyć nową listę ich napisowych reprezentacji. List snum = Arrays.asList("7", "20", "160", "777", "822"); snum = snum.stream().filter(s -> s.length() == 3).map (s-> Integer.parseInt(s)).filter(n-> n%2 == 0).map(n-> String.valueOf(n)).collect(toList()); Powyższy kod da w wyniku listę, złożoną z dwóch napisów "160" i "822".
(c) Krzysztof Barteczko 2014 Przetwarzanie strumieniowe – forEach Zmiany pensji pracowników, wymagające poprzendio metody change(), teraz są łatwe do oprogramowania za pomocą uniwesralnego przetwarzania strumieniowego List emp =... emp.stream().filter(e -> e.getAge() > 30 && e.getSalary() < 4000).forEach(e -> e.setSalary(e.getSalary()*1.1)); emp.forEach( e -> System.out.printf("%s %.0f\n", e, e.getSalary()) ); Kowal Jan 3740 As Ala 4100 Kot Zofia 4070 Puchacz Jan 3960 Zastosowano tu dwa razy metodę forEach. Pierwsze jej użycie dotyczyło strumienia zwracanego przez filter(...) i polegało na wykonaniu podanej operacji (ustalenia nowej pensji) na wszystkich elementach zwróconej sekwencji. Takie forEach, tak jak collect, jest jedną z metod, która kończy operacje strumieniowe. Drugie forEach zastosowano wobec kolekcji (listy pracowników) dla wyprowadzenia informacji. Jest to jeden z przykładów nowych (domyślnych) metod w interfejsach kolekcyjnych (a ścićlej w interfejscie Iterable), którym jako argumenty można podawać lambda-wyrażenia odpowiednich rodzajów.
(c) Krzysztof Barteczko 2014 Przetwarzanie strumieniowe - redukcja Często będziemy potrzebowali uzyskać jakieś zagregowane wartości elementów strumienia. Możemy wtedy zastosować metodę reduce: reduce(init, func) gdzie: init - inicjalna wartośc func - funkcja, która ma jako argumenty wynik dotychczasowego przetwarzania (part) i kolejny element strumienia (next); zwraca ona wynik zastosowania metody apply wobec tych dwóch argumentów. part = init dla_każdego_elementu_strumienia { part = func.apply(part, next) } return part Schemat działania reduce
(c) Krzysztof Barteczko 2014 Redukcja - przykład Przykładowo, możemy zsumować pensje wszystkich pracowników (co da wynik 14800). List emp = Arrays.asList( new Employee("Kowal", "Jan", 34, ), new Employee("As", "Ala", 27, ), new Employee("Kot", "Zofia", 33, ), new Employee("Puchacz", "Jan", 41, )); Double sum = emp.stream().map( Employee::getSalary).reduce( 0.0, (part, next) -> part + next); System.out.println(sum); }
(c) Krzysztof Barteczko 2014 Co daje Java 8? dzięki lambda-wyrażeniom usunęliśmy „boiler-plate code”, związany z implementacją interfejsów w anonimowych klasach wewnętrznych, dzięki gotowym interfejsom z pakietu java.util.function, dopasowanym do częstych przypadków uzycia lambda, uniknęliśmy konieczności pisania własnych interfejsów, dzięki przetwarzaniu strumieniowemy, uniknęliśmy konieczności pisania metod, które mozna wywołaywac z argumentami – lambda wyrażeniami i uzyskalismy dużą elastyczność tworzenia kodu. A co więcej: Strumienie w Javie 8 są ważną, całkiem odmienną od kolekcji, koncepcją. Umożliwiają m.in. tzw. "leniwe wyliczanie" (czyli obliczanie tylko tego co jest w danym kontekście potrzebne) oraz łatwe zrównoleglanie obliczeń. Ponadto strumienie można "nakładać" nie tylko na kolekcje, ale też na wiele innych rodzajów obiektów.