Lambda và functional interface — hàm trở thành giá trị
Lambda expression, functional interface (SAM), 4 interface chuẩn trong java.util.function, method reference. Vì sao Java 8 cần lambda, cơ chế invokedynamic bên dưới, và quy tắc effectively final.
Năm 2014, Java 8 ra mắt và team phát triển gọi đây là "bản release lớn nhất kể từ Java 5". Thay đổi cốt lõi: lambda expression. Trước đó 20 năm, Java viết callback, comparator, event handler thế này:
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
});
5 dòng boilerplate chỉ để gói 1 dòng logic thực sự: "so sánh độ dài chuỗi". Viết hàng trăm lần mỗi project. JavaScript, Python, Scala đã có closure/lambda từ lâu — Java đi sau, cho đến 2014.
Java 8 biến đoạn trên thành:
Collections.sort(names, (a, b) -> Integer.compare(a.length(), b.length()));
Cùng ngữ nghĩa, 1 dòng. Đây không chỉ là "cú pháp ngắn hơn" — nó mở đường cho Stream API (bài 9.2), Optional (bài 9.5), CompletableFuture.thenApply (bài 10.3), và toàn bộ cách Java viết code functional.
Bài này đi sâu vào cơ chế bên dưới lambda: functional interface là gì và tại sao compiler cần nó, 4 interface chuẩn nhất định phải thuộc (Function, Predicate, Consumer, Supplier), method reference khi nào nên dùng, và effectively final — quy tắc capture biến mà compiler bắt buộc.
1. Analogy — Phiếu công thức
Tưởng tượng bạn đưa bếp trưởng một tờ phiếu: "làm món theo công thức này". Phiếu chỉ ghi các bước nấu — không ghi tên món, không ghi tên bạn, không ghi ai sẽ ăn. Bếp trưởng cầm phiếu, có thể:
- Tự nấu ngay.
- Giao cho đầu bếp phó nấu.
- Photocopy gửi cho chi nhánh khác nấu.
- Lưu lại, tuần sau mới nấu.
Phiếu = logic đóng gói thành vật thể cầm được. Có thể truyền đi, lưu, gọi bất cứ lúc nào. Đây chính xác là lambda.
Ngược lại, trước lambda, Java chỉ có "method" — method gắn chặt với class. Muốn truyền logic cho người khác, phải gói nó trong 1 class (anonymous inner class). Giống như bạn muốn đưa công thức cho người khác phải xây riêng một nhà hàng chỉ để chứa đúng 1 công thức — rườm rà không cần thiết.
| Đời thường | Java |
|---|---|
| Phiếu công thức (chỉ có bước) | Lambda (x) -> x * 2 |
| Loại phiếu (đặt món / đánh giá / khiếu nại) | Functional interface (Function, Predicate, ...) |
| Đầu bếp đọc phiếu | Method nhận param kiểu functional interface |
| Trao phiếu | Pass lambda làm argument |
| Photocopy, gửi đi | Store lambda vào biến, pass qua nhiều method |
Kết quả văn hoá: code Java hiện đại (Stream, Spring reactive, microservice async) trông như JavaScript/Kotlin nhiều hơn — functional, pipeline, callback chain. Lambda là nền móng cho phong cách viết đó.
Lambda = hành động đóng gói thành giá trị, truyền đi được. Functional interface = hợp đồng 1 method giúp compiler biết cần map lambda vào method nào.
2. Vấn đề cụ thể trước Java 8
Để cảm nhận được lambda giá trị ra sao, nhìn vào 3 task thường gặp trước và sau Java 8.
Task 1: Comparator
// Truoc Java 8
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
// Sau Java 8
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
5 dòng → 1 dòng. Nhưng điều quan trọng hơn: đọc dòng thứ hai xong bạn hiểu luôn logic. Dòng 1 cần skim qua new ... { @Override ... public int compare ... } boilerplate rồi mới đến logic.
Task 2: Runnable cho thread
// Truoc
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hi");
}
}).start();
// Sau
new Thread(() -> System.out.println("hi")).start();
Task 3: Event listener
// Truoc
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
save();
}
});
// Sau
button.addActionListener(e -> save());
Pattern chung: mỗi lần Java cần truyền "một hành động", trước đây phải tạo anonymous inner class bao quanh đúng 1 method. Lambda bỏ hết boilerplate, giữ đúng phần logic.
Compiler biết lambda (a, b) -> ... phải map vào Comparator<String>.compare(String, String) nhờ target type — vị trí lambda được dùng quyết định kiểu của nó. Chủ đề kế tiếp.
3. Functional interface — hợp đồng 1 method
Câu hỏi: compiler làm sao biết lambda (a, b) -> a.length() - b.length() là kiểu gì? Lambda tự nó không có kiểu. Câu trả lời: target type — kiểu nơi lambda được gán/pass quyết định.
Comparator<String> c = (a, b) -> a.length() - b.length();
// ^^^^^^^^^^^^^^^^^^^
// Target type -> compiler suy lambda phai match method compare(String, String)
Target type phải là functional interface — interface có đúng 1 abstract method (Single Abstract Method — SAM).
@FunctionalInterface
interface Transformer {
int apply(int input); // Dung 1 abstract method -> SAM -> functional interface
}
Transformer doubler = x -> x * 2;
Transformer addOne = x -> x + 1;
System.out.println(doubler.apply(5)); // 10
System.out.println(addOne.apply(5)); // 6
Tại sao SAM?
Nếu interface có 2 method trở lên, compiler không biết lambda map vào method nào — ambiguous. 1 method → map rõ ràng. Đây là giới hạn thiết kế có lý — lambda là cú pháp ngắn, không phù hợp biểu thị "triển khai cả 5 method của một interface lớn".
Muốn triển khai interface nhiều method vẫn dùng anonymous inner class hoặc class thường.
@FunctionalInterface — khuyến nghị, không bắt buộc
@FunctionalInterface
interface Transformer {
int apply(int input);
}
Annotation không bắt buộc để interface trở thành functional — chỉ cần có đúng 1 abstract method, compiler tự công nhận. Vai trò của @FunctionalInterface: khoá contract. Compiler báo lỗi nếu ai đó thêm abstract method thứ 2.
Dùng @FunctionalInterface cho mọi interface bạn cố ý thiết kế làm target lambda. Không dùng cho interface có thể mở rộng sau (Repository, Service kiểu truyền thống).
Default / static method không tính SAM
Quan trọng: functional interface có thể có bao nhiêu default và static method cũng được. SAM chỉ đếm abstract.
@FunctionalInterface
interface MyFunc {
int apply(int x); // abstract - SAM
default MyFunc andThen(MyFunc after) { // default - khong tinh
return x -> after.apply(this.apply(x));
}
static MyFunc identity() { return x -> x; } // static - khong tinh
}
Ví dụ có sẵn trong JDK: Comparator có compare là SAM, còn reversed, thenComparing, comparing là default/static. Bạn vẫn viết Comparator c = (a, b) -> ... bình thường.
Tưởng interface có method kế thừa từ Object (vd equals, hashCode) thì mất SAM. Không đúng. Method kế thừa từ Object không tính. Vì thế Comparator có cả compare và equals trong declaration vẫn là functional interface.
4. 4 functional interface chuẩn — phải thuộc lòng
JDK 8 thêm package java.util.function với ~43 functional interface — phủ gần hết use case thường gặp. Thay vì mỗi project tự định nghĩa Transformer, Predicate, Handler riêng, dùng chung JDK interface để code interoperable.
Trong 43 cái, 4 cái phổ biến nhất bạn gặp 99% thời gian:
Function<T, R> — biến đổi
R apply(T t);
Nhận 1 giá trị kiểu T, trả về kiểu R. T và R có thể khác nhau.
Function<String, Integer> length = s -> s.length();
Function<String, Integer> lenMethodRef = String::length; // method reference - muc 5
length.apply("hello"); // 5
Use case điển hình: transform element trong stream. stream.map(Function) biến Stream<T> thành Stream<R>.
Predicate<T> — điều kiện boolean
boolean test(T t);
Nhận T, trả boolean. Biểu thị điều kiện.
Predicate<String> isEmpty = s -> s.isEmpty();
Predicate<String> isEmptyMethodRef = String::isEmpty;
isEmpty.test(""); // true
isEmpty.test("x"); // false
Use case: filter stream. stream.filter(Predicate) giữ element thoả điều kiện.
Consumer<T> — tiêu thụ, không trả
void accept(T t);
Nhận T, không return. Dùng cho side-effect (in, log, save).
Consumer<String> printer = s -> System.out.println(s);
Consumer<String> printerMethodRef = System.out::println;
printer.accept("hello"); // In: hello
Use case: stream.forEach(Consumer), optional.ifPresent(Consumer).
Supplier<T> — nhà cung cấp
T get();
Không nhận gì, trả T. Dùng khi cần "tạo giá trị lười" — chỉ tạo khi gọi.
Supplier<String> greeting = () -> "Hello";
Supplier<List<Integer>> newList = ArrayList::new; // constructor reference
greeting.get(); // "Hello"
newList.get(); // [] (list moi moi lan goi)
Use case: Optional.orElseGet(Supplier) — chỉ gọi supplier khi Optional empty; CompletableFuture.supplyAsync(Supplier) — task trả kết quả.
Biến thể cho primitive — tránh boxing
Với int, long, double, có biến thể riêng để tránh auto-boxing overhead:
IntFunction<String> // int -> R
ToIntFunction<String> // T -> int
IntPredicate // int -> boolean
IntConsumer // int -> void
IntSupplier // () -> int
IntBinaryOperator // (int, int) -> int
Stream tính tổng dùng IntStream.sum() bên dưới dùng IntBinaryOperator. Khi xử lý 10k+ element, biến thể primitive nhanh đáng kể vì không tạo Integer object mỗi element.
BiFunction<T, U, R> — 2 input
Cho hàm 2 tham số:
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
add.apply(3, 5); // 8
Biến thể: BinaryOperator<T> (cả 2 input + output cùng kiểu T), BiPredicate<T, U>, BiConsumer<T, U>.
Dùng interface JDK trước. Chỉ tạo riêng khi (1) cần semantic domain rõ (OrderValidator đọc hay hơn Predicate<Order>), (2) cần throw checked exception (interface JDK không declare throws), (3) cần 3+ tham số (JDK chỉ có uni và bi).
5. Method reference — :: cho lambda ngắn
Khi lambda chỉ gọi 1 method có sẵn và truyền argument thẳng vào, đổi sang method reference gọn hơn:
// Lambda dai
Function<String, Integer> len = s -> s.length();
// Method reference
Function<String, Integer> len = String::length;
Hai dòng tương đương về semantics — compiler tự biến method reference thành lambda tương ứng.
4 dạng method reference
| Dạng | Cú pháp | Ví dụ | Tương đương lambda |
|---|---|---|---|
| Static method | ClassName::staticMethod | Integer::parseInt | s -> Integer.parseInt(s) |
| Instance method của object cụ thể | instance::method | System.out::println | s -> System.out.println(s) |
| Instance method của kiểu tuỳ ý | ClassName::instanceMethod | String::length | s -> s.length() |
| Constructor | ClassName::new | ArrayList::new | () -> new ArrayList() |
Dạng 2 và 3 dễ nhầm nhất. Phân biệt:
System.out::println:System.outlà instance có sẵn (biến static). Methodprintlnchạy trên instance đó. Lambda:s -> System.out.println(s).String::length:Stringlà class, chưa có instance. Compiler hiểu: instance sẽ là argument đầu tiên của lambda. Lambda:s -> s.length()—slà instance, method gọi trên nó.
Cách nhớ: nếu chỗ đầu :: là class name, argument đầu lambda đóng vai instance. Nếu là instance (biến), method gọi trên instance đó, argument lambda là argument method.
Khi nào nên dùng method reference?
Nên khi lambda chỉ là forward đến 1 method:
stream.map(s -> s.toUpperCase()); // Co the thay
stream.map(String::toUpperCase); // Ngan, intent ro
Không nên khi lambda làm nhiều hơn:
stream.map(s -> "[" + s + "]"); // Giu lambda - method reference khong viet duoc
stream.map(s -> s.length() + 10); // Giu lambda
Đừng cố chuyển mọi lambda thành method reference. Code đọc được quan trọng hơn ngắn.
6. Lambda bên dưới — invokedynamic, không phải inner class
Một câu hỏi nhiều dev thắc mắc: lambda có phải là anonymous inner class viết tắt? Không. Java 8 dùng cơ chế hoàn toàn khác.
Nếu là inner class
Mỗi lambda sẽ compile thành 1 file .class riêng:
MyApp$1.class // cho lambda thu 1
MyApp$2.class // cho lambda thu 2
Tạo 1 class = 1 lần defineClass khi load. Mỗi instance = 1 object trên heap. 1000 lambda trong code = 1000 class file = tốn metaspace + 1000 instance runtime.
Thực tế: invokedynamic
Java 8 dùng bytecode invokedynamic (JSR 292) — feature JVM đã có từ Java 7 cho JRuby/Scala. Lambda compile thành:
invokedynamic #0, 0 // bootstrap LambdaMetafactory.metafactory
Lần đầu JVM chạy đến đây: gọi LambdaMetafactory, runtime sinh class wrapper thực hiện functional interface. Cache class này. Lần sau gọi lại → dùng class đã sinh.
Lợi ích:
- Không có file .class ở compile time — không tốn metaspace cho code ít dùng.
- Stateless lambda dùng singleton — lambda
x -> x * 2không capture biến nào, JVM tạo 1 instance duy nhất, chia sẻ toàn program. - JVM tuỳ chọn implementation — tương lai có thể optimize thêm.
Xem bytecode bằng javap -p -c YourClass.class — sẽ thấy invokedynamic + LambdaMetafactory thay vì new InnerClass().
Lambda không capture biến ngoài (stateless) — JVM tái dùng 1 instance cho mọi call. Lambda capture biến — mỗi lần chạy chỗ declare, JVM tạo instance mới. Code có nhiều lambda stateless chạy nhanh hơn code capture nhiều.
7. Capture biến — effectively final
Lambda có thể đọc biến ngoài scope của nó, nhưng có luật:
int multiplier = 3;
Function<Integer, Integer> f = x -> x * multiplier; // OK
System.out.println(f.apply(5)); // 15
Nhưng:
int bad = 3;
bad = 5; // reassign
Function<Integer, Integer> g = x -> x * bad; // compile error!
// local variable bad defined in an enclosing scope must be final or effectively final
Effectively final là gì
Biến được coi là final nếu sau khi khởi tạo, không bị reassign. Không cần keyword final, compiler tự nhận. "Effectively final" = "effectively = như thể đã final".
int x = 10; // khong reassign -> effectively final -> lambda capture duoc
int y = 10;
y = 20; // reassign -> khong effectively final -> lambda khong capture duoc
Vì sao ràng buộc này tồn tại?
Lambda có thể được store lại, chạy sau, thậm chí chạy ở thread khác. Giả sử cho phép capture biến mutable:
int counter = 0;
Runnable r = () -> System.out.println(counter);
counter = 100;
new Thread(r).start(); // In 0 hay 100?
Câu trả lời ambiguous. Chạy immediately hay sau 1 giờ? Thread main đã chạy hết hay chưa? Nếu capture value tại thời điểm lambda tạo → in 0. Nếu capture reference → in 100. Một trong hai đều gây bug.
Java chọn cách đơn giản nhất: cấm reassign. Nếu biến không thể thay đổi, mọi thread thấy cùng giá trị → không ambiguous. Compiler enforce tại compile time.
Workaround khi thực sự cần mutable
Dùng container reference:
int[] counter = {0}; // mang reference khong doi, noi dung thay doi
Runnable r = () -> counter[0]++;
for (int i = 0; i < 10; i++) r.run();
System.out.println(counter[0]); // 10
Hoặc AtomicInteger:
AtomicInteger counter = new AtomicInteger(0);
Runnable r = () -> counter.incrementAndGet();
Cần dùng workaround thường là dấu hiệu thiết kế cần refactor — thay vì mutate từ lambda, hãy dùng stream.reduce hoặc stream.count.
8. this trong lambda — khác với inner class
Điểm quan trọng và dễ nhầm:
class Foo {
String name = "Foo";
Runnable inLambda = () -> System.out.println(this);
Runnable inAnonymous = new Runnable() {
@Override public void run() {
System.out.println(this);
}
};
}
inLambdain Foo instance (outer). Lambda không cóthisriêng — inherit từ scope ngoài.inAnonymousin Runnable instance (anonymous class). Anonymous class cóthisriêng trỏ vào chính nó.
Hệ quả: trong lambda, this.name = "Foo" (field của Foo). Trong anonymous class, this.name compile error vì Runnable không có field name.
Đây thường là điều bạn muốn: trong code Spring/JPA class method, dùng lambda để callback thì this vẫn là class của bạn — không cần Foo.this verbose.
9. Pitfall tổng hợp
❌ Nhầm 1: Tưởng @FunctionalInterface bắt buộc.
interface MyFunc { int apply(int x); } // Van la functional interface
✅ Không bắt buộc. Annotation chỉ để compiler kiểm SAM count — nên thêm cho interface production.
❌ Nhầm 2: Reassign biến trong lambda.
int sum = 0;
list.forEach(x -> sum += x); // compile error
✅ Dùng stream: int sum = list.stream().mapToInt(Integer::intValue).sum();
❌ Nhầm 3: Function<T, Void> cho side-effect.
Function<String, Void> printer = s -> { System.out.println(s); return null; };
✅ Dùng Consumer<String>: Consumer<String> printer = System.out::println;
❌ Nhầm 4: Method reference nhầm dạng.
list.stream().map(s -> s.toUpperCase()); // Lambda
list.stream().map(String::toUpperCase); // Method ref - OK
list.stream().map(System.out::println); // Loi! Lambda tra void, map can R
✅ Dùng forEach(System.out::println) thay map cho print.
❌ Nhầm 5: Tưởng lambda capture biến theo reference.
int x = 10;
Runnable r = () -> System.out.println(x);
x = 20; // compile error - x khong effectively final
✅ Lambda capture giá trị tại thời điểm tạo, và compiler cấm reassign.
10. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §15.27 Lambda Expressions — semantic đầy đủ: target type, capture, scope, compatibility.
- JLS §9.8 Functional Interfaces — định nghĩa SAM, rule đếm abstract method (bỏ Object method).
- JLS §15.13 Method Reference — 4 dạng method reference chi tiết.
- java.util.function package — 43 functional interface chuẩn.
- JEP 126: Lambda Expressions — lịch sử thiết kế Java 8 lambda, phân tích tradeoff.
- LambdaMetafactory — class JDK sinh implementation runtime.
Ghi chú: JLS §15.27 giải thích vì sao var f = x -> x * 2; không compile — lambda tự nó không có kiểu, cần target type để infer functional interface nào. Trong khi đó Function<Integer, Integer> f = x -> x * 2; OK vì target type là Function<Integer, Integer>.
11. Tóm tắt
- Lambda = hàm đóng gói thành giá trị, truyền đi được. Thay thế anonymous inner class cho 1 method.
- Functional interface = interface có đúng 1 abstract method (SAM). Lambda chỉ compile khi target type là functional interface.
@FunctionalInterfacekhuyến nghị — compiler enforce SAM count, bảo vệ contract.- 4 functional interface chuẩn:
Function<T,R>,Predicate<T>,Consumer<T>,Supplier<T>. Biến thể primitive (IntFunction,ToIntFunction, ...) tránh boxing. - Method reference
::— 4 dạng: static, instance cụ thể, instance tuỳ ý, constructor. Dùng khi lambda chỉ forward đến 1 method. - Bên dưới lambda là
invokedynamic+LambdaMetafactory, không phải anonymous inner class. Stateless lambda dùng singleton, tiết kiệm heap. - Biến local capture phải effectively final — không reassign. Ràng buộc này tránh ambiguous giá trị khi lambda chạy delay/async.
thistrong lambda inherit scope ngoài; trong anonymous class là chính class đó. Thường đây là điều bạn muốn.
12. Tự kiểm tra
Q1Vì sao Java yêu cầu biến capture trong lambda phải effectively final?▸
Lambda có thể được store lại và chạy delay — thậm chí chạy ở thread khác sau vài giờ. Nếu biến local thay đổi sau khi lambda tạo, giá trị lambda "thấy" trở nên ambiguous: giá trị lúc tạo hay lúc chạy? Snapshot hay reference?
Java chọn snapshot tại thời điểm capture. Để snapshot này nhất quán với biến gốc, Java yêu cầu biến không reassign — effectively final. Compiler enforce, reader đọc code biết chắc giá trị lambda không "bí mật" thay đổi.
Nếu cần mutable state, dùng AtomicInteger, array-as-box, hoặc field của class. Nhưng thường đó là dấu hiệu nên refactor sang stream.reduce.
Q2Đoạn sau in gì? Function<Integer, Integer> f = x -> x * 2; Function<Integer, Integer> g = f.andThen(x -> x + 1); System.out.println(g.apply(3));▸
Function<Integer, Integer> f = x -> x * 2; Function<Integer, Integer> g = f.andThen(x -> x + 1); System.out.println(g.apply(3));f nhân 2. g = f.andThen(x -> x + 1) = f rồi cộng 1.
g.apply(3): chạy f với 3 → 6, sau đó + 1 → 7.
7andThen là default method của Function — compose hai function thành chain. Không phải SAM nên không ảnh hưởng trạng thái "functional interface" của Function.
Q3Khi nào dùng Consumer thay cho Function<T, Void>?▸
- Consumer<T>: side-effect, không return. API clean, không cần
return null;cuối body. - Function<T, Void>: anti-pattern.
Voidlà wrapper củavoid, chỉ dùng khi generic signature ép (vd một số API callback cũ bị ràng buộc).
Quy tắc chung: chọn interface theo ý nghĩa không theo hình dạng:
- Biến đổi →
Function. - Side-effect →
Consumer. - Điều kiện →
Predicate. - Producer (lazy tạo giá trị) →
Supplier.
Reader đọc kiểu đoán được intent ngay, không cần mở body lambda.
Q4Method reference String::length tương đương lambda nào, và khác System.out::println ở điểm gì?▸
String::length tương đương lambda nào, và khác System.out::println ở điểm gì?String::length tương đương (String s) -> s.length() — kiểu Function<String, Integer>.
Điểm khác biệt then chốt: String là class name, không phải instance. Compiler hiểu method reference dạng này là "instance method của kiểu tuỳ ý" — argument đầu của lambda đóng vai instance để gọi method trên.
System.out::println: System.out là instance cụ thể (static field `out` của `System` class). Method println gọi trên instance đó, argument lambda là argument của println.
Phân biệt nhanh: chỗ trước :: là class name → argument đầu lambda đóng vai instance. Là biến/instance → method chạy trên biến đó, lambda argument là method argument.
Q5Vì sao Java 8 dùng invokedynamic cho lambda thay vì compile thành anonymous inner class?▸
Anonymous inner class tốn:
- 1 file
.classriêng cho mỗi lambda ở compile time → tốn metaspace, tốn JIT compilation time. - 1 instance heap mỗi lần tạo — kể cả lambda stateless (không capture).
Lambda dùng phổ biến hơn nhiều (stream, callback, listener) — tối ưu cần thiết.
invokedynamic (JSR 292) để JVM quyết định runtime: lần đầu chạy, sinh implementation class qua LambdaMetafactory; cache lại. Lambda stateless tái dùng single instance — không tốn heap mỗi lần tạo. Lambda capturing mới tạo instance mới.
Kết quả: code với 1000 lambda chạy nhanh hơn, dùng ít memory hơn code với 1000 anonymous class. javap -p -c xem bytecode sẽ thấy invokedynamic + LambdaMetafactory.
Q6Trong class Foo { Runnable r = () -> System.out.println(this); }, this trỏ vào gì?▸
class Foo { Runnable r = () -> System.out.println(this); }, this trỏ vào gì?this trỏ vào instance của Foo — outer class. Lambda không có this riêng; nó inherit từ scope khai báo lambda.
Khác hoàn toàn với anonymous inner class:
class Foo {
Runnable r = new Runnable() {
@Override public void run() {
System.out.println(this); // this = anon Runnable instance
}
};
}Anonymous inner class có this riêng. Muốn trỏ outer phải dùng Foo.this.
Với lambda, this luôn là outer — thường đây là điều bạn muốn (callback giữ context class). Đây cũng là lý do không thể dùng lambda khi cần gọi this.someMethod() trỏ vào "listener instance" — dùng anonymous class thay.
Bài tiếp theo: Stream basics — pipeline lazy và terminal operation
Bài này có giúp bạn hiểu bản chất không?