Backend

99 Ngày Java — Ngày 51: Lambda expression

SSite Admin
16 tháng 09, 2026 8 phút đọc 4 lượt xem
99 Ngày Java — Ngày 51: Lambda expression

Năm mươi ngày qua ta viết Java theo lối cổ điển: lớp, đối tượng, vòng lặp. Hôm nay mở ra giai đoạn Java hiện đại, và nó bắt đầu bằng một ý tưởng nhỏ mà đổi cả cách viết: truyền một hành vi như truyền một giá trị. Lambda không phải "cách viết ngắn hơn cho anonymous class" — nó khác thật sự, ở chỗ this trỏ vào đâu và ở chỗ JVM dựng nó thế nào.

Sketchnote Ngày 51: lambda expression — bốn dạng cú pháp, lambda khác anonymous class ở this và ở invokedynamic, FunctionalInterface, và luật effectively final cùng lý do stack

Cú pháp — bốn dạng, chỉ khác chỗ lược bớt

Trước Java 8, muốn truyền một hành vi thì phải gói nó trong một đối tượng — năm dòng chỉ để nói "sắp theo độ dài". Lambda rút xuống một dòng, và lý do rút được là vì trình biên dịch đã biết hết mọi thứ còn lại: interface nào, method nào, kiểu tham số gì.

// LAMBDA — cùng một ý tưởng, viết ngắn đi bốn lần.

// TRƯỚC Java 8: muốn truyền một HÀNH VI thì phải gói nó trong một OBJECT.
danhSach.sort(new Comparator<String>() {         // 5 dòng để nói "sắp theo độ dài"
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
});

// TỪ Java 8:
danhSach.sort((a, b) -> a.length() - b.length());   // 1 dòng, cùng ý nghĩa

// ⭐ Vì sao viết ngắn được như vậy? Vì trình biên dịch đã biết hết:
//    · interface nào (Comparator) — từ kiểu tham số của sort()
//    · method nào  (compare)      — vì Comparator chỉ có ĐÚNG MỘT method trừu tượng
//    · kiểu của a, b (String)     — từ List<String>
//    Tất cả những gì bạn còn phải viết là PHẦN THÂN. Đó chính là ý nghĩa của lambda.

// BỐN DẠNG CÚ PHÁP — chỉ khác nhau ở chỗ lược bớt:
(String a, String b) -> { return a.length() - b.length(); }   // đầy đủ
(a, b) -> { return a.length() - b.length(); }                 // suy ra kiểu
(a, b) -> a.length() - b.length()                             // 1 biểu thức: bỏ return
s -> s.isEmpty()                                              // 1 tham số: bỏ ngoặc

() -> System.out.println("Xong");   // không tham số: BẮT BUỘC có ngoặc rỗng

// ❗ MỘT THÂN, MỘT LUẬT: có dấu ngoặc nhọn thì PHẢI viết return (nếu có trả về).
(a, b) -> { a.length() - b.length(); }     // ❌ không biên dịch được
  • Một biểu thức thì bỏ được cả ngoặc nhọn lẫn return; một tham số thì bỏ được ngoặc tròn.

  • Không tham số thì bắt buộc có () — đây là chỗ hay quên nhất khi mới viết.

  • Có ngoặc nhọn thì phải viết return nếu hàm có trả về; thiếu là lỗi biên dịch chứ không phải chạy sai.

Không phải anonymous class viết ngắn lại

Rất nhiều tài liệu giới thiệu lambda như "cách viết gọn của anonymous class". Câu đó sai, và cái sai lộ ra ngay ở this: trong lambda, this vẫn là đối tượng chứa nó; trong anonymous class, this là chính cái đối tượng ẩn danh vừa tạo.

// LAMBDA vs ANONYMOUS CLASS — KHÔNG phải "cách viết ngắn hơn". Khác hẳn nhau.

// ① this TRỎ VÀO CHỖ KHÁC — khác biệt quan trọng nhất:
class DichVu {
    String ten = "dịch vụ";

    void chayLambda()    { Runnable r = () -> System.out.println(this.ten); }
    // ⭐ this = ĐỐI TƯỢNG DichVu. Lambda KHÔNG tạo phạm vi mới → in ra "dịch vụ".

    void chayAnonymous() {
        Runnable r = new Runnable() {
            public void run() { System.out.println(this.ten); }   // ❌ KHÔNG biên dịch được!
        };  // this = đối tượng Runnable ẩn danh, nó KHÔNG có field ten.
    }       // Muốn lấy thì phải viết DichVu.this.ten
}
// Đây là một trong những chỗ người mới hay bối rối nhất khi đọc code cũ.

// ② KHÔNG SINH RA FILE .class RIÊNG:
//    Anonymous class → DichVu$1.class, DichVu$2.class... nạp lúc khởi động.
//    Lambda → một lệnh bytecode invokedynamic, JVM dựng lúc chạy và TÁI SỬ DỤNG.
//    Với lambda không bắt biến ngoài, JVM chỉ tạo ĐÚNG MỘT thể hiện cho cả chương trình.

// ③ CHỈ DÙNG ĐƯỢC VỚI INTERFACE CÓ ĐÚNG MỘT METHOD TRỪU TƯỢNG:
@FunctionalInterface                  // ⭐ annotation này KHÔNG bắt buộc, nhưng nên có
interface TinhPhi {                   //    nó bắt trình biên dịch KIỂM TRA giúp bạn
    BigDecimal tinh(DonHang d);       //    — thêm method thứ hai là báo lỗi NGAY,
}                                     //    thay vì để người dùng phát hiện sau

// ➜ KHI NÀO VẪN DÙNG ANONYMOUS CLASS: khi cần nhiều method, cần trạng thái riêng,
//    hoặc cần trỏ this vào chính đối tượng đó. Còn lại thì lambda.
  • Lambda không tạo phạm vi mới — nên this trỏ ra ngoài; anonymous class thì phải viết TenLop.this mới lấy được.

  • Anonymous class sinh ra file .class riêng; lambda chỉ là một lệnh invokedynamic, và nếu không bắt biến ngoài thì JVM tái sử dụng đúng một thể hiện.

  • @FunctionalInterface không bắt buộc nhưng nên có: nó bắt trình biên dịch báo lỗi ngay khi ai đó thêm method thứ hai.

  • Vẫn dùng anonymous class khi cần nhiều method, cần trạng thái riêng, hoặc cần this trỏ vào chính nó.

Effectively final — và lý do đằng sau

Đây là luật khiến người mới bực nhất: "local variables must be final or effectively final". Nhưng nó không tùy tiện. Biến local nằm trên stack của method, mà lambda thì có thể sống lâu hơn method tạo ra nó — nên Java buộc phải sao chép giá trị, và đã là bản sao thì cho sửa sẽ sinh ra hai nguồn sự thật.

// EFFECTIVELY FINAL — luật khó chịu nhất, và lý do đằng sau nó.

int tong = 0;
danhSach.forEach(x -> tong += x);      // ❌ "local variables referenced from a lambda
                                       //     expression must be final or effectively final"

// ⭐ EFFECTIVELY FINAL = không có chữ final, nhưng SAU KHI GÁN thì KHÔNG BAO GIỜ đổi nữa.
int thue = 10;                         // effectively final ✅
tinhTien(x -> x * thue / 100);         // dùng được

int thue = 10;
thue = 12;                             // ❌ đã gán lại → HẾT effectively final
tinhTien(x -> x * thue / 100);          //    → lambda ở trên không biên dịch được nữa

// ❗ VÌ SAO JAVA BẮT NHƯ VẬY? Không phải để làm khó bạn.
//    Biến local nằm trên STACK của method. Method kết thúc là stack biến mất.
//    Lambda thì có thể sống LÂU HƠN method tạo ra nó — được lưu vào field,
//    đẩy vào thread khác, hay chạy sau vài giây. Nên Java phải SAO CHÉP giá trị vào lambda.
//    Đã là bản sao thì cho sửa sẽ sinh ra hai nguồn sự thật → bug rất khó tìm.
//    Nói cách khác: đây là hệ quả của thiết kế, không phải một hạn chế tùy tiện.

// ⭐ VÌ SAO ĐỐI TƯỢNG THÌ LẠI SỬA ĐƯỢC? Vì luật áp cho BIẾN, không áp cho NỘI DUNG.
List<Integer> ds = new ArrayList<>();
so.forEach(x -> ds.add(x));            // ✅ hợp lệ: ds không bị GÁN LẠI, chỉ bị thay đổi bên trong
// ds = new ArrayList<>();             // ❌ cái này mới vi phạm

// ➜ CÁCH ĐÚNG để cộng dồn — đừng lách luật bằng mảng một phần tử:
int tong = so.stream().mapToInt(Integer::intValue).sum();   // ✅ rõ ràng, và song song được
int[] lach = {0}; so.forEach(x -> lach[0] += x);            // ❌ chạy được nhưng SAI khi song song
  • Luật áp cho biến, không áp cho nội dung: thêm phần tử vào một List thì được, gán lại chính biến đó thì không.

  • Đừng lách bằng mảng một phần tử — nó biên dịch được nhưng sai ngay khi bạn chạy song song, và đó là loại bug tệ nhất.

  • Muốn cộng dồn thì dùng stream().sum() — rõ ràng hơn, và đúng cả khi song song.

Bài tập nhỏ

  • Viết lại ba anonymous class trong dự án cũ của bạn thành lambda và so số dòng.

  • In this bên trong lambda và bên trong anonymous class để tận mắt thấy hai giá trị khác nhau.

  • Thử gán lại một biến đang được lambda dùng để gặp lỗi effectively final.

  • Tạo @FunctionalInterface rồi thêm method thứ hai — xem trình biên dịch báo lỗi ngay.

  • So sánh số file .class sinh ra khi dùng anonymous class và khi dùng lambda.

Kết lại

Bốn ý gói lại hôm nay: lambda ngắn được vì trình biên dịch đã biết interface, method và kiểu, bạn chỉ còn viết phần thân; nó không phải anonymous class viết gọnthis trỏ khác chỗ, và nó dựng bằng invokedynamic thay vì sinh file .class; @FunctionalInterface nên thêm để trình biên dịch giữ giúp bạn cái hợp đồng một-method; và effectively final là hệ quả của việc biến local nằm trên stack, không phải một hạn chế tùy tiện — nên đừng lách bằng mảng một phần tử. Ngày 52 ta gặp bộ interface làm nền cho toàn bộ Java hiện đại: Function, Consumer, Supplier và Predicate. 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út4
99 Ngày Java — Ngày 52: Functional interface

Bốn interface phân biệt bằng hai câu hỏi — nhận gì vào, trả gì ra; biến thể nguyên thủy tránh boxing; vì sao chỉ nên dùng andThen — và khi nào tự viết interface là đúng, kể cả để lách checked exception.

17 thg 9, 20268 phút4
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