1. Pojęcie kompozycji funkcji
Trochę teorii (ale bez zanudzania)
W matematyce kompozycja funkcji to sytuacja, gdy wynik jednej funkcji staje się wejściem dla innej. Jeśli mamy funkcje f i g, to kompozycja g(f(x)) oznacza: najpierw stosujemy f do x, potem wynik podstawiamy do g.
W programowaniu idea jest taka sama: chcemy budować złożone przekształcenia z prostych, aby nie pisać jednej gigantycznej funkcji od wszystkiego. To czyni kod elastycznym, łatwym do ponownego użycia i czytelnym.
Wyobraź sobie taśmę produkcyjną ciastek: najpierw ciasto (f), potem krem (g), potem posypka (h). Cały proces — to h(g(f(skladniki))).
Dlaczego kompozycja jest ważna?
- Kompozycja pozwala składać program z małych funkcji–„klocków”.
- Łatwiejsze ponowne użycie: jeden „klocek” można wstawić w różne miejsca bez duplikowania.
- Elastyczność: aby zmienić jeden etap, wymieniamy odpowiednią funkcję — reszty nie ruszamy.
- Czytelność i testowalność: małe funkcje są łatwiejsze do czytania, sprawdzania i utrzymania.
2. Metody compose i andThen w interfejsie Function
Interfejs Function: przypomnienie
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// Metody kompozycji:
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)
}
- compose: najpierw wykonywana jest funkcja przekazana do compose, a potem — bieżąca.
- andThen: najpierw wykonywana jest bieżąca funkcja, a potem — ta, która została przekazana do andThen.
Schemat wizualny
// Załóżmy, że mamy dwie funkcje:
Function<String, Integer> parse = s -> Integer.parseInt(s);
Function<Integer, Integer> square = x -> x * x;
// compose: square.compose(parse) == x -> square.apply(parse.apply(x))
"5" --parse--> 5 --square--> 25
// andThen: parse.andThen(square) == x -> square.apply(parse.apply(x))
"5" --parse--> 5 --square--> 25
// Ale kolejność ma znaczenie, jeśli typy są różne!
Przykład: konwersja łańcucha na liczbę, a następnie do kwadratu
import java.util.function.Function;
public class ComposeAndThenDemo {
public static void main(String[] args) {
// Funkcja: zamienia tekst na liczbę
Function<String, Integer> parse = s -> Integer.parseInt(s);
// Funkcja: podnosi liczbę do kwadratu
Function<Integer, Integer> square = x -> x * x;
// Połączmy: najpierw parsujemy, potem podnosimy do kwadratu
Function<String, Integer> parseThenSquare = parse.andThen(square);
System.out.println(parseThenSquare.apply("7")); // 49
// A jeśli zamienimy kolejność?
// square.compose(parse) — taki sam rezultat (dla tych funkcji)
Function<String, Integer> squareOfParsed = square.compose(parse);
System.out.println(squareOfParsed.apply("8")); // 64
}
}
Kiedy kolejność ma znaczenie?
Jeśli typy funkcji się nie zgadzają, kolejność staje się krytyczna. Na przykład:
Function<String, String> addPrefix = s -> "User: " + s;
Function<String, Integer> length = s -> s.length();
Function<String, Integer> composed = addPrefix.andThen(length);
System.out.println(composed.apply("Alice")); // "User: Alice" -> 11
// A tak:
// length.andThen(addPrefix) — błąd kompilacji!
// length zwraca Integer, a addPrefix przyjmuje String.
Tabela: różnica między compose i andThen
|
|
|
|
|---|---|---|---|
|
|
|
|
3. Kompozycja predykatów i innych interfejsów
Predicate<T>: and, or, negate
Funkcyjny interfejs Predicate<T> to funkcja zwracająca boolean. Do łączenia predykatów są specjalne metody:
- and: logiczne I (&&)
- or: logiczne LUB (||)
- negate: logiczne NIE (!)
Przykład: złożone warunki filtrowania
Załóżmy, że mamy klasę użytkownika:
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
Teraz napiszmy różne predykaty:
import java.util.function.Predicate;
Predicate<User> isAdult = user -> user.age >= 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
// Łączymy: pełnoletni i imię na "A"
Predicate<User> adultAndA = isAdult.and(nameStartsWithA);
// Pełnoletni lub imię na "A"
Predicate<User> adultOrA = isAdult.or(nameStartsWithA);
// Niepełnoletni
Predicate<User> notAdult = isAdult.negate();
Teraz możemy użyć tych predykatów przy filtrowaniu, np. za pomocą Stream API:
import java.util.List;
import java.util.stream.Collectors;
List<User> users = List.of(
new User("Alice", 20),
new User("Bob", 17),
new User("Anna", 15),
new User("Mike", 22)
);
List<User> filtered = users.stream()
.filter(adultAndA)
.collect(Collectors.toList());
// Do filtered trafi tylko Alice (pełnoletnia i imię na "A")
Kompozycja Consumer, Function, Supplier
- Consumer<T>: metoda andThen — pozwala wykonać dwie operacje jedna po drugiej.
- Function<T, R>: kompozycję omówiliśmy wyżej.
- Supplier<T>: bezpośrednio się nie łączy, ale może być używany wewnątrz innych funkcji.
Przykład: Consumer<T>.andThen
import java.util.function.Consumer;
Consumer<String> print = s -> System.out.println("Otrzymano: " + s);
Consumer<String> printUpper = s -> System.out.println("Wielkimi literami: " + s.toUpperCase());
Consumer<String> combined = print.andThen(printUpper);
combined.accept("hello");
// Wypisze:
// Otrzymano: hello
// Wielkimi literami: HELLO
4. Praktyka: łańcuchy przekształceń i filtrowania
Zadanie 1: Zbudować łańcuch przekształceń Function
Załóżmy, że w naszej aplikacji przechowujemy użytkowników jako łańcuchy "Imię,Wiek", na przykład "Alice,20". Trzeba:
- Przekształcić łańcuch w obiekt User
- Pobrać wiek
- Sprawdzić, czy użytkownik jest pełnoletni
import java.util.function.Function;
import java.util.function.Predicate;
Function<String, User> stringToUser = str -> {
String[] parts = str.split(",");
return new User(parts[0], Integer.parseInt(parts[1]));
};
Function<User, Integer> getAge = user -> user.age;
Predicate<Integer> isAdultAge = age -> age >= 18;
// Połączymy: tekst -> User -> wiek -> predykat
Function<String, Integer> stringToAge = stringToUser.andThen(getAge);
String input = "Bob,19";
int age = stringToAge.apply(input);
System.out.println("Wiek: " + age); // 19
System.out.println("Pełnoletni? " + isAdultAge.test(age)); // true
Zadanie 2: Łączyć kilka Predicate do filtrowania
Załóżmy, że chcemy wybrać użytkowników, którzy są starsi niż 18 i których imię zaczyna się na „A” lub „M”.
Predicate<User> isAdult = user -> user.age > 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
Predicate<User> nameStartsWithM = user -> user.name.startsWith("M");
Predicate<User> filter = isAdult.and(nameStartsWithA.or(nameStartsWithM));
List<User> filtered = users.stream()
.filter(filter)
.collect(Collectors.toList());
Zadanie 3: Wieloetapowe przekształcenie Function
Załóżmy, że trzeba: pobrać łańcuch, przyciąć spacje, zamienić na wielkie litery, dodać prefiks "USER: ".
Function<String, String> trim = String::trim;
Function<String, String> toUpper = String::toUpperCase;
Function<String, String> addPrefix = s -> "USER: " + s;
// Budujemy łańcuch
Function<String, String> pipeline = trim.andThen(toUpper).andThen(addPrefix);
System.out.println(pipeline.apply(" user ")); // USER: VASYA
5. Typowe błędy przy kompozycji funkcji
Błąd nr 1: Pomylenie kolejności compose/andThen.
Początkujący często mylą, co wykonywane jest pierwsze, a co — drugie. Zapamiętaj: f.compose(g) — najpierw g, potem f; f.andThen(g) — najpierw f, potem g.
Błąd nr 2: Niezgodność typów.
Jeśli typ wyniku jednej funkcji nie zgadza się z typem parametru drugiej — kompilator nie pozwoli ich połączyć. Na przykład nie da się zrobić Function<Integer, String>.andThen(Function<Double, Boolean>).
Błąd nr 3: Zbyt skomplikowane łańcuchy.
Czasem kusi, by zmieścić całą logikę biznesową w jednym łańcuchu i otrzymać „spaghetti”. Dziel je na mniejsze funkcje i nadawaj im zrozumiałe nazwy.
Błąd nr 4: Efekty uboczne w funkcjach.
Funkcje i predykaty najlepiej utrzymywać „czyste” (bez efektów ubocznych), w przeciwnym razie kompozycja staje się ryzykowna i nieprzewidywalna.
GO TO FULL VERSION