Lambda và Functional Interface

Xem thử

Tình huống mở đầu

Màn hình bếp quán Cà Phê Gió đã sắp được đơn theo ưu tiên. Giờ quản lý nhờ thêm ba việc: đếm đơn khẩn, đổi tên mỗi đơn thành chữ hoa để in lên màn hình, và xoá đơn không khẩn khi giờ cao điểm. Cả ba việc đều là "đi qua danh sách và làm một chuyện nhỏ khác nhau ở mỗi đơn". Bạn có thể viết ba vòng for, nhưng ba vòng gần giống nhau, chỉ khác một điều kiện hoặc một dòng ở giữa.

Điều bạn thật sự muốn là đưa phần khác nhau đó vào phương thức như một giá trị: "đây là điều kiện, hãy dùng nó". Trong bài trước bạn đã dùng o -> o.priority như khuôn mẫu mà chưa biết nó là gì. Vậy đoạn code đó là gì, nó chạy lúc nào, và vì sao Java lại hay báo lỗi must be final or effectively final?

Ôn nhanh bài trước: Comparator.comparingInt(o -> o.priority).thenComparing(...) ghép hai tiêu chí. Tiêu chí thứ hai chỉ được hỏi khi nào?

Đáp án: khi tiêu chí thứ nhất trả về 0, nghĩa là hai đơn hoà.

Mục tiêu bài học

Sau bài này, bạn có thể:

  • Viết được lambda (không tham số, một tham số, nhiều tham số, thân khối) và gán nó cho Predicate, Function, Consumer hoặc Supplier.
  • Giải thích được functional interface là gì (đúng một phương thức trừu tượng) và thân của lambda chạy vào lúc nào.
  • Dùng lambda với removeIf, forEach và sort trên danh sách đơn của màn hình bếp.
  • Giải thích được vì sao biến local mà lambda dùng phải final hoặc effectively final.

Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.

Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.

Bức tranh lớn (Big Picture)

Bài này mở chương Java 8. Ý chính của cả chương là: code cũng có thể là một giá trị đưa vào phương thức.

Cách đọc hình: đi từ trái sang phải. Ô trắng bên trái là lambda, một đoạn code ngắn. Nó "fills" (lấp vào) ô giữa là functional interface, một chỗ trống chỉ có một phương thức. Ba ô vàng bên phải là các phương thức của thư viện: removeIf, forEach, sort, mỗi phương thức hỏi chỗ trống đó khi cần. Mỗi ô đều có chữ, màu chỉ là tín hiệu phụ.

Ẩn dụ xuyên suốt: lambda là một tờ phiếu hướng dẫn ngắn bạn đưa cho nhân viên bếp ("đơn nào ưu tiên 1 thì in lên màn hình"). Functional interface là mẫu phiếu có đúng một ô để điền việc. Nhân viên (như removeIf) cầm phiếu và làm theo cho từng đơn. Chỗ ẩn dụ không còn đúng: phiếu giấy chỉ đọc được thì tờ phiếu này còn nhớ được cả giá trị của các biến xung quanh nó, và đó là chuyện của khái niệm cuối bài.

Dự án xuyên suốt vẫn là màn hình bếp quán Cà Phê Gió, giờ mỗi đơn có tên đồ uống và độ ưu tiên.

Khái niệm cốt lõi

Bài này có bốn khái niệm, mỗi khái niệm đi kèm một hình.

Khái niệm 1: functional interface, mẫu phiếu có đúng một ô

Functional interface là interface có đúng một phương thức trừu tượng (không có thân). Phương thức default và static không tính. Nhờ chỉ có một ô, Java biết đoạn code bạn đưa vào là để lấp ô nào. Bạn có thể đánh dấu @FunctionalInterface để trình biên dịch giúp kiểm tra điều này.

Cách đọc hình: ba hàng, mỗi hàng đi từ trái sang phải. Hàng trên: Predicate có đúng một phương thức trừu tượng test nên nhận lambda. Hàng giữa màu đỏ: interface có hai phương thức trừu tượng nên báo lỗi "is not a functional interface". Hàng dưới: Comparator có một phương thức trừu tượng compare cùng nhiều phương thức default, vẫn nhận lambda. Dòng ghi chú cuối hình nhắc rằng chỉ phương thức trừu tượng mới được tính.

Khái niệm 2: lambda, cách viết gọn một phương thức

Lambda là một đoạn code ngắn viết thẳng tại chỗ để cài đặt phương thức duy nhất của functional interface. Dạng chung: danh sách tham số, dấu ->, rồi thân. Một biểu thức thì không cần return, nhiều lệnh thì dùng ngoặc nhọn và return.

Cách đọc hình: bốn hàng, mỗi hàng nối dạng lambda bên trái với interface nó thường lấp bên phải. Không tham số thì viết cặp ngoặc rỗng. Một tham số thì viết tên, không cần ngoặc. Hai tham số thì có ngoặc và dấu phẩy. Thân khối thì dùng ngoặc nhọn và return. Dòng ghi chú cuối hình nhắc kiểu của tham số thường được bỏ vì Java đọc từ interface.

Điều dễ nhầm nhất ở khái niệm này là thời điểm chạy: thân lambda không chạy ở dòng tạo ra nó. Phần Thử phá nó đi bên dưới có hình đặt mô hình sai và mô hình đúng cạnh nhau.

Concept Check 1. Dòng sau chạy trong main. Màn hình in gì ngay sau dòng này?

java
Predicate<Order> urgent = o -> { System.out.println("run"); return o.priority == 1; };
  • A. In run một lần, vì dòng khai báo chạy thân lambda để kiểm tra nó.
  • B. In run bốn lần, vì danh sách orders có bốn đơn để duyệt qua.
  • C. Không in gì cả, vì thân lambda chỉ chạy khi có ai gọi urgent.test(...).
  • D. Lỗi biên dịch, vì lambda không được phép chứa lệnh in ra màn hình.

Đáp án: C. Khai báo chỉ tạo lambda và cất vào biến urgent. A là mô hình sai điển hình. B nhầm vì không có vòng lặp nào được gọi ở dòng này. D sai vì lambda dùng thân khối có thể chứa bao nhiêu lệnh tuỳ ý, miễn là có return khi interface trả giá trị.

Khái niệm 3: bốn functional interface hay dùng nhất

Java có sẵn bộ functional interface trong gói java.util.function. Bốn cái bạn gặp mỗi ngày: Predicate (nhận một giá trị, trả boolean, phương thức test), Function (nhận một giá trị, trả một giá trị khác, phương thức apply), Consumer (nhận một giá trị, không trả gì, phương thức accept), Supplier (không nhận gì, trả một giá trị, phương thức get). Trong bếp: Predicate trả lời "đơn này có khẩn không", Function đổi đơn thành nhãn in, Consumer in nhãn lên màn hình, Supplier tạo một đơn mặc định khi cần.

Cách đọc hình: bốn hàng, mỗi hàng nối interface bên trái với một cách dùng điển hình bên phải. Predicate nhận một đơn và trả đúng hoặc sai, dùng cho removeIf. Function nhận một đơn và trả nhãn chữ. Consumer nhận chữ và không trả gì, dùng cho forEach. Supplier không nhận gì và trả một đơn. Dòng ghi chú cuối nhắc cách nhớ theo hình dạng: hỏi có hoặc không, đổi, làm, tạo.

Khái niệm 4: lambda dùng biến local, và vì sao biến phải không đổi

Lambda có thể dùng biến local của phương thức xung quanh nó. Khi đó lambda giữ một bản sao giá trị của biến lúc nó được tạo. Vì thế biến phải là effectively final: chỉ được gán một lần, và sau đó không đổi (không cần gõ chữ final). Nếu biến đổi, bản sao trong lambda và biến gốc sẽ khác nhau, một cái tên mà hai giá trị, nên Java từ chối biên dịch. Đối tượng được bắt giữ thì khác: bạn không gán lại biến trỏ tới nó, chỉ sửa nội dung bên trong, nên vẫn được.

java
int limit = 6;
Predicate<Integer> big = n -> n > limit;  // lambda giữ một bản sao của limit
nums.removeIf(big);                       // nums là 5, 8, 3, 10 nên còn lại 5 và 3
StringBuilder log = new StringBuilder();
nums.forEach(n -> log.append(n).append(';'));  // sửa nội dung đối tượng thì được
Dòng codeMáy làm gìVì sao quan trọng
int limit = 6;Tạo biến local và gán một lầnGán đúng một lần là điều kiện để biến thành effectively final
n -> n > limitTạo lambda giữ bản sao giá trị 6Về sau limit không được đổi, vì bản sao sẽ không đổi theo
nums.removeIf(big)Gọi big cho từng số, xoá số cho kết quả true8 và 10 lớn hơn 6 nên bị xoá
log.append(n)Thêm vào đối tượng StringBuilder đã được bắt giữBiến log vẫn trỏ tới cùng một đối tượng, chỉ nội dung đổi

Kết quả của chương trình Capture [Đã chạy, JDK 17.0.15]:

text
[5, 3]
5;3;

Cách đọc hình: ô trắng bên trái là biến limit. Nhánh trên đi tới ô lục "Lambda keeps a copy" rồi ô "Object still editable": đối tượng được bắt giữ vẫn sửa được nội dung. Nhánh dưới nét đứt đi tới ô đỏ "limit changed later" rồi tới "Compile error". Dòng ghi chú cuối hình giải thích vì sao: một cái tên mà hai giá trị sẽ gây nhầm lẫn nên Java từ chối.

Hãy xem từng bước vì sao Java cấm đổi limit:

Đang tải Animation…

Bản chữ tương đương của animation:

BướcDòngGiá trị của limitGiá trị lambda đang giữ
1int limit = 6;6chưa có lambda
2tạo big6bản sao 6
3nums.removeIf(big)66, kết quả [5, 3]
4giả sử có limit = 77vẫn 6, hai giá trị khác nhau
5biên dịchkhông biên dịch đượclỗi must be final or effectively final

Ví dụ đầu tiên (Worked Example) và "Xem từng bước"

Đây là chương trình đầu tiên của dự án bài này: màn hình bếp dùng bốn lambda của bốn functional interface đã học ở trên.

BoardLambda.java

java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

public class BoardLambda {
    static class Order {
        final String drink;
        final int priority;

        Order(String drink, int priority) {
            this.drink = drink;
            this.priority = priority;
        }

        @Override
        public String toString() {
            return drink + ":" + priority;
        }
    }

    public static void main(String[] args) {
        List<Order> orders = new ArrayList<>(List.of(
                new Order("Latte", 2), new Order("Mocha", 1), new Order("Tea", 3), new Order("Espresso", 1)));

        // Bốn lambda này chỉ được tạo ra, thân của chúng chưa chạy
        Predicate<Order> urgent = o -> o.priority == 1;
        Function<Order, String> label = o -> o.drink.toUpperCase();
        Consumer<String> show = s -> System.out.println("screen: " + s);
        Supplier<Order> fallback = () -> new Order("Water", 3);

        for (Order o : orders) {
            if (urgent.test(o)) {
                show.accept(label.apply(o));  // thân lambda chạy ở đây, mỗi lần gọi một lần
            }
        }
        orders.removeIf(urgent.negate());  // negate đảo điều kiện: xoá đơn không khẩn
        System.out.println(orders + " " + fallback.get());
    }
}

Predict: hãy dừng lại, lấy giấy nháp và đoán: màn hình in những dòng nào, và dòng cuối cùng in ra gì? Đoán xong rồi hãy đọc tiếp.

Kết quả mong đợi [Đã chạy, JDK 17.0.15]:

text
screen: MOCHA
screen: ESPRESSO
[Mocha:1, Espresso:1] Water:3

Đọc code từng dòng:

Dòng codeMáy làm gìVì sao viết như vậy
o -> o.priority == 1 gán cho Predicate<Order>Tạo lambda trả true khi đơn có ưu tiên 1Predicate có một phương thức test trả boolean, đúng điều kiện cần
o -> o.drink.toUpperCase() gán cho Function<Order, String>Tạo lambda đổi đơn thành tên đồ uống chữ hoaFunction nhận Order và trả String, hai kiểu trong ngoặc nhọn nói điều đó
s -> System.out.println("screen: " + s) gán cho Consumer<String>Tạo lambda in một chuỗi lên màn hìnhConsumer chỉ làm một việc và không trả gì
() -> new Order("Water", 3) gán cho Supplier<Order>Tạo lambda sinh một đơn mới mỗi lần gọiSupplier không nhận tham số nên dùng cặp ngoặc rỗng
urgent.test(o)Chạy thân của lambda urgent cho đơn oĐây là lúc thân chạy, không phải lúc khai báo
show.accept(label.apply(o))Đổi đơn thành nhãn rồi in nhãnHai lambda nối nhau: đầu ra của Function là đầu vào của Consumer
orders.removeIf(urgent.negate())Xoá mọi đơn mà urgent trả falsenegate() tạo điều kiện đảo ngược mà không viết lại lambda

Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy và số lần thân lambda đã chạy:

Đang tải Animation…

Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):

Dòng chạyThân lambda nào chạyTổng số lần chạyMàn hình
bốn dòng khai báo lambdakhông có0trống
vòng for, Latte:2urgent (false)1trống
vòng for, Mocha:1urgent, label, show4screen: MOCHA
vòng for, Tea:3urgent (false)5giữ nguyên
vòng for, Espresso:1urgent, label, show8thêm screen: ESPRESSO
removeIf(urgent.negate())urgent bốn lần12giữ nguyên
fallback.get()fallback13in [Mocha:1, Espresso:1] Water:3

Bạn vừa làm được: phân biệt lúc lambda được tạo với lúc thân của nó chạy, và đếm đúng số lần chạy.

Concept Check xen kẽ

Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới.

Concept Check 2. Đoạn sau có biên dịch được không?

java
int limit = 6;
nums.removeIf(n -> n > limit);
limit = 7;
  • A. Không. Lỗi báo ở dòng lambda, vì limit bị gán lại sau đó nên không còn effectively final.
  • B. Có. Lambda dùng 6 vì lúc đó limit là 6, còn dòng cuối chỉ đổi biến sang 7.
  • C. Có. Lambda dùng 7 vì nó luôn nhìn thấy giá trị mới nhất của biến limit.
  • D. Có, nhưng ném ngoại lệ lúc chạy ở dòng cuối vì lambda đang giữ biến limit.

Đáp án: A. Chỉ cần có một lần gán lại ở bất cứ đâu, biến đã không còn effectively final, và lỗi được báo tại chỗ lambda dùng nó. B và C nhầm rằng lambda theo dõi biến: nó giữ bản sao, và Java chặn tình huống hai giá trị khác nhau thay vì chọn một. D sai vì lỗi này xảy ra lúc biên dịch, không phải lúc chạy.

Concept Check 3. Interface nào dùng được với lambda?

  • A. Interface có hai phương thức trừu tượng open() và close().
  • B. Interface có một phương thức trừu tượng price(), thêm một phương thức default và một phương thức static.
  • C. Một lớp abstract có đúng một phương thức trừu tượng.
  • D. Interface rỗng, không có phương thức nào.

Đáp án: B. Phương thức default và static có thân sẵn nên không tính, chỉ còn một ô trừu tượng cho lambda lấp. A có hai ô nên Java không biết lambda lấp ô nào. C sai vì lambda chỉ dùng cho interface, không dùng cho lớp abstract. D không có ô nào để lấp.

Làm cùng mình (Guided Practice)

Bạn sẽ dựng dần class Screen qua năm bước nhỏ. Hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 5 chỉ còn đề. Gõ code vào file Screen.java rồi chạy bằng java Screen.java.

Bước 1: một Predicate.

java
import java.util.*;
import java.util.function.*;

public class Screen {
    public static void main(String[] args) {
        // TODO 1: viết lambda trả true khi chuỗi dài hơn 4 ký tự
        Predicate<String> isLong = null;
        System.out.println("step 1: " + isLong.test("Tea") + " " + isLong.test("Mocha"));
    }
}

Chạy xong bạn sẽ thấy: step 1: false true.

Bước 2: một Function. Thêm dưới dòng in của bước 1:

java
        // TODO 2: viết lambda trả độ dài của chuỗi
        Function<String, Integer> len = null;
        System.out.println("step 2: " + len.apply("Latte"));

Chạy xong bạn sẽ thấy: step 2: 5. Gợi ý: String::length là cách viết gọn của cùng việc này.

Bước 3: một Consumer đi qua danh sách.

java
        List<String> names = new ArrayList<>(List.of("Latte", "Tea", "Mocha"));
        StringBuilder initials = new StringBuilder();
        // TODO 3: dùng forEach với lambda để nối chữ cái đầu của mỗi tên vào initials
        System.out.println("step 3: " + initials);

Chạy xong bạn sẽ thấy: step 3: LTM.

Vẽ ra giấy: vẽ initials sau từng lần lambda chạy. Đối chiếu: L, rồi LT, rồi LTM. initials không bị gán lại, chỉ được nối thêm, nên lambda dùng được nó.

Bước 4: dùng biến local trong lambda. Bạn tự quyết cách viết.

java
        int limit = 2;
        List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
        // TODO 4: xoá mọi số lớn hơn limit bằng removeIf
        System.out.println("step 4: " + nums);

Chạy xong bạn sẽ thấy: step 4: [1, 2]. Gợi ý: lambda dùng limit được vì limit chỉ được gán một lần.

Bước 5: sắp xếp bằng lambda.

java
        // TODO 5: sắp names theo độ dài giảm dần bằng lambda hai tham số
        System.out.println("step 5: " + names);

Chạy xong bạn sẽ thấy: step 5: [Latte, Mocha, Tea]. Gợi ý: hai tham số a, b, và đảo vai hai độ dài khi so sánh. Lời giải đầy đủ nằm ở cuối bài.

Bạn vừa làm được: tự viết lambda cho Predicate, Function, Consumer, dùng biến local trong lambda, và sắp xếp bằng lambda. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 13-java8/01-lambda của kho GitHub, nhưng bạn không cần mở nó để học.

Thử phá nó đi (Lỗi thường gặp)

Bốn lỗi dưới đây là những lỗi người mới gặp nhiều nhất với lambda. Mỗi dòng đã được chạy thật [Đã chạy, JDK 17.0.15; thông báo lỗi có thể khác chữ ở phiên bản Java khác].

Code lỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
int limit = 2; Predicate<Integer> p = n -> n > limit; limit = 3;local variables referenced from a lambda expression must be final or effectively finalBiến bị gán lại sau khi lambda dùng nóBỏ dòng gán lại, hoặc dùng biến mới cho giá trị mới
int total = 0; orders.forEach(o -> total += o.priority);Cùng thông báo như trên+= gán lại biến total ngay trong lambdaDùng vòng for thường để cộng, hoặc bài sau dùng Stream
Object o = () -> System.out.println("x");incompatible types: Object is not a functional interfaceObject không có phương thức trừu tượng nào để lambda lấpGán cho một functional interface, như Runnable
Function<Integer, Integer> f = (x, y) -> x + y;incompatible types: incompatible parameter types in lambda expressionFunction chỉ nhận một tham số mà lambda khai báo haiDùng BiFunction, hoặc viết lambda một tham số

Lỗi thứ nhất và thứ hai cùng một gốc: lambda không được làm đổi biến local của phương thức bao quanh. Lỗi thứ ba và thứ tư cùng một gốc: lambda phải khớp với phương thức duy nhất của interface mà nó lấp (số tham số, kiểu trả về).

Hiểu lầm lớn nhất không có thông báo lỗi: tưởng rằng thân lambda chạy ngay khi dòng tạo ra nó được thực hiện. Hình dưới đây đặt mô hình sai và mô hình đúng cạnh nhau:

Cách đọc hình: hàng trên màu đỏ là mô hình sai: tin rằng dòng tạo lambda đã chạy thân của nó. Hàng dưới màu lục là mô hình đúng: tạo lambda chỉ cất hành vi lại, và không có gì xảy ra cho tới khi test được gọi. Dòng ghi chú cuối nói: lambda là một kế hoạch chứ không phải một hành động.

Bạn vừa làm được: đọc bốn thông báo lỗi phổ biến của lambda và nói được nguyên nhân bằng lời thường.

Tự tay làm (Bài tập)

Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.

Mức Cơ bản

Bài 1. Điền bảng trace. Dữ liệu vào là đoạn code của khái niệm 4 với limit bằng 6 và nums là [5, 8, 3, 10]. Điền bảng gồm bốn dòng, mỗi dòng một số n: giá trị big.test(n) và số đó còn hay bị xoá. Kết quả mong đợi: danh sách cuối cùng [5, 3]. Tiêu chí nghiệm thu: bạn nói được thân lambda chạy bao nhiêu lần trong removeIf và vì sao. Gợi ý: removeIf gọi điều kiện đúng một lần cho mỗi phần tử.

Bài 2. Chọn interface và viết lambda. Dữ liệu vào là bốn việc: (a) kiểm tra đơn có đồ uống là Tea; (b) từ đơn lấy tên đồ uống viết hoa; (c) in một đơn lên màn hình với tiền tố screen: ; (d) tạo một đơn mặc định Water:3. Với mỗi việc, ghi tên interface và viết lambda. Kết quả mong đợi: bốn interface khác nhau. Tiêu chí nghiệm thu: mỗi lambda khớp với số tham số và kiểu trả về của interface. Gợi ý: nhìn vào hình "Which functional interface for which job".

Bài 3. Sửa lỗi biến bắt giữ. Dữ liệu vào:

java
int total = 0;
orders.forEach(o -> total += o.priority);

Sửa để tính đúng tổng ưu tiên của danh sách. Kết quả mong đợi: tổng bằng 7 với bốn đơn Latte:2, Mocha:1, Tea:3, Espresso:1. Tiêu chí nghiệm thu: không còn lỗi biên dịch và bạn giải thích được vì sao dòng gốc bị từ chối. Gợi ý: một vòng for thông thường không có hạn chế này.

Mức Vận dụng

Bài 4. Màn hình bếp nhận lambda từ ngoài. Viết class OrderBoard giữ một List<Order> và có ba phương thức nhận lambda:

Phương thứcViệc phải làm
int count(Predicate<Order> rule)Đếm số đơn thoả điều kiện
List<String> labels(Function<Order, String> toLabel)Trả danh sách nhãn, mỗi đơn một nhãn
void forEachOrder(Consumer<Order> action)Chạy hành động cho từng đơn

Dữ liệu vào là hàm main sau:

java
OrderBoard board = new OrderBoard();
board.add(new Order("Latte", 2));
board.add(new Order("Mocha", 1));
board.add(new Order("Tea", 3));
board.add(new Order("Espresso", 1));
System.out.println(board.count(o -> o.priority == 1));
System.out.println(board.labels(o -> o.drink + "#" + o.priority));
board.forEachOrder(o -> System.out.println("screen: " + o));

Kết quả mong đợi:

text
2
[Latte#2, Mocha#1, Tea#3, Espresso#1]
screen: Latte:2
screen: Mocha:1
screen: Tea:3
screen: Espresso:1

Tiêu chí nghiệm thu: sáu dòng khớp từng ký tự, và ba phương thức không biết gì về nội dung lambda mà chúng nhận. Gợi ý: mỗi phương thức chỉ là một vòng for gọi test, apply hoặc accept.

Mức Thử thách (tuỳ chọn)

Bài 5. Ghép điều kiện. Dữ liệu vào: năm đơn Latte:2, Mocha:1, Tea:3, Espresso:1, Latte:1 và hai điều kiện urgent (ưu tiên 1) và latte (đồ uống là Latte). Dùng and và negate của Predicate để đếm các đơn khẩn mà không phải Latte. Kết quả mong đợi: 2. Tiêu chí nghiệm thu: không viết thêm lambda thứ ba, chỉ ghép hai lambda có sẵn. Gợi ý: urgent.and(...) nhận một Predicate khác.

Tự kiểm tra (Retrieval Practice)

Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.

Câu 1 (nhớ lại, mục tiêu 2). Functional interface là gì? Kể tên bốn functional interface hay dùng cùng phương thức của chúng.

Câu 2 (dự đoán kết quả code, mục tiêu 3). Đoạn sau in ra gì?

java
List<String> names = new ArrayList<>(List.of("Tea", "Latte", "Mocha", "Chai"));
names.removeIf(s -> s.length() > 4);
names.forEach(s -> System.out.print(s.charAt(0)));

Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 2). Sau mỗi dòng dưới đây, log.size() bằng bao nhiêu?

java
List<Integer> log = new ArrayList<>();
Runnable r = () -> log.add(1);
r.run();
r.run();

Câu 4 (tìm lỗi, mục tiêu 4). Đoạn sau không biên dịch được. Chỉ ra lỗi và viết lại cho đúng.

java
int total = 0;
orders.forEach(o -> total += o.priority);

Câu 5 (giải thích cho bạn chưa học, mục tiêu 1 và 2). Bạn của bạn hỏi: "Lambda là gì mà cứ thấy dấu mũi tên?" Giải thích bằng ví dụ tờ phiếu hướng dẫn cho nhân viên bếp.

Điều cần nhớ

  • Lambda là cách viết gọn cho phương thức duy nhất của một functional interface, như Predicate (test), Function (apply), Consumer (accept), Supplier (get) (mục tiêu 1).
  • Functional interface có đúng một phương thức trừu tượng, và thân lambda chỉ chạy khi có ai gọi phương thức đó (mục tiêu 2).
  • removeIf, forEach, sort nhận lambda, nên một vòng lặp quen thuộc chỉ còn một dòng (mục tiêu 3).
  • Biến local được lambda dùng phải effectively final vì lambda giữ bản sao giá trị, còn đối tượng bắt giữ thì sửa được nội dung (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmInterfacePhương thứcVí dụ lambda
Kiểm tra đúng hoặc saiPredicate<T>testo -> o.priority == 1
Đổi một giá trị thành giá trị khácFunction<T, R>applyo -> o.drink.toUpperCase()
Làm một việc với giá trịConsumer<T>accepts -> System.out.println(s)
Tạo giá trị khi cầnSupplier<T>get() -> new Order("Water", 3)
So hai giá trịComparator<T>compare(a, b) -> Integer.compare(a, b)
Đảo điều kiệnpredicate.negate()trả Predicateurgent.negate()
Ghép hai điều kiệnp1.and(p2), p1.or(p2)trả Predicateurgent.and(latte)
Hình: tóm tắt chọn functional interface cho từng việc

Cách đọc hình: mỗi hàng là một việc bên trái và interface nên chọn bên phải, kèm các phương thức thường dùng. Đọc theo hàng ngang. Dòng ghi chú cuối nhắc String::length là cách viết gọn của lambda chỉ gọi một phương thức.

Câu hỏi để dành cho ngày mai: bạn vừa lọc và đổi nhãn bằng vòng for kèm lambda. Nếu muốn viết cả chuỗi "lọc đơn khẩn, đổi thành nhãn, gom thành danh sách" chỉ trong một câu liền mạch, không cần danh sách trung gian nào, bạn nghĩ nó sẽ trông thế nào? Hãy nghĩ trước khi sang bài sau.

Đào sâu

Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.

Bên trong. Lambda không chỉ là cách viết gọn của lớp ẩn danh. Một khác biệt có thể đo được là từ khoá this. Trong lớp ẩn danh, this trỏ tới chính đối tượng ẩn danh. Trong lambda, this trỏ tới đối tượng bao quanh. Trong một lần thử, cùng một lớp có trường v bằng 7 và lớp ẩn danh có trường v bằng 99: lambda in 7, lớp ẩn danh in 99 [Đã chạy, JDK 17.0.15].

So với lựa chọn khác. Khi lambda chỉ gọi đúng một phương thức, bạn có thể dùng method reference: String::length thay cho s -> s.length(), System.out::println thay cho s -> System.out.println(s), ArrayList::new thay cho () -> new ArrayList<>() [Đã chạy, cả ba đều biên dịch và chạy]. Chúng ngắn hơn nhưng không thay được lambda khi bạn cần thêm logic. Ngoài ra Function có andThen và compose: với add là x -> x + 10 và twice là x -> x * 2, add.andThen(twice).apply(5) cho 30 còn add.compose(twice).apply(5) cho 20 [Đã chạy].

Hiểu lầm nâng cao. Quy tắc effectively final chỉ áp dụng cho biến local và tham số. Trường của đối tượng thì lambda đọc và sửa tự do. Nhiều người lách quy tắc bằng cách bọc biến vào một mảng một phần tử rồi tăng phần tử đó. Cách này biên dịch được, nhưng nó che đi vấn đề thật là một lambda đang âm thầm thay đổi trạng thái bên ngoài, và sẽ gây lỗi khó thấy khi nhiều luồng cùng chạy. Bài này không khuyến khích cách đó.

Giới hạn của bài. Bài này chưa nói về BiFunction, UnaryOperator, BinaryOperator, các interface cho kiểu nguyên thuỷ như IntPredicate, về cách lambda xử lý ngoại lệ có kiểm tra (checked exception), và cách chuỗi lambda kết hợp với Stream ở bài sau. Số liệu trong bài thuộc JDK 17.0.15.

Đọc thêm (tài liệu chính thống, Java SE 17):

Bài tiếp theo

Bài sau là Stream API: nối nhiều lambda thành một chuỗi xử lý liền mạch như lọc, đổi, gom, mà không cần danh sách trung gian nào.

Mini-dự án nối tiếp: màn hình bếp sẽ thống kê đơn bằng Stream: đếm đơn khẩn, gom đồ uống theo ưu tiên, tính tổng. Hãy giữ lại class Order và OrderBoard có ba phương thức nhận lambda bạn vừa viết làm điểm xuất phát.

Lời giải và đáp án

Hãy thử xong rồi mới đọc phần này.

Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng năm dòng step [Đã chạy, JDK 17.0.15]:

java
Predicate<String> isLong = s -> s.length() > 4;
Function<String, Integer> len = s -> s.length();
names.forEach(n -> initials.append(n.charAt(0)));  // initials không bị gán lại
nums.removeIf(n -> n > limit);                     // limit chỉ gán một lần
names.sort((a, b) -> Integer.compare(b.length(), a.length()));  // đảo vai a và b
Dòng codeMáy làm gìLý do chọn cách này
s -> s.length() > 4Trả true khi chuỗi dài hơn 4 ký tựPredicate cần kết quả boolean, và biểu thức so sánh đã là boolean
s -> s.length()Trả độ dài của chuỗiFunction<String, Integer> nhận chuỗi, trả số nguyên
names.forEach(n -> initials.append(n.charAt(0)))Với mỗi tên, nối chữ cái đầu vào initialsinitials là đối tượng được bắt giữ, ta chỉ sửa nội dung nên hợp lệ
nums.removeIf(n -> n > limit)Xoá mọi số lớn hơn limitLambda dùng limit được vì nó chỉ gán một lần
(a, b) -> Integer.compare(b.length(), a.length())Đặt tên dài hơn lên trướcĐảo a và b biến thứ tự tăng thành giảm, Latte và Mocha hoà nên giữ nguyên thứ tự cũ

Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật [5, 3]]

nbig.test(n)Còn hay xoá
5falsecòn
8truexoá
3falsecòn
10truexoá

Thân lambda chạy 4 lần trong removeIf, mỗi phần tử một lần. Danh sách cuối là [5, 3], và đoạn code sau đó cho log là 5;3;.

Lời giải Bài 2. [Đã chạy] (a) Predicate<Order> isTea = o -> o.drink.equals("Tea"); (b) Function<Order, String> up = o -> o.drink.toUpperCase(); (c) Consumer<Order> show = o -> System.out.println("screen: " + o); (d) Supplier<Order> def = () -> new Order("Water", 3);. Bốn lambda có số tham số khác nhau (một, một, một, không) và kiểu trả về khác nhau (boolean, String, không có, Order), nên cần bốn interface khác nhau.

Lời giải Bài 3. [Đã chạy, tổng bằng 7]

java
int total = 0;
for (Order o : orders) {
    total += o.priority;  // vòng for thường có thể gán lại total
}

Dòng gốc bị từ chối vì total += ... gán lại biến total ngay trong lambda, mà lambda chỉ giữ bản sao nên không thể đổi biến gốc. Vòng for thường không có lambda nên không có giới hạn này.

Lời giải Bài 4. [Đã chạy, sáu dòng khớp với kết quả mong đợi]

java
public int count(Predicate<Order> rule) {
    int n = 0;
    for (Order o : orders) {
        if (rule.test(o)) {
            n++;
        }
    }
    return n;
}

public List<String> labels(Function<Order, String> toLabel) {
    List<String> out = new ArrayList<>();
    for (Order o : orders) {
        out.add(toLabel.apply(o));
    }
    return out;
}
Phương thứcMáy làm gìLý do chọn cách này
countGọi rule.test(o) cho từng đơn và đếm số truePhương thức không biết điều kiện là gì, chỉ hỏi test
labelsGọi toLabel.apply(o) cho từng đơn rồi gom kết quảHành vi đổi nhãn do người gọi quyết định
forEachOrderGọi action.accept(o) cho từng đơnChỉ khác ở chỗ gọi accept và không trả gì

Lời giải Bài 5. [Đã chạy, kết quả 2] Ghép bằng Predicate<Order> rule = urgent.and(latte.negate());, rồi đếm các đơn mà rule.test(o) là true. Trong năm đơn, chỉ Mocha:1 và Espresso:1 vừa khẩn vừa không phải Latte, còn Latte:1 khẩn nhưng là Latte nên bị loại.

Đáp án Tự kiểm tra.

  1. Functional interface là interface có đúng một phương thức trừu tượng. Bốn interface hay dùng: Predicate (test), Function (apply), Consumer (accept), Supplier (get). Kiểm tra mục tiêu 2.
  2. In ra TC [Đã chạy]. removeIf xoá Latte và Mocha (dài 5), còn Tea và Chai, rồi forEach in chữ cái đầu của mỗi tên còn lại. Kiểm tra mục tiêu 3.
  3. Sau dòng tạo r: 0 (lambda chưa chạy). Sau r.run() lần một: 1. Sau r.run() lần hai: 2 [Đã chạy: 0 rồi 2]. Kiểm tra mục tiêu 2.
  4. Lỗi: total += ... gán lại biến local trong lambda, vi phạm effectively final. Viết lại bằng vòng for thường, hoặc dùng một StringBuilder hay mảng, nhưng vòng for rõ ràng nhất. Kiểm tra mục tiêu 4.
  5. Lambda là một tờ phiếu hướng dẫn ngắn bạn đưa cho nhân viên bếp, ví dụ "đơn nào ưu tiên 1 thì in lên màn hình". Dấu -> ngăn cách phần đầu vào ("đơn nào") và phần việc phải làm. Nhân viên cầm phiếu và làm theo cho từng đơn, còn bạn không phải đứng bên cạnh. Kiểm tra mục tiêu 1 và 2.