Java OO & Functional/Lambda và functional interface — hàm trở thành giá trị
28/38
Bài 28 / 38~15 phútStream API & LambdaMiễn phí lượt xem

Lambda và functional interface — hàm trở thành giá trị

Lambda expression, functional interface (SAM), 4 interface chuẩn trong java.util.function, và quy tắc effectively final khi capture biến. Vì sao Java 8 cần lambda và compiler suy kiểu lambda qua target type thế nào.

TL;DR: Lambda expression biến một hành động thành giá trị truyền đi được — thay anonymous inner class 5 dòng bằng 1 dòng. Compiler suy kiểu lambda qua target type: vị trí lambda được gán/pass phải là functional interface — interface có đúng 1 abstract method (SAM). Bốn interface chuẩn phải thuộc: Function (biến đổi), Predicate (điều kiện), Consumer (side-effect), Supplier (tạo giá trị lười). Lambda capture biến local theo giá trị, và compiler bắt buộc biến đó effectively final — không reassign sau khi gán — để tránh lệch pha dữ liệu giữa stack và heap. Pitfall hay gặp: tưởng lambda capture theo reference, hoặc dùng Function<T, Void> thay vì Consumer.

Năm 2014, Java 8 ra mắt và team phát triển gọi đây là "bản release lớn nhất kể từ Java 5". Thay đổi cốt lõi: lambda expression. Trước đó 20 năm, Java viết callback, comparator, event handler thế này:

Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});

5 dòng boilerplate chỉ để gói 1 dòng logic thực sự: "so sánh độ dài chuỗi". Viết hàng trăm lần mỗi project. JavaScript, Python, Scala đã có closure/lambda từ lâu — Java đi sau, cho đến 2014.

Java 8 biến đoạn trên thành:

Collections.sort(names, (a, b) -> Integer.compare(a.length(), b.length()));

Cùng ngữ nghĩa, 1 dòng. Đây không chỉ là "cú pháp ngắn hơn" — nó mở đường cho Stream API, Optional, CompletableFuture.thenApply (course Java Internals), và toàn bộ cách Java viết code functional.

Bài này đi sâu vào: functional interface là gì và tại sao compiler cần nó, 4 interface chuẩn nhất định phải thuộc (Function, Predicate, Consumer, Supplier), và effectively final — quy tắc capture biến mà compiler bắt buộc. Method reference và cơ chế bytecode invokedynamic bên dưới lambda được tách riêng sang bài 02.

1. Analogy — Phiếu công thức

Tưởng tượng bạn đưa bếp trưởng một tờ phiếu: "làm món theo công thức này". Phiếu chỉ ghi các bước nấu — không ghi tên món, không ghi tên bạn, không ghi ai sẽ ăn. Bếp trưởng cầm phiếu, có thể:

  • Tự nấu ngay.
  • Giao cho đầu bếp phó nấu.
  • Photocopy gửi cho chi nhánh khác nấu.
  • Lưu lại, tuần sau mới nấu.

Phiếu = logic đóng gói thành vật thể cầm được. Có thể truyền đi, lưu, gọi bất cứ lúc nào. Đây chính xác là lambda.

Ngược lại, trước lambda, Java chỉ có "method" — method gắn chặt với class. Muốn truyền logic cho người khác, phải gói nó trong 1 class (anonymous inner class). Giống như bạn muốn đưa công thức cho người khác phải xây riêng một nhà hàng chỉ để chứa đúng 1 công thức — rườm rà không cần thiết.

Đời thườngJava
Phiếu công thức (chỉ có bước)Lambda (x) -> x * 2
Loại phiếu (đặt món / đánh giá / khiếu nại)Functional interface (Function, Predicate, ...)
Đầu bếp đọc phiếuMethod nhận param kiểu functional interface
Trao phiếuPass lambda làm argument
Photocopy, gửi điStore lambda vào biến, pass qua nhiều method

Kết quả văn hoá: code Java hiện đại (Stream, Spring reactive, microservice async) trông như JavaScript/Kotlin nhiều hơn — functional, pipeline, callback chain. Lambda là nền móng cho phong cách viết đó.

💡 Cách nhớ

Lambda = hành động đóng gói thành giá trị, truyền đi được. Functional interface = hợp đồng 1 method giúp compiler biết cần map lambda vào method nào.

2. Vấn đề cụ thể trước Java 8

Để cảm nhận được lambda giá trị ra sao, nhìn vào 3 task thường gặp trướcsau Java 8.

Task 1: Comparator

// Truoc Java 8
Comparator<String> byLength = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
};

// Sau Java 8
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

5 dòng → 1 dòng. Nhưng điều quan trọng hơn: đọc dòng thứ hai xong bạn hiểu luôn logic. Dòng 1 cần skim qua new ... { @Override ... public int compare ... } boilerplate rồi mới đến logic.

Task 2: Runnable cho thread

// Truoc
new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("hi");
    }
}).start();

// Sau
new Thread(() -> System.out.println("hi")).start();

Task 3: Event listener

// Truoc
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        save();
    }
});

// Sau
button.addActionListener(e -> save());

Pattern chung: mỗi lần Java cần truyền "một hành động", trước đây phải tạo anonymous inner class bao quanh đúng 1 method. Lambda bỏ hết boilerplate, giữ đúng phần logic.

Compiler biết lambda (a, b) -> ... phải map vào Comparator<String>.compare(String, String) nhờ target type — vị trí lambda được dùng quyết định kiểu của nó. Chủ đề kế tiếp.

3. Functional interface — hợp đồng 1 method

Câu hỏi: compiler làm sao biết lambda (a, b) -> a.length() - b.length() là kiểu gì? Lambda tự nó không có kiểu. Câu trả lời: target type — kiểu nơi lambda được gán/pass quyết định.

Comparator<String> c = (a, b) -> a.length() - b.length();
//      ^^^^^^^^^^^^^^^^^^^
//      Target type -> compiler suy lambda phai match method compare(String, String)

Target type phải là functional interface — interface có đúng 1 abstract method (Single Abstract Method — SAM).

@FunctionalInterface
interface Transformer {
    int apply(int input);   // Dung 1 abstract method -> SAM -> functional interface
}

Transformer doubler = x -> x * 2;
Transformer addOne  = x -> x + 1;

System.out.println(doubler.apply(5));   // 10
System.out.println(addOne.apply(5));    // 6

Tại sao SAM?

Nếu interface có 2 method trở lên, compiler không biết lambda map vào method nào — ambiguous. 1 method → map rõ ràng. Đây là giới hạn thiết kế có lý — lambda là cú pháp ngắn, không phù hợp biểu thị "triển khai cả 5 method của một interface lớn".

Muốn triển khai interface nhiều method vẫn dùng anonymous inner class hoặc class thường.

@FunctionalInterface — khuyến nghị, không bắt buộc

@FunctionalInterface
interface Transformer {
    int apply(int input);
}

Annotation không bắt buộc để interface trở thành functional — chỉ cần có đúng 1 abstract method, compiler tự công nhận. Vai trò của @FunctionalInterface: khoá contract. Compiler báo lỗi nếu ai đó thêm abstract method thứ 2.

Dùng @FunctionalInterface cho mọi interface bạn cố ý thiết kế làm target lambda. Không dùng cho interface có thể mở rộng sau (Repository, Service kiểu truyền thống).

Default / static method không tính SAM

Quan trọng: functional interface có thể có bao nhiêu default và static method cũng được. SAM chỉ đếm abstract.

@FunctionalInterface
interface MyFunc {
    int apply(int x);                              // abstract - SAM

    default MyFunc andThen(MyFunc after) {         // default - khong tinh
        return x -> after.apply(this.apply(x));
    }

    static MyFunc identity() { return x -> x; }    // static - khong tinh
}

Ví dụ có sẵn trong JDK: Comparatorcompare là SAM, còn reversed, thenComparing, comparing là default/static. Bạn vẫn viết Comparator c = (a, b) -> ... bình thường.

⚠️ Nhầm lẫn thường gặp

Tưởng interface có method kế thừa từ Object (vd equals, hashCode) thì mất SAM. Không đúng. Method kế thừa từ Object không tính. Vì thế Comparator có cả compareequals trong declaration vẫn là functional interface.

4. 4 functional interface chuẩn — phải thuộc lòng

JDK 8 thêm package java.util.function với ~43 functional interface — phủ gần hết use case thường gặp. Thay vì mỗi project tự định nghĩa Transformer, Predicate, Handler riêng, dùng chung JDK interface để code interoperable.

Trong 43 cái, 4 cái phổ biến nhất bạn gặp 99% thời gian:

Function<T, R> — biến đổi

R apply(T t);

Nhận 1 giá trị kiểu T, trả về kiểu R. T và R có thể khác nhau.

Function<String, Integer> length = s -> s.length();
Function<String, Integer> lenMethodRef = String::length;   // method reference - bai 02

length.apply("hello");   // 5

Use case điển hình: transform element trong stream. stream.map(Function) biến Stream<T> thành Stream<R>.

Predicate<T> — điều kiện boolean

boolean test(T t);

Nhận T, trả boolean. Biểu thị điều kiện.

Predicate<String> isEmpty = s -> s.isEmpty();
Predicate<String> isEmptyMethodRef = String::isEmpty;

isEmpty.test("");    // true
isEmpty.test("x");   // false

Use case: filter stream. stream.filter(Predicate) giữ element thoả điều kiện.

Consumer<T> — tiêu thụ, không trả

void accept(T t);

Nhận T, không return. Dùng cho side-effect (in, log, save).

Consumer<String> printer = s -> System.out.println(s);
Consumer<String> printerMethodRef = System.out::println;

printer.accept("hello");   // In: hello

Use case: stream.forEach(Consumer), optional.ifPresent(Consumer).

Supplier<T> — nhà cung cấp

T get();

Không nhận gì, trả T. Dùng khi cần "tạo giá trị lười" — chỉ tạo khi gọi.

Supplier<String> greeting = () -> "Hello";
Supplier<List<Integer>> newList = ArrayList::new;   // constructor reference

greeting.get();    // "Hello"
newList.get();     // [] (list moi moi lan goi)

Use case: Optional.orElseGet(Supplier) — chỉ gọi supplier khi Optional empty; CompletableFuture.supplyAsync(Supplier) — task trả kết quả.

Biến thể cho primitive — tránh boxing

Với int, long, double, có biến thể riêng để tránh auto-boxing overhead:

IntFunction<String>      // int -> R
ToIntFunction<String>    // T -> int
IntPredicate             // int -> boolean
IntConsumer              // int -> void
IntSupplier              // () -> int
IntBinaryOperator        // (int, int) -> int

Stream tính tổng dùng IntStream.sum() bên dưới dùng IntBinaryOperator. Khi xử lý 10k+ element, biến thể primitive nhanh đáng kể vì không tạo Integer object mỗi element.

BiFunction<T, U, R> — 2 input

Cho hàm 2 tham số:

BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
add.apply(3, 5);   // 8

Biến thể: BinaryOperator<T> (cả 2 input + output cùng kiểu T), BiPredicate<T, U>, BiConsumer<T, U>.

💡 Khi nào tạo functional interface riêng?

Dùng interface JDK trước. Chỉ tạo riêng khi (1) cần semantic domain rõ (OrderValidator đọc hay hơn Predicate<Order>), (2) cần throw checked exception (interface JDK không declare throws), (3) cần 3+ tham số (JDK chỉ có uni và bi).

5. Capture biến — effectively final

Lambda có thể đọc các biến cục bộ (local variables) ở scope bên ngoài — gọi là capture. Nhưng Java áp dụng một quy tắc nghiêm ngặt: các biến đó phải là final hoặc effectively final (biến không bị reassign sau khi khởi tạo).

effectively final là gì?

Một biến được coi là effectively final nếu nó không hề bị thay đổi giá trị (reassign) sau khi được gán lần đầu tiên. Bạn không cần phải khai báo tường minh từ khóa final, compiler sẽ tự động phân tích và thừa nhận nếu quy tắc này được thỏa mãn.

int x = 10;          // Không reassign -> effectively final -> lambda capture được
int y = 10;
y = 20;              // Có reassign -> không còn effectively final -> compile error nếu đưa vào lambda!

Bản chất bên dưới: Luồng Stack & Heap (Tại sao phải bất biến?)

Để hiểu tại sao Java bắt buộc biến capture phải bất biến, hãy nhìn vào bản chất quản lý bộ nhớ của JVM.

Biến local sống trên Stack Frame của phương thức chứa nó. Khi phương thức kết thúc, Stack Frame này lập tức bị hủy và biến local biến mất. Tuy nhiên, lambda expression (được đóng gói thành một đối tượng trên Heap) có thể được chuyển sang một luồng (Thread) khác để thực thi bất đồng bộ hoặc chạy trễ sau đó rất lâu.

Để lambda có thể truy cập được biến local đã biến mất, JVM thực hiện cơ chế capture by value (sao chép giá trị của biến local sang một trường dữ liệu trong đối tượng lambda trên Heap).

Dưới đây là sơ đồ ASCII trực quan mô tả luồng Stack và Heap khi Lambda thực thi:

[ THREAD CHÍNH (Main Thread) ]              [ THREAD CON (Async Lambda Thread) ]
==============================              ====================================
  Stack Frame: myMethod()
+----------------------------+
| int num = 42;              |
|                            |
| // Khởi tạo Lambda         |             Heap (Bộ nhớ dùng chung)
| myLambda = () -> { ... } --+----------> +----------------------------------+
+----------------------------+            | Class ẩn của Lambda (Instance)   |
              |                           | - copy_of_num = 42 (Bản copy)    |
              +----------------------------+
              v                                            |
[ myMethod() KẾT THÚC ]                                    |
 Stack Frame bị hủy hoàn toàn                              | (Chạy bất đồng bộ)
 (Biến "num" trên Stack biến mất)                           v
                                          +----------------------------------+
                                          | Thực thi logic Lambda:           |
                                          | Đọc copy_of_num (42) -> OK!      |
                                          +----------------------------------+

Tại sao biến bắt buộc phải bất biến (effectively final)?

  1. Tránh mất đồng bộ dữ liệu (Data Inconsistency): Nếu Java cho phép biến num trên Stack thay đổi sau khi lambda đã được tạo, bản copy copy_of_num trên Heap sẽ bị lệch pha với Stack. Người đọc code sẽ bối rối vì biến đã đổi thành 99 nhưng lambda vẫn in ra 42.
  2. Ngăn chặn Race Condition trong đa luồng: Khi lambda được thực thi song song trên một Thread khác, việc cho phép ghi (mutate) vào cùng một biến cục bộ sẽ gây ra các vấn đề về an toàn luồng (thread safety), xung đột ghi-đọc dữ liệu mà Stack không có cơ chế lock bảo vệ.

Vì vậy, ép buộc biến phải là effectively final là giải pháp an toàn mà Java lựa chọn để đảm bảo tính nhất quán dữ liệu giữa Stack và Heap.

Workaround khi thực sự cần mutable state

Nếu bạn thực sự cần thay đổi trạng thái từ bên trong lambda (side-effect), hãy bọc biến đó trong một đối tượng reference (vật chứa trên Heap). Bản thân reference của object là bất biến (effectively final), nhưng dữ liệu bên trong object thì có thể thay đổi:

  • Dùng mảng 1 phần tử (Cách thủ công):
int[] counter = {0};   // Reference toi mang co dinh, noi dung counter[0] thay doi duoc
Runnable r = () -> counter[0]++;
  • Dùng AtomicInteger (Khuyên dùng trong môi trường đa luồng):
AtomicInteger counter = new AtomicInteger(0);
Runnable r = () -> counter.incrementAndGet();

Lưu ý: Việc lạm dụng các biện pháp này để mutate dữ liệu từ lambda thường là một dấu hiệu cần refactor (code smell). Trong hầu hết trường hợp, bạn nên tái cấu trúc logic để sử dụng các phép toán Stream thuần túy như reduce hoặc collect để đảm bảo code sạch và an toàn.

6. this trong lambda — khác với inner class

Điểm quan trọng và dễ nhầm:

class Foo {
    String name = "Foo";

    Runnable inLambda = () -> System.out.println(this);
    Runnable inAnonymous = new Runnable() {
        @Override public void run() {
            System.out.println(this);
        }
    };
}
  • inLambda in Foo instance (outer). Lambda không có this riêng — inherit từ scope ngoài.
  • inAnonymous in Runnable instance (anonymous class). Anonymous class có this riêng trỏ vào chính nó.

Hệ quả: trong lambda, this.name = "Foo" (field của Foo). Trong anonymous class, this.name compile error vì Runnable không có field name.

Đây thường là điều bạn muốn: trong code Spring/JPA class method, dùng lambda để callback thì this vẫn là class của bạn — không cần Foo.this verbose.

7. Pitfall tổng hợp

Nhầm 1: Tưởng @FunctionalInterface bắt buộc.

interface MyFunc { int apply(int x); }   // Van la functional interface

✅ Không bắt buộc. Annotation chỉ để compiler kiểm SAM count — nên thêm cho interface production.

Nhầm 2: Reassign biến trong lambda.

int sum = 0;
list.forEach(x -> sum += x);   // compile error

✅ Dùng stream: int sum = list.stream().mapToInt(Integer::intValue).sum();

Nhầm 3: Function<T, Void> cho side-effect.

Function<String, Void> printer = s -> { System.out.println(s); return null; };

✅ Dùng Consumer<String>: Consumer<String> printer = System.out::println;

Nhầm 4: Tưởng lambda capture biến theo reference.

int x = 10;
Runnable r = () -> System.out.println(x);
x = 20;   // compile error - x khong effectively final

✅ Lambda capture giá trị tại thời điểm tạo, và compiler cấm reassign.

8. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: JLS §15.27 giải thích vì sao var f = x -> x * 2; không compile — lambda tự nó không có kiểu, cần target type để infer functional interface nào. Trong khi đó Function<Integer, Integer> f = x -> x * 2; OK vì target type là Function<Integer, Integer>.

9. Tóm tắt

  • Lambda = hàm đóng gói thành giá trị, truyền đi được. Thay thế anonymous inner class cho 1 method.
  • Functional interface = interface có đúng 1 abstract method (SAM). Lambda chỉ compile khi target type là functional interface.
  • @FunctionalInterface khuyến nghị — compiler enforce SAM count, bảo vệ contract.
  • 4 functional interface chuẩn: Function<T,R>, Predicate<T>, Consumer<T>, Supplier<T>. Biến thể primitive (IntFunction, ToIntFunction, ...) tránh boxing.
  • Biến local capture phải effectively final — không reassign. Ràng buộc này tránh ambiguous giá trị khi lambda chạy delay/async.
  • this trong lambda inherit scope ngoài; trong anonymous class là chính class đó. Thường đây là điều bạn muốn.
  • Method reference (::) và cơ chế invokedynamic bên dưới lambda — đào sâu ở bài 02.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Java yêu cầu biến capture trong lambda phải effectively final?

Lambda có thể được store lại và chạy delay — thậm chí chạy ở thread khác sau vài giờ. Nếu biến local thay đổi sau khi lambda tạo, giá trị lambda "thấy" trở nên ambiguous: giá trị lúc tạo hay lúc chạy? Snapshot hay reference?

Java chọn snapshot tại thời điểm capture. Để snapshot này nhất quán với biến gốc, Java yêu cầu biến không reassign — effectively final. Compiler enforce, reader đọc code biết chắc giá trị lambda không "bí mật" thay đổi.

Nếu cần mutable state, dùng AtomicInteger, array-as-box, hoặc field của class. Nhưng thường đó là dấu hiệu nên refactor sang stream.reduce.

Q2
Vì sao var f = x -> x * 2; không compile, còn Function<Integer, Integer> f = x -> x * 2; thì OK?

Lambda tự nó không có kiểu. Compiler suy kiểu lambda từ target type — kiểu tại vị trí lambda được gán hoặc pass. Với var, compiler phải suy kiểu biến từ vế phải, nhưng vế phải (lambda) lại cần kiểu từ vế trái — vòng suy luận không có điểm bắt đầu, compile error.

Với Function<Integer, Integer> f = ..., target type rõ ràng: lambda map vào SAM apply(Integer), tham số x được suy ra là Integer.

Hệ quả thú vị: cùng một lambda có thể mang nhiều kiểu khác nhau tuỳ target type — () -> "hi" vừa hợp lệ làm Supplier<String> vừa làm Callable<String>. JLS gọi lambda là "poly expression" vì tính chất này.

Q3
Đoạn sau in gì? Function<Integer, Integer> f = x -> x * 2; Function<Integer, Integer> g = f.andThen(x -> x + 1); System.out.println(g.apply(3));

f nhân 2. g = f.andThen(x -> x + 1) = f rồi cộng 1.

g.apply(3): chạy f với 3 → 6, sau đó + 1 → 7.

7

andThen là default method của Function — compose hai function thành chain. Không phải SAM nên không ảnh hưởng trạng thái "functional interface" của Function.

Q4
Khi nào dùng Consumer thay cho Function<T, Void>?
  • Consumer<T>: side-effect, không return. API clean, không cần return null; cuối body.
  • Function<T, Void>: anti-pattern. Void là wrapper của void, chỉ dùng khi generic signature ép (vd một số API callback cũ bị ràng buộc).

Quy tắc chung: chọn interface theo ý nghĩa không theo hình dạng:

  • Biến đổi → Function.
  • Side-effect → Consumer.
  • Điều kiện → Predicate.
  • Producer (lazy tạo giá trị) → Supplier.

Reader đọc kiểu đoán được intent ngay, không cần mở body lambda.

Q5
Trong class Foo { Runnable r = () -> System.out.println(this); }, this trỏ vào gì?

this trỏ vào instance của Foo — outer class. Lambda không có this riêng; nó inherit từ scope khai báo lambda.

Khác hoàn toàn với anonymous inner class:

class Foo {
  Runnable r = new Runnable() {
      @Override public void run() {
          System.out.println(this);   // this = anon Runnable instance
      }
  };
}

Anonymous inner class có this riêng. Muốn trỏ outer phải dùng Foo.this.

Với lambda, this luôn là outer — thường đây là điều bạn muốn (callback giữ context class). Đây cũng là lý do không thể dùng lambda khi cần gọi this.someMethod() trỏ vào "listener instance" — dùng anonymous class thay.

Bài tiếp theo: Method reference và invokedynamic — lambda bên dưới bytecode

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên