- Bối cảnh: Cuộc cách mạng lập trình hàm trong Java
- Cú pháp Lambda và cơ chế hoạt động dưới mui xe
- Functional Interface: Nền tảng của Lambda
- Effectively Final và quy tắc biến bên ngoài
- Method References: Toán tử ::
- Thực chiến 1: Anonymous Class và Lambda
- Thực chiến 2: Xây dựng luồng xử lý nghiệp vụ
- Thực chiến 3: Tự xây dựng Functional Interface
- Kết hợp Lambda với Stream API
- Bẫy thường gặp và Best Practices
- 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.
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>.
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ả:
[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.
- Chúng ta cần một quy tắc so sánh hai số nguyên.
- Java yêu cầu quy tắc đó được biểu diễn thông qua phương thức
compare()củaComparator. - Anonymous Inner Class bổ sung cú pháp tạo class vô danh, khai báo phương thức và viết phần triển khai.
Đ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.
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.
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à:
[1, 3, 5, 7, 9]
Hãy quan sát biểu thức:
(a, b) -> Integer.compare(a, b)
(a, b): danh sách tham số đầu vào.->: toán tử Lambda, phân cách tham số và phần xử lý.Integer.compare(a, b): biểu thức trả về kết quả so sánh.
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ố:
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);
}
}
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.
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:
// 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ỏ.
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");
}
}
Xin chao Java 25 6 [INFO] Application started
Một số quy tắc cần ghi nhớ:
- Lambda không có tham số phải dùng cặp ngoặc đơn:
() -> .... - Lambda có một tham số và đã suy luận được kiểu có thể bỏ ngoặc đơn:
n -> n * n. - Lambda có nhiều tham số phải đặt trong ngoặc đơn:
(a, b) -> a + b. - Khi thân Lambda là một biểu thức, không cần viết
returnnếu biểu thức đó cung cấp giá trị trả về. - Khi thân Lambda là một khối lệnh, phương thức có kiểu trả về khác
voidthường phải có câu lệnhreturntrên mọi nhánh có thể thực thi. - Lambda không tự xác định được kiểu tham số chỉ từ tên biến. Kiểu đích của biểu thức thường cung cấp thông tin cần thiết.
Ví dụ, hai cách dưới đây đều hợp lệ:
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.
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:
- Tham số đầu tiên có kiểu
Integer. - Tham số thứ hai có kiểu
Integer. - Kết quả phải tương thích với kiểu
Integer. - Phương thức trừu tượng tương ứng là
BiFunction.apply().
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.
30
Chúng ta cũng có thể khai báo kiểu tham số tường minh:
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.
// 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:
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ữ.
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ã 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:
- Không nên mặc định rằng mọi Lambda đều được hiện thực bằng một class ẩn danh theo cùng một cách.
- Lambda thường không tạo một file
.classđộc lập tương ứng với từng biểu thức trong mã nguồn. - JVM có thể tạo hoặc sử dụng các dạng triển khai nội bộ khác nhau tùy phiên bản, cấu hình và khả năng tối ưu hóa.
- Lambda không bảo đảm luôn tạo đúng một đối tượng mới mỗi lần thực thi. Các Lambda không capture trạng thái có thể được tái sử dụng hoặc tối ưu hóa.
- Địa chỉ lớp triển khai, tên class runtime và chiến lược tạo đối tượng không nên được xem là hợp đồng API ổn định.
Đ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.
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:
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.
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.
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:
@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:
public class GreetingDemo {
public static void main(String[] args) {
GreetingAction action =
name -> System.out.println(
"Xin chao, " + name
);
action.greet("Qui");
}
}
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ệ:
@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ệ:
@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.
@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.
@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:
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);
}
}
[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:
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));
}
}
true false true true
Những phương thức quan trọng:
test(T): thực thi điều kiện.and(other): yêu cầu cả hai điều kiện đúng. Có short-circuit; nếu điều kiện bên trái sai, điều kiện bên phải không được gọi.or(other): yêu cầu ít nhất một điều kiện đúng. Có short-circuit; nếu điều kiện bên trái đúng, điều kiện bên phải không được gọi.negate(): phủ định kết quả.isEqual(target): tạo Predicate kiểm tra bằng nhau bằngObjects.equals().
Ví dụ nghiệp vụ: chỉ cho phép đơn hàng được giao khi đã thanh toán và chưa bị hủy.
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.
@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.
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);
}
}
[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ự:
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");
}
}
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.
@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.
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);
}
}
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:
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"));
}
}
JAVA 4 8 8
Ở ví dụ trên:
andThen()thực thi Function hiện tại trước, sau đó truyền kết quả cho Function tiếp theo.compose()thực thi Function được truyền vào trước, sau đó đưa kết quả cho Function hiện tại.identity()tạo Function trả về chính giá trị đầu vào mà không thực hiện biến đổi.
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.
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Ví dụ đơn giản nhất là sinh một mã ngẫu nhiên:
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:
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.
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);
}
}
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.
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);
}
}
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.
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 ")
);
}
}
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.
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);
}
}
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.
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));
}
}
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.
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)
);
}
}
}
}
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:
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")
);
}
}
true Nguyen An
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.
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ệ:
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.
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.
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);
}
}
[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:
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());
}
}
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:
- Biến cục bộ thường gắn với khung thực thi của phương thức. Lambda có thể sống lâu hơn lần thực thi phương thức đã tạo ra nó.
- Capture một giá trị effectively final tạo ra ngữ nghĩa rõ ràng: Lambda sử dụng giá trị đã xác định khi được tạo, thay vì phụ thuộc vào một biến cục bộ bị thay đổi sau đó.
- Giới hạn này giúp giảm những dạng chia sẻ trạng thái ngầm và khiến việc reasoning về code dễ dàng hơn.
- Việc capture tham chiếu không tự động giải quyết đồng bộ hóa, visibility hoặc atomicity của đối tượng mutable.
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:
- Sử dụng một đối tượng có API an toàn luồng như
AtomicIntegerkhi đúng với yêu cầu nghiệp vụ. - Sử dụng cấu trúc dữ liệu đồng bộ hóa phù hợp hoặc khóa khi cần bảo vệ nhiều thao tác như một đơn vị.
- Tránh chia sẻ trạng thái mutable giữa các Lambda hoặc thread nếu có thể thiết kế theo hướng dữ liệu bất biến.
- Với Stream API, ưu tiên các phép biến đổi không có side effect và các toán tử reduction được thiết kế đúng hợp đồng.
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:
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:
ClassName::staticMethod
Ví dụ, thay vì viết text -> Integer.parseInt(text), chúng ta có thể viết Integer::parseInt.
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);
}
}
[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:
objectReference::instanceMethod
Ví dụ:
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:
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.
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);
}
}
[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.
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);
}
}
[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à:
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.
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);
}
}
[An, Binh, Chi]
Constructor Reference cũng có thể nhận tham số thông qua Functional Interface tương ứng.
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);
}
}
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.
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:
=== 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.
- Anonymous Inner Class phù hợp khi cần thêm trạng thái riêng, triển khai nhiều phương thức hoặc tạo một class vô danh với hành vi phức tạp.
- Lambda phù hợp khi cần triển khai một hành vi đơn lẻ của Functional Interface.
- Việc dùng Lambda không làm thay đổi quy tắc nghiệp vụ của
apply(). Nó chỉ cung cấp cách viết khác để tạo hành vi tương thích.
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.
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:
- Predicate: lọc các đơn hàng có giá trị từ 100 trở lên.
- Function: chuyển đơn hàng thành chuỗi báo cáo.
- Consumer: in báo cáo ra console.
- Supplier: cung cấp mã xử lý khi cần.
Chúng ta sẽ giữ code trong một file để có thể sao chép, biên dịch và chạy ngay.
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.
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ý:
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:
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ụ.
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ả:
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:
- Functional Interface có thể có default method. Chúng không làm mất tính chất SAM.
- Mỗi Lambda có thể đại diện cho một chiến lược nghiệp vụ khác nhau.
- Phương thức nhận
ShippingPolicycó thể sử dụng bất kỳ chính sách nào mà không cần biết cách tính phí bên trong.
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:
class ShippingService {
public double checkout(
double orderAmount,
ShippingPolicy policy) {
double fee = policy.calculateFee(orderAmount);
return orderAmount + fee;
}
}
Khi sử dụng:
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);
}
}
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.
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.
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);
}
}
[KEYBOARD, MONITOR]
Pipeline có thể được diễn giải như sau:
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.
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:
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.
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);
}
}
[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.
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:
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ụ:
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.
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();
}
}
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:
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);
}
}
[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:
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:
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.
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.
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:
- Kiểu đích của Lambda có rõ ràng không?
- Functional Interface được chọn có diễn đạt đúng nghiệp vụ không?
- Lambda có quá dài hoặc chứa nhiều điều kiện lồng nhau không?
- Có thể tách hành vi thành phương thức có tên để tăng khả năng đọc hiểu không?
- Lambda có capture biến hoặc đối tượng mutable không cần thiết không?
- Nếu chạy trong nhiều thread, trạng thái được capture có an toàn không?
- Exception được xử lý đúng ở ranh giới nghiệp vụ chưa?
- Pipeline có phụ thuộc vào thứ tự side effect không được bảo đảm không?
- Nếu đang tối ưu hiệu năng, đã đo đạc bằng benchmark phù hợp thay vì suy đoán chưa?
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:
- Lambda là biểu thức biểu diễn hành vi: nó triển khai hợp đồng của một Functional Interface tương thích.
- Functional Interface là nền tảng kiểu dữ liệu: nó xác định tham số, kiểu trả về và hợp đồng của hành vi.
- Target Typing giúp xác định kiểu Lambda: trình biên dịch dùng ngữ cảnh để suy luận và kiểm tra tính tương thích.
- invokedynamic là một phần quan trọng của cơ chế runtime: Lambda thường được liên kết thông qua cơ chế này thay vì luôn tạo một class file riêng như Anonymous Inner Class truyền thống.
- Predicate, Consumer, Function và Supplier là bốn interface cần thành thạo đầu tiên.
- Effectively final giới hạn việc gán lại biến cục bộ được capture, nhưng không tự bảo đảm Thread Safety cho đối tượng mutable.
- Method References giúp rút gọn Lambda khi hành vi chỉ cần tham chiếu đến một phương thức hoặc constructor có sẵn.
- Stream API và Lambda bổ trợ cho nhau: Lambda cung cấp hành vi, còn Stream biểu diễn pipeline xử lý dữ liệu.
- Code dễ đọc quan trọng hơn code ngắn: hãy tách logic phức tạp thành các phương thức hoặc class có tên phù hợp.
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:
- Tạo class
Productgồm mã sản phẩm, tên, giá và trạng thái còn hàng. - Viết Predicate để lọc sản phẩm còn hàng và có giá lớn hơn một mức cho trước.
- Viết Function để chuyển Product thành chuỗi báo cáo.
- Viết Consumer để ghi báo cáo ra console.
- Viết Supplier để sinh mã tham chiếu cho một phiên xử lý.
- Viết Custom Functional Interface cho chính sách giảm giá.
- Dùng Stream API để lọc, chuyển đổi và thu thập dữ liệu.
- Thử thay Lambda bằng Method Reference ở những nơi phù hợp.
- Kiểm tra trường hợp null, danh sách rỗng và các giá trị biên.
- Viết unit test cho các quy tắc lọc và tính giá sau giảm.
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.