Wyrażenia lambda – programowanie funkcyjne w Javie

Wyrażenia lambda są kawałkiem kodu, który można przekazać do późniejszego wykonania. Można dzięki nim na przykład przekazać kod do wykonania w momencie wywołania metody wykonującej. Ponieważ najlepiej jest się uczyć na przykładzie to takowy przytoczę. Załóżmy że chcemy jakąś czynność wykonać wielokrotnie. Niech będzie że chcemy wyświetlić jakiś napis X razy na ekranie. Moglibyśmy np zastosować choćby taką pętlę:

for(int i=1;i<=x;i++){

System.out.println("hello..");

}

Nawet moglibyśmy opakować ten kod w jakąś metodę np:

 

private static void wykonajXrazyBiedaEdyszyn(int x){

for(int i=1;i<=x;i++){

System.out.println("hello..");

}

}

a następnie ją wywołać:

 

wykonajXrazyBiedaEdyszyn(4);

Problem jednak polega na tym, że mamy z góry zdefiniowane co chcemy zrobić i jak ma to być wykonane. W naszym przypadku ogranicza się to do wypisania tekstu na ekranie:

 

System.out.println("hello..");

A co jeśli chciałbym mieć taką metodę w postaci generycznej tj takiej która umożliwi przekazanie przez parametr kodu do wielokrotnego wykonania? Mógłbym zdeklarować taki interfejs:

private interface ToDo{

public void zrob();

}

By go następnie w osobnej metodzie wykorzystać:

 

private static void wykonajXrazy(int x, ToDo td){

for(int i=1;i<=x;i++){

td.zrob();

}

}

Jak dotąd nigdzie nie jest napisane co ma robić metoda zrob(). I bardzo dobrze że tak jest, bo to co ma być wykonane chcę przekazać podczas wywołania metody. Dopiero w momencie wywołania metody wykonajXrazy będę musiał podać implementację metody zrob z interfejsu ToDo:

 

wykonajXrazy(4, new ToDo() {

@Override

public void zrob() {

System.out.println("hello...");

}

});

Taki kod jednak zdecydowanie nie jest czytelny. A tymczasem to samo zapisane za pomocą wyrażeń lambda:

 

wykonajXrazy(5,()-> System.out.println("hello..."));

Prawda że czytelniej? Co jest co, po co i z czego wynika wyjaśniam poniżej. :)

 

Implementacja interfejsów w locie vs wyrażenie lambda

Stworzyłem proste POJO:

 

public class Samochod {

private String marka;

private String model;

private String numerRejestracyjny;

@Override

public String toString() {

return "Samochod{" + "marka=" + getMarka() + ", model=" + getModel() + ", numerRejestracyjny=" + getNumerRejestracyjny() + '}';

}

public Samochod(String marka, String model, String numerRejestracyjny) {

this.marka = marka;

this.model = model;

this.numerRejestracyjny = numerRejestracyjny;

}

//w tym miejscu następują gettery i settery ale ich tu nie umieszczam bo szkoda miejsca

}

Jak widzimy jest to prosta klasa posiadająca trzy pola tekstowe, przesłoniętą metodę toString i konstruktor sparametryzowany.

Przypuśćmy teraz że chcę teraz w metodzie innej klasy stworzyć listę obiektów klasy Samochod i w jakiś na razie nieokreślony sposób ją przetworzyć. Uzupełnienie listy danymi:

 

public static void main(String[] args) {

List<Samochod> lista = new ArrayList<>();

lista.add(new Samochod("Audi","A4", "WA 12345"));

lista.add(new Samochod("Porsche","Panamera", "EL 56564"));

lista.add(new Samochod("Skoda","Octavia", "LLU 12345"));

}

Gdybym już teraz wiedział co chcę z tą listą zrobić – mógłbym po prostu zaimplementować odpowiedni kod i oddelegować go do osobnej metody np:

 

private static void przetworzListeSamochodow(List<Samochod> lista){

for(Samochod s: lista){

System.out.println(s);

}

}

Tutaj jawnie określam zadanie dla każdego elementu, każdy z nich ma zostać wypisany. Na ten moment jednak nie wiem co będę z tą listą robił. Wiem za to że będę chciał dla każdego elementu tej listy wykonać bliżej nieokreślone "COŚ" w przyszłości. Aby móc opóźnić deklarację co robi "COŚ" mogę posłużyć się interfejsem. Najpierw tworzę interfejs posiadający metodę przyjmującą przez parametr pojedynczy obiekt klasy Samochod:

 

public interface DoSome {

public void doThat(Samochod s);

}

a metodę przetworzListeSamochodow przerabiam w taki sposób by przyjmowała przez parametr interfejs DoSome:

 

private static void przetworzListeSamochodow(List<Samochod> lista,DoSome d){

for(Samochod s: lista){

d.doThat(s);

}

}

W tej chwili dla każdego elementu listy zostanie wykonana metoda doThat z interfejsu DoSome. Nie jest znany sposób implementacji metody doThat (jako że przez parametr podaję interfejs a nie obiekt go implementujący). W związku z tym w chwili wywołania metody przetworzListeSamochodow będę musiał podać implementację doThat:

przetworzListeSamochodow(lista, new DoSome() {

@Override

public void doThat(Samochod s) {

System.out.println(s);

}

});

Cały kod klasy wywołującej zamyka się więc w takiej postaci:

 

public class WyrazeniaLambda {

private static void przetworzListeSamochodow(List<Samochod> lista,DoSome d){

for(Samochod s: lista){

d.doThat(s);

}

}

public static void main(String[] args) {

List<Samochod> lista = new ArrayList<>();

lista.add(new Samochod("Audi","A4", "WA 12345"));

lista.add(new Samochod("Porsche","Panamera", "EL 56564"));

lista.add(new Samochod("Skoda","Octavia", "LLU 12345"));

przetworzListeSamochodow(lista, new DoSome() {

@Override

public void doThat(Samochod s) {

System.out.println(s);

}

});

}

}

Już troszkę zaczyna to przypominać programowanie funkcyjne, jednak to nadal jeszcze nie jest to. Skupmy się teraz na drugim parametrze metody przetworzListeSamochodow. To czego oczekuje kompilator to podania mu implementacji jednej metody interfejsu. W zasadzie to jest mu potrzebna informacja co ma zrobić z otrzymanym elementem. Wyrzućmy teraz z tej klasy element :

 

przetworzListeSamochodow(lista, new DoSome() {

@Override

public void doThat(Samochod s) {

System.out.println(s);

}

});

a zastąpmy go takim zapisem:

 

przetworzListeSamochodow(lista, s -> System.out.println(s));

Co my właściwie podaliśmy jako drugi parametr? Właśnie wyrażenie lambda. Zawarliśmy w nim to samo co wcześniej w implementacji interfejsu – a właściwie metody doThat. Ograniczyliśmy tym samym ilość kodu i czytelność zapisu. Co oznaczają poszczególne elementy zapisu

 

s -> System.out.println(s)

s przed strzałką informuje o nazwie parametru wchodzącego (jeśli taki istnieje). To że nazwa się akurat pokrywa z nazwą parametru metody doThat nie ma nic do rzeczy. Równie dobrze mógłbyś ten zapis zmienić w taki sposób:

x -> System.out.println(x)

Chodzi jedynie o nazwę wewnątrz wyrażenia lambda, tak by kompilator wiedział do czego się odnosisz. Znacznik -> informuje kompilator że ma do czynienia z wyrażeniem lambda. No i zapis System.out.println(x) to instrukcja do wykonania – czyli zasadniczo to co byśmy wpisali w implementacji interfejsu. W tym momencie jest miejsce na gromkie AAAAaaaaaa.... :)

 

Return w wyrażeniach lambda

Mamy taką listę:

 

List<Integer> wejscie = Arrays.asList(1,2,3,4,5,6,7,8);

Będziemy chcieli ją filtrować – tj stosować różne warunki filtracji i odbierać tylko elementy spełniające te warunki. Podobnie jak poprzednio deklarujemy interfejs:

 

public interface Filtr {

public boolean czyChcemy(int x);

}

Definiuje on metodę która będzie zwracała true albo false w zależności od tego czy dany element (podany przez parametr) spełnia nasze oczekiwania czy nie. Nie znana jest na ten moment metoda sprawdzania naszych oczekiwać co do filtrowanego elementu, więc podobnie jak poprzednio będziemy musieli tę metodę zaimplementować przy wywołaniu.

Deklarujemy teraz metodę odfiltruj która przetwarza otrzymaną przez parametr listę i w zależności od tego co odpowie metoda czyChcemy z interfejsu który również przekazujemy przez parametr dodaje element do listy wynikowej albo tego nie robi:

 

private static List<Integer> odfiltruj(List<Integer> wejscie,Filtr f){

List<Integer> wyjscie = new ArrayList<>();

for(Integer i: wejscie){

if(f.czyChcemy(i)) wyjscie.add(i);

}

return wyjscie;

}

Podobnie jak i w poprzednim przykładzie wywołujemy metodę interfejsu nie znając jej implementacji:

if(f.czyChcemy(i)) wyjscie.add(i);

Sposób implementacji metody czyChcemy będziemy musieli w takim przypadku (podobnie jak i wcześniej) podać przy wywołaniu metody odfiltruj. Ja sobie wymyśliłem że spośród podanych przez wejściową listę liczb chcę otrzymać tylko te parzyste. Tak też zaimplementowałem zasadniczą część metody czyChcemy

 

List<Integer> wejscie = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);

List<Integer> odebrane = odfiltruj(wejscie, new Filtr() {

@Override

public boolean czyChcemy(int x) {

return x % 2 == 0;

}

});

for (Integer i : odebrane) {

System.out.println(i);

}

Kluczowy jest tu fragment:

 

return x % 2 == 0;

W zależności od tego czy reszta z dzielenia przyjętego przez parametr x jest równa 0 czy 1 liczba jest parzysta lub nie i return zwraca true albo false. Znowu mamy tutaj przerost formy nad treścią. Na końcu po prostu wypisujemy zawartość tablicy zawierającej już odfiltrowane elementy. Fragment:

 

List<Integer> odebrane = odfiltruj(wejscie, new Filtr() {

@Override

public boolean czyChcemy(int x) {

return x % 2 == 0;

}

});

moglibyśmy równie dobrze zapisać:

 

List<Integer> odebrane = odfiltruj(wejscie, x -> x%2==0);

używając wyrażenia lambda, zamykającego się w :

 

x -> x%2==0

Całe wywołanie możemy teraz uprościć do zapisu:

 

List<Integer> wejscie = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);

for (Integer i : odfiltruj(wejscie, x -> x % 2 == 0) ) System.out.println(i);

albo nawet :

 

for (Integer i : odfiltruj(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8), x -> x % 2 == 0) ) System.out.println(i);

 

Kilka wyrażeń lambda w jednym wywołaniu

Mam nadzieję że moje dotychczasowe wyjaśnienia są pomocne i pomogły Ci zrozumieć sposób działania wyrażeń lambda. Jeśli nie, wróć do poprzedniego rozdziału i przeanalizuj wszystko jeszcze raz. W tym rozdziale wejdziemy w nieco głębszą wodę. Zastosujemy kilka wyrażeń lambda w jednym wywołaniu. Podobnie jak w poprzednim przykładzie będziemy przetwarzać listę liczb (nawet taką samą) , ale tym razem będziemy jednocześnie filtrować jak i przetwarzać dane jednocześnie. Dane wejściowe takie same jak poprzednio:

 

List<Integer> lista = Arrays.asList(1,2,3,4,5,6,7,8);

Podobnie jak poprzednio deklarujemy interfejs do filtrowania danych – możesz skopiować go z poprzedniego przykładu:

 

public interface Filtr {

public boolean czyChcemy(int x);

}

Dodajemy też osobny interfejs którego zadaniem będzie przetworzenie elementów listy:

 

public interface Przetworz {

public int przetworz(int x);

}

Do tego metoda przyjmująca listę danych oraz oba interfejsy przez parametry. Z interfejsu Filtr wywołujemy jak poprzednio metodę czyChcemy(i) w celu stosowania przetworzeń tylko dla wybranych elementów. Dla elementów które przejdą filtr wywołujemy metodę przetworz(i) z interfejsu Przetworz:

 

private static void filtrujPrzetworz(List<Integer> lista, Filtr f,Przetworz p){

for(Integer i: lista){

if(f.czyChcemy(i)) System.out.println(p.przetworz(i));

}

}


Wywołanie całości:

 

public static void main(String args[]) {

List<Integer> lista = Arrays.asList(1,2,3,4,5,6,7,8);

filtrujPrzetworz(lista, i -> i%2==0, i -> i*2);

}

Pierwszy parametr wywoływanej metody filtrujPrzetworz to lista liczb do przetworzenia, drugi warunek filtracji – czyli implemenetacja metody filtruj z interfejsu Filtr, trzeci to przetworzenie dokonywane na liczbach – czyli implementacji metody przetworz z interfejsu Przetworz.

Wieloliniowe wyrażenie lambda

W dotychczasowych przykładach zamykaliśmy się w maksymalnie jednolinijkowych wyrażeniach lambda. Co jeśli zechcę tych linii mieć więcej? Przeanalizujemy to na przykładzie podawania wykonywalnej treści do wątku z użyciem wyrażeń lambda (swoją drogą użyteczne samo w sobie).

Wg. "starej" systematyki mogliśmy utworzyć obiekt klasy Thread podając mu poprzez konstruktor interfejs Runnable – wymaga to jednak od razu implementacji metody run:

new Thread(new Runnable() {

@Override

public void run() {

for (int x = 1; x <= 10; x++) {

try {

System.out.println("Hello from here..." + x);

Thread.sleep(1000);

} catch (Exception e) {

e.printStackTrace();

}

}

}

}).start();

Dokładnie to samo możemy uzyskać przy pomocy wyrażenia lambda. Zauważ że nie ma tutaj odwołania do interfejsu Runnable:

 

new Thread(() -> {

for (int x = 1; x <= 10; x++) {

try {

System.out.println("Hello from here..." + x);

Thread.sleep(1000);

} catch (Exception e) {

e.printStackTrace();

}

}

}).start();

Wyrażenie lambda to ta część:

 

() -> {

for (int x = 1; x <= 10; x++) {

try {

System.out.println("Hello from here..." + x);

Thread.sleep(1000);

} catch (Exception e) {

e.printStackTrace();

}

}


Metoda run interfejsu Runnable nie przyjmuje żadnych parametrów – stąd zapis () -> na początku. Dotychczas spotykaliśmy się z zapisem typu x->x*2 w sytuacjach gdzie x był parametrem. Reszta to po prostu kod który wstawialiśmy do implementacji metody run objęty nawiasami klamrowymi.

 

Stosowanie wielu parametrów w wyrażeniach lambda

Często trafi się sytuacja w której zechcemy w ramach wyrażeń lambda stosować wiele parametrów. Na potrzeby przykładu stworzyłem interfejs Przeliczenie posiadający jedną metodę przelicz przyjmującą dwa parametry:

 

public interface Przeliczenie {

public int przelicz(int x, int y);

}

W klasie uruchomieniowej dodałem statyczną metodę przeliczWyswietl która przyjmuje przez parametry dwie liczby, oraz interfejs którego metody przelicz używamy do przetworzenia parametrów i zwrócenia wartości do lokalnej zmiennej z. Na koniec wynik jest wyświetlany. Nic nadzwyczajnego:

 

private static void przeliczWyswietl(int x,int y, Przeliczenie p){

int z = p.przelicz(x, y);

System.out.println(z);

}

Jak jednak zapisać wyliczenie odnoszące się do obu parametrów? Dotychczas stosowaliśmy zapis typu

 

x -> x*2

jeśli parametrów jest więcej, możemy zastosować taką konstrukcję:

 

(a,b)->a*b

Nazwy parametrów są dowolne, wartości do nich są przypisywane wg kolejności podania.

Uruchomienie całości:

 

public static void main(String args[]) {

przeliczWyswietl(10, 5, (a,b)->a*b);

}

 

Kod źródłowy do pobrania:

jsystems.pl/static/download/blog/java/WyrazeniaLambda.zip

Ten artykuł jest elementem poniższych kursów: