Dla kogo jest ten artykuł?
- Dla tych, którzy myślą, że dobrze znają już Java Core, ale nie mają pojęcia o wyrażeniach lambda w Javie. A może słyszałeś już coś o lambdach, ale bez szczegółów.
- dla tych, którzy mają pewną wiedzę na temat wyrażeń lambda, ale nadal boją się ich używać i nie są w stanie ich używać.
Jeśli nie należysz do żadnej z tych kategorii, ten artykuł może okazać się nudny, niepoprawny i ogólnie „niefajny”. W takim przypadku możesz przejść obok lub, jeśli jesteś dobrze zorientowany w temacie, zasugeruj w komentarzach, jak mógłbym ulepszyć lub uzupełnić artykuł. Materiał nie ma żadnej wartości akademickiej, a tym bardziej nowości. Wręcz przeciwnie: postaram się w nim opisać skomplikowane (dla niektórych) rzeczy w możliwie najprostszy sposób. Do napisania zainspirowała mnie prośba o wyjaśnienie API strumieniowego. Przemyślałem to i doszedłem do wniosku, że bez zrozumienia wyrażeń lambda niektóre moje przykłady dotyczące „strumieni” będą niezrozumiałe. Zacznijmy więc od lambd.
Jaka wiedza jest wymagana, aby zrozumieć ten artykuł:
- Znajomość programowania obiektowego (zwanego dalej OOP), a mianowicie:
- wiedza czym są klasy i obiekty, jaka jest między nimi różnica;
- wiedza czym są interfejsy, czym różnią się od klas, jakie jest pomiędzy nimi powiązanie (interfejsy i klasy);
- wiedza czym jest metoda, jak ją nazwać, czym jest metoda abstrakcyjna (lub metoda bez implementacji), jakie są parametry/argumenty metody, jak je tam przekazać;
- modyfikatory dostępu, metody/zmienne statyczne, metody/zmienne końcowe;
- dziedziczenie (klasy, interfejsy, wielokrotne dziedziczenie interfejsów).
- Znajomość Java Core: generyczne, kolekcje (listy), wątki.
Cóż, zaczynajmy.
Trochę historii
Wyrażenia lambda pojawiły się w Javie z programowania funkcjonalnego, a tam z matematyki. W połowie XX wieku w Ameryce na Uniwersytecie Princeton pracował niejaki Alonzo Church, który bardzo lubił matematykę i wszelkiego rodzaju abstrakcje. To Alonzo Church wymyślił rachunek lambda, który początkowo był zbiorem abstrakcyjnych idei i nie miał nic wspólnego z programowaniem. W tym samym czasie na tym samym Uniwersytecie Princeton pracowali matematycy, tacy jak Alan Turing i John von Neumann. Wszystko się połączyło: Church wymyślił system rachunku lambda, Turing opracował swoją abstrakcyjną maszynę liczącą, znaną obecnie jako „maszyna Turinga”. Cóż, von Neumann zaproponował schemat architektury komputerów, który stanowił podstawę współczesnych komputerów (i obecnie nazywa się go „architekturą von Neumanna”). Idee Alonzo Churcha nie zyskały wówczas takiej sławy, jak prace jego kolegów (z wyjątkiem dziedziny „czystej” matematyki). Jednak nieco później ideą Churcha zainteresował się niejaki John McCarthy (również absolwent Uniwersytetu Princeton, w chwili pisania tej historii pracownik Massachusetts Institute of Technology). Na ich podstawie w 1958 roku stworzył pierwszy funkcjonalny język programowania Lisp. A 58 lat później idee programowania funkcjonalnego wyciekły do Javy jako numer 8. Nie minęło nawet 70 lat... Tak naprawdę nie jest to najdłuższy okres czasu na zastosowanie idei matematycznej w praktyce.
Esencja
Wyrażenie lambda jest taką funkcją. Można o tym myśleć jak o zwykłej metodzie w Javie, z tą tylko różnicą, że można ją przekazać innym metodom jako argument. Tak, możliwe stało się przekazywanie do metod nie tylko liczb, ciągów znaków i kotów, ale także innych metod! Kiedy możemy tego potrzebować? Na przykład, jeśli chcemy przekazać jakieś wywołanie zwrotne. Metoda, którą wywołujemy, jest nam potrzebna, aby móc wywołać inną metodę, którą jej przekazujemy. Oznacza to, że w niektórych przypadkach mamy możliwość przesłania jednego wywołania zwrotnego, a w innych innego. A nasza metoda, która akceptowałaby nasze wywołania zwrotne, wywołałaby je. Prostym przykładem jest sortowanie. Załóżmy, że piszemy skomplikowane sortowanie, które wygląda mniej więcej tak:
public void mySuperSort() {
if(compare(obj1, obj2) > 0)
}
Where
if
nazywamy metodą
compare()
, przekazujemy tam dwa obiekty, które porównujemy i chcemy się dowiedzieć, który z tych obiektów jest „większy”. Przedstawimy ten, który jest „więcej”, przed tym, który jest „mniejszy”. Napisałem „więcej” w cudzysłowie, ponieważ piszemy uniwersalną metodę, która będzie mogła sortować nie tylko w porządku rosnącym, ale i malejącym (w tym przypadku „więcej” będzie obiektem, który jest zasadniczo mniejszy i odwrotnie) . Aby ustawić regułę określającą dokładnie sposób sortowania, musimy w jakiś sposób przekazać ją do naszego pliku
mySuperSort()
. W takim przypadku będziemy mogli w jakiś sposób „kontrolować” naszą metodę w trakcie jej wywoływania. Oczywiście możesz napisać dwie osobne metody
mySuperSortAsc()
sortowania
mySuperSortDesc()
w kolejności rosnącej i malejącej. Lub przekaż jakiś parametr wewnątrz metody (na przykład
boolean
if
true
sortuj w kolejności rosnącej, a jeśli
false
w kolejności malejącej). Co jednak, jeśli chcemy posortować nie jakąś prostą strukturę, ale na przykład listę tablic ciągów znaków? Skąd nasza metoda będzie
mySuperSort()
wiedzieć, jak sortować te tablice ciągów? Na wymiar? Według całkowitej długości słów? Być może alfabetycznie, w zależności od pierwszego wiersza w tablicy? Co jednak, jeśli w niektórych przypadkach będziemy musieli posortować listę tablic według rozmiaru tablicy, a w innym przypadku według całkowitej długości słów w tablicy? Myślę, że słyszałeś już o komparatorach i że w takich przypadkach po prostu przekazujemy obiekt komparatora do naszej metody sortowania, w której opisujemy zasady, według których chcemy sortować. Ponieważ metoda standardowa
sort()
jest realizowana na tej samej zasadzie, co
mySuperSort()
w przykładach, posłużę się metodą standardową
sort()
.
String[] array1 = {"Matka", "mydło", "rama"};
String[] array2 = {"I", "Bardzo", "Kocham", "java"};
String[] array3 = {"świat", "praca", "Móc"};
List<String[]> arrays = new ArrayList<>();
arrays.add(array1);
arrays.add(array2);
arrays.add(array3);
Comparator<String[]> sortByLength = new Comparator<String[]>() {
@Override
public int compare(String[] o1, String[] o2) {
return o1.length - o2.length;
}
};
Comparator<String[]> sortByWordsLength = new Comparator<String[]>() {
@Override
public int compare(String[] o1, String[] o2) {
int length1 = 0;
int length2 = 0;
for (String s : o1) {
length1 += s.length();
}
for (String s : o2) {
length2 += s.length();
}
return length1 - length2;
}
};
arrays.sort(sortByLength);
Wynik:
- mama umyła ramę
- pokój Pracy może
- Naprawdę kocham Javę
Tutaj tablice są sortowane według liczby słów w każdej tablicy. Tablicę zawierającą mniej słów uważa się za „mniejszą”. Dlatego pojawia się na początku. Ten, w którym jest więcej słów, jest uważany za „więcej” i kończy się na końcu. Jeżeli do metody
sort()
przekażemy inny komparator
(sortByWordsLength)
to
wynik będzie inny:
- pokój Pracy może
- mama umyła ramę
- Naprawdę kocham Javę
Teraz tablice są sortowane według całkowitej liczby liter w słowach takiej tablicy. W pierwszym przypadku jest ich 10, w drugim 12, a w trzecim 15. Jeśli użyjemy tylko jednego komparatora, to nie możemy stworzyć dla niego osobnej zmiennej, a po prostu stworzyć obiekt anonimowej klasy bezpośrednio przy czas wywołania metody
sort()
. Tak:
String[] array1 = {"Matka", "mydło", "rama"};
String[] array2 = {"I", "Bardzo", "Kocham", "java"};
String[] array3 = {"świat", "praca", "Móc"};
List<String[]> arrays = new ArrayList<>();
arrays.add(array1);
arrays.add(array2);
arrays.add(array3);
arrays.sort(new Comparator<String[]>() {
@Override
public int compare(String[] o1, String[] o2) {
return o1.length - o2.length;
}
});
Wynik będzie taki sam jak w pierwszym przypadku. Zadanie 1 . Przepisz ten przykład tak, aby sortował tablice nie w kolejności rosnącej według liczby słów w tablicy, ale w kolejności malejącej. To wszystko już wiemy. Wiemy jak przekazywać obiekty do metod, możemy przekazać ten lub inny obiekt do metody w zależności od tego, czego w danej chwili potrzebujemy, a wewnątrz metody, w której przekazujemy taki obiekt, zostanie wywołana metoda, dla której pisaliśmy implementację . Powstaje pytanie: co mają z tym wspólnego wyrażenia lambda?
Biorąc pod uwagę, że lambda jest obiektem zawierającym dokładnie jedną metodę. To jest jak obiekt metody. Metoda zawinięta w obiekt. Mają po prostu nieco nietypową składnię (ale o tym później). Przyjrzyjmy się jeszcze raz temu wpisowi
arrays.sort(new Comparator<String[]>() {
@Override
public int compare(String[] o1, String[] o2) {
return o1.length - o2.length;
}
});
Tutaj bierzemy naszą listę
arrays
i wywołujemy jej metodę
sort()
, gdzie przekazujemy obiekt komparatora za pomocą jednej metody
compare()
(nie ma dla nas znaczenia, jak się ona nazywa, ponieważ jest jedyna w tym obiekcie, nie przegapimy tego). Metoda ta przyjmuje dwa parametry, z którymi będziemy dalej pracować. Jeśli pracujesz w
IntelliJ IDEA , prawdopodobnie widziałeś, jak oferuje ci ten kod do znacznego skrócenia:
arrays.sort((o1, o2) -> o1.length - o2.length);
W ten sposób sześć linijek zamieniło się w jedną krótką. 6 linijek przepisano w jedną krótką. Coś zniknęło, ale gwarantuję, że nie zniknęło nic ważnego, a ten kod będzie działał dokładnie tak samo, jak z klasą anonimową.
Zadanie 2 . Dowiedz się, jak przepisać rozwiązanie problemu 1 za pomocą wyrażeń lambda (w ostateczności poproś
IntelliJ IDEA o przekształcenie twojej anonimowej klasy w lambdę).
Porozmawiajmy o interfejsach
Zasadniczo interfejs to po prostu lista abstrakcyjnych metod. Kiedy tworzymy klasę i mówimy, że będzie ona implementować jakiś interfejs, musimy napisać w naszej klasie implementację metod, które są wymienione w interfejsie (lub w ostateczności nie pisać tego, ale uczynić klasę abstrakcyjną ). Istnieją interfejsy z wieloma różnymi metodami (na przykład
List
), istnieją interfejsy z tylko jedną metodą (na przykład ten sam Komparator lub Runnable). Istnieją interfejsy, które w ogóle nie posiadają jednej metody (tzw. interfejsy znacznikowe, np. Serializable). Interfejsy posiadające tylko jedną metodę nazywane są także
interfejsami funkcjonalnymi . W Javie 8 są one nawet oznaczone specjalną adnotacją
@FunctionalInterface . To interfejsy z jedną metodą, które nadają się do użycia w wyrażeniach lambda. Jak powiedziałem powyżej, wyrażenie lambda jest metodą opakowaną w obiekt. A kiedy gdzieś przekazujemy taki obiekt, w rzeczywistości przekazujemy tę jedną metodę. Okazuje się, że nie ma dla nas znaczenia, jak nazwie się tę metodę. Dla nas ważne są tylko parametry, jakie przyjmuje ta metoda, a właściwie sam kod metody. Zasadniczo wyrażenie lambda jest. implementacja interfejsu funkcjonalnego. Tam, gdzie widzimy interfejs z jedną metodą, oznacza to, że taką anonimową klasę możemy przepisać za pomocą lambdy. Jeśli interfejs ma więcej/mniej niż jedną metodę, to wyrażenie lambda nam nie będzie odpowiadać i skorzystamy z klasy anonimowej, lub nawet zwykłej. Czas zagłębić się w lambdy. :)
Składnia
Ogólna składnia jest mniej więcej taka:
(параметры) -> {тело метода}
Oznacza to, że w nawiasach znajdują się parametry metody, „strzałka” (są to dwa znaki z rzędu: minus i większy), po czym treść metody jak zawsze jest ujęta w nawiasy klamrowe. Parametry odpowiadają parametrom określonym w interfejsie przy opisie metody. Jeżeli kompilator potrafi jasno określić typ zmiennych (w naszym przypadku wiadomo na pewno, że pracujemy z tablicami ciągów znaków, bo
List
typuje się je precyzyjnie poprzez tablice ciągów znaków), to typ zmiennych
String[]
nie musi być zostać napisanym.
Jeśli nie jesteś pewien, określ typ, a IDEA podświetli go na szaro, jeśli nie będzie potrzebny. |
Więcej informacji można znaleźć na przykład w
samouczku Oracle . Nazywa się to
„wpisywaniem celu” . Zmiennym można nadać dowolne nazwy, niekoniecznie te określone w interfejsie. Jeśli nie ma parametrów, po prostu nawiasy. Jeśli jest tylko jeden parametr, wystarczy nazwa zmiennej bez nawiasów. Uporządkowaliśmy parametry, teraz o treści samego wyrażenia lambda. Wewnątrz nawiasów klamrowych napisz kod jak w przypadku zwykłej metody. Jeśli cały kod składa się tylko z jednej linii, nie musisz w ogóle pisać nawiasów klamrowych (jak w przypadku if i pętli). Jeśli twoja lambda coś zwraca, ale jej ciało składa się z jednej linii,
return
nie ma potrzeby pisania. Ale jeśli masz nawiasy klamrowe, to, jak w zwykłej metodzie, musisz jawnie napisać
return
.
Przykłady
Przykład 1.
() -> {}
Najprostsza opcja. I ten najbardziej bezsensowny :).Bo nic nie daje.
Przykład 2.
() -> ""
Również ciekawa opcja. Nie akceptuje niczego i zwraca pusty ciąg znaków (
return
pominięty jako niepotrzebny). To samo, ale z
return
:
() -> {
return "";
}
Przykład 3. Witaj świecie przy użyciu lambd
() -> System.out.println("Hello world!")
Nic nie otrzymuje, nic nie zwraca (nie możemy umieścić
return
przed wywołaniem
System.out.println()
, ponieważ typ zwrotu w metodzie
println() — void)
po prostu wyświetla napis na ekranie. Idealny do implementacji interfejsu
Runnable
. Ten sam przykład jest pełniejszy:
public class Main {
public static void main(String[] args) {
new Thread(() -> System.out.println("Hello world!")).start();
}
}
Lub tak:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> System.out.println("Hello world!"));
t.start();
}
}
Możemy też zapisać wyrażenie lambda jako obiekt typu
Runnable
, a następnie przekazać je konstruktorowi
thread’а
:
public class Main {
public static void main(String[] args) {
Runnable runnable = () -> System.out.println("Hello world!");
Thread t = new Thread(runnable);
t.start();
}
}
Przyjrzyjmy się bliżej momentowi zapisania wyrażenia lambda do zmiennej. Interfejs
Runnable
mówi nam, że jego obiekty muszą mieć metodę
public void run()
. Zgodnie z interfejsem metoda run nie akceptuje niczego jako parametrów. I nic nie zwraca
(void)
. Dlatego pisząc w ten sposób, zostanie utworzony obiekt za pomocą jakiejś metody, która niczego nie akceptuje ani nie zwraca. Co jest całkiem spójne z metodą
run()
w interfejsie
Runnable
. Dlatego mogliśmy umieścić to wyrażenie lambda w zmiennej takiej jak
Runnable
.
Przykład 4
() -> 42
Ponownie niczego nie przyjmuje, ale zwraca liczbę 42. To wyrażenie lambda można umieścić w zmiennej typu
Callable
, ponieważ ten interfejs definiuje tylko jedną metodę, która wygląda mniej więcej tak:
V call(),
gdzie
V
jest typem zwracanej wartości (w naszym przypadku
int
). W związku z tym możemy przechowywać takie wyrażenie lambda w następujący sposób:
Callable<Integer> c = () -> 42;
Przykład 5. Lambda w kilku liniach
() -> {
String[] helloWorld = {"Hello", "world!"};
System.out.println(helloWorld[0]);
System.out.println(helloWorld[1]);
}
Ponownie jest to wyrażenie lambda bez parametrów i typu zwracanego
void
(ponieważ nie ma
return
).
Przykład 6
x -> x
Tutaj bierzemy coś do zmiennej
х
i zwracamy to. Należy pamiętać, że jeśli zostanie zaakceptowany tylko jeden parametr, nawiasy wokół niego nie muszą być zapisywane. To samo, ale z nawiasami:
(x) -> x
A oto opcja z wyraźną opcją
return
:
x -> {
return x;
}
Lub tak, z nawiasami i
return
:
(x) -> {
return x;
}
Lub z wyraźnym wskazaniem typu (i odpowiednio w nawiasach):
(int x) -> x
Przykład 7
x -> ++x
Akceptujemy
х
i zwracamy, ale za
1
więcej. Możesz też przepisać to w ten sposób:
x -> x + 1
W obu przypadkach nie umieszczamy nawiasów wokół parametru, treści metody i słowa
return
, ponieważ nie jest to konieczne. Opcje z nawiasami i zwrotem opisano w przykładzie 6.
Przykład 8
(x, y) -> x % y
Akceptujemy część
х
i
у
zwracamy resztę podziału
x
przez
y
. Nawiasy wokół parametrów są już tutaj wymagane. Są opcjonalne tylko wtedy, gdy istnieje tylko jeden parametr. W ten sposób z wyraźnym wskazaniem typów:
(double x, int y) -> x % y
Przykład 9
(Cat cat, String name, int age) -> {
cat.setName(name);
cat.setAge(age);
}
Akceptujemy obiekt Cat, ciąg znaków z nazwą i wiekiem będącym liczbą całkowitą. W samej metodzie przekazujemy Catowi imię i wiek.
cat
Ponieważ nasza zmienna jest typem referencyjnym, obiekt Cat znajdujący się poza wyrażeniem lambda ulegnie zmianie (otrzyma przekazaną wewnątrz nazwę i wiek). Nieco bardziej skomplikowana wersja, która wykorzystuje podobną lambdę:
public class Main {
public static void main(String[] args) {
Cat myCat = new Cat();
System.out.println(myCat);
Settable<Cat> s = (obj, name, age) -> {
obj.setName(name);
obj.setAge(age);
};
changeEntity(myCat, s);
System.out.println(myCat);
}
private static <T extends WithNameAndAge> void changeEntity(T entity, Settable<T> s) {
s.set(entity, "Murzik", 3);
}
}
interface WithNameAndAge {
void setName(String name);
void setAge(int age);
}
interface Settable<C extends WithNameAndAge> {
void set(C entity, String name, int age);
}
class Cat implements WithNameAndAge {
private String name;
private int age;
@Override
public void setName(String name) {
this.name = name;
}
@Override
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Cat{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
Wynik: Cat{name='null', age=0} Cat{name='Murzik', age=3} Jak widać, obiekt Cat początkowo miał jeden stan, lecz po użyciu wyrażenia lambda stan się zmienił . Wyrażenia lambda dobrze współpracują z wyrażeniami rodzajowymi. A jeśli potrzebujemy
Dog
np. stworzyć klasę, która będzie również implementować
WithNameAndAge
, to w metodzie
main()
możemy wykonać te same operacje z Dogiem, w ogóle nie zmieniając samego wyrażenia lambda.
Zadanie 3 . Napisz interfejs funkcjonalny z metodą pobierającą liczbę i zwracającą wartość logiczną. Napisz implementację takiego interfejsu w postaci wyrażenia lambda, które zwraca,
true
jeśli przekazana liczba jest podzielna przez 13 bez reszty.Zadanie
4 . Napisz interfejs funkcjonalny z metodą, która pobiera dwa ciągi znaków i zwraca ten sam ciąg. Napisz implementację takiego interfejsu w postaci lambdy zwracającej najdłuższy ciąg znaków.
Zadanie 5 . Napisz interfejs funkcjonalny za pomocą metody, która akceptuje trzy liczby ułamkowe: ,
a
i zwraca tę samą liczbę ułamkową. Napisz implementację takiego interfejsu w postaci wyrażenia lambda zwracającego dyskryminator. Kto zapomniał,
D = b^2 - 4ac .
Zadanie 6 . Korzystając z interfejsu funkcjonalnego z zadania 5, napisz wyrażenie lambda zwracające wynik operacji .
Popularny na temat wyrażeń lambda w Javie. Z przykładami i zadaniami. Część 2.b
c
a * b^c
GO TO FULL VERSION