· rszmit · 9 min
Wyzwanie Java #7: Programowanie funkcyjne
Do tej pory cały czas mówiliśmy, że Java jest językiem obiektowym. Poświęciliśmy temu zagadnieniu dwa dedykowane wyzwania, choć tak naprawdę już od pierwszego programu, gdyż użyliśmy słowa kluczowego class programowaliśmy obiektowo w Javie. Tak naprawdę tylko typy proste, ze względu na pewne optymalizacje, nie są obiektami, czyli jak już wiemy z poprzednich wyzwań, nie dziedziczą po java.lang.Object.
Jednak programowanie obiektowe nie jest jedynym paradygmatem programowania jaki może wykorzystywać język programowania. Aktualnie coraz szersze zastosowania znajduje programowanie funkcyjne. Ma ono swoje podstawy już w latach trzydziestych XX wieku gdy to Alonzo Church opracował rachunek lambda. Dla przypomnienia, język Simula wprowadzający programowanie obiektowe został opracowany dopiero w latach sześćdziesiątych XX wieku. Pierwszym językiem funkcyjnym był IPL (Information Processing Language), jednak popularyzacja tego paradygmatu zaczęła się wraz z pojawieniem się języka LISP. Jednym z najpopularniejszych funkcyjnych języków programowania jest Haskell.
W programowaniu funkcyjnym, w odróżnieniu od programowania obiektowego, najważniejszym i zarazem jedynym narzędziem są funkcje. Funkcje mogą przyjmować na wejściu także funkcje oraz zwracać funkcje jako wynik. W przeciwieństwie do podejścia “klasycznego” gdzie opisywaliśmy zawsze jak coś zrobić krok po kroku, tutaj musimy napisać co ma być zrobione a już kompilator zajmie się całą resztą. Programista nie steruje także kolejnością wykonywania działań, definiuje jedynie szereg matematycznych zależności i funkcji. Pozbywamy się także wielu problemów znanych ze świata obiektowego, czyli stan obiektu i jego synchronizacja w aplikacjach wielowątkowych - w programowaniu funkcyjnym nie ma czegoś takiego, przez co pisanie wielowątkowych i rozproszonych systemów staje się prostsze. W językach funkcyjnych także kod potrafi być od kilku do kilkudziesięciu razy zwięźlejszy i czytelniejszy niż analogiczny algorytm napisany w języku obiektowym. Dzięki tym zaletom, programowanie funkcyjne aktualnie rozkwita i jest coraz częściej stosowane. Niestety, nie bez powodu czekaliśmy na ten moment tyle lat - takie podejście ma także swoje wady. Bardzo często programy stworzone w językach funkcyjnych są po prostu wolne. Nie da się także każdego problemu informatycznego tak łatwo przedstawić w postaci zbioru zależnych od siebie funkcji matematycznych bez uciekania się do tego wszystkiego co znamy z języków obiektowych. Dlatego też coraz więcej powstaje języków hybrydowych, które pozwalają pisać swój kod zarówno w sposób obiektowy jak i funkcyjny.
Java od wersji 8 także zyskała wiele mechanizmów znanych z paradygmatu programowania funkcyjnego. Niektórzy twierdzą, że jest to najbardziej rewolucyjna i znacząca aktualizacja JVM. Mimo to, Java w dalszym ciągu jest językiem obiektowym. W tym poście będziemy chcieli poznać podstawy programowania funkcyjnego w Javie i nowości które weszły wraz z wersją 8.
Wyrażenia lambda
Przed chwilą dowiedzieliśmy się, że z językami funkcyjnymi związane są lambdy. Pomijając matematyczne wywody, lambda to tak naprawdę kawałek kodu, który można przekazać do innego kawałka programu w celu wykonania. Czyli parametrem nie jest obiekt czy wartość, tylko nasz kod.
Wyobraźmy sobie, że mamy kolekcję obiektów i chcemy tą kolekcję posortować. Zrobilibyśmy pewnie tak:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class LambdaExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words); // sortowanie kolekcji według naturalnego porządku
System.out.println(words);
}
}
Powyższy kod korzysta z klasy Collections i wbudowanej tam metody sort, która wykonała nam co trzeba. Ale co jeśli chcielibyśmy posortować w odwrotnej kolejności lub z zachowaniem własnych innych reguł? Do tego służy tak zwany komparator:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class LambdaExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words, new WordComparator()); // sortowanie kolekcji odwrócone
System.out.println(words);
}
}
class WordComparator implements Comparator<String> {
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
}
W powyższym kodzie musieliśmy stworzyć dodatkową klasę która definiuje warunek sortowania. Moglibyśmy także tą klasę zdefiniować we wnętrzu klasy LambdaExample, tworząc tak zwaną klasę wewnętrzną:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class LambdaExample {
static class WordComparator implements Comparator<String> {
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
}
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words, new WordComparator()); // sortowanie kolekcji odwrócone
System.out.println(words);
}
}
Gdybyśmy chcieli uprościć nasz kod, możemy napisać coś takiego:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class LambdaExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
}); // sortowanie kolekcji odwrócone
System.out.println(words);
}
}
Wykorzystaliśmy do tego tak zwaną klasę anonimową, czyli taką która nie ma nazwy (wcześniej WordComparator), mimo że robi dokładnie to samo co wcześniej. Często tworzenie takich klas “w locie” jest po prostu łatwiejsze i szybsze, niż definiowanie nowych klas i wymyślanie im nazw. Jedyny problem jest taki, że z takiej klasy nie skorzystamy już gdzieś indziej, tylko w tym miejscu naszego kodu.
Dzięki wyrażeniom lambda nasz kod może się uprościć jeszcze bardziej:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class LambdaExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words, (String o1, String o2) -> {return o2.compareTo(o1);}); // lambda
System.out.println(words);
}
}
Składnia lambdy jest dość prosta:
(parametry...) -> {kod}
Jeśli nie ma parametrów, wystarczy podać pusty nawias. Jeśli parametry mają typ którego kompilator potrafi się domyślić, można je pominąć:
Collections.sort(words, (o1, o2) -> {return o2.compareTo(o1);});
Jeśli kod jest tylko pojedynczym wyrażeniem, można pominąć klamry i słowo “return” otrzymując:
Collections.sort(words, (o1, o2) -> o2.compareTo(o1));
Lambdy można tworzyć tam gdzie parametrem jest interfejs z jedną metodą abstrakcyjną, w pozostałych przypadkach trzeba zaimplementować go standardowo. Takie interfejsy nazywamy funkcyjnymi.
Referencje do metod
Jeśli metoda której chcemy użyć w naszym komparatorze już istnieje, możemy jej także użyć w naszym wyrażeniu lambda. Możemy ją po prostu wywołać tak jak wcześniej:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class LambdaExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words, (o1, o2) -> o1.compareTo(o2)); // sortowanie kolekcji według naturalnego porządku
System.out.println(words);
}
}
ale możemy też przekazać samą funkcję compareTo
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class LambdaExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
Collections.sort(words, String::compareTo); // sortowanie kolekcji według naturalnego porządku
System.out.println(words);
}
}
Powyższe dwa kawałki kodu są identyczne w działaniu, zaś drugi jest dużo prostszy do napisania. Na uwagę zasługuje znak ”::” wskazujący, że przekazujemy funkcję do wykonania.
Biblioteka strumieni
Ostatnią dużą nowością, która weszła wraz z Javą w wersji 8 jest biblioteka strumieni. Umożliwiają one operacje na kolekcjach w zupełnie inny sposób, z wykorzystaniem tak zwanej wewnętrznej iteracji. Strumienie to tak naprawdę ciąg elementów, jeden po drugim, na których możemy wykonywać różne operacje.
Wyobraźmy sobie, że mając ten sam zbiór słów co wyżej, chcemy je wszystkie wyświetlić. Znając pętle z języka Java, moglibyśmy napisać taki kod:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
for (String word : words) {
System.out.println(word);
}
}
}
Dzięki strumieniom powyższą pętlę foreach można zamienić na stream:
words.stream().forEach((String s) -> {System.out.println(s);});
lub łatwiej:
words.stream().forEach(s -> System.out.println(s));
czy nawet:
words.stream().forEach(System.out::println);
Cały program wtedy będzie wyglądać tak:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
words.stream().forEach(System.out::println);
}
}
Gdybyśmy chcieli teraz dla przykładu zostawić tylko słowa dłuższe lub równe 3 literom możemy napisać tak:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
words.stream()
.filter(s -> s.length() > 3)
.forEach(System.out::println);
}
}
Możemy też z łatwością zliczyć ile było elementów jednocześnie je wyświetlając:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
long count = words.stream()
.filter(s -> s.length() > 3)
.peek(System.out::println)
.count();
System.out.println("Elementów: " + count);
}
}
Należy tylko zauważyć, że zmieniliśmy funkcję forEach na peek gdyż ta pierwsza wymaga zakończenia operacji na strumieniu, ta druga zaś pozwala dalej wykonywać operacje jak choćby nasz “count”.
Dzięki strumieniom możemy także łatwo przekształcać nasze zbiory w inne:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StreamExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
List<String> newWords = words.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
newWords.stream()
.filter(s -> s.length() > 3)
.forEach(System.out::println);
}
}
W powyższym przykładzie kolekcja słów została zamieniona w kolekcję, że każde słowo jest pisane dużymi literami. Pobranie wyniku przetwarzania dzieje się w metodzie collect.
Powyższy program można by było uprościć także do takiej postaci:
package pl.kodolamacz.func;
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Ala", "ma", "czarnego", "kota", "o", "imieniu", "Bonifacy");
words.stream()
.map(String::toUpperCase)
.filter(s -> s.length() > 3)
.forEach(System.out::println);
}
}
Jak widać, biblioteka strumieni w Javie jest zarazem bardzo prosta i przejrzysta a jednocześnie oferuje niezwykle bogaty wachlarz funkcjonalności. Napisanie powyższych fragmentów kodu z użyciem pętli zamiast strumieni, kod byłby bardziej zawiły i wymagał chwili zastanowienia co tam się dzieje.
Wyzwanie
Czas na nasze wyzwanie. Chcielibyśmy, by za pomocą poznanej wiedzy, czyli strumieni, wyrażeń lambda oraz referencji do metod napisali program, który wczyta zawartość pliku z filmami ze zbioru MovieLens (zbiór MovieLens 20M Dataset, plik movies.csv) i wyliczy następujące rzeczy:
- Łączną ilość filmów w pliku
- Przedział lat w jakim te filmy wyszły (daty są w nawiasie w tytule)
- Najczęstszy gatunek filmowy
- Ile filmów znajduje się w każdym gatunku filmowym
Wynik ma być wyświetlony w konsoli.
Rozwiązanie wyzwania #7 opublikujemy w piątek na stronie naszego wydarzenia na Facebooku.
Jak uda się Wam poprawnie wykonać zadanie - pochwalcie się tym koniecznie w przeznaczonym do tego poście na FB!
To już ostatnie zadanie w ramach naszego wyzwania z podstaw Javy. Jeżeli nie jesteście na bieżąco z materiałem, zachęcamy do nadrobienia poprzednich zadań!
Gotowe rozwiązanie zadania 7 znajdziecie tutaj.
Wszystkie wspisy z serii #javowewyzwanie:
Wyzwanie 2 - Podstawowe instrukcje
Wyzwanie 3 - Programowanie obiektowe
Wyzwanie 4 - Algorytmy i struktury danych w języku Java
Wyzwanie 5 - Interfejsy i dziedziczenie
Wyzwanie 6 - Operacje wejścia - wyjścia
Wyzwanie 7 - Programowanie funkcyjne
Materiały dodatkowe do wyzwania:
Wprowadzenie do świata języka Java
Czego się uczyć by zostać programistą?
Java od środka, czyli jak to wszystko działa?
Wprowadzenie do Apache Maven, czyli jak tworzy się projekty w świecie Java