Nội dung bài viết
  1. Bối cảnh: Cuộc cách mạng lập trình hàm trong Java
  2. Cú pháp Lambda và cơ chế hoạt động dưới mui xe
  3. Functional Interface: Nền tảng của Lambda
  4. Effectively Final và quy tắc biến bên ngoài
  5. Method References: Toán tử ::
  6. Thực chiến 1: Anonymous Class và Lambda
  7. Thực chiến 2: Xây dựng luồng xử lý nghiệp vụ
  8. Thực chiến 3: Tự xây dựng Functional Interface
  9. Kết hợp Lambda với Stream API
  10. Bẫy thường gặp và Best Practices
  11. Tổng kết và lộ trình thực hành

Trong quá trình học Java, có một thời điểm bạn sẽ nhận ra rằng rất nhiều đoạn code không thực sự cần đến cả một class chỉ để thực hiện một hành động nhỏ. Chúng ta muốn lọc danh sách sản phẩm, chuyển đổi dữ liệu, kiểm tra điều kiện, ghi log hoặc truyền một cách xử lý từ nơi này sang nơi khác. Tuy nhiên, trước Java 8, việc biểu diễn những hành động này thường đòi hỏi khá nhiều mã nguồn phụ trợ.

Java Lambda Expression xuất hiện để giải quyết chính vấn đề đó. Kể từ Java 8, chúng ta có thể biểu diễn hành vi bằng một cú pháp ngắn gọn, truyền hành vi vào phương thức như một tham số và kết hợp nhiều thao tác xử lý dữ liệu theo phong cách lập trình hàm. Lambda cũng trở thành một thành phần cốt lõi của Stream API và hệ sinh thái lập trình Java hiện đại.

Nhưng học Lambda không đơn giản chỉ là học cách viết dấu ->. Muốn sử dụng thành thạo trong dự án thực tế, chúng ta cần hiểu Lambda được gán kiểu như thế nào, Functional Interface đóng vai trò gì, biến bên ngoài được capture ra sao, JVM thực thi Lambda bằng cơ chế nào và khi nào nên sử dụng Lambda thay vì một phương thức hoặc class riêng.

Learning log:
Bài viết này đi từ nền tảng lập trình hàm đến cơ chế thực thi của Lambda trong JVM, sau đó chuyển sang các ví dụ nghiệp vụ có thể sao chép và chạy trực tiếp trên IDE.

Mục tiêu sau khi hoàn thành: - Hiểu được bản chất của Lambda và sự khác biệt với Anonymous Inner Class. - Sử dụng thành thạo Predicate, Consumer, Function, Supplier và các biến thể. - Nắm vững Target Typing, Type Inference, invokedynamic và effectively final. - Biết sử dụng Method References, Stream API và Custom Functional Interface. - Viết code ngắn gọn nhưng vẫn dễ đọc, dễ kiểm thử và dễ bảo trì.

Phiên bản tham chiếu: Java 8 trở lên. Các ví dụ chính sử dụng cú pháp tương thích Java 8; những phần nói về bytecode cần được hiểu theo phiên bản JDK đang sử dụng.

1. Bối cảnh: Cuộc cách mạng lập trình hàm trong Java

Để hiểu tại sao Lambda quan trọng, trước tiên hãy nhìn vào một vấn đề rất phổ biến: chúng ta muốn sắp xếp danh sách nhân viên theo lương giảm dần. Bản thân thao tác sắp xếp không khó. Điều khiến code dài ra là cách chúng ta phải mô tả quy tắc so sánh trong Java truyền thống.

1.1. Anonymous Inner Class và những hạn chế

Giả sử có một danh sách số nguyên và chúng ta muốn sắp xếp theo thứ tự tăng dần. Trước Java 8, một cách viết phổ biến là sử dụng Anonymous Inner Class để triển khai giao diện Comparator<Integer>.

Main.java — Java trước Java 8
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers =
                Arrays.asList(9, 3, 7, 1, 5);

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

        System.out.println(numbers);
    }
}

Kết quả:

Console Output
[1, 3, 5, 7, 9]

Code hoàn toàn đúng. Tuy nhiên, hãy phân tích những gì thực sự cần thiết cho bài toán này.

Điều đáng chú ý là phần lớn mã nguồn không phải nghiệp vụ sắp xếp. Nó là cú pháp phụ trợ để diễn đạt một hành vi tương đối nhỏ.

Anonymous Inner Class vẫn có những trường hợp sử dụng hợp lý. Nó có thể triển khai giao diện có nhiều phương thức trừu tượng, mở rộng class, hoặc tạo một đối tượng có trạng thái và hành vi riêng. Vấn đề không nằm ở việc kỹ thuật này xấu, mà ở chỗ nó quá dài khi mục tiêu chỉ là truyền một hành vi đơn giản vào một phương thức.

Lưu ý về hiệu năng: Không nên kết luận rằng Anonymous Inner Class luôn lãng phí bộ nhớ hoặc luôn chậm hơn Lambda. Anonymous Class thông thường được biên dịch thành một class file riêng, trong khi Lambda của Java 8 trở lên thường sử dụng cơ chế invokedynamic. Chi phí thực tế phụ thuộc vào cách triển khai, JIT, khả năng tối ưu hóa và cách sử dụng. Lợi ích dễ thấy nhất của Lambda là giảm boilerplate và làm rõ ý định của code.

1.2. Lambda Expression là gì?

Lambda Expression là một biểu thức biểu diễn việc triển khai phương thức trừu tượng của một Functional Interface. Nói đơn giản hơn, thay vì viết toàn bộ class vô danh, chúng ta chỉ cần mô tả các tham số và phần xử lý cần thực hiện.

Main.java — Lambda Expression
import java.util.Arrays;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers =
                Arrays.asList(9, 3, 7, 1, 5);

        numbers.sort((a, b) -> Integer.compare(a, b));

        System.out.println(numbers);
    }
}

Kết quả vẫn là:

Console Output
[1, 3, 5, 7, 9]

Hãy quan sát biểu thức:

Lambda Expression
(a, b) -> Integer.compare(a, b)

Trình biên dịch biết rằng phương thức sort() cần một Comparator<Integer>. Giao diện này yêu cầu một phương thức compare(Integer, Integer) trả về int. Vì vậy, Java có đủ thông tin để hiểu Lambda đang cung cấp phần triển khai cho phương thức đó.

1.3. Tư duy lập trình hàm và First-Class Citizen

Trong lập trình hướng đối tượng truyền thống, chúng ta thường tổ chức chương trình xung quanh đối tượng, trạng thái và phương thức. Lập trình hàm bổ sung một cách tư duy khác: xem việc tính toán và biến đổi dữ liệu thông qua hàm như một thành phần quan trọng của chương trình.

Java không trở thành một ngôn ngữ lập trình hàm thuần túy kể từ Java 8. Java vẫn là ngôn ngữ hướng đối tượng, nhưng Lambda cho phép chúng ta áp dụng nhiều kỹ thuật lập trình hàm trong một ngôn ngữ đa mô hình.

Ví dụ, giả sử chúng ta có một phương thức nhận vào quy tắc kiểm tra một số:

PassingBehavior.java
import java.util.function.Predicate;

public class PassingBehavior {

    static boolean check(
            int number,
            Predicate<Integer> rule) {
        return rule.test(number);
    }

    public static void main(String[] args) {
        boolean even = check(
                10,
                number -> number % 2 == 0
        );

        boolean positive = check(
                -3,
                number -> number > 0
        );

        System.out.println(even);
        System.out.println(positive);
    }
}
Console Output
true
false

Điểm mấu chốt là phương thức check() không cần biết quy tắc cụ thể là kiểm tra số chẵn, số dương hay một điều kiện khác. Nó chỉ cần biết cách gọi giao diện Predicate.

Đây là tư duy truyền hành vi như truyền dữ liệu. Lambda thường được gọi là cách Java biểu diễn hàm, nhưng về mặt hệ thống kiểu dữ liệu, Lambda luôn cần một kiểu đích tương thích, thường là một Functional Interface. Nó không phải một giá trị hàm độc lập hoàn toàn giống như trong một số ngôn ngữ lập trình hàm thuần túy.

Tư duy của System Architect: Lambda giúp tách phần điều phối khỏi phần chính sách xử lý. Một phương thức có thể cung cấp cơ chế xử lý chung, trong khi bên gọi quyết định hành vi cụ thể bằng cách truyền Lambda. Đây là một nền tảng hữu ích cho chiến lược sắp xếp, lọc dữ liệu, callback, xử lý sự kiện và thiết kế các API có khả năng mở rộng.

2. Cú pháp Lambda và cơ chế hoạt động dưới mui xe

Muốn hiểu Lambda thực sự, chúng ta cần phân biệt ba vấn đề: cú pháp được viết trong mã nguồn, cách trình biên dịch xác định kiểu của biểu thức và cách JVM tạo ra cơ chế thực thi khi chương trình chạy.

2.1. Cấu trúc cú pháp chuẩn

Có hai dạng cú pháp chính của Lambda:

Lambda Syntax
// Dạng 1: Một biểu thức
(parameters) -> expression

// Dạng 2: Một khối lệnh
(parameters) -> {
    statements;
}

Hãy xem từng dạng qua những ví dụ nhỏ.

LambdaSyntax.java
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

public class LambdaSyntax {
    public static void main(String[] args) {

        // Không tham số
        Supplier<String> greeting =
                () -> "Xin chao Java";

        // Một tham số: có thể bỏ ngoặc đơn
        Function<Integer, Integer> square =
                n -> n * n;

        // Nhiều tham số
        Function<String, Integer> length =
                text -> text.length();

        // Nhiều câu lệnh
        Consumer<String> printer = text -> {
            String formatted = "[INFO] " + text;
            System.out.println(formatted);
        };

        System.out.println(greeting.get());
        System.out.println(square.apply(5));
        System.out.println(length.apply("Lambda"));
        printer.accept("Application started");
    }
}
Console Output
Xin chao Java
25
6
[INFO] Application started

Một số quy tắc cần ghi nhớ:

Ví dụ, hai cách dưới đây đều hợp lệ:

Expression Body và Block Body
import java.util.function.Function;

Function<Integer, Integer> a = n -> n * 2;

Function<Integer, Integer> b = n -> {
    int result = n * 2;
    return result;
};

Với logic ngắn, biểu thức một dòng dễ đọc hơn. Khi cần kiểm tra điều kiện, khai báo biến cục bộ hoặc xử lý nhiều bước, khối lệnh sẽ phù hợp hơn.

2.2. Target Typing và Type Inference

Đây là một trong những điểm quan trọng nhất của Lambda: tại sao Java có thể hiểu kiểu của a và b trong biểu thức (a, b) -> a + b mà không cần chúng ta khai báo kiểu dữ liệu?

Câu trả lời nằm ở hai khái niệm liên quan chặt chẽ: Target Typing và Type Inference.

Target Typing có nghĩa là kiểu đích của biểu thức được sử dụng để kiểm tra và diễn giải biểu thức đó. Type Inference là quá trình trình biên dịch suy luận những thông tin kiểu còn thiếu dựa trên ngữ cảnh và các ràng buộc kiểu dữ liệu.

TypeInference.java
import java.util.function.BiFunction;

public class TypeInference {
    public static void main(String[] args) {

        BiFunction<Integer, Integer, Integer> add =
                (a, b) -> a + b;

        System.out.println(add.apply(10, 20));
    }
}

Với khai báo trên, Java biết:

Vì vậy, biểu thức a + b được kiểm tra theo các kiểu này. Trong trường hợp hai toán hạng là Integer, Java thực hiện unboxing để phép cộng được thực hiện trên giá trị nguyên thủy, sau đó kết quả được boxing lại thành Integer khi cần.

Console Output
30

Chúng ta cũng có thể khai báo kiểu tham số tường minh:

Explicit Types
BiFunction<Integer, Integer, Integer> add =
        (Integer a, Integer b) -> a + b;

Cả hai cách đều hợp lệ. Cách thứ nhất ngắn gọn hơn, còn cách thứ hai có thể hữu ích trong một số ngữ cảnh phức tạp hoặc khi muốn làm rõ kiểu dữ liệu.

Một điểm dễ nhầm: Java không thể luôn suy luận kiểu Lambda nếu không có kiểu đích phù hợp.

InvalidLambda.java — Ví dụ không hợp lệ
// Không có kiểu đích để xác định kiểu của tham số
// var operation = x -> x + 1;

Trong Java 8, biểu thức Lambda không thể được khai báo trực tiếp với var như một giá trị hàm không có kiểu đích. Tính năng var cho biến cục bộ được giới thiệu từ Java 10, nhưng điều đó không có nghĩa Lambda có thể tự tồn tại mà không cần một kiểu đích.

Hãy so sánh với một biểu thức thông thường:

TargetTypingExample.java
import java.util.function.Function;

public class TargetTypingExample {
    public static void main(String[] args) {
        Function<String, Integer> parser =
                text -> Integer.parseInt(text);

        int number = parser.apply("123");

        System.out.println(number);
    }
}

Trong ví dụ này, kiểu đích xác định rằng Lambda nhận String và trả về Integer. Kiểu đích có thể xuất hiện ở vế trái của phép gán, kiểu tham số của phương thức hoặc trong một ngữ cảnh khác mà Java có thể xác định kiểu đích theo quy tắc của ngôn ngữ.

Ghi nhớ: Lambda không tự quyết định giao diện mà nó triển khai. Kiểu đích xác định Functional Interface cần đáp ứng, sau đó trình biên dịch kiểm tra số lượng tham số, kiểu dữ liệu, giá trị trả về và các quy tắc tương thích khác.

2.3. invokedynamic và cơ chế thực thi Lambda

Đây là phần thường bị giải thích quá đơn giản. Có người nói Lambda chỉ là cú pháp viết tắt của Anonymous Inner Class. Cách diễn đạt này hữu ích khi mới bắt đầu, nhưng không hoàn toàn chính xác về cơ chế triển khai.

Với Anonymous Inner Class truyền thống, trình biên dịch thông thường tạo ra một class file riêng cho class vô danh. Ví dụ, một class chính tên Main có thể đi kèm một class file có tên gần giống Main$1.class. Tên cụ thể phụ thuộc vào mã nguồn và trình biên dịch.

Với Lambda trong Java 8 trở lên, trình biên dịch thông thường sử dụng lệnh bytecode invokedynamic để kết nối biểu thức Lambda với một cơ chế tạo hoặc lấy đối tượng triển khai Functional Interface tại thời điểm chạy.

Mô hình biên dịch và thực thi
Mã nguồn Java
       |
       v
   javac compiler
       |
       +------------------------------+
       |                              |
       v                              v
Anonymous Inner Class             Lambda
       |                              |
       v                              v
Class file riêng               Bytecode invokedynamic
                                      |
                                      v
                            Bootstrap method linkage
                                      |
                                      v
                           LambdaMetafactory (thường gặp)
                                      |
                                      v
                           Đối tượng triển khai SAM
                                      |
                                      v
                           Thực thi hành vi Lambda

Trong cơ chế thường gặp của Java 8, javac phát sinh một lệnh invokedynamic cùng thông tin bootstrap. Khi JVM liên kết lệnh này, cơ chế của LambdaMetafactory có thể tạo một đối tượng triển khai giao diện đích và kết nối nó với phần mã xử lý Lambda.

Điều này khác với cách tạo class file riêng cho mỗi Anonymous Inner Class. Tuy nhiên, cần hiểu chính xác các giới hạn sau:

Điểm đáng giá của invokedynamic là nó cho JVM một cơ chế liên kết linh hoạt hơn. JVM có thể quyết định cách kết nối hành vi với giao diện đích tại thời điểm chạy, thay vì buộc mọi Lambda phải tuân theo mô hình triển khai class vô danh truyền thống.

2.4. Kiểm tra bytecode bằng javap

Để không chỉ dừng ở lý thuyết, chúng ta có thể kiểm tra bytecode của một chương trình đơn giản. Đây là cách tốt để nhìn thấy sự khác biệt giữa biểu diễn Lambda trong mã nguồn và lệnh mà JVM thực sự nhận được.

Main.java
import java.util.function.IntUnaryOperator;

public class Main {
    public static void main(String[] args) {
        IntUnaryOperator square = n -> n * n;

        System.out.println(square.applyAsInt(5));
    }
}

Biên dịch và kiểm tra bytecode bằng JDK:

CMD / Terminal
javac Main.java
javap -c -p Main
java Main

Trong kết quả của javap, bạn thường thấy một lệnh invokedynamic trong phương thức main() tại vị trí Lambda được tạo. Bạn cũng có thể thấy một phương thức tổng hợp có tên dạng lambda$main$0 trong phần bytecode được trình biên dịch phát sinh.

Ví dụ minh họa dưới đây chỉ mô tả hình dạng thường gặp; chỉ số constant pool, vị trí bytecode và tên phương thức tổng hợp có thể khác theo phiên bản JDK.

Minh họa bytecode — không phải bản sao đầu ra cố định
public static void main(java.lang.String[]);
  Code:
     ...
     invokedynamic #... // applyAsInt:()Ljava/util/function/IntUnaryOperator;
     ...
     invokeinterface ... // IntUnaryOperator.applyAsInt:(I)I
     ...

private static int lambda$main$0(int);
  Code:
     iload_0
     iload_0
     imul
     ireturn

Phương thức tổng hợp minh họa thực hiện phép nhân hai giá trị nguyên giống nhau. Lệnh invokedynamic chịu trách nhiệm liên kết biểu thức Lambda với giao diện đích; lệnh gọi giao diện sau đó thực thi hành vi khi chương trình gọi applyAsInt().

Để so sánh, hãy viết thêm một phiên bản sử dụng Anonymous Inner Class trong một file riêng rồi kiểm tra bằng javap. Bạn thường sẽ thấy một class file riêng cho class vô danh. Đây là minh chứng trực quan cho khác biệt trong chiến lược biên dịch phổ biến của hai cách viết.

Góc nhìn JVM: Lambda là một tính năng của ngôn ngữ được triển khai bằng cơ chế runtime. Đừng phụ thuộc vào tên class tổng hợp, việc tái sử dụng instance hay chi tiết nội bộ của LambdaMetafactory trong logic nghiệp vụ. Hãy dựa vào hợp đồng của Functional Interface và hành vi được Java Language Specification bảo đảm.

3. Functional Interface: Nền tảng của Lambda

Nếu Lambda là cách mô tả hành vi, vậy Java dựa vào đâu để biết hành vi đó phải có bao nhiêu tham số, kiểu dữ liệu nào và trả về kiểu gì?

Câu trả lời là Functional Interface, còn thường được gọi là SAM Interface, viết tắt của Single Abstract Method.

3.1. Khái niệm và sức mạnh của @FunctionalInterface

Một Functional Interface là giao diện có một phương thức trừu tượng duy nhất theo các quy tắc của Java. Các phương thức mặc định có phần triển khai, các phương thức static và những phương thức trừu tượng tương ứng với phương thức public của Object không làm mất tính chất Functional Interface.

Ví dụ, chúng ta có thể tự định nghĩa một giao diện đại diện cho hành động chào hỏi:

GreetingAction.java
@FunctionalInterface
public interface GreetingAction {
    void greet(String name);
}

Giao diện trên chỉ có một phương thức trừu tượng là greet(). Vì vậy, nó có thể được triển khai bằng Lambda:

GreetingDemo.java
public class GreetingDemo {
    public static void main(String[] args) {
        GreetingAction action =
                name -> System.out.println(
                        "Xin chao, " + name
                );

        action.greet("Qui");
    }
}
Console Output
Xin chao, Qui

Annotation @FunctionalInterface không bắt buộc để Lambda hoạt động. Tuy nhiên, nó yêu cầu trình biên dịch kiểm tra rằng giao diện đáp ứng các quy tắc của Functional Interface. Nếu ai đó vô tình thêm một phương thức trừu tượng không tương thích, trình biên dịch sẽ báo lỗi.

Ví dụ, phần sau không hợp lệ:

InvalidAction.java
@FunctionalInterface
interface InvalidAction {
    void execute();
    void cancel();
}

Hai phương thức trên đều là phương thức trừu tượng độc lập. Do đó, giao diện không còn là Functional Interface và annotation sẽ khiến trình biên dịch báo lỗi.

Ngược lại, trường hợp này vẫn hợp lệ:

ValidAction.java
@FunctionalInterface
interface ValidAction {

    void execute();

    default void log() {
        System.out.println("Action executed");
    }

    static ValidAction empty() {
        return () -> {};
    }
}

Phương thức log() là default method, còn empty() là static method. Cả hai không tạo thêm phương thức trừu tượng độc lập cho giao diện.

3.1.1. Quy tắc xác định phương thức trừu tượng duy nhất

Khái niệm SAM không có nghĩa giao diện chỉ được phép chứa đúng một dòng khai báo phương thức. Điều cần xác định là tập hợp các phương thức trừu tượng thành viên tạo nên một hợp đồng phương thức duy nhất theo quy tắc của Java.

Ví dụ, các phương thức equals(Object), hashCode() và toString() là những phương thức tương ứng với Object. Việc giao diện khai báo lại những phương thức này không nhất thiết khiến giao diện mất tính chất Functional Interface.

FunctionalInterfaceRules.java
@FunctionalInterface
interface Task {

    void execute();

    boolean equals(Object other);
}

Giao diện này vẫn có thể đáp ứng quy tắc Functional Interface vì equals(Object) tương ứng với một phương thức public của Object. Tuy nhiên, khi thiết kế giao diện cho dự án, chúng ta vẫn nên ưu tiên hợp đồng rõ ràng, đơn giản và dễ hiểu.

3.2. Predicate<T>: Kiểm tra điều kiện

Predicate<T> thuộc gói java.util.function, biểu diễn một điều kiện nhận đầu vào kiểu T và trả về boolean.

Predicate Signature
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

Có thể hiểu Predicate như một câu hỏi có đáp án đúng hoặc sai. Ví dụ: nhân viên có lương trên 30 triệu hay không? Sản phẩm còn hàng hay không? Đơn hàng đã thanh toán hay chưa?

Ví dụ thực tế với danh sách nhân viên:

PredicateExample.java
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class PredicateExample {

    public static void main(String[] args) {
        List<Integer> salaries =
                Arrays.asList(1200, 2500, 3200, 1800, 4500);

        Predicate<Integer> highSalary =
                salary -> salary >= 3000;

        List<Integer> result = salaries.stream()
                .filter(highSalary)
                .collect(Collectors.toList());

        System.out.println(result);
    }
}
Console Output
[3200, 4500]

Ở đây, filter() nhận một Predicate để quyết định phần tử nào được giữ lại. Nó không cần biết điều kiện là lương, tuổi hay trạng thái đơn hàng.

Các phương thức kết hợp của Predicate:

PredicateComposition.java
import java.util.function.Predicate;

public class PredicateComposition {
    public static void main(String[] args) {

        Predicate<Integer> positive =
                n -> n > 0;

        Predicate<Integer> even =
                n -> n % 2 == 0;

        Predicate<Integer> positiveEven =
                positive.and(even);

        Predicate<Integer> positiveOdd =
                positive.and(even.negate());

        System.out.println(positiveEven.test(8));
        System.out.println(positiveEven.test(-8));
        System.out.println(positiveOdd.test(7));

        Predicate<Integer> either =
                positive.or(n -> n == 0);

        System.out.println(either.test(0));
    }
}
Console Output
true
false
true
true

Những phương thức quan trọng:

Ví dụ nghiệp vụ: chỉ cho phép đơn hàng được giao khi đã thanh toán và chưa bị hủy.

OrderRules.java
Predicate<Order> paid = Order::isPaid;
Predicate<Order> notCancelled =
        order -> !order.isCancelled();

Predicate<Order> canShip =
        paid.and(notCancelled);

Đoạn code trên giả định class Order có các phương thức tương ứng. Cách kết hợp Predicate giúp thể hiện quy tắc nghiệp vụ bằng những thành phần nhỏ có thể kiểm thử độc lập.

3.3. Consumer<T>: Tiêu thụ dữ liệu

Consumer<T> nhận một giá trị kiểu T và thực hiện một hành động, nhưng không trả về kết quả qua giá trị trả về của phương thức. Nó thường được dùng để in dữ liệu, ghi log, gửi thông báo hoặc cập nhật một thành phần bên ngoài.

Consumer Signature
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

Ví dụ, chúng ta muốn xử lý danh sách tên khách hàng và ghi log cho từng người.

ConsumerExample.java
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class ConsumerExample {

    public static void main(String[] args) {
        List<String> customers =
                Arrays.asList("An", "Binh", "Chi");

        Consumer<String> logger =
                name -> System.out.println(
                        "[CUSTOMER] " + name
                );

        customers.forEach(logger);
    }
}
Console Output
[CUSTOMER] An
[CUSTOMER] Binh
[CUSTOMER] Chi

Consumer không có nghĩa là không tạo ra bất kỳ tác động nào. Trái lại, nó thường được dùng để thực hiện side effect, chẳng hạn ghi log hoặc gửi email. Chỉ có điều kết quả của accept() không được trả về dưới dạng một giá trị.

Consumer còn có phương thức andThen(), cho phép nối hai hành động theo thứ tự:

ConsumerAndThen.java
import java.util.function.Consumer;

public class ConsumerAndThen {
    public static void main(String[] args) {

        Consumer<String> print =
                value -> System.out.println(value);

        Consumer<String> audit =
                value -> System.out.println(
                        "AUDIT: " + value
                );

        Consumer<String> pipeline =
                print.andThen(audit);

        pipeline.accept("Order-1001");
    }
}
Console Output
Order-1001
AUDIT: Order-1001

Trong ứng dụng thực tế, hãy cẩn thận khi kết hợp nhiều side effect. Nếu Consumer đầu tiên ném exception, Consumer tiếp theo sẽ không được thực thi. Việc ghi log, gửi thông báo và cập nhật trạng thái nên có chính sách xử lý lỗi phù hợp với nghiệp vụ.

3.4. Function<T, R>: Chuyển đổi dữ liệu

Function<T, R> nhận đầu vào kiểu T, xử lý và trả về giá trị kiểu R. Đây là một trong những Functional Interface hữu ích nhất khi xây dựng luồng chuyển đổi dữ liệu, đặc biệt là DTO, Entity, View Model và các đối tượng phục vụ API.

Function Signature
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

Ví dụ, một đối tượng DTO chứa dữ liệu nhận từ API cần được chuyển đổi thành Entity để lưu xuống cơ sở dữ liệu.

FunctionExample.java
import java.util.function.Function;

class UserRequest {
    private final String name;
    private final String email;

    UserRequest(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

class UserEntity {
    private final String name;
    private final String email;

    UserEntity(String name, String email) {
        this.name = name;
        this.email = email;
    }

    @Override
    public String toString() {
        return "UserEntity{name='" + name
                + "', email='" + email + "'}";
    }
}

public class FunctionExample {

    public static void main(String[] args) {
        UserRequest request =
                new UserRequest(
                        "Nguyen An",
                        "an@example.com"
                );

        Function<UserRequest, UserEntity> mapper =
                dto -> new UserEntity(
                        dto.getName(),
                        dto.getEmail()
                );

        UserEntity entity = mapper.apply(request);

        System.out.println(entity);
    }
}
Console Output
UserEntity{name='Nguyen An', email='an@example.com'}

Trong dự án thực tế, phần chuyển đổi DTO sang Entity có thể được tách thành phương thức riêng hoặc lớp mapper chuyên dụng. Lambda phù hợp khi cần truyền quy tắc chuyển đổi vào một API tổng quát, còn mapper riêng thường dễ bảo trì hơn khi logic chuyển đổi có nhiều điều kiện.

Các phương thức kết hợp quan trọng của Function:

FunctionComposition.java
import java.util.function.Function;

public class FunctionComposition {
    public static void main(String[] args) {

        Function<String, String> trim =
                String::trim;

        Function<String, String> uppercase =
                String::toUpperCase;

        Function<String, Integer> length =
                String::length;

        Function<String, String> normalize =
                trim.andThen(uppercase);

        Function<String, Integer> normalizeAndCount =
                trim.andThen(uppercase).andThen(length);

        Function<String, Integer> countThenDouble =
                length.andThen(n -> n * 2);

        Function<String, Integer> doubleThenCount =
                length.compose(s -> s + s);

        System.out.println(normalize.apply("  java  "));
        System.out.println(normalizeAndCount.apply("  java  "));
        System.out.println(countThenDouble.apply("Java"));
        System.out.println(doubleThenCount.apply("Java"));
    }
}
Console Output
JAVA
4
8
8

Ở ví dụ trên:

Hai Function có thể ghép thành một pipeline để mô tả các bước chuyển đổi. Tuy nhiên, nếu pipeline có quá nhiều bước hoặc cần xử lý lỗi phức tạp, hãy cân nhắc đặt tên cho từng bước hoặc tách chúng thành các phương thức có ý nghĩa nghiệp vụ.

3.5. Supplier<T>: Cung cấp dữ liệu

Supplier<T> không nhận tham số và trả về một giá trị kiểu T. Nó phù hợp khi chúng ta muốn trì hoãn việc tạo dữ liệu cho đến lúc thực sự cần sử dụng.

Supplier Signature
@FunctionalInterface
public interface Supplier<T> {
    T get();
}

Ví dụ đơn giản nhất là sinh một mã ngẫu nhiên:

SupplierExample.java
import java.util.UUID;
import java.util.function.Supplier;

public class SupplierExample {
    public static void main(String[] args) {

        Supplier<String> idGenerator =
                () -> UUID.randomUUID().toString();

        System.out.println(idGenerator.get());
        System.out.println(idGenerator.get());
    }
}

Kết quả sẽ có dạng sau; giá trị thực tế thay đổi theo mỗi lần chạy:

Console Output — Minh họa
d9428888-122b-4c9b-b8d4-8d8a8c7e1234
a1f95d80-2d72-4f39-8d4b-1b1a6c2e5678

Điều quan trọng là biểu thức sinh UUID nằm bên trong Supplier. Nó chỉ được gọi khi get() được thực thi, thay vì sinh UUID ngay tại thời điểm khai báo Supplier.

Supplier cũng hữu ích cho lazy initialization. Ví dụ, một phương thức chỉ thực hiện thao tác tạo dữ liệu mặc định khi giá trị đầu vào chưa tồn tại.

LazySupplier.java
import java.util.function.Supplier;

public class LazySupplier {

    static String valueOrDefault(
            String value,
            Supplier<String> fallback) {

        if (value != null) {
            return value;
        }

        return fallback.get();
    }

    public static void main(String[] args) {

        String existing =
                valueOrDefault(
                        "Existing value",
                        () -> {
                            System.out.println(
                                    "Creating fallback..."
                            );
                            return "Default value";
                        }
                );

        String missing =
                valueOrDefault(
                        null,
                        () -> {
                            System.out.println(
                                    "Creating fallback..."
                            );
                            return "Default value";
                        }
                );

        System.out.println(existing);
        System.out.println(missing);
    }
}
Console Output
Creating fallback...
Existing value
Default value

Trong ví dụ này, dòng thông báo được in ra khi Supplier của lời gọi thứ hai được thực thi. Supplier ở lời gọi thứ nhất không được gọi vì giá trị đầu vào đã tồn tại.

Cần phân biệt Supplier với một đối tượng lazy có khả năng ghi nhớ kết quả. Supplier thông thường không tự cache kết quả. Nếu gọi get() nhiều lần, phần triển khai có thể tạo ra nhiều đối tượng khác nhau. Nếu muốn khởi tạo một lần và tái sử dụng, cần bổ sung cơ chế cache hoặc sử dụng một abstraction lazy chuyên dụng.

3.5.1. Lazy Evaluation với Optional.orElseGet()

Một ví dụ rất thực tế xuất hiện trong API Optional. Hai phương thức orElse() và orElseGet() có hành vi đánh giá đối số khác nhau.

OptionalSupplier.java
import java.util.Optional;

public class OptionalSupplier {

    static String createDefault() {
        System.out.println("Creating default");
        return "DEFAULT";
    }

    public static void main(String[] args) {

        Optional<String> value =
                Optional.of("EXISTING");

        String a = value.orElse(createDefault());
        System.out.println(a);

        String b = value.orElseGet(
                () -> createDefault()
        );
        System.out.println(b);
    }
}
Console Output
Creating default
EXISTING
EXISTING

Với orElse(), đối số được đánh giá trước khi phương thức được gọi. Vì vậy, createDefault() vẫn chạy ngay cả khi Optional chứa giá trị. Với orElseGet(), Supplier chỉ được gọi khi Optional rỗng.

Đây là khác biệt rất quan trọng khi việc tạo giá trị mặc định tốn chi phí, truy vấn dữ liệu hoặc thực hiện một thao tác phụ.

3.6. UnaryOperator<T> và BinaryOperator<T>

UnaryOperator<T> là một dạng chuyên biệt của Function<T, T>: nhận một giá trị kiểu T và trả về giá trị cùng kiểu T.

UnaryOperatorExample.java
import java.util.function.UnaryOperator;

public class UnaryOperatorExample {
    public static void main(String[] args) {

        UnaryOperator<String> normalize =
                text -> text.trim().toUpperCase();

        System.out.println(
                normalize.apply("  java lambda  ")
        );
    }
}
Console Output
JAVA LAMBDA

BinaryOperator<T> là một dạng chuyên biệt của BiFunction<T, T, T>: nhận hai đầu vào cùng kiểu và trả về kết quả cùng kiểu.

BinaryOperatorExample.java
import java.util.Arrays;
import java.util.List;
import java.util.function.BinaryOperator;

public class BinaryOperatorExample {
    public static void main(String[] args) {

        BinaryOperator<Integer> sum =
                (a, b) -> a + b;

        List<Integer> numbers =
                Arrays.asList(10, 20, 30, 40);

        int total = numbers.stream()
                .reduce(0, sum);

        System.out.println(total);
    }
}
Console Output
100

Phương thức reduce() kết hợp các phần tử thành một kết quả duy nhất. Khi dùng phép kết hợp với xử lý song song, cần chú ý tính kết hợp của toán tử. Phép cộng số nguyên là phép kết hợp trong miền số nguyên thông thường, nhưng phép trừ không có tính chất này. Với kiểu số có giới hạn hoặc số thực dấu phẩy động, các quy tắc số học và sai số cũng cần được xem xét.

Functional Interface Đầu vào Đầu ra Ứng dụng điển hình
Function<T, R> Một giá trị T R Chuyển DTO thành Entity
UnaryOperator<T> Một giá trị T T Chuẩn hóa dữ liệu
BiFunction<T, U, R> T và U R Kết hợp hai nguồn dữ liệu
BinaryOperator<T> T và T T Cộng dồn hoặc kết hợp giá trị
Predicate<T> T boolean Lọc hoặc kiểm tra điều kiện
Consumer<T> T void Ghi log hoặc thực hiện hành động
Supplier<T> Không có T Sinh dữ liệu hoặc khởi tạo trì hoãn

3.7. Primitive Functional Interfaces: Tránh boxing/unboxing

Java cung cấp nhiều Functional Interface dành riêng cho kiểu nguyên thủy nhằm tránh việc phải chuyển đổi qua các wrapper class trong những trường hợp phù hợp. Các interface này đặc biệt hữu ích khi xử lý số lượng lớn dữ liệu số.

Hãy xem xét một số interface tiêu biểu:

Interface Phương thức chính Chức năng
IntPredicate boolean test(int value) Kiểm tra điều kiện với int
LongPredicate boolean test(long value) Kiểm tra điều kiện với long
IntConsumer void accept(int value) Tiêu thụ int
LongConsumer void accept(long value) Tiêu thụ long
IntFunction<R> R apply(int value) Chuyển int thành đối tượng R
IntUnaryOperator int applyAsInt(int value) Biến đổi int thành int
IntBinaryOperator int applyAsInt(int a, int b) Kết hợp hai giá trị int
ToIntFunction<T> int applyAsInt(T value) Chuyển T thành int
ToLongFunction<T> long applyAsLong(T value) Chuyển T thành long
IntToLongFunction long applyAsLong(int value) Chuyển int thành long

Ví dụ, nếu chúng ta sử dụng Function<Integer, Integer> để nhân một số nguyên với hai, đầu vào và đầu ra được biểu diễn bằng wrapper class Integer. Tùy ngữ cảnh, việc boxing/unboxing có thể phát sinh.

PrimitiveFunctionExample.java
import java.util.function.Function;
import java.util.function.IntUnaryOperator;

public class PrimitiveFunctionExample {
    public static void main(String[] args) {

        Function<Integer, Integer> boxed =
                n -> n * 2;

        IntUnaryOperator primitive =
                n -> n * 2;

        System.out.println(boxed.apply(10));
        System.out.println(primitive.applyAsInt(10));
    }
}
Console Output
20
20

Hai biểu thức có cùng ý nghĩa nghiệp vụ. Tuy nhiên, interface nguyên thủy không yêu cầu biểu diễn đầu vào và đầu ra thông qua Integer trong hợp đồng phương thức.

Điều này có thể giảm chi phí boxing/unboxing và cấp phát wrapper trong những tình huống thích hợp. Nhưng không nên suy luận rằng chỉ cần đổi sang primitive interface thì chương trình chắc chắn nhanh hơn đáng kể. Hiệu năng còn phụ thuộc vào JIT, cấu trúc dữ liệu, lượng dữ liệu, cách cấp phát và toàn bộ pipeline.

Ví dụ thực tế: tính bình phương của hàng triệu số nguyên bằng IntUnaryOperator hoặc kiểm tra số chẵn bằng IntPredicate là lựa chọn hợp lý khi dữ liệu thực sự nằm trong miền số nguyên nguyên thủy.

PrimitivePredicateExample.java
import java.util.function.IntPredicate;
import java.util.function.IntUnaryOperator;

public class PrimitivePredicateExample {
    public static void main(String[] args) {

        IntPredicate isEven =
                n -> n % 2 == 0;

        IntUnaryOperator square =
                n -> n * n;

        for (int n = 1; n <= 5; n++) {
            if (isEven.test(n)) {
                System.out.println(
                        n + " -> " + square.applyAsInt(n)
                );
            }
        }
    }
}
Console Output
2 -> 4
4 -> 16

3.8. Các Functional Interface hữu ích khác

Ngoài bốn interface cốt lõi, java.util.function còn cung cấp nhiều interface để biểu diễn những kiểu hành vi phổ biến.

Interface Phương thức Ví dụ ứng dụng
BiPredicate<T, U> boolean test(T, U) So sánh hai đối tượng theo quy tắc nghiệp vụ
BiConsumer<T, U> void accept(T, U) Ghi nhận cặp khóa và giá trị
BiFunction<T, U, R> R apply(T, U) Kết hợp hai dữ liệu đầu vào
ToIntFunction<T> int applyAsInt(T) Lấy số lượng hoặc điểm từ đối tượng
IntSupplier int getAsInt() Sinh một giá trị int
ObjIntConsumer<T> void accept(T, int) Xử lý đối tượng kèm chỉ số số nguyên

Một ví dụ nhỏ với BiFunction và BiPredicate:

BiFunctionalInterfaces.java
import java.util.function.BiFunction;
import java.util.function.BiPredicate;

public class BiFunctionalInterfaces {
    public static void main(String[] args) {

        BiPredicate<String, Integer> validLength =
                (text, minimum) ->
                        text != null
                        && text.length() >= minimum;

        BiFunction<String, String, String> fullName =
                (first, last) -> first + " " + last;

        System.out.println(
                validLength.test("Lambda", 5)
        );

        System.out.println(
                fullName.apply("Nguyen", "An")
        );
    }
}
Console Output
true
Nguyen An
Quy tắc chọn interface: Hãy bắt đầu từ hợp đồng mà hành vi cần biểu diễn. Nếu nhận T và trả boolean, nghĩ đến Predicate. Nếu nhận T và trả R, nghĩ đến Function. Nếu chỉ thực hiện hành động, nghĩ đến Consumer. Nếu không nhận đầu vào và tạo ra T, nghĩ đến Supplier. Khi dữ liệu là kiểu nguyên thủy, cân nhắc biến thể primitive tương ứng.

4. Effectively Final và quy tắc biến bên ngoài

Một trong những lỗi thường gặp khi mới học Lambda là cố gắng thay đổi biến cục bộ được khai báo bên ngoài Lambda. Java có một quy tắc quan trọng: biến cục bộ được Lambda capture phải là final hoặc effectively final.

4.1. Quy tắc final và effectively final

Biến final là biến chỉ được gán giá trị một lần. Biến effectively final không nhất thiết phải khai báo từ khóa final, nhưng sau khi được khởi tạo, nó không bị gán lại.

EffectivelyFinalExample.java
import java.util.function.Consumer;

public class EffectivelyFinalExample {
    public static void main(String[] args) {

        String message = "Hello";

        Consumer<String> printer =
                name -> System.out.println(
                        message + ", " + name
                );

        printer.accept("Java");
    }
}

Biến message không được khai báo bằng từ khóa final, nhưng không bị gán lại sau khi khởi tạo. Vì vậy, nó là effectively final và có thể được Lambda capture.

Ngược lại, đoạn code sau không hợp lệ:

InvalidCapture.java
import java.util.function.Supplier;

public class InvalidCapture {
    public static void main(String[] args) {

        int counter = 0;

        // Không hợp lệ:
        // counter sẽ bị gán lại sau khi Lambda capture.
        // Supplier<Integer> supplier =
        //         () -> counter;

        counter++;
    }
}

Nếu bỏ phần chú thích và thực sự khai báo Supplier sử dụng counter, trình biên dịch sẽ báo lỗi vì counter không còn effectively final.

Điểm quan trọng không phải biến đó có phải hằng số hay không. Điểm quan trọng là biến cục bộ được capture không bị gán lại trong phạm vi mà Java dùng để kiểm tra effectively final.

4.2. Lambda capture biến như thế nào?

Để hiểu quy tắc này, chúng ta cần phân biệt biến cục bộ với đối tượng mà biến đó tham chiếu đến.

Một biến cục bộ chứa giá trị hoặc tham chiếu và có phạm vi sống gắn với lần thực thi phương thức. Lambda có thể được sử dụng ở nơi khác trong luồng thực thi, thậm chí được trả về từ phương thức hoặc được thực thi sau đó bởi một thread khác. Vì vậy, Java không cho phép Lambda phụ thuộc vào việc thay đổi giá trị của một biến cục bộ đã capture theo cách làm mất tính nhất quán của môi trường thực thi.

Trong triển khai thông thường, giá trị của biến cục bộ được capture được truyền vào cơ chế tạo Lambda. Không nên hình dung rằng Lambda đơn giản giữ nguyên một ô biến cục bộ trên stack sau khi phương thức kết thúc.

Minh họa phạm vi sống
Phương thức tạo Lambda
        |
        +-- biến cục bộ: message = "Hello"
        |
        +-- Lambda capture giá trị/tham chiếu cần thiết
                     |
                     v
              Lambda được sử dụng
                     |
                     v
              Có thể chạy sau đó

Với biến cục bộ kiểu nguyên thủy, giá trị của biến được capture. Với biến tham chiếu, giá trị được capture là tham chiếu đến đối tượng, chứ không phải một bản sao sâu của toàn bộ đối tượng.

Điều này dẫn đến một điểm rất quan trọng: effectively final không có nghĩa đối tượng mà biến tham chiếu đến là bất biến.

MutableObjectCapture.java
import java.util.ArrayList;
import java.util.List;

public class MutableObjectCapture {
    public static void main(String[] args) {

        List<String> names = new ArrayList<>();

        Runnable task = () -> {
            names.add("An");
            names.add("Binh");
        };

        task.run();

        System.out.println(names);
    }
}
Console Output
[An, Binh]

Ví dụ trên hợp lệ vì biến names không bị gán lại sang một danh sách khác. Tuy nhiên, đối tượng danh sách vẫn có thể thay đổi trạng thái.

Đây là nơi nhiều lập trình viên hiểu nhầm: Java kiểm soát việc gán lại biến được capture, nhưng không tự động bảo đảm tính bất biến hoặc an toàn luồng của đối tượng được tham chiếu.

4.3. An toàn luồng và những hiểu lầm thường gặp

Quy tắc effectively final giúp hạn chế một dạng phụ thuộc không an toàn vào biến cục bộ. Tuy nhiên, không nên nói rằng quy tắc này tự nó bảo đảm Thread Safety.

Hãy xem một ví dụ về bộ đếm:

CounterExample.java
import java.util.concurrent.atomic.AtomicInteger;

public class CounterExample {
    public static void main(String[] args) {

        AtomicInteger counter = new AtomicInteger(0);

        Runnable task = () -> {
            counter.incrementAndGet();
        };

        task.run();
        task.run();

        System.out.println(counter.get());
    }
}
Console Output
2

Biến tham chiếu counter không bị gán lại, nên có thể được capture. Phương thức incrementAndGet() của AtomicInteger cung cấp thao tác cập nhật nguyên tử. Nếu nhiều thread cùng sử dụng đối tượng này, tính an toàn của phép cập nhật được hỗ trợ bởi cơ chế atomic của lớp.

Ngược lại, nếu thay AtomicInteger bằng một đối tượng mutable không có đồng bộ hóa phù hợp, việc capture hợp lệ không ngăn được race condition.

Tại sao Java không cho phép capture biến cục bộ rồi gán lại nó? Có nhiều lý do liên quan đến thiết kế ngôn ngữ và mô hình thực thi:

Với những nghiệp vụ cần trạng thái có thể thay đổi, hãy cân nhắc một trong các hướng sau:

Quy tắc vàng: Effectively final kiểm soát việc gán lại biến cục bộ được capture. Nó không làm đối tượng trở thành immutable, không biến một danh sách thông thường thành thread-safe và không thay thế các cơ chế đồng bộ hóa của Java Memory Model.

5. Method References: Toán tử ::

Khi đã quen với Lambda, bạn sẽ thường gặp một cú pháp ngắn gọn hơn: ::, được gọi là Method Reference. Nó cho phép tham chiếu đến một phương thức hoặc constructor hiện có thay vì viết một Lambda chỉ để gọi lại phương thức đó.

Ví dụ, hai cách sau tương đương về mục đích:

LambdaVsMethodReference.java
import java.util.Arrays;
import java.util.List;

public class LambdaVsMethodReference {
    public static void main(String[] args) {

        List<String> names =
                Arrays.asList("An", "Binh", "Chi");

        names.forEach(name -> System.out.println(name));

        names.forEach(System.out::println);
    }
}

Trong trường hợp này, System.out::println tham chiếu đến phương thức println của đối tượng System.out.

Java có bốn nhóm Method References thường được giới thiệu trong tài liệu học tập.

5.1. Static method reference

Cú pháp:

Static Method Reference
ClassName::staticMethod

Ví dụ, thay vì viết text -> Integer.parseInt(text), chúng ta có thể viết Integer::parseInt.

StaticMethodReference.java
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StaticMethodReference {
    public static void main(String[] args) {

        List<String> values =
                Arrays.asList("10", "20", "30");

        List<Integer> numbers = values.stream()
                .map(Integer::parseInt)
                .collect(Collectors.toList());

        System.out.println(numbers);
    }
}
Console Output
[10, 20, 30]

Static Method Reference phù hợp khi phương thức đã có sẵn, có chữ ký tương thích với Functional Interface đích và không cần viết thêm logic trung gian.

5.2. Instance method reference của đối tượng cụ thể

Cú pháp:

Bound Instance Method Reference
objectReference::instanceMethod

Ví dụ:

BoundMethodReference.java
import java.util.function.Consumer;

public class BoundMethodReference {
    public static void main(String[] args) {

        Consumer<String> printer =
                System.out::println;

        printer.accept("Hello Lambda");
    }
}

Đối tượng đứng trước :: đã được xác định. Khi Consumer được gọi, phương thức println() được gọi trên đối tượng System.out tương ứng.

Điều này khác với việc tham chiếu một instance method chưa gắn với đối tượng cụ thể. Trong trường hợp bound reference, biểu thức đối tượng được đánh giá tại thời điểm tạo Method Reference. Nếu biểu thức đó trả về null, việc tạo tham chiếu sẽ gây NullPointerException.

5.3. Instance method reference của một kiểu bất kỳ

Cú pháp thường gặp:

Unbound Instance Method Reference
ClassName::instanceMethod

Loại này khác với static method reference dù cùng sử dụng tên lớp trước toán tử ::. Ví dụ, String::toUpperCase tham chiếu đến phương thức instance của một đối tượng String, không phải phương thức static.

UnboundMethodReference.java
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class UnboundMethodReference {
    public static void main(String[] args) {

        List<String> names =
                Arrays.asList("an", "binh", "chi");

        List<String> upperNames = names.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println(upperNames);
    }
}
Console Output
[AN, BINH, CHI]

Về mặt ý nghĩa, String::toUpperCase có thể được hiểu như một hàm nhận vào một String rồi gọi phương thức toUpperCase() trên chính đối tượng đó.

Đối với phương thức instance có tham số, kiểu đích còn quyết định cách các tham số của Functional Interface được ánh xạ vào receiver và tham số của phương thức. Ví dụ, String::compareToIgnoreCase có thể được dùng với một Comparator tương thích.

ComparatorMethodReference.java
import java.util.Arrays;
import java.util.List;

public class ComparatorMethodReference {
    public static void main(String[] args) {

        List<String> names =
                Arrays.asList("banana", "Apple", "cherry");

        names.sort(String::compareToIgnoreCase);

        System.out.println(names);
    }
}
Console Output
[Apple, banana, cherry]

5.4. Constructor Reference

Constructor Reference cho phép tham chiếu đến constructor để tạo đối tượng. Cú pháp thông thường là:

Constructor Reference
ClassName::new

Ví dụ, khi muốn chuyển chuỗi thành danh sách String mới, chúng ta có thể dùng ArrayList::new.

ConstructorReference.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;

public class ConstructorReference {
    public static void main(String[] args) {

        Supplier<List<String>> listFactory =
                ArrayList::new;

        List<String> names = listFactory.get();

        names.addAll(
                Arrays.asList("An", "Binh", "Chi")
        );

        System.out.println(names);
    }
}
Console Output
[An, Binh, Chi]

Constructor Reference cũng có thể nhận tham số thông qua Functional Interface tương ứng.

ConstructorFunction.java
import java.util.function.Function;

class Product {
    private final String name;

    Product(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Product{name='" + name + "'}";
    }
}

public class ConstructorFunction {
    public static void main(String[] args) {

        Function<String, Product> factory =
                Product::new;

        Product product =
                factory.apply("Mechanical Keyboard");

        System.out.println(product);
    }
}
Console Output
Product{name='Mechanical Keyboard'}

Ngoài constructor thông thường, Java còn hỗ trợ constructor reference cho mảng, chẳng hạn String[]::new khi kiểu đích phù hợp với một Function nhận kích thước và trả về mảng String.

5.5. Khi nào nên dùng Method Reference?

Lambda Method Reference Nhận xét
x -> Integer.parseInt(x) Integer::parseInt Phù hợp khi chỉ gọi lại phương thức static
x -> System.out.println(x) System.out::println Phù hợp khi chỉ gọi instance method
s -> s.toUpperCase() String::toUpperCase Phù hợp với instance method của kiểu
name -> new Product(name) Product::new Phù hợp khi chỉ gọi constructor
x -> x.trim().toUpperCase() Không có một tham chiếu đơn giản tương đương Giữ Lambda vì có nhiều bước xử lý

Đừng thay mọi Lambda bằng Method Reference chỉ để code ngắn hơn. Nếu Lambda có logic biến đổi, kiểm tra điều kiện hoặc tên tham số giúp làm rõ nghiệp vụ, giữ Lambda có thể là lựa chọn tốt hơn.

6. Thực chiến 1: Anonymous Class và Lambda

Bây giờ chúng ta sẽ xây dựng một chương trình hoàn chỉnh để so sánh hai cách triển khai một giao diện tùy chỉnh. Ví dụ sử dụng giao diện PriceRule, đại diện cho một quy tắc điều chỉnh giá sản phẩm.

Để dễ kiểm chứng, toàn bộ code được đặt trong một file Main.java. Phần đầu tiên sử dụng Anonymous Inner Class; phần thứ hai sử dụng Lambda.

Main.java — Chạy trực tiếp trên IDE
import java.util.Arrays;
import java.util.List;

@FunctionalInterface
interface PriceRule {
    double apply(double price);
}

public class Main {

    public static void main(String[] args) {

        List<Double> prices =
                Arrays.asList(100.0, 250.0, 500.0);

        // Cách cũ: Anonymous Inner Class
        PriceRule oldRule = new PriceRule() {
            @Override
            public double apply(double price) {
                return price * 0.9;
            }
        };

        // Cách mới: Lambda
        PriceRule lambdaRule =
                price -> price * 0.9;

        System.out.println("=== Anonymous Class ===");
        for (Double price : prices) {
            System.out.println(
                    oldRule.apply(price)
            );
        }

        System.out.println("=== Lambda ===");
        for (Double price : prices) {
            System.out.println(
                    lambdaRule.apply(price)
            );
        }
    }
}

Kết quả đầu ra:

Console Output
=== Anonymous Class ===
90.0
225.0
450.0
=== Lambda ===
90.0
225.0
450.0

Hai cách đều tuân thủ cùng một hợp đồng PriceRule và cho kết quả giống nhau. Khác biệt nằm ở cách diễn đạt hành vi.

Trong dự án lớn, việc khai báo một Functional Interface như PriceRule có thể có giá trị hơn việc chỉ sử dụng Function<Double, Double>, vì tên PriceRule truyền tải ý nghĩa nghiệp vụ trực tiếp.

Bài học kiến trúc: Đừng chọn Lambda chỉ vì nó ngắn hơn. Hãy chọn abstraction thể hiện đúng ý nghĩa nghiệp vụ. Functional Interface có tên miền rõ ràng có thể giúp API dễ đọc hơn đáng kể so với các kiểu tổng quát khi hành vi mang tính đặc thù.

7. Thực chiến 2: Xây dựng luồng xử lý nghiệp vụ

Trong ví dụ này, chúng ta sẽ mô phỏng một phần xử lý đơn hàng. Yêu cầu gồm bốn bước:

Chúng ta sẽ giữ code trong một file để có thể sao chép, biên dịch và chạy ngay.

OrderProcessingDemo.java
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;

class Order {
    private final String id;
    private final double amount;
    private final boolean paid;

    Order(String id, double amount, boolean paid) {
        this.id = id;
        this.amount = amount;
        this.paid = paid;
    }

    public String getId() {
        return id;
    }

    public double getAmount() {
        return amount;
    }

    public boolean isPaid() {
        return paid;
    }
}

public class OrderProcessingDemo {

    public static void main(String[] args) {

        List<Order> orders = Arrays.asList(
                new Order("ORD-001", 150.0, true),
                new Order("ORD-002", 80.0, true),
                new Order("ORD-003", 320.0, false),
                new Order("ORD-004", 500.0, true)
        );

        // 1. Predicate: chỉ chọn đơn hàng đã thanh toán
        // và có giá trị từ 100 trở lên.
        Predicate<Order> eligibleOrder =
                order -> order.isPaid()
                        && order.getAmount() >= 100;

        // 2. Function: chuyển Order thành báo cáo.
        Function<Order, String> toReport =
                order -> "Order " + order.getId()
                        + " | amount=" + order.getAmount();

        // 3. Consumer: in báo cáo.
        Consumer<String> logger =
                report -> System.out.println(
                        "[REPORT] " + report
                );

        // 4. Supplier: sinh mã xử lý khi cần.
        Supplier<String> requestId =
                () -> UUID.randomUUID().toString();

        System.out.println(
                "Request ID: " + requestId.get()
        );

        List<String> reports = orders.stream()
                .filter(eligibleOrder)
                .map(toReport)
                .collect(Collectors.toList());

        reports.forEach(logger);

        System.out.println(
                "Total eligible orders: " + reports.size()
        );
    }
}

Đầu ra có cấu trúc như sau. Giá trị Request ID thay đổi ở mỗi lần chạy.

Console Output — Minh họa
Request ID: 9b8e1c2a-7a21-4d30-9f12-123456789abc
[REPORT] Order ORD-001 | amount=150.0
[REPORT] Order ORD-004 | amount=500.0
Total eligible orders: 2

Hãy quan sát kiến trúc của luồng xử lý:

Order Processing Pipeline
Danh sách Order
       |
       v
filter(eligibleOrder)
       |
       | Predicate<Order>
       v
Đơn hàng hợp lệ
       |
       v
map(toReport)
       |
       | Function<Order, String>
       v
Danh sách báo cáo
       |
       v
forEach(logger)
       |
       | Consumer<String>
       v
Console Output

Supplier<String>
       |
       v
Tạo Request ID khi get() được gọi

Ví dụ này cho thấy mỗi Functional Interface đảm nhiệm một trách nhiệm riêng. Predicate không cần biết cách in báo cáo. Function không cần biết đơn hàng có hợp lệ hay không. Consumer không cần biết dữ liệu ban đầu đến từ đâu. Supplier chịu trách nhiệm cung cấp một giá trị khi được yêu cầu.

Cách phân tách này giúp các thành phần có thể được kiểm thử độc lập, thay đổi hành vi mà không cần viết lại toàn bộ luồng điều phối.

7.1. Nâng cấp ví dụ bằng Method References

Khi các hành vi đã được đặt thành phương thức có tên, chúng ta có thể dùng Method References để diễn đạt ý định ngắn gọn hơn. Ví dụ, giả sử class Order có phương thức isPaid(), thì một Predicate kiểm tra thanh toán có thể được viết như sau:

MethodReferenceInBusiness.java
Predicate<Order> paidOnly = Order::isPaid;

Function<Order, String> idExtractor =
        Order::getId;

Consumer<String> printer =
        System.out::println;

Những Method Reference này giúp code ngắn hơn mà vẫn giữ được ý nghĩa rõ ràng. Tuy nhiên, nếu điều kiện cần kết hợp nhiều thuộc tính, Lambda tường minh có thể dễ đọc hơn.

8. Thực chiến 3: Tự xây dựng Functional Interface

Trong các dự án doanh nghiệp, đôi khi một hành vi không phù hợp với những interface tổng quát trong java.util.function. Ví dụ, chúng ta cần một quy tắc tính phí giao hàng, một chiến lược tính chiết khấu hoặc một chính sách xác thực dữ liệu.

Thay vì sử dụng một Function chung chung, chúng ta có thể định nghĩa giao diện với tên thể hiện đúng nghiệp vụ.

ShippingPolicyDemo.java
import java.util.Arrays;
import java.util.List;

@FunctionalInterface
interface ShippingPolicy {

    double calculateFee(double orderAmount);

    default boolean isFreeShipping(double orderAmount) {
        return calculateFee(orderAmount) == 0.0;
    }
}

public class ShippingPolicyDemo {

    public static void main(String[] args) {

        ShippingPolicy standard =
                amount -> amount >= 500
                        ? 0.0
                        : 30.0;

        ShippingPolicy premium =
                amount -> amount >= 200
                        ? 0.0
                        : 15.0;

        List<Double> amounts =
                Arrays.asList(100.0, 250.0, 600.0);

        System.out.println("Standard shipping:");

        for (Double amount : amounts) {
            System.out.println(
                    amount + " -> fee="
                            + standard.calculateFee(amount)
            );
        }

        System.out.println("Premium shipping:");

        for (Double amount : amounts) {
            System.out.println(
                    amount + " -> fee="
                            + premium.calculateFee(amount)
            );
        }

        System.out.println(
                "Premium free shipping for 250: "
                        + premium.isFreeShipping(250.0)
        );
    }
}

Kết quả:

Console Output
Standard shipping:
100.0 -> fee=30.0
250.0 -> fee=30.0
600.0 -> fee=0.0
Premium shipping:
100.0 -> fee=15.0
250.0 -> fee=0.0
600.0 -> fee=0.0
Premium free shipping for 250: true

Ví dụ này minh họa ba đặc điểm quan trọng:

8.1. Sử dụng Custom Functional Interface trong Service

Chúng ta có thể tiến thêm một bước bằng cách viết một phương thức tổng quát nhận chính sách vận chuyển:

ShippingService.java
class ShippingService {

    public double checkout(
            double orderAmount,
            ShippingPolicy policy) {

        double fee = policy.calculateFee(orderAmount);

        return orderAmount + fee;
    }
}

Khi sử dụng:

ShippingCheckout.java
public class ShippingCheckout {
    public static void main(String[] args) {

        ShippingService service =
                new ShippingService();

        ShippingPolicy policy =
                amount -> amount >= 300
                        ? 0.0
                        : 25.0;

        double total =
                service.checkout(250.0, policy);

        System.out.println(total);
    }
}
Console Output
275.0

Đây là một ví dụ đơn giản của việc áp dụng Strategy Pattern thông qua Functional Interface. Nếu chiến lược chỉ gồm một hành vi ngắn, Lambda giúp loại bỏ class triển khai riêng cho từng chiến lược. Nếu mỗi chiến lược cần nhiều trạng thái, nhiều phương thức hoặc cấu hình phức tạp, class triển khai tường minh vẫn là lựa chọn phù hợp.

Best Practice: Hãy tạo Custom Functional Interface khi tên miền nghiệp vụ cần được thể hiện rõ, khi cần bổ sung hợp đồng riêng hoặc khi các phương thức của interface tổng quát không diễn đạt đúng ý nghĩa. Đừng tạo một interface mới chỉ để đổi tên một phép biến đổi đơn giản nếu Function, Predicate hoặc một interface chuẩn đã đủ rõ ràng.

9. Kết hợp Lambda với Stream API

Lambda và Stream API thường được sử dụng cùng nhau. Stream API cho phép mô tả một pipeline xử lý dữ liệu gồm nhiều thao tác trung gian và thao tác kết thúc. Lambda cung cấp hành vi cho các thao tác đó.

Cần nhớ rằng Stream không phải là một cấu trúc dữ liệu lưu trữ. Nó là một luồng xử lý được xây dựng từ nguồn dữ liệu như Collection, mảng hoặc một nguồn sinh phần tử.

9.1. Pipeline xử lý dữ liệu

Giả sử chúng ta có danh sách sản phẩm. Mục tiêu là lấy tên của những sản phẩm đang hoạt động, có giá từ 100 trở lên, sau đó chuyển tên thành chữ in hoa và thu thập vào một danh sách mới.

ProductStreamDemo.java
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

class Product {
    private final String name;
    private final double price;
    private final boolean active;

    Product(String name, double price, boolean active) {
        this.name = name;
        this.price = price;
        this.active = active;
    }

    public String getName() {
        return name;
    }

    public double getPrice() {
        return price;
    }

    public boolean isActive() {
        return active;
    }
}

public class ProductStreamDemo {
    public static void main(String[] args) {

        List<Product> products = Arrays.asList(
                new Product("Keyboard", 150.0, true),
                new Product("Mouse", 50.0, true),
                new Product("Monitor", 300.0, true),
                new Product("Cable", 20.0, false)
        );

        List<String> result = products.stream()
                .filter(Product::isActive)
                .filter(p -> p.getPrice() >= 100)
                .map(Product::getName)
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println(result);
    }
}
Console Output
[KEYBOARD, MONITOR]

Pipeline có thể được diễn giải như sau:

Stream Pipeline
Source: List<Product>
          |
          v
filter(Product::isActive)
          |
          v
filter(p -> p.getPrice() >= 100)
          |
          v
map(Product::getName)
          |
          v
map(String::toUpperCase)
          |
          v
collect(Collectors.toList())
          |
          v
List<String>

Mỗi bước thể hiện một trách nhiệm. Hai bước filter() dùng Predicate, còn hai bước map() dùng Function. Các thao tác trung gian chưa nhất thiết thực thi ngay khi được khai báo; pipeline thường được đánh giá khi thao tác kết thúc yêu cầu kết quả.

9.2. Lazy Evaluation và Short-Circuit

Stream API có cơ chế lazy evaluation đối với các thao tác trung gian. Điều đó có nghĩa là những thao tác như filter() và map() thường chưa xử lý phần tử cho đến khi một terminal operation được gọi.

LazyStreamDemo.java
import java.util.Arrays;
import java.util.List;

public class LazyStreamDemo {
    public static void main(String[] args) {

        List<Integer> numbers =
                Arrays.asList(1, 2, 3, 4, 5);

        numbers.stream()
                .filter(n -> {
                    System.out.println("filter: " + n);
                    return n % 2 == 0;
                })
                .map(n -> {
                    System.out.println("map: " + n);
                    return n * 10;
                })
                .findFirst()
                .ifPresent(System.out::println);
    }
}

Kết quả điển hình:

Console Output
filter: 1
filter: 2
map: 2
20

Stream chỉ cần tìm phần tử đầu tiên thỏa điều kiện, vì vậy nó không cần xử lý toàn bộ danh sách. Đây là ví dụ về short-circuit operation.

Các terminal operation có khả năng short-circuit phổ biến gồm findFirst(), findAny(), anyMatch(), allMatch() và noneMatch(). Không phải mọi terminal operation đều short-circuit; ví dụ, count() thông thường cần xác định số phần tử theo ngữ nghĩa của pipeline.

Không nên dựa vào thứ tự side effect của Lambda để thiết kế logic nghiệp vụ trong Stream. Với parallel stream, thứ tự xử lý có thể khác; một số thao tác còn có thể được tối ưu hóa theo đặc tả và không thực hiện đúng số lần mà người đọc code ngây thơ kỳ vọng.

9.3. Tổ hợp Predicate và Function

Trong một hệ thống thực tế, chúng ta có thể tái sử dụng những điều kiện và quy tắc chuyển đổi độc lập để tạo thành pipeline linh hoạt.

ComposablePipeline.java
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class ComposablePipeline {
    public static void main(String[] args) {

        List<String> names = Arrays.asList(
                "  an ",
                "Binh",
                "  chi ",
                "",
                "dung"
        );

        Predicate<String> notBlank =
                text -> text != null
                        && !text.trim().isEmpty();

        Function<String, String> normalize =
                String::trim;

        Function<String, String> uppercase =
                String::toUpperCase;

        List<String> result = names.stream()
                .filter(notBlank)
                .map(normalize.andThen(uppercase))
                .collect(Collectors.toList());

        System.out.println(result);
    }
}
Console Output
[AN, BINH, CHI, DUNG]

Ở đây, Predicate loại bỏ chuỗi rỗng hoặc chỉ chứa khoảng trắng, còn Function kết hợp hai bước chuẩn hóa. Đây là một ví dụ nhỏ của khả năng tổ hợp hành vi trong lập trình hàm.

Trong ứng dụng thật, hãy lưu ý rằng nếu nguồn dữ liệu có thể chứa null, các bước xử lý phải có chính sách xử lý null rõ ràng. Ví dụ trên kiểm tra null trong Predicate trước khi gọi trim(), nên các phần tử null sẽ bị loại bỏ an toàn.

Nguyên tắc khi sử dụng Stream: Ưu tiên Lambda không có side effect, tách các bước biến đổi dữ liệu thành những phép toán dễ hiểu và không giả định pipeline sẽ chạy theo thứ tự hoặc số lần gọi tùy ý. Nếu một thao tác cần nhiều bước phức tạp, hãy đặt nó trong phương thức có tên rồi tham chiếu bằng Method Reference.

10. Bẫy thường gặp và Best Practices

Lambda giúp code ngắn hơn, nhưng code ngắn không đồng nghĩa với code tốt. Một Lambda quá dài, quá nhiều điều kiện hoặc chứa nhiều side effect có thể khó đọc hơn một phương thức truyền thống.

10.1. Bẫy số 1: Lambda quá dài, biến code thành write-only code

Hãy xem ví dụ sau:

HardToRead.java — Không nên viết theo cách này
orders.stream()
    .filter(o -> o != null
            && o.getCustomer() != null
            && o.getCustomer().isActive()
            && o.getItems() != null
            && !o.getItems().isEmpty()
            && o.getItems().stream()
                .mapToDouble(item -> item.getPrice()
                        * item.getQuantity())
                .sum() > 1000)
    .map(o -> {
        String customer = o.getCustomer().getName();
        double total = o.getItems().stream()
                .mapToDouble(item -> item.getPrice()
                        * item.getQuantity())
                .sum();

        return customer + ": " + total;
    })
    .forEach(text -> {
        System.out.println("[ORDER] " + text);
        // Có thể thêm nhiều side effect tại đây.
    });

Đoạn code có thể hợp lệ, nhưng người đọc phải liên tục theo dõi nhiều điều kiện và phép tính lồng nhau. Nó cũng lặp lại logic tính tổng đơn hàng ở hai nơi.

Một hướng cải thiện là đặt tên cho những khái niệm nghiệp vụ:

ReadablePipeline.java — Cách diễn đạt dễ bảo trì hơn
Predicate<Order> eligibleOrder =
        OrderRules::isEligible;

Function<Order, String> orderSummary =
        OrderFormatter::format;

orders.stream()
        .filter(eligibleOrder)
        .map(orderSummary)
        .forEach(OrderLogger::log);

Ví dụ thứ hai giả định các class và phương thức nghiệp vụ đã được định nghĩa. Mục tiêu là cho thấy cách tách điều kiện, định dạng và ghi log thành những thành phần có tên, thay vì nhồi toàn bộ logic vào một pipeline.

10.2. Bẫy số 2: Nhầm lẫn giữa Lambda và Anonymous Inner Class

Lambda không đơn giản là Anonymous Inner Class được rút gọn cú pháp. Một khác biệt quan trọng liên quan đến this.

Trong Anonymous Inner Class, từ khóa this thường tham chiếu đến instance của class vô danh. Trong Lambda, this không tạo ra một phạm vi đối tượng mới; nó tham chiếu đến đối tượng bao quanh theo ngữ cảnh từ vựng.

ThisInLambda.java
public class ThisInLambda {

    private final String name = "Outer";

    public void run() {

        Runnable lambda = () ->
                System.out.println(this.name);

        lambda.run();
    }

    public static void main(String[] args) {
        new ThisInLambda().run();
    }
}
Console Output
Outer

Đây là một điểm khác biệt về ngữ nghĩa, không chỉ là phong cách viết. Khi chuyển Anonymous Inner Class thành Lambda, hãy kiểm tra các tham chiếu đến this, super và các thành viên của đối tượng bao quanh để bảo đảm hành vi vẫn đúng.

10.3. Bẫy số 3: Giả định Lambda luôn tạo đối tượng mới

Không nên viết logic phụ thuộc vào việc mỗi lần đánh giá một Lambda không capture trạng thái sẽ tạo một instance khác nhau. JVM có quyền tái sử dụng hoặc tối ưu hóa những Lambda phù hợp. Việc so sánh danh tính đối tượng bằng toán tử == không phải cách đúng để xác định một Lambda có cùng hành vi hay không.

Nếu nghiệp vụ cần một đối tượng mới cho mỗi lần gọi, hãy sử dụng Supplier hoặc factory method để tạo đối tượng rõ ràng:

FactoryExample.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

public class FactoryExample {
    public static void main(String[] args) {

        Supplier<List<String>> factory =
                ArrayList::new;

        List<String> first = factory.get();
        List<String> second = factory.get();

        first.add("A");

        System.out.println(first);
        System.out.println(second);
    }
}
Console Output
[A]
[]

Ở đây, mỗi lần gọi factory.get() sẽ thực thi constructor tham chiếu trong Supplier và tạo một danh sách mới. Chúng ta đang dựa vào hợp đồng của factory, không dựa vào danh tính của chính đối tượng Lambda.

10.4. Bẫy số 4: Lạm dụng side effect trong Stream

Đoạn code dưới đây thường không phải lựa chọn tốt khi cần tính tổng:

AvoidSideEffects.java
int[] total = {0};

numbers.stream().forEach(n -> {
    total[0] += n;
});

Đoạn code trên sử dụng mảng để chứa trạng thái có thể thay đổi. Nó hoạt động trong một số ngữ cảnh tuần tự nhưng khiến logic phụ thuộc vào side effect, không dễ mở rộng sang xử lý song song và không bảo đảm an toàn luồng.

Với mục tiêu tính tổng, hãy sử dụng một phép reduction:

PreferReduction.java
int total = numbers.stream()
        .mapToInt(Integer::intValue)
        .sum();

Cách này thể hiện đúng mục tiêu của phép tính, giảm trạng thái mutable và cho phép thư viện sử dụng các tối ưu hóa phù hợp.

10.5. Bẫy số 5: Overload và kiểu đích gây nhập nhằng

Một Lambda có thể không xác định được phương thức overload nào được gọi nếu có nhiều overload tương thích hoặc thông tin kiểu không đủ rõ ràng.

OverloadExample.java
import java.util.function.Consumer;
import java.util.function.Function;

public class OverloadExample {

    static void process(Consumer<String> action) {
        action.accept("Java");
    }

    static void process(Function<String, String> mapper) {
        System.out.println(mapper.apply("Java"));
    }

    public static void main(String[] args) {

        process((String value) -> {
            System.out.println(value);
        });

        process((String value) -> value.toUpperCase());
    }
}

Trong ví dụ này, khối Lambda đầu tiên phù hợp với Consumer vì không trả về kết quả. Lambda thứ hai trả về một String nên phù hợp với Function. Nếu Lambda có hình dạng tương thích với nhiều overload, bạn có thể cần khai báo kiểu tường minh hoặc ép kiểu sang Functional Interface mong muốn.

Khi thiết kế API có overload nhận các Functional Interface khác nhau, hãy tránh những chữ ký khiến bên gọi thường xuyên phải ép kiểu để trình biên dịch hiểu ý định.

10.6. Bẫy số 6: Exception checked trong Lambda

Các Functional Interface chuẩn trong java.util.function như Function, Predicate và Consumer không khai báo ném checked exception trong các phương thức trừu tượng của chúng. Vì vậy, khi gọi API có thể ném checked exception bên trong Lambda, bạn cần xử lý hoặc chuyển đổi ngoại lệ theo thiết kế phù hợp.

CheckedExceptionExample.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.function.Supplier;

public class CheckedExceptionExample {

    static String readFile() throws IOException {
        return new String(
                Files.readAllBytes(
                        Paths.get("config.txt")
                )
        );
    }

    public static void main(String[] args) {

        Supplier<String> reader = () -> {
            try {
                return readFile();
            } catch (IOException e) {
                throw new IllegalStateException(
                        "Cannot read config file", e
                );
            }
        };

        try {
            System.out.println(reader.get());
        } catch (IllegalStateException e) {
            System.err.println(e.getMessage());
        }
    }
}

Đây là một cách xử lý minh họa. Với ứng dụng thực tế, hãy chọn cách xử lý ngoại lệ dựa trên ranh giới nghiệp vụ và chính sách lỗi của hệ thống. Không nên bắt mọi exception rồi âm thầm trả về giá trị mặc định nếu điều đó có thể che giấu lỗi dữ liệu hoặc lỗi vận hành.

10.7. Khi nào dùng Lambda, khi nào nên tách phương thức hoặc class?

Tình huống Khuyến nghị Lý do
Phép biến đổi ngắn, rõ nghĩa Dùng Lambda Ít boilerplate, dễ hiểu tại chỗ
Chỉ gọi một phương thức có sẵn Dùng Method Reference Thể hiện ý định trực tiếp
Logic dài hoặc có nhiều nhánh Tách phương thức Dễ đặt tên, kiểm thử và gỡ lỗi
Hành vi có tên nghiệp vụ riêng Tạo Custom Functional Interface khi cần Làm rõ hợp đồng và mục đích sử dụng
Đối tượng có nhiều trạng thái và nhiều hành vi Dùng class tường minh Quản lý trạng thái và vòng đời rõ ràng
Chính sách có cấu hình hoặc phụ thuộc phức tạp Dùng class hoặc service chuyên biệt Dễ quản lý dependency và kiểm thử
Pipeline dữ liệu có nhiều bước đơn giản Kết hợp Stream và Lambda Thể hiện luồng biến đổi dữ liệu
Pipeline có nhiều side effect và logic lồng nhau Tách thành các bước có tên Tránh code khó đọc và khó kiểm soát

10.8. Checklist trước khi đưa Lambda vào production

Trước khi đưa code sử dụng Lambda vào môi trường thực tế, hãy kiểm tra những điểm sau:

Nguyên tắc thiết kế: Lambda là công cụ biểu diễn hành vi, không phải mục tiêu để làm code ngắn bằng mọi giá. Một phương thức có tên rõ ràng thường tốt hơn một Lambda dài; một class có trạng thái được quản lý tốt thường tốt hơn một biểu thức chứa quá nhiều trách nhiệm.

11. Tổng kết và lộ trình thực hành

Java Lambda Expression là một trong những thay đổi quan trọng nhất của Java 8. Nó giúp Java biểu diễn hành vi một cách ngắn gọn, kết hợp với Functional Interface để truyền hành vi vào phương thức và trở thành nền tảng cho nhiều API xử lý dữ liệu hiện đại.

Để nắm vững Lambda ở mức lập trình viên có thể áp dụng vào dự án, hãy ghi nhớ những điểm cốt lõi sau:

11.1. Lộ trình luyện tập đề xuất

Thay vì chỉ đọc lý thuyết, hãy thực hiện các bài tập theo thứ tự từ đơn giản đến thực chiến. Mỗi bài nên được đặt trong một file Java riêng để bạn có thể biên dịch và kiểm chứng độc lập.

Bài tập Yêu cầu Kiến thức trọng tâm
01 Sắp xếp danh sách nhân viên theo lương và tên Lambda, Comparator, Method Reference
02 Lọc sản phẩm theo giá, trạng thái và danh mục Predicate, and(), or(), negate()
03 Chuyển danh sách DTO thành danh sách Entity Function, map(), collect()
04 Ghi log và tạo báo cáo cho đơn hàng Consumer, andThen()
05 Tạo factory khởi tạo đối tượng theo yêu cầu Supplier, Constructor Reference
06 Viết chính sách chiết khấu theo loại khách hàng Custom Functional Interface, Strategy Pattern
07 Kiểm tra effectively final và capture đối tượng mutable Variable Capture, Thread Safety
08 Đọc bytecode của Lambda bằng javap invokedynamic, JVM, bytecode
09 Tối ưu pipeline số nguyên với primitive interfaces IntPredicate, IntUnaryOperator, boxing
10 Xây dựng pipeline xử lý dữ liệu có kiểm thử Stream API, composition, clean code

11.2. Bài tập tổng hợp cuối bài

Hãy xây dựng một ứng dụng quản lý sản phẩm nhỏ với các yêu cầu sau:

Mục tiêu của bài tập không phải tạo ra đoạn code ngắn nhất, mà là tạo được một chương trình trong đó mỗi thành phần có trách nhiệm rõ ràng, có thể kiểm thử và dễ mở rộng khi yêu cầu nghiệp vụ thay đổi.

Kết luận: Khi đã hiểu Lambda, bạn sẽ bắt đầu nhìn các bài toán Java theo một cách khác. Thay vì tạo class cho mọi hành động nhỏ, bạn có thể biểu diễn hành vi bằng Functional Interface, tổ hợp các hành vi qua Function và Predicate, điều phối dữ liệu bằng Stream API và sử dụng Method References khi muốn làm rõ ý định. Kết hợp những kỹ thuật này với thiết kế hướng đối tượng, nguyên tắc SOLID và kiểm thử tự động, bạn sẽ có nền tảng vững chắc để viết Java hiện đại vừa súc tích vừa chuẩn mực.