Nhiều người nghĩ lambda chỉ là cách viết tắt cho lớp ẩn danh. Không phải — và khác biệt đo được bằng đúng một dấu ==. Cửa sổ rõ nhất để nhìn vào nó là chuyện gì xảy ra khi lambda "bắt" một biến: nó gói theo một bản sao chụp lại, và chính bản sao đó là lý do biến phải "effectively final". Mở đầu chặng lập trình hàm, trước khi tới Stream, ta cần hiểu cơ chế nằm dưới này. Bắt đầu bằng một phép thử.

Gọi hai lần, nhận về cùng một đối tượng

static Runnable taoKhongBat() {
    return () -> System.out.println("không bắt gì");
}

Runnable a = taoKhongBat(), b = taoKhongBat();
  a == b ? true
  lớp     : Lam$$Lambda/0x0000000201000a18

Hai lời gọi, cùng một đối tượng. So với lớp ẩn danh:

  e == f ? false
  lớp     : Lam$1

Lớp ẩn danh luôn tạo đối tượng mới, và nó là một lớp thật — có file Lam$1.class trên đĩa.

Lambda thì không có file nào. Cái tên Lam$$Lambda/0x... cho thấy nó là lớp được sinh ra lúc chạy, và vì lambda này không phụ thuộc trạng thái gì nên JVM giữ lại một thể hiện dùng chung.

Đây là khác biệt thực chất đầu tiên: lambda không bắt biến gần như miễn phí, còn lớp ẩn danh thì mỗi lần một đối tượng.

Lambda có bắt biến thì khác

static Supplier<Integer> taoBatBienCucBo() {
    int x = 42;
    return () -> x;
}
  c == d ? false
  trường bên trong: [private final int Lam$$Lambda/0x...arg$1]

Giờ mỗi lần gọi là một đối tượng mới, và nó có một trường arg$1 chứa giá trị đã bắt — bản sao chụp lại.

So với lớp nội ở bài lớp lồng: lớp nội mang this$0 — tham chiếu tới cả đối tượng ngoài. Lambda chỉ mang đúng những giá trị nó cần. Đó là lý do lambda ít gây rò rỉ bộ nhớ hơn.

Nhưng có một ngoại lệ quan trọng, xem mục dưới.

Bytecode: invokedynamic cộng một phương thức riêng

  static java.lang.Runnable taoKhongBat();
    Code:
       0: invokedynamic #13,  0   // InvokeDynamic #0:run:()Ljava/lang/Runnable;
       5: areturn

Và trong danh sách phương thức của lớp:

  private java.lang.Integer lambda$thuTruong$5();
  private void lambda$thuThis$4();

Trình biên dịch làm hai việc: đưa thân lambda vào một phương thức private có tên sinh tự động, rồi thay chỗ dùng bằng một lệnh invokedynamic.

invokedynamic — ta đã gặp nó ở bài bytecode với phép nối chuỗi — nghĩa là "chỗ này cần một đối tượng cài Runnable, hãy để thư viện lúc chạy lo". Lần gọi đầu tiên, LambdaMetafactory sinh ra một lớp và gắn vào; những lần sau dùng lại.

Cách thiết kế này có lợi thật sự: file .class không phình ra vì lambda (không có file phụ như lớp ẩn danh), và JVM đời sau tối ưu tốt hơn thì mã cũ tự hưởng lợi mà không cần biên dịch lại.

this trỏ vào đâu

  lambda : this là Lam
  ẩn danh: this là          <- rỗng, vì lớp ẩn danh không có tên

Trong lambda, this là đối tượng của lớp bao ngoài. Trong lớp ẩn danh, this là chính thể hiện ẩn danh đó.

Đây là khác biệt gây bất ngờ khi chuyển mã cũ sang lambda. Với lớp ẩn danh, muốn gọi phương thức của lớp ngoài phải viết TenLopNgoai.this.phuongThuc(). Với lambda thì this đã là lớp ngoài rồi.

Cũng vì thế mà lambda không tự tham chiếu được — không viết được một lambda đệ quy trực tiếp. Cần đệ quy thì dùng phương thức thường, hoặc khai một biến trước rồi gán sau.

Effectively final: vì sao lại bắt buộc

error: local variables referenced from a lambda expression must be final or effectively final
        Runnable r = () -> System.out.println(dem);
                                              ^

Biến cục bộ nằm trên ngăn xếp và biến mất khi phương thức kết thúc. Lambda thì có thể sống lâu hơn — được trả về, lưu vào danh sách, chạy ở luồng khác.

Nên Java sao chép giá trị vào trường arg$1 của lambda. Và vì có hai bản, nếu cho phép gán lại thì chúng lệch nhau, sinh ra câu hỏi không có câu trả lời hay: lambda thấy bản nào?

Java chọn cách tránh hẳn: cấm gán lại. "Effectively final" nghĩa là bạn không cần viết final, chỉ cần không gán lại.

Biến chạy của vòng for cũng vi phạm, vì nó bị gán lại mỗi vòng:

error: ... must be final or effectively final
            Supplier<Integer> s = () -> i;

Cách vòng là sao ra một biến mới trong thân vòng lặp:

for (int i = 0; i < 3; i++) {
    int cuoiCung = i;
    ds.add(() -> cuoiCung);
}
  giá trị bắt được: 0 1 2

Mỗi lambda giữ một bản riêng. Với for-each thì không cần mẹo này — biến của for-each đã effectively final sẵn.

Trường thì khác hẳn

Supplier<Integer> s = () -> truongThuc;   // trường của đối tượng
truongThuc = 10;
  đặt trường = 10 rồi gọi: 10
  đặt trường = 20 rồi gọi: 20

Trường không bị luật effectively final ràng buộc, và lambda thấy giá trị mới nhất.

Lý do: lambda không sao chép giá trị của trường; nó bắt this rồi đọc trường mỗi lần chạy — không phải bản chụp, mà là đường dây mở tới đối tượng.

Hai hệ quả:

Lambda dùng trường sẽ giữ cả đối tượng bao ngoài sống — đúng vấn đề this$0 của bài lớp lồng, chỉ là qua đường khác. Một listener lambda đọc một trường sẽ neo giữ cả đối tượng chứa nó.

Kết quả phụ thuộc thời điểm gọi, không phải thời điểm tạo. Đây là nguồn của những lỗi khó tìm khi lambda được lưu lại rồi chạy sau.

Muốn "đóng băng" giá trị thì sao ra biến cục bộ trước:

int giaTriLucNay = truongThuc;
Supplier<Integer> s = () -> giaTriLucNay;

Cú pháp

Bốn dạng, từ đầy đủ tới rút gọn:

(String a, String b) -> { return a.length() - b.length(); }   // đủ
(a, b) -> { return a.length() - b.length(); }                 // bỏ kiểu
(a, b) -> a.length() - b.length()                             // một biểu thức
s -> s.isEmpty()                                              // một tham số, bỏ ngoặc
() -> System.out.println("x")                                 // không tham số

Vài luật nhỏ: bỏ kiểu thì phải bỏ hết, không bỏ nửa chừng. Một tham số thì bỏ được ngoặc, nhưng nếu ghi kiểu thì phải giữ ngoặc. Thân là một biểu thức thì giá trị của nó chính là giá trị trả về, không cần return.

Từ Java 11 dùng được var trong tham số — chủ yếu để đặt chú thích:

(@NonNull var a, @NonNull var b) -> a + b

Khi nào dùng lớp ẩn danh

Lambda thay được lớp ẩn danh trong đa số trường hợp, nhưng không phải tất cả:

Interface có nhiều phương thức trừu tượng — lambda chỉ dùng được với giao diện hàm.

Cần trạng thái riêng giữa các lần gọi — lớp ẩn danh có trường được, lambda thì không.

Cần this trỏ vào chính nó.

Cần đặt tên cho kiểu vì lý do gỡ lỗi — stack trace của lambda có tên như Lam.lambda$thuThis$4, khó đọc hơn tên lớp thật.

Muốn tự thấy lambda khác lớp ẩn danh trong ba mươi giây: viết hai lambda giống hệt nhau, một cái không bắt biến nào, một cái bắt một biến cục bộ, rồi so == hai lần gọi của mỗi loại. Cái đầu cho true, cái sau cho false — bằng chứng rằng lambda không phải "đường cú pháp cho lớp ẩn danh", nó là một cơ chế khác hẳn, và khác biệt đó đo được.

Mẫu số chung

Cái "bao đóng" (closure) — một hàm gói theo môi trường xung quanh nó — không phải của riêng Java, và câu hỏi cốt lõi ở mọi ngôn ngữ đều là một: nó gói theo bản sao giá trị hay đường dây tới biến thật. Java tách rạch: biến cục bộ thì chụp bản sao (nên phải effectively final), còn trường thì đọc sống mỗi lần. C++ để bạn tự chọn tường minh trong ngoặc vuông: [=] chụp bản sao, [&] giữ tham chiếu — không đoán. Còn JavaScript, Python và Go thì mặc định giữ tham chiếu, và cả ba từng dính đúng cùng một cái bẫy vòng lặp: đóng gói biến chạy i trong vòng for, rồi mọi closure cùng đọc ra giá trị cuối cùng. Cách chữa cũng giống hệt cái mẹo "sao ra biến mới trong thân vòng" ở trên: JavaScript đổi var thành let (mỗi vòng một ràng buộc mới), Python phải truyền i làm đối số mặc định, và Go 1.22 vừa sửa thẳng trong ngôn ngữ bằng cách cho biến vòng lặp một bản mới mỗi vòng.

Điểm chung, và là thứ đáng mang theo, gồm hai vế. Vế cơ chế: một closure luôn gói theo môi trường, và điều quyết định hành vi là nó gói ảnh chụp hay đường dây sống — ảnh chụp thì đóng băng giá trị lúc tạo, đường dây thì cho ra giá trị lúc gọi; lẫn hai cái là nguồn của những lỗi "lưu lại rồi chạy sau" khó tìm nhất. Vế cái bẫy: đóng gói biến chạy vòng lặp là lỗi kinh điển của gần như mọi ngôn ngữ, và lời giải hội tụ về đúng một ý — mỗi vòng phải có một biến mới để mỗi closure bắt một thứ khác nhau. Java bắt bạn tự sao ra biến; các ngôn ngữ khác dần dần đưa cách sửa ấy vào chính cú pháp vòng lặp. Ai hiểu "chụp hay nối dây" là đọc được closure ở bất kỳ ngôn ngữ nào.

Ngày mai: các functional interface có sẵn — Function, Supplier, Consumer, Predicate và bộ biến thể cho kiểu nguyên thuỷ mà nhiều người không biết là có.