Stream API

Xem thử

Tình huống mở đầu

Cuối ca, chủ quán Cà Phê Gió xin ba con số: danh sách đồ uống của các đơn khẩn, tổng doanh thu, và mỗi đồ uống bán được mấy ly. Bạn viết bằng vòng for: ba biến tạm, hai danh sách phụ, khoảng 30 dòng. Một đồng nghiệp viết cùng việc đó bằng ba câu ngắn, mỗi câu một dòng đọc từ trái sang phải. Với 5 đơn thì chẳng ai thấy khác biệt, nhưng khi chủ quán đổi ý ("cho thêm cột nào đó") bạn phải sửa lại cả ba vòng, còn đồng nghiệp chỉ thêm một bước.

Có một điều lạ: đồng nghiệp viết xong một câu mà chạy ra danh sách đúng, nhưng lúc xây câu thì chẳng thấy máy làm gì. Vậy câu ngắn đó chạy lúc nào, và nó có làm hỏng danh sách đơn gốc không?

Ôn nhanh bài trước: vì sao int total = 0; orders.forEach(o -> total += o.price); không biên dịch được?

Đáp án: lambda giữ bản sao giá trị của total nên total phải effectively final, mà += gán lại nó.

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

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

  • Viết được một pipeline stream(), filter, map, collect trên danh sách đơn để lấy danh sách kết quả.
  • Phân biệt được thao tác trung gian với thao tác kết thúc, và giải thích vì sao không có gì chạy cho tới khi có thao tác kết thúc.
  • Dùng mapToInt().sum(), groupingBy với counting() để tính tổng và đếm theo nhóm.
  • Nhận ra và sửa được bẫy: dùng lại một stream đã chạy, và cộng dồn vào biến ngoài từ trong lambda.

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)

Trước khi vào chi tiết, hãy xem một pipeline nhìn như thế nào từ trên cao.

Cách đọc hình: đi từ trái sang phải ở hàng trên. Ô trắng là danh sách nguồn có 4 đơn. Hai ô xanh ngọc là các bước ở giữa: filter giữ đơn khẩn, map đổi đơn thành tên đồ uống. Ô lục collect gom kết quả, và nó đi xuống ô kết quả bên phải. Ô xám ở dưới bên trái nối nét đứt với nguồn, nhắc rằng danh sách nguồn vẫn còn 4 đơn. Mỗi ô đều có chữ, màu chỉ là tín hiệu phụ.

Ẩn dụ xuyên suốt: một băng chuyền trong bếp. Đơn đặt lên băng chuyền, đi qua trạm kiểm tra (filter), trạm đổi nhãn (map), rồi rơi vào khay ở cuối (collect). Băng chuyền chỉ chạy khi ai đó bấm nút ở cuối. Chỗ ẩn dụ không còn đúng: băng chuyền thật chuyển chính món đồ, còn stream chỉ đọc đơn từ danh sách nguồn và không bao giờ lấy đơn ra khỏi danh sách đó.

Dự án xuyên suốt là màn hình bếp quán Cà Phê Gió, mỗi đơn giờ có thêm giá 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: stream, băng chuyền đọc dữ liệu

Stream là một dòng phần tử được tạo từ một nguồn như List, để bạn xử lý theo từng bước. Nó khác danh sách ở hai điểm. Stream không lưu dữ liệu, nó chỉ đọc từ nguồn, nên không sửa nguồn. Stream chỉ dùng được một lần: sau khi chạy xong, nó hết hạn.

Cách đọc hình: ô vàng bên trái là danh sách orders lưu đơn. Ô xanh ngọc ở giữa là orders.stream(), băng chuyền đọc từ danh sách. Từ đó có hai nhánh: nhánh trên màu lục là dùng một lần, nhánh dưới nét đứt màu đỏ là dùng lại thì ném IllegalStateException. Dòng ghi chú nhắc danh sách có thể tạo stream mới bất cứ lúc nào.

Khái niệm 2: thao tác trung gian, viết công thức mà chưa nấu

Thao tác trung gian (intermediate) là bước nhận một stream và trả về một stream mới, như filter (giữ phần tử thoả điều kiện, nhận Predicate), map (đổi mỗi phần tử, nhận Function), sorted (sắp xếp), limit(n) (chỉ lấy n phần tử đầu). Chúng lazy: chỉ ghi lại việc cần làm, giống như viết công thức mà chưa nấu.

Cách đọc hình: hàng trên là bốn bước trung gian, mỗi bước có một dòng giải thích ngắn. Cả bốn đều có mũi tên đi xuống cùng một ô lục "A new Stream", ghi "still waiting for a terminal step". Dòng ghi chú cuối hình so sánh với việc viết công thức: chưa có gì được nấu.

Khái niệm 3: thao tác kết thúc, nút bấm làm băng chuyền chạy

Thao tác kết thúc (terminal) là bước cuối, trả về một kết quả thật hoặc không trả gì: collect (gom thành List, Map), count, sum, findFirst, forEach. Chỉ khi một thao tác kết thúc được gọi thì cả pipeline mới chạy, và từng phần tử đi qua toàn bộ các bước rồi mới tới phần tử kế tiếp. Sau đó stream hết hạn. Một số thao tác kết thúc như findFirst còn dừng sớm khi đã đủ kết quả.

Cách đọc hình: hàng trên là bốn thao tác kết thúc cùng kiểu kết quả của mỗi cái (List, long, int, Optional). Cả bốn nối vào ô giữa "The pipeline runs": đó là lúc đường ống thực sự làm việc, từng phần tử một. Dòng ghi chú cuối hình nhắc sau một thao tác kết thúc, stream đã dùng hết.

Đoạn code dưới đây của chương trình Lazy cho thấy điều này bằng cách in mỗi lần một đơn được đọc:

java
// Chỉ dựng đường ống: chưa có thao tác kết thúc nên không in gì
orders.stream()
        .peek(o -> System.out.println("source " + o.drink))
        .filter(o -> o.priority == 1);
// findFirst là thao tác kết thúc, và nó dừng ngay khi có kết quả
Optional<String> first = orders.stream()
        .peek(o -> System.out.println("source " + o.drink))
        .filter(o -> o.priority == 1)
        .map(o -> o.drink)
        .findFirst();
Dòng codeMáy làm gìVì sao viết như vậy
orders.stream()Tạo một stream đọc từ danh sáchMọi pipeline đều bắt đầu bằng một nguồn
.peek(o -> System.out.println(...))Chạy lambda cho mỗi phần tử đi ngang qua, rồi cho nó đi tiếppeek dùng để quan sát đường ống khi thử nghiệm, nó không đổi phần tử
.filter(o -> o.priority == 1)Giữ đơn có ưu tiên 1Chỉ ghi lại việc cần làm, chưa chạy
Câu đầu không có gì sau filterKhông thao tác kết thúc nào được gọiVì vậy không có đơn nào được đọc, không in gì
.map(o -> o.drink)Đổi mỗi đơn thành tên đồ uốngChạy cho từng đơn đi qua filter
.findFirst()Lấy phần tử đầu tiên có mặt, rồi dừngThao tác kết thúc nên làm cả đường ống chạy, và dừng ngay khi có đáp án

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

text
built, nothing ran
source Latte
source Mocha
first: Mocha

Chỉ có hai dòng source: Latte rồi Mocha. Tea và Espresso không bao giờ được đọc. Hãy xem từng bước:

Đang tải Animation…

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

BướcViệc xảy raSố đơn đã đọcDòng in ra
1Dựng đường ống không có thao tác kết thúc0built, nothing ran
2findFirst đọc Latte:2, filter loại1source Latte
3findFirst đọc Mocha:1, qua filter, map lấy Mocha, dừng2source Mocha
4Tea và Espresso không được đọc2 trên 4first: Mocha

Concept Check 1. Dòng orders.stream().filter(o -> o.priority == 1).map(o -> o.drink); đứng một mình, không có gì nối tiếp. Chương trình làm gì khi chạy tới dòng này?

  • A. Lọc và đổi cả bốn đơn ngay lập tức, rồi vứt kết quả đi vì không ai dùng.
  • B. Chỉ chạy filter cho cả bốn đơn, còn map thì đợi đến lúc có ai cần kết quả.
  • C. Ném lỗi biên dịch, vì pipeline phải kết thúc bằng một thao tác kết thúc.
  • D. Không đọc đơn nào cả, vì chưa có thao tác kết thúc nên đường ống chưa chạy.

Đáp án: D. Thao tác trung gian chỉ ghi lại việc cần làm, và dòng này biên dịch bình thường. A và B nhầm rằng các bước chạy ngay. C sai vì thiếu thao tác kết thúc không phải lỗi biên dịch, chỉ là pipeline không làm gì cả.

Khái niệm 4: gom nhóm với groupingBy và counting

Việc thứ ba của chủ quán, đếm số ly mỗi loại, là việc gom nhóm. Collectors là bộ công cụ nói cho collect biết gom theo kiểu gì. Collectors.toList() gom thành danh sách. Collectors.groupingBy(f, ...) chia phần tử thành các nhóm theo khoá f tính ra, và đối số thứ hai nói mỗi nhóm trở thành gì: Collectors.counting() biến nhóm thành số phần tử, kết quả là một Map.

Cách đọc hình: ô trắng bên trái là bốn đơn Latte, Mocha, Latte, Tea. Ô xanh ngọc giữa là groupingBy(drink) chia ra ba nhóm. Ba ô vàng bên phải là ba nhóm: Latte có hai đơn, Mocha một đơn, Tea một đơn. Dòng ghi chú nói rằng thêm counting() thì mỗi nhóm thành một số, và kết quả là một Map như Latte bằng 2, Mocha bằng 1, Tea bằng 1.

Mẫu code đếm theo độ dài tên ở bước cuối của phần Làm cùng mình:

java
Map<Integer, Long> byLength = names.stream()
        .collect(Collectors.groupingBy(s -> s.length(), Collectors.counting()));
Dòng codeMáy làm gìVì sao viết như vậy
Collectors.groupingBy(s -> s.length(), ...)Tính khoá bằng độ dài của từng tên, đưa tên vào nhóm của khoá đóKhoá của nhóm do lambda quyết định
Collectors.counting()Biến mỗi nhóm thành số phần tử của nóKhông có đối số thứ hai thì nhóm sẽ là một List
Map<Integer, Long>Kiểu kết quả: khoá là độ dài, giá trị là số têncounting() luôn cho Long, không phải Integer

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: hai câu truy vấn trên danh sách đơn của quán.

StreamBoard.java

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

public class StreamBoard {
    static class Order {
        final String drink;
        final int priority;
        final int price;

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

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

        // Nguồn, hai thao tác trung gian, rồi một thao tác kết thúc
        List<String> urgentDrinks = orders.stream()
                .filter(o -> o.priority == 1)
                .map(o -> o.drink)
                .collect(Collectors.toList());
        System.out.println("urgent: " + urgentDrinks);

        // mapToInt đổi sang luồng số nguyên để có sum()
        int revenue = orders.stream().mapToInt(o -> o.price).sum();
        System.out.println("revenue: " + revenue + ", source still " + orders.size() + " orders");
    }
}

Predict: hãy dừng lại, lấy giấy nháp và đoán hai dòng in ra. Đặc biệt: đơn khẩn nào được chọn, và orders.size() sau hai pipeline còn bao nhiêu? Đoán xong rồi hãy đọc tiếp.

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

text
urgent: [Mocha, Espresso]
revenue: 165, source still 4 orders

Đọc code từng dòng:

Dòng codeMáy làm gìVì sao viết như vậy
List.of(new Order(...), ...)Tạo danh sách bốn đơn không sửa đượcDữ liệu mẫu chỉ để đọc nên không cần sửa
orders.stream()Mở một stream từ danh sáchĐiểm bắt đầu của pipeline
.filter(o -> o.priority == 1)Giữ đơn khẩnĐơn ưu tiên khác 1 bị loại ở đây
.map(o -> o.drink)Đổi đơn thành tên đồ uốngKết quả cuối cần danh sách tên, không phải danh sách đơn
.collect(Collectors.toList())Chạy cả pipeline, gom tên vào một List mớiThao tác kết thúc, bấm nút cho băng chuyền chạy
orders.stream().mapToInt(o -> o.price).sum()Đổi mỗi đơn thành giá (số nguyên), rồi cộngmapToInt cho luồng số có sẵn sum, 45 cộng 50 cộng 30 cộng 40 bằng 165
orders.size()Vẫn là 4Stream không sửa nguồn

Bây giờ xem từng đơn đi qua đường ống:

Đ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 đơn và khớp với kết quả chạy thật):

ĐơnQua filter?Sau mapTrong kết quả
Latte:2không, ưu tiên 2không tới[]
Mocha:1cóMocha[Mocha]
Tea:3không, ưu tiên 3không tới[Mocha]
Espresso:1cóEspresso[Mocha, Espresso]

Bạn vừa làm được: đọc một pipeline và nói đơn nào bị loại ở bước nào, kết quả cuối là gì, và nguồn không đổi.

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 chạy thế nào?

java
Stream<Order> s = orders.stream();
long n = s.count();
List<Order> all = s.collect(Collectors.toList());
  • A. Chạy tốt: count đọc từ đầu, rồi collect lại đọc từ đầu một lần nữa.
  • B. Ném IllegalStateException ở dòng cuối, vì count đã dùng hết stream này.
  • C. Ném IllegalStateException ở dòng count, vì stream chưa có thao tác trung gian.
  • D. Không biên dịch được, vì một stream không được có hai biến cùng dùng.

Đáp án: B. count() là thao tác kết thúc nên stream s hết hạn, dùng tiếp thì ném IllegalStateException với thông báo stream has already been operated upon or closed. A nhầm stream với danh sách, mà danh sách thì đọc lại được. C sai vì dòng count chạy bình thường. D sai vì đoạn code biên dịch bình thường, lỗi chỉ lộ ra lúc chạy.

Concept Check 3. Stream.of("Tea", "Latte", "Mocha", "Chai").collect(Collectors.groupingBy(String::length, Collectors.counting())) cho ra Map có mấy cặp, và giá trị của khoá 5 là gì?

  • A. 4 cặp, vì mỗi tên là một cặp riêng và giá trị của khoá 5 là 1.
  • B. 3 cặp, và giá trị của khoá 5 là 5 vì khoá 5 là độ dài của tên.
  • C. 3 cặp (khoá 3, 4, 5), và giá trị của khoá 5 là 2 vì có hai tên dài 5 ký tự.
  • D. 2 cặp, vì counting() chỉ đếm các tên có độ dài lẻ.

Đáp án: C. Độ dài là 3 (Tea), 5 (Latte), 5 (Mocha), 4 (Chai), nên có ba khoá và khoá 5 có hai tên. A nhầm gom nhóm với liệt kê. B nhầm khoá với giá trị: giá trị là số tên trong nhóm. D tự bịa quy tắc chẵn lẻ, counting() đếm mọi phần tử trong nhóm.

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

Bạn sẽ dựng dần class Report 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 Report.java rồi chạy bằng java Report.java.

Bước 1: lọc và gom.

java
import java.util.*;
import java.util.stream.*;

public class Report {
    public static void main(String[] args) {
        List<String> names = List.of("Latte", "Tea", "Mocha", "Chai");
        // TODO 1: lọc các tên dài hơn 3 ký tự rồi gom vào một List
        List<String> longNames = null;
        System.out.println("step 1: " + longNames);
    }
}

Chạy xong bạn sẽ thấy: step 1: [Latte, Mocha, Chai].

Bước 2: đổi mỗi phần tử. Thêm dưới dòng in của bước 1:

java
        // TODO 2: đổi mỗi tên thành độ dài của nó, gom vào List
        List<Integer> lengths = null;
        System.out.println("step 2: " + lengths);

Chạy xong bạn sẽ thấy: step 2: [5, 3, 5, 4].

Vẽ ra giấy: vẽ bốn tên chạy qua bước map: Latte thành 5, Tea thành 3, Mocha thành 5, Chai thành 4. Đối chiếu: số phần tử không đổi, chỉ đổi từng phần tử.

Bước 3: sắp xếp và cắt bớt.

java
        List<Integer> nums = List.of(5, 3, 8, 1, 9);
        // TODO 3: lấy 3 số nhỏ nhất theo thứ tự tăng dần
        List<Integer> smallest = null;
        System.out.println("step 3: " + smallest);

Chạy xong bạn sẽ thấy: step 3: [1, 3, 5]. Gợi ý: hai thao tác trung gian tên nghe như "sắp xếp" và "giới hạn".

Bước 4: một con số. Bạn tự quyết cách viết.

java
        // TODO 4: tính tổng nums bằng stream
        int total = 0;
        System.out.println("step 4: " + total);

Chạy xong bạn sẽ thấy: step 4: 26. Gợi ý: mapToInt(n -> n) đổi luồng sang số nguyên có sẵn sum().

Bước 5: đếm theo nhóm.

java
        // TODO 5: đếm số tên theo độ dài bằng groupingBy và counting
        Map<Integer, Long> byLength = null;
        System.out.println("step 5: " + new TreeMap<>(byLength));

Chạy xong bạn sẽ thấy: step 5: {3=1, 4=1, 5=2}. Vẽ ra giấy: vẽ ba nhóm với khoá 3, 4, 5 và các tên rơi vào mỗi nhóm. Đối chiếu: nhóm 5 có hai tên là Latte và Mocha. Lời giải đầy đủ nằm ở cuối bài.

Bạn vừa làm được: tự viết lọc, đổi, sắp xếp, tính tổng và gom nhóm bằng stream. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 13-java8/02-stream 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 stream. 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
s.count(); rồi s.collect(Collectors.toList()); trên cùng stream sIllegalStateException: stream has already been operated upon or closedStream chỉ dùng một lần, count đã dùng hếtTạo stream mới từ nguồn cho mỗi lần cần
Stream.of("a", "a").collect(Collectors.toMap(x -> x, x -> 1))IllegalStateException: Duplicate key a (attempted merging values 1 and 1)Hai phần tử cho cùng một khoá mà không nói cách gộpThêm đối số thứ ba để gộp, hoặc dùng groupingBy
Stream.of("a").toList().add("b")UnsupportedOperationExceptionStream.toList() trả về danh sách không sửa đượcDùng collect(Collectors.toList()) nếu cần sửa, hoặc bọc vào new ArrayList<>(...)
int total = 0; orders.stream().forEach(o -> total += o.price);local variables referenced from a lambda expression must be final or effectively final khi biên dịchLambda không gán lại được biến local của phương thứcorders.stream().mapToInt(o -> o.price).sum()

Lỗi thứ tư là bẫy nguy hiểm nhất vì ngay cả khi lách được (ví dụ cộng vào một mảng), nó vẫn sai về cách nghĩ: stream muốn bạn mô tả kết quả, không phải sửa biến bên ngoài. Hiểu lầm lớn nhất về thứ tự chạy được đặt cạnh nhau ở hình dưới.

Cách đọc hình: hàng trên màu đỏ là mô hình sai: nghĩ filter chạy hết cả bốn đơn rồi map mới chạy, và dự đoán cả bốn đơn đều bị đọc. Hàng dưới màu lục là mô hình đúng: mỗi đơn đi hết các bước rồi mới tới đơn sau, và findFirst dừng sau Mocha, chỉ hai đơn được đọc. Dòng ghi chú cuối hình nêu kết quả đo bằng peek.

Bạn vừa làm được: đọc bốn lỗi phổ biến của stream 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. Vẽ trạng thái đường ống. Dữ liệu vào:

java
Stream.of(1, 2, 3, 4, 5, 6)
        .filter(n -> n % 2 == 0)
        .map(n -> n * n)
        .limit(2)
        .collect(Collectors.toList());

Điền bảng sáu dòng, mỗi dòng một số n: số đó có được đọc không, có qua filter không, giá trị sau map, có vào kết quả không. Kết quả mong đợi: danh sách [4, 16]. Tiêu chí nghiệm thu: bạn chỉ ra được số nào không bao giờ được đọc và giải thích bằng limit. Gợi ý: limit(2) dừng đường ống ngay khi đủ hai phần tử.

Bài 2. Chọn thao tác kết thúc. Dữ liệu vào: năm đơn Latte:2:45, Mocha:1:50, Tea:3:30, Espresso:1:40, Latte:1:45 (đồ uống, ưu tiên, giá). Viết ba câu stream: (a) đếm số đơn có giá trên 40; (b) lấy tên các đồ uống khác nhau theo thứ tự abc, gom vào List; (c) có đơn nào ưu tiên 3 không. Kết quả mong đợi: 3, [Espresso, Latte, Mocha, Tea], true. Tiêu chí nghiệm thu: mỗi câu kết thúc bằng một thao tác kết thúc phù hợp. Gợi ý: có distinct() loại trùng, và một thao tác kết thúc trả boolean có tên bắt đầu bằng any.

Bài 3. Sửa lỗi dùng lại stream. Dữ liệu vào:

java
Stream<Order> s = orders.stream().filter(o -> o.priority == 1);
long n = s.count();
List<Order> list = s.collect(Collectors.toList());

Sửa để cả n và list có giá trị đúng. Kết quả mong đợi: không ném ngoại lệ. Tiêu chí nghiệm thu: bạn giải thích được vì sao dòng thứ ba bị lỗi. Gợi ý: tạo stream mới từ nguồn cho mỗi lần dùng.

Mức Vận dụng

Bài 4. Báo cáo cuối ca cho màn hình bếp. Viết class OrderBoard giữ một List<Order> bên trong, với Order có drink, priority, price:

Phương thứcViệc phải làm
List<String> urgentLabels()Tên đồ uống của các đơn ưu tiên 1, theo thứ tự đã thêm
int revenue()Tổng giá của mọi đơn
Map<String, Long> countByDrink()Số đơn của mỗi đồ uống, in theo thứ tự abc

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

java
OrderBoard board = new OrderBoard();
board.add(new Order("Latte", 2, 45));
board.add(new Order("Mocha", 1, 50));
board.add(new Order("Tea", 3, 30));
board.add(new Order("Espresso", 1, 40));
board.add(new Order("Latte", 1, 45));
System.out.println(board.urgentLabels());
System.out.println(board.revenue());
System.out.println(board.countByDrink());

Kết quả mong đợi:

text
[Mocha, Espresso, Latte]
210
{Espresso=1, Latte=2, Mocha=1, Tea=1}

Tiêu chí nghiệm thu: ba dòng khớp từng ký tự, và không phương thức nào có vòng for hoặc biến tạm cộng dồn. Gợi ý: để in theo thứ tự abc, bọc kết quả của groupingBy vào new TreeMap<>(...).

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

Bài 5. Hai đơn đắt nhất. Dữ liệu vào: năm đơn của Bài 2. Viết một pipeline lấy tên đồ uống của hai đơn có giá cao nhất, theo thứ tự giá giảm dần. Kết quả mong đợi: [Mocha, Latte]. Tiêu chí nghiệm thu: một pipeline duy nhất, không có biến tạm, và bạn nói được thao tác nào dùng Comparator của bài trước. Gợi ý: sorted nhận một Comparator, và limit cắt bớt.

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). Phân biệt thao tác trung gian và thao tác kết thúc. Cho hai ví dụ mỗi loại.

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

java
System.out.println(new TreeMap<>(Stream.of("Tea", "Latte", "Mocha", "Chai")
        .collect(Collectors.groupingBy(String::length, Collectors.counting()))));

Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 2). Đoạn sau in ra những gì, theo thứ tự nào?

java
Stream<Integer> st = Stream.of(1, 2, 3).map(n -> { System.out.print("m" + n + " "); return n * 2; });
System.out.print("start ");
List<Integer> r = st.collect(Collectors.toList());

Câu 4 (tìm lỗi, mục tiêu 4). Đoạn sau ném lỗi ở dòng cuối. Chỉ ra lỗi và sửa.

java
Stream<String> s = names.stream().filter(n -> n.length() > 3);
System.out.println(s.count());
System.out.println(s.collect(Collectors.toList()));

Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn hỏi: "Stream khác gì danh sách? Sao không thấy gì chạy cho tới cuối câu?" Giải thích bằng ví dụ băng chuyền trong bếp.

Điều cần nhớ

  • Một pipeline gồm nguồn, các thao tác trung gian như filter, map, sorted, limit, và một thao tác kết thúc như collect (mục tiêu 1).
  • Thao tác trung gian lazy, chỉ khi có thao tác kết thúc thì đường ống mới chạy, từng phần tử đi hết đường ống, và findFirst hay limit có thể dừng sớm (mục tiêu 2).
  • mapToInt().sum() tính tổng, groupingBy với counting() đếm theo nhóm, và collect gom kết quả (mục tiêu 3).
  • Mỗi stream chỉ dùng một lần, và không cộng dồn vào biến ngoài từ trong lambda mà để thao tác kết thúc tính kết quả (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmCú phápLoạiGhi chú
Mở stream từ danh sáchlist.stream()nguồnKhông sửa danh sách
Giữ phần tử thoả điều kiệnfilter(predicate)trung gianNhận Predicate
Đổi mỗi phần tửmap(function), mapToInt(...)trung gianmapToInt cho luồng số
Sắp xếp, bỏ trùng, cắtsorted(), distinct(), limit(n)trung giansorted nhận được Comparator
Gom thành danh sáchcollect(Collectors.toList())kết thúcKết quả sửa được
Gom theo nhóm và đếmcollect(groupingBy(f, counting()))kết thúcKết quả là Map
Một con sốcount(), sum(), max()kết thúcsum có trên luồng số
Hỏi có hay khônganyMatch(...), findFirst()kết thúcCó thể dừng sớm
Hình: tóm tắt bước stream nào cho việc nào

Cách đọc hình: mỗi hàng là một việc bên trái và bước nên dùng bên phải, kèm loại bước (trung gian hoặc kết thúc). Đọc theo hàng ngang. Dòng ghi chú cuối hình nhắc luôn kết thúc bằng một thao tác kết thúc và không cộng dồn vào danh sách bên ngoài từ trong lambda.

Câu hỏi để dành cho ngày mai: findFirst() trả về Optional<String> chứ không phải String. Nếu không có đơn nào khẩn thì sao: nó trả về null hay một thứ gì khác? 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. Mỗi thao tác trung gian bọc stream trước đó thành một bước mới trong một chuỗi, và thao tác kết thúc kéo từng phần tử qua chuỗi này. Vì vậy peek trong ví dụ Lazy in source Latte rồi source Mocha xen kẽ với kết quả, chứ không in cả bốn dòng trước. Số liệu trong bài thuộc JDK 17.0.15. Một chi tiết nhỏ: count() đôi khi tính được kết quả thẳng từ nguồn mà không chạy đường ống, nên đừng dựa vào peek bên trong một pipeline kết thúc bằng count().

So với lựa chọn khác. Với vài phần tử và một việc đơn giản, vòng for vẫn rõ ràng và nhanh. Stream đáng dùng khi bạn ghép nhiều bước (lọc, đổi, gom). Collectors.toList() cho danh sách sửa được, còn Stream.toList() (từ Java 16) cho danh sách không sửa được [Đã chạy: add ném UnsupportedOperationException]. Luồng số IntStream.rangeClosed(1, 100).sum() cho 5050 [Đã chạy]. flatMap dùng khi mỗi phần tử tạo ra nhiều phần tử, ví dụ tách câu to be và or not thành [to, be, or, not] [Đã chạy].

Hiểu lầm nâng cao. Nhiều người viết forEach(list::add) để gom kết quả vào danh sách bên ngoài. Cách này chạy được ở stream tuần tự, nhưng nó biến lambda thành một thứ có tác dụng phụ (side effect) và sẽ hỏng khi dùng stream song song. Hãy để collect làm việc đó.

Giới hạn của bài. Bài này chưa nói về parallelStream, Collectors.partitioningBy, toMap với hàm gộp, reduce, và stream vô hạn như Stream.iterate. Optional do findFirst trả về sẽ được học ở bài tiếp theo.

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

Bài tiếp theo

Bài sau là Optional: thứ mà findFirst trả về khi có thể không có kết quả, và cách tránh NullPointerException mà không phải kiểm tra null khắp nơi.

Mini-dự án nối tiếp: màn hình bếp sẽ tra "đơn khẩn đầu tiên của khách tên X" có thể không tồn tại, dùng Optional. Hãy giữ lại class OrderBoard có ba câu stream 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
longNames = names.stream().filter(s -> s.length() > 3).collect(Collectors.toList());
lengths = names.stream().map(s -> s.length()).collect(Collectors.toList());
smallest = nums.stream().sorted().limit(3).collect(Collectors.toList());  // sắp rồi cắt 3 số đầu
total = nums.stream().mapToInt(n -> n).sum();  // mapToInt để có sum
byLength = names.stream().collect(Collectors.groupingBy(s -> s.length(), Collectors.counting()));
Dòng codeMáy làm gìLý do chọn cách này
filter(s -> s.length() > 3) rồi collect(toList())Giữ tên dài hơn 3, gom vào Listfilter quyết định giữ, collect kết thúc đường ống
map(s -> s.length())Đổi mỗi tên thành sốSố phần tử không đổi, chỉ đổi từng phần tử
sorted().limit(3)Sắp tăng dần rồi lấy ba số đầuThứ tự quan trọng: limit rồi sorted sẽ cho kết quả khác
mapToInt(n -> n).sum()Đổi sang luồng số nguyên rồi cộngsum có trên luồng số, không có trên Stream<Integer>
groupingBy(..., counting())Chia theo độ dài và đếm mỗi nhómKết quả Map<Integer, Long>, in qua TreeMap để có thứ tự khoá

Lời giải Bài 1. [Đã chạy, kết quả [4, 16]]

nĐược đọc?Qua filter?Sau mapVào kết quả?
1cókhôngkhông tớikhông
2cócó4có
3cókhôngkhông tớikhông
4cócó16có, đủ 2 phần tử
5khôngkhông đọckhông tớikhông
6khôngkhông đọckhông tớikhông

limit(2) dừng đường ống ngay khi có hai phần tử, nên 5 và 6 không bao giờ được đọc (thử bằng peek thấy seen1 seen2 seen3 seen4).

Lời giải Bài 2. [Đã chạy] (a) orders.stream().filter(o -> o.price > 40).count() cho 3. (b) orders.stream().map(o -> o.drink).distinct().sorted().collect(Collectors.toList()) cho [Espresso, Latte, Mocha, Tea]. (c) orders.stream().anyMatch(o -> o.priority == 3) cho true. Mỗi câu kết thúc bằng count, collect, anyMatch, là ba thao tác kết thúc khác nhau.

Lời giải Bài 3. [Đã chạy]

java
long n = orders.stream().filter(o -> o.priority == 1).count();
List<Order> list = orders.stream().filter(o -> o.priority == 1).collect(Collectors.toList());

Dòng thứ ba gốc lỗi vì count() đã dùng hết stream s. Mỗi lần cần, tạo stream mới từ orders, hoặc lưu kết quả vào một List rồi lấy size().

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

java
public List<String> urgentLabels() {
    return orders.stream().filter(o -> o.priority == 1).map(o -> o.drink).collect(Collectors.toList());
}

public int revenue() {
    return orders.stream().mapToInt(o -> o.price).sum();
}

public Map<String, Long> countByDrink() {
    // Bọc vào TreeMap để kết quả in ra theo thứ tự abc
    return new TreeMap<>(orders.stream().collect(Collectors.groupingBy(o -> o.drink, Collectors.counting())));
}
Phương thứcMáy làm gìLý do chọn cách này
urgentLabelsLọc đơn khẩn rồi lấy tênGiữ thứ tự thêm vào vì filter và map không đổi thứ tự
revenueĐổi mỗi đơn thành giá rồi cộngKhông cần biến tạm cộng dồn
countByDrinkGom theo tên đồ uống, đếm mỗi nhóm, rồi đưa vào TreeMapgroupingBy mặc định cho HashMap không bảo đảm thứ tự, nên TreeMap cố định thứ tự abc

Lời giải Bài 5. [Đã chạy, [Mocha, Latte]] Pipeline: orders.stream().sorted(Comparator.comparingInt((Order o) -> o.price).reversed()).limit(2).map(o -> o.drink).collect(Collectors.toList()). sorted nhận Comparator đảo chiều của bài 43, limit(2) lấy hai đơn giá cao nhất là Mocha 50 và Latte 45 (đơn Latte đầu tiên được giữ vì hai đơn Latte cùng giá 45 và sắp xếp ổn định).

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

  1. Thao tác trung gian trả về một stream mới và lazy, ví dụ filter và map. Thao tác kết thúc trả về kết quả thật và làm đường ống chạy, ví dụ collect và count. Kiểm tra mục tiêu 2.
  2. In ra {3=1, 4=1, 5=2} [Đã chạy]. Độ dài 3 có Tea, 4 có Chai, 5 có Latte và Mocha, nên khoá 5 đếm được 2. Kiểm tra mục tiêu 1 và 3.
  3. In ra start m1 m2 m3 [Đã chạy], và r là [2, 4, 6]. Dòng map chỉ ghi lại việc cần làm, nên start in trước. Chỉ khi collect chạy thì m1, m2, m3 mới in. Kiểm tra mục tiêu 2.
  4. Lỗi: s.count() đã dùng hết stream s, nên s.collect(...) ném IllegalStateException. Sửa bằng cách lưu List<String> list = names.stream().filter(...).collect(Collectors.toList()); rồi dùng list.size() và list. Kiểm tra mục tiêu 4.
  5. Danh sách giống như cái khay chứa đơn thật. Stream giống băng chuyền: bạn đặt các bước kiểm tra, đổi nhãn dọc băng chuyền, nhưng băng chuyền chỉ chạy khi bạn bấm nút ở cuối (thao tác kết thúc). Lúc bạn mới lắp các trạm thì chưa có đơn nào đi qua. Khi đã chạy, các đơn đã đi hết và băng chuyền hết hạn. Kiểm tra mục tiêu 2.