Backend

99 Ngày Java — Ngày 52: Functional interface

SSite Admin
17 tháng 09, 2026 8 phút đọc 0 lượt xem
99 Ngày Java — Ngày 52: Functional interface

Hôm qua ta viết được lambda, nhưng còn một câu hỏi chưa trả lời: lambda đó có kiểu gì? Java không có kiểu hàm như nhiều ngôn ngữ khác, nên mỗi lambda buộc phải "đội lốt" một interface. Hôm nay là bộ interface cả ngành dùng chung — bốn cái chính, và bạn phân biệt chúng bằng đúng hai câu hỏi: có nhận gì vào không, có trả gì ra không.

Sketchnote Ngày 52: functional interface — bốn interface Function Consumer Supplier Predicate phân biệt bằng nhận và trả, biến thể nguyên thủy tránh boxing, andThen và compose, và khi nào nên tự viết interface

Bốn interface, phân biệt bằng hai câu hỏi

Đừng học thuộc bảng. Chỉ cần hỏi hai câu: nó nhận gì vàonó trả gì ra. Nhận vào có trả ra là Function; nhận vào không trả ra là Consumer; không nhận mà có trả là Supplier; trả về booleanPredicate. Xong.

// BỐN INTERFACE — nhớ được bốn cái này là đọc được 90% code Java hiện đại.
// Tất cả nằm trong java.util.function, và phân biệt nhau bằng ĐÚNG HAI câu hỏi:
//   ① Nó có NHẬN gì vào không?   ② Nó có TRẢ gì ra không?

//                nhận  trả      method        dùng khi
Function<T,R>   //  T     R      apply()       BIẾN ĐỔI: từ cái này ra cái kia
Consumer<T>     //  T     —      accept()      LÀM GÌ ĐÓ rồi thôi (in, lưu, gửi)
Supplier<T>     //  —     T      get()         SINH RA giá trị (không cần đầu vào)
Predicate<T>    //  T   boolean  test()        HỎI ĐÚNG/SAI (lọc, kiểm tra)

Function<String, Integer> doDai = s -> s.length();
Consumer<String>          in    = s -> System.out.println(s);
Supplier<LocalDate>       homNay = () -> LocalDate.now();
Predicate<String>         rong  = s -> s.isEmpty();

// ⭐ VÌ SAO PHẢI CÓ BỐN CÁI RIÊNG mà không dùng một cái chung?
//    Vì Java KHÔNG có kiểu hàm như các ngôn ngữ khác. Một lambda BẮT BUỘC
//    phải có một interface để "đội lốt". Bốn interface này là bốn hình dạng
//    phổ biến nhất, được đưa sẵn vào thư viện chuẩn để cả ngành dùng chung tên gọi.

// ❗ TRÁNH BOXING với kiểu nguyên thủy — đây là lý do có cả một họ biến thể:
Function<Integer, Integer> cham = x -> x * 2;   // ❌ mỗi lần gọi: unbox → tính → box lại
IntUnaryOperator          nhanh = x -> x * 2;   // ✅ int thuần, không sinh rác
// Còn có IntPredicate, ToIntFunction, IntSupplier... Với vòng lặp triệu phần tử,
// khác biệt này là thật, không phải tối ưu vặt.

// BIẾN THỂ HAI THAM SỐ — khi một tham số là không đủ:
BiFunction<String, Integer, String> lap = (s, n) -> s.repeat(n);
BinaryOperator<Integer> cong = (a, b) -> a + b;   // BiFunction<T,T,T> viết gọn
UnaryOperator<String>   hoa  = s -> s.toUpperCase();  // Function<T,T> viết gọn
  • Java không có kiểu hàm, nên lambda buộc phải có interface để đội lốt — bốn cái này là bốn hình dạng phổ biến nhất, đưa sẵn vào thư viện chuẩn.

  • Biến thể nguyên thủy tồn tại để tránh boxing: IntUnaryOperator không sinh rác, còn Function<Integer,Integer> thì unbox rồi box lại mỗi lần gọi.

  • BiFunction cho hai tham số; UnaryOperatorBinaryOperator là cách viết gọn khi các kiểu trùng nhau.

Ghép hàm — và giá trị thật nằm ở cái tên

andThencompose cho cùng kết quả nhưng đọc ngược chiều nhau. Lời khuyên thực dụng: chỉ dùng andThen, vì nó đọc theo đúng thứ tự xảy ra. Nhưng thứ đáng giá nhất trong phần này không phải cú pháp ghép — mà là việc điều kiện lọc có tên và tái sử dụng được.

// GHÉP HÀM — chỗ mấy interface này thật sự phát huy sức mạnh.

Function<String, String> bo   = String::trim;
Function<String, String> hoa  = String::toUpperCase;

// andThen: LÀM CÁI NÀY TRƯỚC, rồi mới tới cái kia (đọc từ TRÁI sang PHẢI)
Function<String, String> lam1 = bo.andThen(hoa);      // trim rồi mới upper
// compose: NGƯỢC LẠI — cái trong ngoặc chạy TRƯỚC (đọc từ PHẢI sang TRÁI)
Function<String, String> lam2 = hoa.compose(bo);      // cũng là trim rồi upper

// ❗ HAI CÁI TRÊN CHO KẾT QUẢ GIỐNG NHAU, nhưng đọc ngược chiều nhau.
//    Lời khuyên thực dụng: CHỈ DÙNG andThen. Nó đọc theo thứ tự xảy ra,
//    và người đọc code không phải dừng lại để đảo ngược trong đầu.

// PREDICATE ghép bằng and / or / negate — thay cho một rừng if lồng nhau:
Predicate<DonHang> daTra   = d -> d.trangThai() == DA_TRA;
Predicate<DonHang> lonHon1M = d -> d.tong().compareTo(MOT_TRIEU) > 0;

var donVip = daTra.and(lonHon1M);            // ⭐ đọc gần như tiếng Việt
var chuaTra = daTra.negate();
danhSach.stream().filter(donVip).toList();

// ⭐ GIÁ TRỊ THẬT KHÔNG PHẢI Ở CÚ PHÁP, MÀ Ở CHỖ ĐẶT TÊN:
//    Điều kiện lọc giờ có TÊN và TÁI SỬ DỤNG được, thay vì một biểu thức
//    dài lặp lại ở năm chỗ khác nhau trong dự án.

// CONSUMER cũng ghép được — chạy TUẦN TỰ, không phải thay thế nhau:
Consumer<DonHang> ghiLog = d -> log.info("Xử lý {}", d.id());
Consumer<DonHang> guiMail = d -> email.gui(d);
ghiLog.andThen(guiMail).accept(don);     // cả hai đều chạy, theo đúng thứ tự

// ❗ Nếu ghiLog ném ngoại lệ thì guiMail KHÔNG chạy — chuỗi dừng ngay tại đó.
  • Predicate ghép bằng and, or, negate — thay cho một rừng if lồng nhau, và đọc gần như tiếng Việt.

  • Đặt tên cho điều kiện quan trọng hơn việc ghép: donVip nói rõ ý định, còn một biểu thức dài lặp ở năm chỗ thì không.

  • Consumer.andThen chạy tuần tự cả hai, không phải thay thế nhau — nhưng nếu cái đầu ném ngoại lệ thì cái sau không chạy.

Khi nào nên tự viết interface

Đừng viết lại Predicate dưới một cái tên khác — dùng tên chuẩn thì người mới vào dự án hiểu ngay. Nhưng có ba trường hợp tự viết là đúng: khi cái tên mang ý nghĩa nghiệp vụ, khi cần ném checked exception, và khi muốn đặt tên cho một quy tắc hay thay đổi.

// TỰ VIẾT FUNCTIONAL INTERFACE — khi nào nên, và khi nào đừng.

// ❌ ĐỪNG viết lại thứ đã có tên trong thư viện chuẩn:
@FunctionalInterface
interface KiemTraChuoi { boolean kiem(String s); }   // đây chính là Predicate<String>
// Dùng lại tên chuẩn thì người mới vào dự án hiểu ngay, khỏi đọc thêm.

// ✅ NÊN viết khi cái tên mang Ý NGHĨA NGHIỆP VỤ:
@FunctionalInterface
interface ChinhSachGiamGia {
    BigDecimal tinh(DonHang don, KhachHang khach);
}
// So sánh hai cách khai báo cùng một thứ:
BiFunction<DonHang, KhachHang, BigDecimal> cs;   // đúng kiểu, nhưng vô hồn
ChinhSachGiamGia cs;                              // ⭐ đọc là hiểu ngay nó làm gì
// Với người đọc code sáu tháng sau, khác biệt này lớn hơn bạn tưởng.

// ✅ NÊN viết khi cần NÉM CHECKED EXCEPTION — đây là hạn chế thật của bộ chuẩn:
list.forEach(f -> Files.delete(f));      // ❌ Files.delete ném IOException (checked)
                                          //    mà Consumer.accept KHÔNG khai ném gì cả

@FunctionalInterface
interface ConsumerNemDuoc<T> { void accept(T t) throws Exception; }
// ⭐ ĐÂY LÀ ĐIỂM ĐAU LỚN NHẤT của lập trình hàm trong Java: toàn bộ
//    java.util.function KHÔNG khai ném checked exception. Nên trong stream,
//    bạn hoặc bọc lại thành unchecked, hoặc tự định nghĩa interface như trên.
//    (Ngày 41–43 đã nói về checked vs unchecked — đây là hệ quả thực tế của nó.)

// ✅ VÀ NÊN VIẾT khi muốn đặt tên cho một QUY TẮC hay thay đổi:
Map<String, ChinhSachGiamGia> chinhSach = Map.of(
    "TET",    (d, k) -> d.tong().multiply(new BigDecimal("0.20")),
    "VIP",    (d, k) -> k.hangThanhVien() == VANG ? d.tong().multiply(...) : ZERO,
    "KHONG",  (d, k) -> BigDecimal.ZERO);
// ⭐ Thêm chương trình khuyến mãi mới = thêm MỘT DÒNG, không sửa if-else nào.
//    Đây là mẫu Strategy, viết bằng lambda thay vì bằng năm class riêng.
  • ChinhSachGiamGia đọc là hiểu, còn BiFunction<DonHang,KhachHang,BigDecimal> thì đúng kiểu nhưng vô hồn.

  • Điểm đau lớn nhất: toàn bộ java.util.function không khai ném checked exception — nên trong stream bạn phải bọc lại, hoặc tự định nghĩa interface.

  • Một Map các chính sách là mẫu Strategy viết bằng lambda: thêm khuyến mãi mới chỉ là thêm một dòng, không sửa if-else nào.

Bài tập nhỏ

  • Viết bốn lambda cho bốn interface chính rồi gọi thẳng apply, accept, get, test.

  • Đo thời gian chạy một triệu lần với Function<Integer,Integer> và với IntUnaryOperator.

  • Ghép ba Predicate bằng andnegate, đặt tên có nghĩa cho từng cái.

  • Thử gọi Files.delete trong forEach để gặp lỗi checked exception, rồi tự viết ConsumerNemDuoc.

  • Chuyển một khối if-else tính giảm giá thành một Map các chính sách.

Kết lại

Bốn ý gói lại hôm nay: bốn interface phân biệt bằng hai câu hỏi — nhận gì vào, trả gì ra — và chúng tồn tại vì Java không có kiểu hàm; biến thể nguyên thủy tránh boxing, và trên vòng lặp triệu phần tử thì đó là khác biệt thật; chỉ dùng andThen để chuỗi đọc theo đúng thứ tự xảy ra, còn giá trị lớn nhất của Predicateđặt được tên cho điều kiện; và tự viết interface khi cái tên mang ý nghĩa nghiệp vụ hoặc khi cần ném checked exception — điểm đau lớn nhất của lập trình hàm trong Java. Ngày 53method reference: bốn loại, và khi nào nó thật sự dễ đọc hơn lambda. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 52: @WebMvcTest & MockMvc

Slice test nạp đúng tầng web nên mọi service phải khai @MockitoBean, MockMvc gọi thẳng DispatcherServlet không cần server, đây là nơi duy nhất chứng minh @Valid và @ControllerAdvice thật sự chạy — cùng cái bẫy 401 khiến nhiều người bỏ cuộc.

17 thg 9, 20267 phút0
99 Ngày Spring — Ngày 51: Unit test tầng service

Service là class Java bình thường nên test nó không cần Spring — vài mili-giây thay vì vài giây; Mockito với Mock và InjectMocks, verifyNoInteractions khẳng định điều không xảy ra, và bốn cái bẫy tạo ra test luôn xanh mà vô dụng.

16 thg 9, 20267 phút5
99 Ngày Java — Ngày 51: Lambda expression

Bốn dạng cú pháp và lý do lược bớt được, vì sao lambda KHÔNG phải anonymous class viết gọn — this trỏ khác chỗ và nó dựng bằng invokedynamic — cùng lý do thật đằng sau luật effectively final.

16 thg 9, 20268 phút4