Gói java.util.function43 giao diện. Nghe đáng sợ, nhưng thực ra chỉ có bốn cần nhớ; số còn lại là biến thể của chúng.

Bốn cái cốt lõi

Function<String,Integer> doDai = String::length;   // vào một, ra một
Supplier<String> nguon = () -> "xin chào";         // không vào, ra một
Consumer<String> in = s -> System.out.println(s);  // vào một, không ra
Predicate<String> rong = String::isBlank;          // vào một, ra boolean
  Function : "java" -> 4
  Supplier : xin chào
  Consumer : đã nhận
  Predicate: "  " rỗng? true

Phân biệt bằng hình dạng, không phải bằng tên:

Nhận gì Trả gì Phương thức
Function<T,R> T R apply
Supplier<T> T get
Consumer<T> T accept
Predicate<T> T boolean test

Predicate thực ra là Function<T,Boolean>, nhưng có riêng vì nó quá phổ biến — và vì nó tránh được việc đóng hộp Boolean.

Ba biến thể hay dùng nữa:

BiFunction<String,Integer,String> lap = (s, n) -> s.repeat(n);   // hai vào, một ra
BinaryOperator<Integer> cong = Integer::sum;                      // hai vào cùng kiểu
UnaryOperator<String> hoa = String::toUpperCase;                  // vào ra cùng kiểu
  BiFunction     : ababab
  BinaryOperator : 5
  UnaryOperator  : JAVA

UnaryOperator<T> chỉ là Function<T,T> viết gọn, BinaryOperator<T>BiFunction<T,T,T>. Chúng tồn tại vì đọc dễ hơn — thấy UnaryOperator<String> là biết ngay vào ra cùng kiểu.

Ghép hàm lại

Đây là phần khiến chúng đáng dùng hơn là tự khai interface riêng.

Function<Integer,Integer> nhanHai = x -> x * 2;
Function<Integer,Integer> congBa  = x -> x + 3;
  andThen (nhân trước, cộng sau): 13
  compose (cộng trước, nhân sau): 16

f.andThen(g) chạy f trước rồi g. f.compose(g) thì ngược lại — g trước.

Cách nhớ: andThen đọc theo thứ tự tự nhiên trong câu; compose theo ký hiệu toán học f∘g, tức g áp dụng trước.

Với Predicate thì có ba phép logic:

  and    : [Minh]
  or     : [Minh, An, Phuong]
  negate : [An]
ds.stream().filter(truongThanh.and(tenNgan))

Cái hay là mỗi điều kiện được đặt tên riêng và tái sử dụng được, thay vì nhét hết vào một lambda dài. So sánh:

.filter(n -> n.tuoi() >= 18 && n.ten().length() <= 4)          // một cục
.filter(truongThanh.and(tenNgan))                               // nói ra ý định

Bản thứ hai còn cho phép dựng điều kiện lúc chạy:

Predicate<Nguoi> loc = n -> true;
if (chiTruongThanh) loc = loc.and(truongThanh);
if (chiTenNgan)     loc = loc.and(tenNgan);

Đây là mẫu rất hữu ích khi làm bộ lọc động từ tham số tìm kiếm.

Java 11 thêm Predicate.not cho trường hợp không dùng được negate vì đang viết method reference:

  lọc chuỗi không rỗng: [a, b]
.filter(Predicate.not(String::isBlank))

Biến thể cho kiểu nguyên thuỷ, và cái giá của việc quên chúng

IntPredicate chan = x -> x % 2 == 0;
IntUnaryOperator binhPhuong = x -> x * x;
IntFunction<String> sangChuoi = x -> "so-" + x;
ToIntFunction<String> lay = String::length;

Vì sao cần chúng? Vì generics không nhận kiểu nguyên thuỷ — Function<int,int> không tồn tại, phải viết Function<Integer,Integer>, và mỗi lần gọi là hai lần đóng/mở hộp.

Đo trên một triệu lần gọi:

DongHop.coDongHop     avgt  932.583 us/op    <- Function<Integer,Integer>
DongHop.khongDongHop  avgt  271.597 us/op    <- IntUnaryOperator

Chậm hơn 3,4 lần, và mỗi lần gọi tạo một đối tượng Integer cho GC dọn sau.

Cách đọc tên biến thể, một khi nắm quy luật thì không cần thuộc:

Tiền tố Nghĩa Ví dụ
Int / Long / Double + tên tham số là kiểu đó IntPredicate, IntConsumer
To + kiểu + Function trả về kiểu đó ToIntFunction<T>
kiểu + To + kiểu + Function vào một kiểu, ra kiểu khác IntToDoubleFunction
Obj + kiểu + Consumer nhận một đối tượng và một số ObjIntConsumer<T>

Chỉ có ba kiểu nguyên thuỷ được hỗ trợ: int, long, double. Với byte, short, char, float thì phải dùng bản int/double hoặc chịu đóng hộp.

Khi nào cần quan tâm? Khi lambda chạy hàng triệu lần. Với mã nghiệp vụ thông thường thì chênh lệch 0,7 micro giây cho một triệu lần gọi là không đáng kể — và Function<Integer,Integer> đọc dễ hơn. Nhưng trong vòng lặp nóng, hoặc khi xử lý mảng số lớn, hãy dùng biến thể nguyên thuỷ. Đây cũng là lý do IntStream tồn tại tách khỏi Stream<Integer>, chuyện của mấy bài tới.

Tự khai giao diện hàm khi nào

Bốn cái cốt lõi phủ gần hết. Nhưng đôi khi khai riêng vẫn tốt hơn:

Khi tên nói lên ý nghĩa nghiệp vụ:

@FunctionalInterface
interface TinhPhiVanChuyen {
    long tinh(DonHang don, DiaChi diaChi);
}

đọc rõ hơn BiFunction<DonHang, DiaChi, Long> nhiều — và tránh luôn việc đóng hộp Long.

Khi cần ném checked exception. Không có giao diện chuẩn nào cho phép:

@FunctionalInterface
interface HamCoTheHong<T,R> {
    R apply(T t) throws Exception;
}

Khi cần hơn hai tham số. JDK dừng ở hai; ba tham số thì phải tự khai, hoặc gom lại thành một record — tôi thường chọn cách thứ hai.

Luôn đặt @FunctionalInterface: nó không bắt buộc, nhưng khiến trình biên dịch báo lỗi nếu ai đó lỡ thêm phương thức trừu tượng thứ hai, thay vì để mọi lambda đang dùng gãy hàng loạt.

Vài chỗ hay vấp

Consumer trả về void, nên không ghép chuỗi kiểu map được. Nó có andThen để chạy nối tiếp hai consumer, chỉ vậy.

Supplier là cách hoãn tính toán. Đây chính là khác biệt orElseorElseGet ở bài Optional, và cũng là lý do log.debug("{}", () -> tinhTonKem()) không tốn gì khi DEBUG tắt.

Lambda không nhận checked exception. list.forEach(this::ghiFile) sẽ không biên dịch nếu ghiFile khai throws IOException. Cách xử lý: bọc trong try-catch bên trong lambda, hoặc tự khai giao diện như trên.

Đừng dùng Function cho thứ có tác dụng phụ. Tên gọi ngụ ý thuần tuý; nếu nó ghi file hay gọi mạng thì người đọc sẽ bất ngờ.

Thử ba mươi giây

Viết Function<Integer,Integer> f = x -> x + 1; rồi đo một triệu lần gọi. Đổi sang IntUnaryOperator và đo lại.

Nếu bạn đang xử lý mảng số lớn ở đâu đó trong dự án, con số chênh lệch sẽ nói cho bạn biết có đáng đổi không — và câu trả lời thường là "có, nhưng chỉ ở đúng chỗ đó".

Ngày mai: method reference — bốn dạng, và vì sao String::toUpperCase có lúc là "gọi trên tham số" có lúc lại là "gọi trên đối tượng đã có".