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,collecttrê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(),groupingByvớicounting()để 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:
// 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 code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
orders.stream() | Tạo một stream đọc từ danh sách | Mọ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ếp | peek 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 1 | Chỉ ghi lại việc cần làm, chưa chạy |
Câu đầu không có gì sau filter | Không thao tác kết thúc nào được gọi | Vì 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ống | Chạy cho từng đơn đi qua filter |
.findFirst() | Lấy phần tử đầu tiên có mặt, rồi dừng | Thao 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]:
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ước | Việc xảy ra | Số đơn đã đọc | Dòng in ra |
|---|---|---|---|
| 1 | Dựng đường ống không có thao tác kết thúc | 0 | built, nothing ran |
| 2 | findFirst đọc Latte:2, filter loại | 1 | source Latte |
| 3 | findFirst đọc Mocha:1, qua filter, map lấy Mocha, dừng | 2 | source Mocha |
| 4 | Tea và Espresso không được đọc | 2 trên 4 | first: 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
filtercho cả bốn đơn, cònmapthì đợ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:
Map<Integer, Long> byLength = names.stream()
.collect(Collectors.groupingBy(s -> s.length(), Collectors.counting()));
| Dòng code | Má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ên | counting() 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
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]:
urgent: [Mocha, Espresso]
revenue: 165, source still 4 orders
Đọc code từng dòng:
| Dòng code | Má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 được | Dữ 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ống | Kế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ới | Thao 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ộng | mapToInt 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à 4 | Stream 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):
| Đơn | Qua filter? | Sau map | Trong kết quả |
|---|---|---|---|
| Latte:2 | không, ưu tiên 2 | không tới | [] |
| Mocha:1 | có | Mocha | [Mocha] |
| Tea:3 | không, ưu tiên 3 | không tới | [Mocha] |
| Espresso:1 | có | 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?
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ồicollectlạ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òngcount, 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.
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:
// 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.
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.
// 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.
// 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ỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
s.count(); rồi s.collect(Collectors.toList()); trên cùng stream s | IllegalStateException: stream has already been operated upon or closed | Stream chỉ dùng một lần, count đã dùng hết | Tạ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ộp | Thêm đối số thứ ba để gộp, hoặc dùng groupingBy |
Stream.of("a").toList().add("b") | UnsupportedOperationException | Stream.toList() trả về danh sách không sửa được | Dù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ịch | Lambda không gán lại được biến local của phương thức | orders.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:
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:
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ức | Việ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:
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:
[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ì?
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?
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.
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à
findFirsthaylimitcó thể dừng sớm (mục tiêu 2). mapToInt().sum()tính tổng,groupingByvớicounting()đếm theo nhóm, vàcollectgom 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àm | Cú pháp | Loại | Ghi chú |
|---|---|---|---|
| Mở stream từ danh sách | list.stream() | nguồn | Không sửa danh sách |
| Giữ phần tử thoả điều kiện | filter(predicate) | trung gian | Nhận Predicate |
| Đổi mỗi phần tử | map(function), mapToInt(...) | trung gian | mapToInt cho luồng số |
| Sắp xếp, bỏ trùng, cắt | sorted(), distinct(), limit(n) | trung gian | sorted nhận được Comparator |
| Gom thành danh sách | collect(Collectors.toList()) | kết thúc | Kết quả sửa được |
| Gom theo nhóm và đếm | collect(groupingBy(f, counting())) | kết thúc | Kết quả là Map |
| Một con số | count(), sum(), max() | kết thúc | sum có trên luồng số |
| Hỏi có hay không | anyMatch(...), findFirst() | kết thúc | Có thể dừng sớm |
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):
- The Java Tutorials: Aggregate Operations
- Javadoc: package java.util.stream
- Javadoc: java.util.stream.Stream
- Javadoc: java.util.stream.Collectors
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]:
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 code | Má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 List | filter 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ố đầu | Thứ 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ộng | sum có trên luồng số, không có trên Stream<Integer> |
groupingBy(..., counting()) | Chia theo độ dài và đếm mỗi nhóm | Kế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 map | Vào kết quả? |
|---|---|---|---|---|
| 1 | có | không | không tới | không |
| 2 | có | có | 4 | có |
| 3 | có | không | không tới | không |
| 4 | có | có | 16 | có, đủ 2 phần tử |
| 5 | không | không đọc | không tới | không |
| 6 | không | không đọc | không tới | khô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]
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]
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ức | Máy làm gì | Lý do chọn cách này |
|---|---|---|
urgentLabels | Lọc đơn khẩn rồi lấy tên | Giữ 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ộng | Không cần biến tạm cộng dồn |
countByDrink | Gom theo tên đồ uống, đếm mỗi nhóm, rồi đưa vào TreeMap | groupingBy 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.
- Thao tác trung gian trả về một stream mới và lazy, ví dụ
filtervàmap. Thao tác kết thúc trả về kết quả thật và làm đường ống chạy, ví dụcollectvàcount. Kiểm tra mục tiêu 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. - In ra
start m1 m2 m3[Đã chạy], vàrlà[2, 4, 6]. Dòngmapchỉ ghi lại việc cần làm, nênstartin trước. Chỉ khicollectchạy thìm1,m2,m3mới in. Kiểm tra mục tiêu 2. - Lỗi:
s.count()đã dùng hết streams, nêns.collect(...)némIllegalStateException. Sửa bằng cách lưuList<String> list = names.stream().filter(...).collect(Collectors.toList());rồi dùnglist.size()vàlist. Kiểm tra mục tiêu 4. - 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.