Command Palette

Search for a command to run...

[Advanced Java] Lambda Expression trong Java: cú pháp, target typing và method reference

Lambda expression không có type. Đoạn text x -> x.length() không phải là một Function, không phải ToIntFunction, cũng không phải bất cứ thứ gì khác cho tới khi bạn viết nó vào một chỗ đang chờ một trong những type đó; compiler đọc kỳ vọng ấy rồi mới dựng lambda cho khớp. Bỏ context xung quanh đi thì expression đó không còn là Java hợp lệ nữa.

Đúng một sự thật đó giải thích gần như mọi điều gây bất ngờ về lambda: vì sao var f = x -> x.length(); không compile được, vì sao hai overload lại làm một lambda rõ ràng trở nên ambiguous, vì sao checked exception không thoát ra khỏi body của lambda được kể cả khi method bao ngoài đã khai báo throws, và vì sao ArrayList::new lại chọn constructor khác nhau ở những chỗ khác nhau. Bài này đi qua toàn bộ các dạng cú pháp, luật capture cùng những error mà compiler dùng để ép luật đó, ý nghĩa của this bên trong body của lambda, và bốn loại method reference.

Một đoạn text lambda toả ra thành ba interface type khác nhau

Mọi message lỗi và mọi kết quả chạy bên dưới đều lấy từ việc compile và chạy code trên OpenJDK 21.0.6 (arm64). Chỗ nào một dạng cú pháp phụ thuộc vào language level thì mốc chuyển được tìm bằng cách compile lại đúng source đó với --release, và release nơi nó đổi được ghi ra cụ thể.

Một bài trước trong series này đã so sánh chi tiết lambda với anonymous class — this khác nhau, một bên ghi ra class file còn một bên thì không, invokedynamic so với new — nên bài này không lặp lại so sánh đó. Ở đây ta bắt đầu từ cú pháp và đi tiếp.

Mọi dạng cú pháp của lambda

Một lambda gồm danh sách parameter, một mũi tên, và một body. Mọi thứ còn lại chỉ là biến thể của ba phần đó.

Supplier<String> zero = () -> "no parameters";
Runnable noResult = () -> System.out.println("runnable body, no result");

Function<String, Integer> oneBare = s -> s.length();
Function<String, Integer> oneParen = (s) -> s.length();

BinaryOperator<Integer> two = (a, b) -> a + b;
TriString three = (a, b, c) -> a + b + c;

Function<Integer, Integer> expr = n -> n * 2;
Function<Integer, Integer> block = n -> {
    int doubled = n * 2;
    return doubled;
};

BiFunction<String, Integer, String> inferred = (s, n) -> s.repeat(n);
BiFunction<String, Integer, String> explicit = (String s, Integer n) -> s.repeat(n);
BiFunction<String, Integer, String> withVar = (var s, var n) -> s.repeat(n);
BiFunction<String, Integer, String> finalVar = (final var s, final var n) -> s.repeat(n);
zero      -> no parameters
runnable body, no result
oneBare   -> 6
oneParen  -> 6
two       -> 5
three     -> abc
expr      -> 42
block     -> 42
inferred  -> ababab
explicit  -> ababab
withVar   -> xyxy
finalVar  -> xyxy

TriString ở đó là một interface ba argument tự viết, vì thư viện chuẩn dừng ở hai. Cú pháp lambda hoàn toàn không quan tâm có bao nhiêu parameter.

Lambda với zero, một và nhiều parameter

Cặp ngoặc quanh danh sách parameter là bắt buộc, trừ đúng một trường hợp: một parameter duy nhất và type được suy diễn. s -> s.length()(s) -> s.length() là cùng một lambda. Ngay khi bạn viết type, modifier hay annotation, hoặc ngay khi có nhiều hơn một parameter, cặp ngoặc quay trở lại.

ParameterCách viếtCặp ngoặc
không có() -> "x"bắt buộc
một, suy diễns -> s.length() hoặc (s) -> s.length()tuỳ chọn
một, tường minh(String s) -> s.length()bắt buộc
nhiều(a, b) -> a + bbắt buộc

Đếm sai số lượng là lỗi type chứ không phải lỗi cú pháp, vì số lượng đó phải khớp với abstract method của target:

E17.java:4: error: incompatible types: incompatible parameter types in lambda expression
    static Function<String, Integer> f = (a, b) -> 1;
                                         ^
1 error

Expression body và block body

Expression body là một expression duy nhất, không ngoặc nhọn và không return; giá trị của lambda chính là giá trị của expression đó. Block body là ngoặc nhọn, các câu lệnh bên trong, và một return tường minh ở nơi target cần kết quả.

Compiler kiểm tra body theo return type của target, và hai hướng sai có message riêng. Trả về một giá trị trong khi target trả void:

E15.java:2: error: incompatible types: bad return type in lambda expression
    static Runnable r = () -> { return 1; };
                                       ^
    unexpected return value
1 error

Và chạy hết block mà không trả gì trong khi target cần kết quả:

E16.java:4: error: incompatible types: bad return type in lambda expression
    static Supplier<String> s = () -> { };
                                ^
    missing return value
1 error

Có một chỗ bất đối xứng đáng biết. Expression body mà bản thân expression là một lời gọi method thì void-compatible: cùng một đoạn text vừa khớp target cần kết quả, vừa khớp target vứt kết quả đi. List.add trả về boolean, và cả hai dòng dưới đây đều compile được:

List<String> list = new ArrayList<>(List.of("a"));
Consumer<String> asConsumer = s -> list.add(s);
Function<String, Boolean> asFunction = s -> list.add(s);
Consumer discards the result -> [a, b]
Function keeps it            -> true

Trong block body nhắm tới một method void, return; đứng một mình là hợp lệ còn return value; thì không — đó chính là error ở trên.

Type suy diễn, type tường minh và var

Ba cách viết type cho parameter, và không được trộn lẫn:

BiFunction<String, Integer, String> inferred = (s, n) -> s.repeat(n);
BiFunction<String, Integer, String> explicit = (String s, Integer n) -> s.repeat(n);
BiFunction<String, Integer, String> withVar = (var s, var n) -> s.repeat(n);

Suy diễn là mặc định và là cách nên viết. Type tường minh chỉ đáng viết khi type suy ra thật sự khó đoán với người đọc, hoặc khi bạn cần một type rõ ràng để giúp việc chọn overload.

var parameter tồn tại vì đúng một lý do: một parameter không có phần khai báo thì không có chỗ nào để đặt modifier hay annotation. final s@NonNull s không đúng ngữ pháp — parser bỏ cuộc trước cả khi tới mũi tên:

E5.java:4: error: <identifier> expected
    static Function<String, Integer> f = (final s) -> s.length();
                                                 ^
1 error
E4.java:8: error: illegal start of expression
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                          ^
E4.java:8: error: ')' expected
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                                  ^
E4.java:8: error: ';' expected
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                                    ^
E4.java:8: error: <identifier> expected
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                                                 ^
4 errors

Viết thêm var thì cả hai đều hợp lệ, trong khi type vẫn được suy diễn:

Function<String, Integer> ok = (@NonNull var s) -> s.length();
BiFunction<String, Integer, String> finalVar = (final var s, final var n) -> s.repeat(n);

Đó là toàn bộ mục đích của tính năng này, thêm vào từ Java 11. Ở language level cũ hơn, cú pháp bị từ chối thẳng và message nêu rõ release:

V.java:4: error: var syntax in implicit lambdas are not supported in -source 10
    static Function<String, Integer> f = (var s) -> s.length();
                                              ^
  (use -source 11 or higher to enable var syntax in implicit lambdas)
1 error

Bạn không được trộn ba cách viết trong cùng một danh sách parameter, và javac có ghi chú riêng cho từng kiểu trộn:

E1.java:4: error: invalid lambda parameter declaration
    static BiFunction<String, Integer, String> f = (String s, n) -> s.repeat(n);
                                                   ^
  (cannot mix implicitly-typed and explicitly-typed parameters)
1 error
E2.java:4: error: invalid lambda parameter declaration
    static BiFunction<String, Integer, String> f = (var s, Integer n) -> s.repeat(n);
                                                   ^
  (cannot mix 'var' and explicitly-typed parameters)
1 error
E3.java:4: error: invalid lambda parameter declaration
    static BiFunction<String, Integer, String> f = (var s, n) -> s.repeat(n);
                                                   ^
  (cannot mix 'var' and implicitly-typed parameters)
1 error

Hoặc tất cả, hoặc không cái nào, tính theo từng lambda.

Target typing: lambda không có type của riêng nó

Đây là ý tưởng mà phần còn lại của bài dựa vào. Lambda expression không phải một giá trị đã có type rồi compiler mới đem đi đối chiếu với context. Nó là một poly expression: context được đọc trước, rồi lambda mới được compile thành đúng thứ mà context đó cần.

Một đoạn text lambda được compile thành ba interface type khác nhau, và bị từ chối khi không có target

Cùng một đoạn text, ba type không liên quan

Ba khai báo, một đoạn text lambda, giống nhau từng ký tự:

interface Sizer { int sizeOf(String s); }

Function<String, Integer> asFunction = x -> x.length();
ToIntFunction<String>     asToInt    = x -> x.length();
Sizer                     asSizer    = x -> x.length();
one lambda text, three types:
  Function
  ToIntFunction
  Sizer
asFunction.apply("target")  -> 6
asToInt.applyAsInt("target")-> 6
asSizer.sizeOf("target")    -> 6
Function boxes  -> java.lang.Integer

Ba interface khác nhau, ba abstract method tên khác nhau, và một trong ba box int thành Integer còn hai cái kia thì không. FunctionToIntFunction không hề có quan hệ kế thừa với nhau; Sizer cũng vậy, nó là interface tự viết chẳng dính dáng gì tới thư viện chuẩn. Không có gì trong bản thân lambda quyết định chuyện này. Type khai báo của variable mới là thứ quyết định.

Khi không có gì để suy ra type

Nếu context không đưa ra được type nào thì cũng chẳng có gì để compile lambda thành. var chính là loại context đó, và compiler nói thẳng điều ấy:

E6.java:3: error: cannot infer type for local variable f
        var f = x -> x.length();
            ^
  (lambda expression needs an explicit target-type)
1 error

Object cũng hỏng, cùng lý do nhưng diễn đạt khác — Object là một class chứ không phải functional interface, nên không có abstract method nào để lambda trở thành:

E7.java:2: error: incompatible types: Object is not a functional interface
    static Object o = () -> System.out.println("x");
                      ^
1 error

Một phép cast cũng là một context, nên cast sửa được cả hai:

var f = (Function<String, Integer>) x -> x.length();
var with a cast -> 4

Khi hai overload cùng khớp

Nếu một lambda thoả mãn được nhiều hơn một overload thì không có luật nào ưu tiên cái nào, và lời gọi không compile được. Hai interface một method với hình dạng tương thích là đủ để gây ra chuyện đó:

interface Callable { String call(); }

static void run(Supplier<String> s) { ... }
static void run(Callable c)         { ... }

run(() -> "ambiguous");
E8.java:10: error: reference to run is ambiguous
        run(() -> "ambiguous");
        ^
  both method run(Supplier<String>) in E8 and method run(Callable) in E8 match
1 error

Supplier<String>Callable là hai type không liên quan nhưng cùng hình dạng — không parameter, trả về String — và lambda khớp cả hai như nhau. Cách sửa là tự đưa context vào:

run((Supplier<String>) () -> "picked by the cast");
run((Callable) () -> "picked by the cast");
supplier -> picked by the cast
callable -> picked by the cast

Nhưng bài học thiết kế lại nằm ở chiều ngược lại: nếu bạn là người viết API, đừng overload một method trên hai functional interface có cùng hình dạng. Mọi người gọi nó sẽ phải viết cast.

Capture: lambda được đọc những variable nào

Body của lambda đọc được local variable của method bao ngoài. Nó làm được điều đó bằng cách copy chúng vào object mà lambda trở thành, và vì thế bản copy phải là ảnh chụp của một thứ sẽ không đổi nữa.

Cùng một counter viết theo kiểu local và kiểu mảng một phần tử, cho thấy capture copy cái gì

Effectively final và error bạn sẽ gặp

Local được capture phải effectively final: không bao giờ bị gán lại sau khi khởi tạo, dù bạn có viết final hay không. Chỉ cần gán cho nó ở bất kỳ đâu trong method là việc capture hết compile được — và error chỉ vào chỗ dùng bên trong lambda chứ không phải chỗ gán:

int count = 0;
Supplier<Integer> s = () -> count;
count = 1;
E9.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        Supplier<Integer> s = () -> count;
                                    ^
1 error

Gán từ bên trong lambda cũng là vi phạm y hệt, và được báo hai lần vì count++ vừa đọc vừa ghi:

E10.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        Supplier<Integer> s = () -> { count++; return count; };
                                      ^
E10.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        Supplier<Integer> s = () -> { count++; return count; };
                                                      ^
2 errors

Để ý cách diễn đạt: a lambda expression. Error tương ứng của anonymous class hay local class ghi là "an inner class", nên chỉ nhìn message là biết compiler đang phàn nàn về cấu trúc nào.

Cái bẫy kinh điển là vòng for cơ bản, vì nó dùng lại đúng một variable và tăng dần nó:

for (int i = 0; i < 3; i++) {
    out.add(() -> i);
}
E11.java:8: error: local variables referenced from a lambda expression must be final or effectively final
            out.add(() -> i);
                          ^
1 error

Copy giá trị sang một local mới ngay trong thân vòng lặp là compile được:

for (int i = 0; i < 3; i++) {
    int captured = i;
    out.add(() -> captured);
}
0 1 2

Vòng for dạng enhanced không cần bản copy đó, vì nó khai báo một variable mới ở mỗi vòng.

Field và phần tử mảng không bị ràng buộc

Luật này chỉ nói về local, và chỉ local mà thôi. Field không hề bị capture — lambda capture this (hoặc không capture gì, với field static) rồi đọc field qua đó, tại thời điểm gọi. Nên field muốn gán bao nhiêu lần cũng được:

private int instanceField = 0;
private static int staticField = 0;

void fields() {
    Runnable r = () -> { instanceField++; staticField++; };
    r.run(); r.run(); r.run();
}
instanceField -> 3
staticField   -> 3

Phần tử mảng cũng vậy, và đây chính là gốc của mẹo mảng một phần tử. Reference counter không bao giờ bị gán lại nên luật effectively final vẫn thoả; còn phần tử mà nó trỏ tới không phải local variable nên không được bảo vệ:

int[] counter = { 0 };
Runnable r = () -> counter[0]++;
r.run(); r.run(); r.run();
counter[0]    -> 3

Đoạn đó compile được và chạy được. Nhưng nó vẫn là một code smell chứ không phải một mẹo hay, vì hai lý do. Nó vô hiệu hoá một kiểm tra mà ngôn ngữ cố tình đặt ra — một ô nhớ mutable dùng chung giữa method và một lambda có thể chạy sau đó, trên thread khác, không có bảo đảm gì về memory visibility. Và nó thường có nghĩa là code đang đi ngược hình dạng của bài toán: một counter cộng dồn bên trong forEach gần như luôn là một count, một sum hoặc một reduce bị viết ngược. Khi thật sự cần một ô nhớ mutable dùng chung, hãy dùng AtomicInteger hoặc một field, cả hai đều nói đúng ý định.

⚠️ Mảng một phần tử không thread safe. counter[0]++ là một lần đọc, một phép cộng và một lần ghi, không có đồng bộ nào, và không có gì trong cơ chế lambda làm cho nó thành atomic.

Capture copy reference chứ không copy object

Capture copy giá trị của variable. Với một object thì giá trị đó là một reference, nên lambda và method bao ngoài cùng trỏ tới một object — và object đó vẫn có thể đổi ngay giữa lúc bạn viết lambda và lúc nó chạy:

List<String> seen = new ArrayList<>();
Supplier<String> s = () -> "seen = " + seen;
seen.add("added after the lambda was created");
System.out.println(s.get());
seen = [added after the lambda was created]

seen đang rỗng lúc lambda được tạo. Lambda không bao giờ nhìn thấy một list rỗng, vì nó chưa từng capture một list — nó capture một reference tới list. Đóng băng variable và đóng băng object là hai chuyện khác nhau, và Java chỉ làm chuyện thứ nhất.

this bên trong lambda chính là enclosing object

Bên trong body của lambda, this là enclosing instance. Không có object mới nào để nó trỏ tới — bài trước trong series này đã chứng minh điều đó bằng một lệnh in getClass() ở cả hai phía. Có hai hệ quả của sự thật đó đáng được lấy ví dụ riêng.

Lambda lưu trong field sẽ chạy về sau

Một lambda tạo trong constructor rồi giữ trong field là một đoạn code sẽ chạy vào một lúc nào đó về sau, với this và mọi field được đọc tại thời điểm đó:

static class Widget {
    String name = "widget-1";
    final Supplier<String> describe;

    Widget() {
        describe = () -> "this is a " + this.getClass().getSimpleName() + " named " + name;
    }
}
stored lambda, invoked later -> this is a Widget named widget-1
after the field changed      -> this is a Widget named renamed

getSimpleName() trả về Widget chứ không phải một tên lambda được sinh ra: this bên trong body chính là Widget, đúng như trong một method bình thường. Và name được đọc ở mỗi lần gọi, nên đổi tên field thì lambda đã lưu cũng nói khác đi. Không điều nào trong hai điều đó đúng với một local đã capture, vốn bị đóng băng ngay lúc tạo.

Ở đây còn một cái bẫy bậc hai. Lambda gán trong constructor được tạo trước khi constructor chạy xong, nên nếu body đọc một field mà dòng sau của constructor mới khởi tạo, lambda sẽ thấy giá trị tồn tại lúc nó chạy chứ không phải lúc nó được tạo. Đó thường là điều bạn muốn, và nó ngược với thứ người ta quen chờ đợi từ capture.

Lambda đọc field sẽ giữ object của nó sống

Vì đọc một field nghĩa là capture this, một lambda chạm vào đúng một instance field sẽ giữ strong reference tới toàn bộ enclosing object — cả mảng, cả cache, cả buffer. Hai Runnable cùng in ra một chuỗi làm khác biệt này lộ ra:

Runnable readsField() { return () -> System.out.println(name); }

Runnable copiesValue() {
    String copy = name;
    return () -> System.out.println(copy);
}

Bỏ reference duy nhất tới Widget, chỉ giữ lại Runnable, rồi hỏi một WeakReference xem Widget còn sống không:

lambda reading the field   -> STILL REACHABLE
lambda holding a copy      -> collected
readsField  captured a Widget
copiesValue captured a String

Hai dòng cuối là type của field đã capture, đọc ngược lại bằng reflection. Một lambda capture một Widget; cái kia capture một String. Lambda đưa cho executor, cho event bus hay cho một registry static sẽ giữ thứ nó capture chừng nào registry còn giữ nó, nên hãy đọc field ra một local trước khi lambda sống lâu hơn object.

Bốn loại method reference

Method reference là một lambda mà body chỉ làm đúng một việc: gọi một method với chính những parameter nó nhận được. Integer::parseInts -> Integer.parseInt(s) compile ra cùng một thứ và mang cùng ý nghĩa. Gần như bài nào cũng liệt kê bốn loại; câu hỏi hữu ích là cái gì phân biệt chúng, và câu trả lời luôn là receiver đến từ đâu.

Bound và unbound method reference như hai cách điền vào ô receiver

LoạiCách viếtLambda tương đươngReceiver
staticInteger::parseInts -> Integer.parseInt(s)không có
bound instanceprefix::startsWitha -> prefix.startsWith(a)cố định lúc tạo reference
unbound instanceString::startsWith(r, a) -> r.startsWith(a)chính là parameter đầu tiên
constructorArrayList::new() -> new ArrayList<>()do lời gọi tạo ra
Function<String, Integer> parse = Integer::parseInt;

String prefix = "java.util.List";
Predicate<String> startsWithIt = prefix::startsWith;

BiPredicate<String, String> startsWith = String::startsWith;
ToIntFunction<String> length = String::length;

Supplier<List<String>> newList = ArrayList::new;
IntFunction<int[]> newArray = int[]::new;
Function<String, StringBuilder> newSb = StringBuilder::new;
static   Integer::parseInt   -> 42
bound    prefix::startsWith  -> true
unbound  String::startsWith  -> true
unbound  String::length      -> 14
ctor     ArrayList::new      -> []
ctor     int[]::new          -> [0, 0, 0]
ctor     StringBuilder::new  -> seeded

int[]::new là dạng constructor reference cho mảng, và nó nghĩa là n -> new int[n]. Đó là lý do toArray nhận một cái như vậy: List.of("x", "y").toArray(String[]::new) cho ra một String[].

Constructor reference cũng được target-typed như mọi thứ khác, nên cùng một đoạn text chọn constructor khác nhau tuỳ chỗ nó được viết:

Supplier<ArrayList<String>> empty = ArrayList::new;
IntFunction<ArrayList<String>> sized = ArrayList::new;
Function<Collection<String>, ArrayList<String>> copy = ArrayList::new;
Supplier    -> []
IntFunction -> []
Function    -> [a, b]

Ba constructor của ArrayList — cái không argument, cái nhận capacity và cái copy — được chọn hoàn toàn bằng type khai báo ở vế trái.

Bound và unbound

Đây là cặp làm người ta rối, và cùng một tên method viết theo cả hai cách làm khác biệt lộ ra ngay:

String prefix = "java.util.List";
Predicate<String> bound = prefix::startsWith;
BiPredicate<String, String> unbound = String::startsWith;

bound.test("java.util");                        // true
unbound.test("java.util.List", "java.util");    // true

Trong prefix::startsWith, receiver là giá trị của prefix, được quyết định đúng một lần. Method của interface nhận đúng những argument mà startsWith nhận: một String. Trong String::startsWith, String là một type chứ không phải một giá trị, nên chưa có receiver nào cả; nó được đưa vào làm argument đầu tiên ở mỗi lần gọi. Vì thế method của interface nhận nhiều hơn một parameter.

Chính parameter dôi ra đó là toàn bộ khác biệt về signature: bound reference tới một method n argument thì khớp một method có n parameter, còn unbound thì khớp n+1. String::length không nhận argument nào, nên ở dạng unbound nó thành ToIntFunction<String> một parameter.

Cứ đọc phần trước dấu :: là phân biệt được. Nếu đó là một giá trị — một variable, một field, this, một lời gọi method — thì reference là bound. Nếu đó là tên một type thì là unbound, trừ khi method đó là static.

Bound reference đánh giá receiver ngay lập tức

Đây là khác biệt thật sự gây đau, và là trường hợp duy nhất mà method reference không tương đương với lambda mà nó trông giống. Expression receiver của một bound method reference được đánh giá ngay tại chỗ tạo ra reference, chứ không phải ở mỗi lần gọi. Dạng lambda thì đánh giá lại ở mọi lần gọi.

static StringBuilder target = new StringBuilder("first");

Supplier<String> viaMethodRef = target::toString;
Supplier<String> viaLambda    = () -> target.toString();

target = new StringBuilder("second");
target::toString    -> first
() -> target.toString() -> second

Hai expression đọc lên y hệt nhau, mà kết quả lại khác nhau. Method reference đã giữ đúng object mà target trỏ tới lúc reference được tạo; lambda thì đọc lại field ở mỗi lần gọi.

Chỗ sắc nhất là một receiver null. Bound method reference dereference nó ngay lập tức:

with target = null:
  creating target::toString threw java.lang.NullPointerException
  at java.base/java.util.Objects.requireNonNull(Objects.java:233)
  creating () -> target.toString() threw nothing
  calling it threw java.lang.NullPointerException: Cannot invoke "java.lang.StringBuilder.toString()" because "Eager.target" is null

Stack frame nói rõ luôn: runtime gọi Objects.requireNonNull lên receiver trong lúc dựng reference. Nên map.get(k)::toString ném exception ngay khi map không tìm thấy key, chứ không phải lúc về sau có ai đó gọi function — và exception đó không mang message hữu ích nào, vì nó đến từ phần hạ tầng chứ không phải từ code của bạn. Chỗ nào receiver có thể null hoặc có thể đổi, hãy viết lambda.

Khi method reference bị ambiguous

Một type có thể có cả method static lẫn instance method trùng tên, và khi đó dạng reference không quyết định được bạn muốn cái nào. Integer là ví dụ chuẩn, với toString(int)toString():

E18.java:4: error: incompatible types: invalid method reference
    static Function<Integer, String> f = Integer::toString;
                                         ^
    reference to toString is ambiguous
      both method toString(int) in Integer and method toString() in Integer match
1 error

Cả hai cách đọc đều cho ra một function một argument từ số nguyên sang String, nên target type cũng không phá thế được. Hãy viết lambda, hoặc chọn một target mà chỉ một trong hai khớp:

Function<Integer, String> unbound = i -> i.toString();
IntFunction<String> staticOne = Integer::toString;
unbound   -> 42
staticOne -> 42

Checked exception không thoát ra khỏi lambda được

Body của lambda chỉ được ném những gì abstract method của target khai báo. Function.apply không khai báo gì cả, nên một checked exception bên trong nó là lỗi compile:

Function<String, String> reader = p -> Files.readString(Path.of(p));
E12.java:6: error: unreported exception IOException; must be caught or declared to be thrown
    static Function<String, String> reader = p -> Files.readString(Path.of(p));
                                                                  ^
1 error

Thêm throws IOException vào method bao ngoài cũng không cứu được, và chỗ này khiến nhiều người mắc. Body của lambda không do method bao ngoài chạy — nó do bất cứ ai gọi apply chạy, có thể rất lâu sau, có thể trên thread khác — nên mệnh đề throws của method bao ngoài chẳng liên quan gì:

E13.java:7: error: unreported exception IOException; must be caught or declared to be thrown
        return p -> Files.readString(Path.of(p));
                                    ^
1 error

Có hai cách sửa tử tế. Cả hai đều nằm bên dưới; những cách không tử tế — mẹo sneakyThrows và nuốt exception — đều tệ hơn cả hai.

Catch ngay trong lambda

Xử lý ngay tại chỗ nó xảy ra, và nếu không có gì hợp lý để làm thì dịch nó sang một unchecked exception có giữ nguyên cause:

static Function<String, String> wrapping = p -> {
    try {
        return Files.readString(Path.of(p));
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
};
wrapping  -> the file contents
wrapping on a missing file -> java.nio.file.NoSuchFileException: missing.txt

UncheckedIOException sinh ra đúng cho việc này và là wrapper phù hợp cho IOException; với thứ khác thì RuntimeException có gắn cause là đủ. Phần quan trọng là cause: e.getCause() vẫn là NoSuchFileException gốc, nên không mất thông tin chẩn đoán nào.

Khai báo một interface có throws

Nếu exception thật sự thuộc về phía caller thì vấn đề là bạn đã chọn nhầm target type, một type cấm nó. Hãy viết một type không cấm:

interface IoFunction<T, R> { R apply(T t) throws IOException; }

static IoFunction<String, String> declaring = p -> Files.readString(Path.of(p));
declaring -> the file contents

Bây giờ checked exception lan ra bình thường, và mọi nơi gọi declaring.apply(...) đều phải xử lý nó — đúng công dụng của checked exception. java.util.concurrent.Callable là phiên bản của ý tưởng này trong thư viện chuẩn: method call() của nó khai báo throws Exception, đó là lý do lambda đưa cho một ExecutorService được phép ném bất cứ thứ gì.

Cái giá phải trả là có thật: interface của riêng bạn không ghép được với thư viện chuẩn, nên Stream hay Comparator sẽ không nhận nó. Dùng cách thứ nhất ở ranh giới nơi bạn đưa code cho một method của thư viện chuẩn, và dùng cách thứ hai khi cả chuỗi gọi đều là của bạn.

Viết lambda để người khác đọc được

Vài quy tắc đứng vững được trong thực tế.

Giữ body trong một expression. Lambda mạnh nhất khi người đọc nắm được nó mà không cần đưa mắt dò. Một khi body đã có ngoặc nhọn và nhiều hơn hai ba câu lệnh, nó là một method bị viết nhầm vào trong danh sách argument.

Nếu body cần một comment, hãy tách nó ra. Comment đó đang nói với bạn rằng đoạn code này có một cái tên. Đặt tên cho nó rồi truyền method reference vào:

static boolean isBillable(Order o) {
    return o.paid() && o.cents() >= 10_00;
}

billable.removeIf(Predicate.not(Practice::isBillable));
billable -> [A-1, A-4]

Bản viết predicate thẳng vào chỗ gọi cần một comment để giải thích "billable" nghĩa là gì. Bản có method đặt tên thì không, và cái tên đó bây giờ test được, dùng lại được và grep được.

Một lambda bạn không đặt tên nổi thường là một lambda bạn chưa hiểu. Thử đặt tên cho method mà bạn sẽ tách ra. Nếu không nghĩ ra tên nào, body đang làm nhiều hơn một việc, và đó mới là vấn đề thật.

Ưu tiên method reference khi nó thật sự tương đươngOrder::cents đọc dễ hơn o -> o.cents() — nhưng không dùng khi receiver có thể null hoặc có thể đổi, vì lý do đã trình bày ở trên.

Đừng capture nhiều hơn mức cần. Một lambda đọc đúng một field của một object lớn sẽ capture cả object đó. Hãy đọc field ra một local trước, nếu lambda sống lâu hơn method.

Áp vào một đoạn code thật nho nhỏ, toàn bộ từ vựng của bài này gói gọn trong sáu dòng:

sorted.sort(Comparator.comparingInt(Order::cents).reversed());
billable.removeIf(Predicate.not(Practice::isBillable));
ORDERS.forEach(o -> totals.merge(o.customer(), o.cents(), Integer::sum));
sorted by cents desc:
  A-2 9900
  A-4 4200
  A-1 1250
  A-3 500
billable -> [A-1, A-4]
totals   -> {kim=1750, linh=9900, minh=4200}

Order::cents là unbound, Practice::isBillableInteger::sum là static, và lambda duy nhất còn lại là cái làm một việc mà một method đặt tên cũng không mô tả hay hơn được.

FAQ

Lambda expression trong Java là gì?

Là một danh sách parameter, một mũi tên và một body, viết ở nơi đang chờ một functional interface: s -> s.length(). Nó không phải một object có type riêng — nó là một poly expression, lấy type của context nơi nó xuất hiện, và vì thế cùng một đoạn text có thể thành Function, thành ToIntFunction hoặc thành một interface tự viết, chỉ tuỳ vào variable được gán. Java có lambda từ Java 8.

Vì sao gặp lỗi "lambda expression needs an explicit target-type"?

Vì bạn viết lambda ở chỗ không cung cấp type nào để suy ra — gần như luôn là var. var f = x -> x.length(); hỏng với cannot infer type for local variable f cùng ghi chú đó, vì var lấy type từ phần khởi tạo mà phần khởi tạo lại chưa có type cho tới khi có gì đó bảo nó phải thành cái gì. Hãy khai báo variable bằng một functional interface type, hoặc cast lambda: var f = (Function<String, Integer>) x -> x.length();.

Vì sao variable dùng trong lambda phải final hoặc effectively final?

Vì capture copy giá trị vào một field của object mà lambda trở thành, ngay tại thời điểm lambda được tạo. Object đó sống lâu hơn stack frame của method, nên không có vùng nhớ chung nào để giữ field và local đồng bộ. Nếu local còn được gán lại sau đó, hai bên sẽ âm thầm lệch nhau. Compiler chọn cách từ chối, với message local variables referenced from a lambda expression must be final or effectively final. Ràng buộc chỉ nằm ở việc gán lại variable — object mà nó trỏ tới vẫn sửa được.

Lambda có sửa được variable của method bao ngoài không?

Local variable thì không. Field thì có, vì field được đọc qua this lúc gọi chứ không bị capture; và nội dung của một object hay một mảng đã capture cũng sửa được, vì chỉ reference bị đóng băng. Đó là lý do int[] counter = { 0 }; rồi () -> counter[0]++ compile được còn int count = 0; rồi () -> count++ thì không. Bản dùng mảng chạy được nhưng không thread safe, và thường là dấu hiệu cho thấy vòng lặp đáng ra phải là một phép cộng dồn.

this bên trong lambda trỏ tới đâu?

Tới enclosing instance — đúng object mà this trỏ tới ở dòng ngay phía trên lambda. Body của lambda không mở một scope mới cho this, khác với body của anonymous class, nơi this là chính object anonymous mới. Hệ quả thực tế là một lambda đọc instance field sẽ capture toàn bộ enclosing object, nên lambda đăng ký vào một thứ sống lâu sẽ giữ object đó không được giải phóng.

Khác nhau giữa bound và unbound method reference là gì?

Bound reference đặt một giá trị trước dấu ::prefix::startsWith — và giá trị đó là receiver, được đánh giá đúng một lần lúc reference được tạo. Unbound reference đặt một type — String::startsWith — nên receiver được đưa vào làm argument đầu tiên ở mỗi lần gọi, và method của interface nhận nhiều hơn một parameter: Predicate<String> so với BiPredicate<String, String>. Cứ đọc vế trái dấu ::: một giá trị nghĩa là bound, một type nghĩa là unbound, trừ khi method đó là static.

Khi nào method reference không tương đương với lambda?

Khi expression receiver có ảnh hưởng. target::toString đánh giá target lúc reference được tạo; () -> target.toString() đánh giá nó ở mỗi lần gọi. Nếu target bị gán lại ở giữa thì hai bên trả về khác nhau, còn nếu targetnull thì method reference ném NullPointerException ngay lập tức — từ Objects.requireNonNull bên trong cơ chế reference — trong khi lambda chỉ ném khi được gọi. Chỗ nào receiver có thể đổi hoặc có thể null, hãy dùng lambda.

Làm sao throw checked exception từ trong lambda?

Không được, trừ khi abstract method của target có khai báo nó. Function.apply không khai báo gì, nên p -> Files.readString(Path.of(p)) hỏng với unreported exception IOException, và thêm throws vào method bao ngoài cũng vô ích vì method bao ngoài không phải thứ chạy body đó. Hoặc catch ngay trong lambda rồi ném lại dạng unchecked — UncheckedIOException cho IOException — hoặc định nghĩa interface riêng có method khai báo exception, theo đúng cách Callable.call() khai báo throws Exception.

Kết luận

Lambda không phải cách viết tắt và cũng không phải một object literal. Nó là một yêu cầu gửi cho compiler: dựng cho tôi một thứ thuộc type mà vị trí này cần, với body này. Bỏ vị trí đó đi thì không còn gì để compile, và var f = x -> ... chứng minh điều đó. Mọi thứ khác đều theo sau. Các cách viết type cho parameter không trộn được vì chúng là một khai báo chứ không phải nhiều khai báo. Cùng một đoạn text thành Function, thành ToIntFunction hay thành interface của riêng bạn mà không cần cast, và thành ambiguous khi hai overload cùng khớp. Local được capture theo giá trị và phải effectively final; field không hề bị capture mà được đọc qua chính this mà lambda giữ lại — đó cũng là lý do một lambda nằm trong registry có thể giữ cả một object sống mãi.

Method reference là cùng cơ chế đó với body được suy ra, và bốn loại chỉ khác nhau ở chỗ receiver đến từ đâu: không có, một giá trị cố định lúc tạo, argument đầu tiên, hay một object mới toanh. Chỗ duy nhất sự tương đương bị vỡ chính là cái receiver cố định ấy — đánh giá ngay, kiểm tra null ngay tại chỗ.

Bài tiếp theo trong series: functional interfaceSupplier, Consumer, FunctionPredicate, @FunctionalInterface thật sự ràng buộc điều gì, cách ghép chúng bằng andThencompose, và các phiên bản chuyên cho primitive sinh ra để giữ boxing tránh xa những đoạn code nóng.

Bài viết liên quan

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Class Collections trong Java: algorithm, wrapper và factory

java.util.Collections trên OpenJDK 21 sắp xếp theo đúng bản chất từng nhóm method: các algorithm ghi đè tại chỗ cùng mẹo insertion point của binarySearch, nCopies trả về một reference lặp n lần, ba wrapper unmodifiable, synchronized và checked vốn là view chứ không phải bản copy, checkedList bắt heap pollution ngay lúc insert, và các immutable factory đã thay thế phân nửa số method cũ.

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Nguyên lý SOLID trong Java: Năm quy tắc và khi nào nên bỏ qua

Năm nguyên lý SOLID trong Java trên OpenJDK 21, mỗi nguyên lý một cặp before/after compile và chạy được: một class tách theo lý do thay đổi, một switch phình to thay bằng interface, một subclass phá caller mà không có warning nào, một UnsupportedOperationException lẽ ra compiler đã chặn được, một class không chạy nổi nếu thiếu file, và chỗ mà mỗi nguyên lý không còn đáng để áp dụng.