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.

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 đượcMộ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
returnnế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
thistrỏ ra ngoài; anonymous class thì phải viếtTenLop.thismớ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.@FunctionalInterfacekhô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
thistrỏ 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 songLuật áp cho biến, không áp cho nội dung: thêm phần tử vào một
Listthì đượ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
thisbê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
@FunctionalInterfacerồi thêm method thứ hai — xem trình biên dịch báo lỗi ngay.So sánh số file
.classsinh 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ọn — this 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!
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.


