1. Khái niệm về kết hợp (composition) hàm
Một chút lý thuyết (nhưng không quá khô khan)
Trong toán học, kết hợp (composition) của các hàm là khi kết quả của một hàm trở thành đầu vào của hàm khác. Nếu ta có các hàm f và g, thì kết hợp g(f(x)) có nghĩa là: trước hết áp dụng f cho x, sau đó đưa kết quả vào g.
Trong lập trình, ý tưởng cũng vậy: ta muốn ghép những chuyển đổi phức tạp từ các phần đơn giản, thay vì viết một hàm khổng lồ cho mọi thứ. Điều này làm cho mã linh hoạt, tái sử dụng được và dễ đọc.
Hãy hình dung dây chuyền làm bánh: trước là bột (f), rồi kem (g), rồi rắc topping (h). Toàn bộ quy trình là h(g(f(ingredients))).
Vì sao kết hợp hàm quan trọng?
- Kết hợp cho phép xây dựng chương trình từ các “khối” hàm nhỏ.
- Dễ tái sử dụng: một “khối” có thể đưa vào nhiều chỗ mà không cần lặp lại.
- Linh hoạt: để thay đổi một bước, chỉ cần thay hàm tương ứng — phần còn lại không phải đụng tới.
- Dễ đọc và dễ kiểm thử: hàm nhỏ dễ đọc, dễ kiểm tra và bảo trì hơn.
2. Các phương thức compose và andThen trong Function
Interface Function: nhắc lại
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// Các phương thức để kết hợp:
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: trước hết chạy hàm bạn truyền vào compose, sau đó mới đến hàm hiện tại.
- andThen: trước hết chạy hàm hiện tại, sau đó là hàm được truyền vào andThen.
Sơ đồ trực quan
// Giả sử có hai hàm:
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
// Nhưng thứ tự sẽ quan trọng nếu kiểu khác nhau!
Ví dụ: chuyển chuỗi thành số, rồi bình phương
import java.util.function.Function;
public class ComposeAndThenDemo {
public static void main(String[] args) {
// Hàm: chuyển chuỗi thành số
Function<String, Integer> parse = s -> Integer.parseInt(s);
// Hàm: bình phương một số
Function<Integer, Integer> square = x -> x * x;
// Kết hợp: parse trước rồi bình phương
Function<String, Integer> parseThenSquare = parse.andThen(square);
System.out.println(parseThenSquare.apply("7")); // 49
// Nếu đảo thứ tự thì sao?
// square.compose(parse) — kết quả tương tự (với các hàm này)
Function<String, Integer> squareOfParsed = square.compose(parse);
System.out.println(squareOfParsed.apply("8")); // 64
}
}
Khi nào thứ tự quan trọng?
Khi kiểu của các hàm không khớp nhau, thứ tự trở nên then chốt. Ví dụ:
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
// Còn thế này:
// length.andThen(addPrefix) — lỗi biên dịch!
// length trả về Integer còn addPrefix nhận String.
Bảng: khác biệt giữa compose và andThen
|
|
|
|
|---|---|---|---|
|
|
|
|
3. Kết hợp predicate và các interface khác
Predicate<T>: and, or, negate
Interface hàm Predicate<T> là một hàm trả về boolean. Để kết hợp các predicate có các phương thức chuyên biệt:
- and: phép logic AND (&&)
- or: phép logic OR (||)
- negate: phủ định (NOT) (!)
Ví dụ: điều kiện lọc phức tạp
Giả sử chúng ta có lớp người dùng:
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
Bây giờ viết vài predicate:
import java.util.function.Predicate;
Predicate<User> isAdult = user -> user.age >= 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
// Kết hợp: người lớn và tên bắt đầu bằng "A"
Predicate<User> adultAndA = isAdult.and(nameStartsWithA);
// Người lớn hoặc tên bắt đầu bằng "A"
Predicate<User> adultOrA = isAdult.or(nameStartsWithA);
// Không phải người lớn
Predicate<User> notAdult = isAdult.negate();
Bây giờ có thể dùng các predicate này khi lọc, ví dụ với 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());
// Trong filtered chỉ có Alice (đã trưởng thành và tên bắt đầu bằng "A")
Kết hợp Consumer, Function, Supplier
- Consumer<T>: phương thức andThen — cho phép thực hiện hai thao tác liên tiếp.
- Function<T, R>: phần kết hợp đã xem ở trên.
- Supplier<T>: không kết hợp trực tiếp, nhưng có thể dùng bên trong các hàm khác.
Ví dụ: Consumer<T>.andThen
import java.util.function.Consumer;
Consumer<String> print = s -> System.out.println("Nhận được: " + s);
Consumer<String> printUpper = s -> System.out.println("Chữ hoa: " + s.toUpperCase());
Consumer<String> combined = print.andThen(printUpper);
combined.accept("hello");
// Sẽ in:
// Nhận được: hello
// Chữ hoa: HELLO
4. Thực hành: chuỗi biến đổi và lọc
Bài 1: Xây dựng chuỗi biến đổi Function
Giả sử trong ứng dụng, ta lưu người dùng dưới dạng chuỗi "Tên,Tuổi", ví dụ "Alice,20". Cần:
- Chuyển chuỗi thành đối tượng User
- Lấy tuổi
- Kiểm tra người dùng đã trưởng thành hay chưa
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;
// Kết hợp: String -> User -> tuổi -> predicate
Function<String, Integer> stringToAge = stringToUser.andThen(getAge);
String input = "Bob,19";
int age = stringToAge.apply(input);
System.out.println("Tuổi: " + age); // 19
System.out.println("Đã thành niên? " + isAdultAge.test(age)); // true
Bài 2: Kết hợp nhiều Predicate để lọc
Giả sử ta cần chọn người dùng lớn hơn 18 tuổi và tên bắt đầu bằng "A" hoặc "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());
Bài 3: Biến đổi nhiều bước với Function
Giả sử cần: nhận một chuỗi, cắt khoảng trắng, chuyển sang chữ hoa, thêm tiền tố "USER: ".
Function<String, String> trim = String::trim;
Function<String, String> toUpper = String::toUpperCase;
Function<String, String> addPrefix = s -> "USER: " + s;
// Tạo pipeline
Function<String, String> pipeline = trim.andThen(toUpper).andThen(addPrefix);
System.out.println(pipeline.apply(" vasya ")); // USER: VASYA
5. Những lỗi thường gặp khi kết hợp hàm
Lỗi 1: Nhầm thứ tự compose/andThen.
Người mới thường nhầm phần nào chạy trước, phần nào chạy sau. Hãy nhớ: f.compose(g) — đầu tiên g, sau đó f; f.andThen(g) — đầu tiên f, sau đó g.
Lỗi 2: Kiểu không khớp.
Nếu kiểu kết quả của một hàm không khớp kiểu tham số của hàm kia — trình biên dịch sẽ không cho ghép. Ví dụ, không thể làm Function<Integer, String>.andThen(Function<Double, Boolean>).
Lỗi 3: Chuỗi quá phức tạp.
Đôi khi muốn nhét toàn bộ business logic vào một chuỗi và nhận lại “spaghetti”. Hãy tách thành các hàm nhỏ và đặt tên dễ hiểu.
Lỗi 4: Tác dụng phụ (side effect) trong hàm.
Tốt nhất giữ các hàm và predicate “thuần” (không có tác dụng phụ), nếu không việc kết hợp sẽ trở nên nguy hiểm và khó lường.
GO TO FULL VERSION