1. Giới thiệu
Hãy hình dung chúng ta có một danh sách sinh viên, và mỗi sinh viên có một danh sách sở thích của họ. Ví dụ:
List<List<String>> hobbies = List.of(
List.of("Bơi lội", "Cờ vua"),
List.of("Bóng đá"),
List.of("Lập trình", "Đọc sách", "Phim")
);
Nhiệm vụ của bạn: lấy một danh sách thống nhất của tất cả sở thích để biết sinh viên quan tâm điều gì. Hợp lý là thử dùng map:
List<Stream<String>> streams = hobbies.stream()
.map(list -> list.stream())
.collect(Collectors.toList());
Chúng ta nhận được gì? Một danh sách các luồng! Tức là Stream<Stream<String>>. Đây không hẳn là thứ ta cần — ta muốn một Stream<String> đơn giản để làm việc trực tiếp với từng sở thích.
Hãy tưởng tượng bạn có một chiếc hộp, bên trong là các hộp khác chứa đồ chơi. Phương thức map chỉ lấy từng hộp ra và đưa cho bạn chiếc hộp (Stream<Stream<String>>). Còn bạn muốn thấy ngay tất cả đồ chơi (Stream<String>) mà không phải lục lọi trong các hộp.
2. flatMap: cách “trải phẳng” các bộ sưu tập lồng nhau
Để “mở hộp” và nhận một luồng phẳng, bạn cần phương thức flatMap.
Nó nhận một hàm mà với mỗi phần tử trả về một luồng, và ngay lập tức “làm phẳng” tất cả các luồng lồng nhau thành một luồng duy nhất.
Cú pháp của flatMap
Stream<T> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)
Nói ngắn gọn: flatMap kỳ vọng bạn trả về một luồng (Stream) cho mỗi phần tử, và nó sẽ “san phẳng” tất cả thành một luồng lớn duy nhất.
Ví dụ: hợp nhất tất cả sở thích của sinh viên
List<String> allHobbies = hobbies.stream()
.flatMap(list -> list.stream())
.collect(Collectors.toList());
System.out.println(allHobbies);
// [Bơi lội, Cờ vua, Bóng đá, Lập trình, Đọc sách, Phim]
Với mỗi danh sách sở thích, chúng ta gọi list.stream() (nhận luồng các sở thích của một sinh viên). Còn flatMap “làm phẳng” tất cả các luồng thành một luồng lớn — giờ chúng ta thấy tất cả sở thích thay vì các luồng sở thích.
Sơ đồ trực quan
hobbies.stream()
|
|---> [Bơi lội, Cờ vua] -> stream
|---> [Bóng đá] -> stream
|---> [Lập trình, ...] -> stream
|
flatMap: gộp tất cả vào một Stream<String>
Tại sao map không giải quyết được bài toán?
Nếu dùng map, bạn sẽ nhận Stream<Stream<String>>, và làm việc với nó khá bất tiện: chẳng hạn, không thể duyệt ngay tất cả chuỗi; bạn phải thực hiện các vòng lặp bổ sung.
3. Ví dụ thực tế khi dùng flatMap
Tách chuỗi thành ký tự (List<String> → Stream<Character>)
Giả sử bạn có một danh sách chuỗi và muốn lấy một luồng của tất cả ký tự xuất hiện trong tất cả chuỗi:
List<String> words = List.of("Java", "Stream");
List<Character> characters = words.stream()
.flatMap(word -> word.chars().mapToObj(ch -> (char) ch))
.collect(Collectors.toList());
System.out.println(characters);
// [J, a, v, a, S, t, r, e, a, m]
Giải thích:
- word.chars() trả về IntStream (luồng các mã ký tự).
- mapToObj chuyển mã thành ký tự.
- flatMap gộp tất cả các luồng ký tự thu được thành một.
Làm việc với Optional: Stream<Optional<T>> → Stream<T>
Giả sử bạn có một danh sách các đối tượng Optional, và bạn muốn nhận một luồng chỉ gồm các giá trị thực sự hiện diện:
List<Optional<String>> optionals = List.of(
Optional.of("Java"),
Optional.empty(),
Optional.of("Stream")
);
List<String> present = optionals.stream()
.flatMap(opt -> opt.stream())
.collect(Collectors.toList());
System.out.println(present);
// [Java, Stream]
Mẹo:
Optional<T> (từ Java 9) có phương thức stream(), phương thức này trả về hoặc luồng rỗng, hoặc luồng gồm một phần tử. flatMap gộp tất cả các giá trị không rỗng vào một luồng.
4. Phương thức mới mapMulti: khi flatMap không cần thiết
Vì sao mapMulti ra đời?
Xuất hiện trong Java 16, mapMulti hoạt động tương tự flatMap, nhưng hiệu quả và linh hoạt hơn một chút.
flatMap là công cụ mạnh mẽ, nhưng có một nhược điểm: với mỗi phần tử, bạn buộc phải tạo một Stream mới, ngay cả khi bạn chỉ muốn trả về 0, 1 hoặc vài phần tử. Điều này có thể kém hiệu quả, nhất là khi bạn chỉ muốn “trải phẳng” phần tử mà không cần tạo các bộ sưu tập hay luồng trung gian.
mapMulti là phiên bản cải tiến của flatMap, cho phép thêm trực tiếp các giá trị cần thiết vào luồng kết quả thông qua Consumer, không tạo cấu trúc trung gian. Thay vì trả về một luồng, bạn chỉ định trực tiếp những phần tử cần thêm vào luồng kết quả qua Consumer.
Cú pháp của mapMulti
<R> Stream<R> mapMulti(BiConsumer<? super T, ? super Consumer<R>> mapper)
Với mỗi phần tử, hàm mapper được gọi; nó nhận chính phần tử và một Consumer để bạn có thể “đẩy” các giá trị mới vào.
Ví dụ: lọc và trải phẳng trong một lượt
Giả sử bạn có một danh sách số, và bạn muốn với số chẵn thì thêm vào luồng hai lần, còn số lẻ — không thêm lần nào (tức là vừa lọc vừa “trải phẳng” đồng thời):
List<Integer> numbers = List.of(1, 2, 3, 4);
List<Integer> result = numbers.stream()
.mapMulti((number, consumer) -> {
if (number % 2 == 0) {
consumer.accept(number);
consumer.accept(number); // Thêm hai lần
}
// Không làm gì với số lẻ (lọc)
})
.collect(Collectors.toList());
System.out.println(result);
// [2, 2, 4, 4]
So sánh với flatMap
Bài toán tương tự qua flatMap sẽ viết như sau:
List<Integer> result = numbers.stream()
.flatMap(number -> number % 2 == 0
? Stream.of(number, number)
: Stream.empty())
.collect(Collectors.toList());
Ở đây bạn vẫn buộc phải tạo Stream.of(...) hoặc Stream.empty() cho từng phần tử, dù điều đó có thể kém hiệu quả.
5. Khi nào dùng flatMap, khi nào dùng mapMulti?
- map — khi bạn biến đổi mỗi phần tử thành đúng 1 phần tử.
- flatMap — khi bạn biến đổi mỗi phần tử thành một luồng các phần tử.
- mapMulti — khi bạn muốn phát sinh nhiều phần tử mà không tạo luồng trung gian (hiệu quả hơn, đặc biệt trong các vòng lặp nóng).
Ví dụ nữa: trải phẳng Map<Integer, List<String>> thành Stream<Pair(Integer, String)>
Giả sử ta có một map: id → danh sách sở thích.
Map<Integer, List<String>> studentHobbies = Map.of(
1, List.of("Bơi lội", "Cờ vua"),
2, List.of("Bóng đá"),
3, List.of("Lập trình", "Đọc sách", "Phim")
);
List<String> allHobbies = studentHobbies.values().stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
System.out.println(allHobbies);
// [Bơi lội, Cờ vua, Bóng đá, Lập trình, Đọc sách, Phim]
Còn nếu chúng ta muốn nhận các cặp (id, hobby):
List<String> pairs = studentHobbies.entrySet().stream()
.flatMap(entry -> entry.getValue().stream()
.map(hobby -> entry.getKey() + ": " + hobby))
.collect(Collectors.toList());
System.out.println(pairs);
// [1: Bơi lội, 1: Cờ vua, 2: Bóng đá, 3: Lập trình, 3: Đọc sách, 3: Phim]
Với mapMulti (Java 16+):
List<String> pairs = studentHobbies.entrySet().stream()
.mapMulti((entry, consumer) -> {
for (String hobby : entry.getValue()) {
consumer.accept(entry.getKey() + ": " + hobby);
}
})
.collect(Collectors.toList());
Ưu điểm:
mapMulti không tạo các luồng trung gian, mà “đẩy” giá trị trực tiếp vào luồng kết quả.
Minh họa: flatMap và mapMulti
| Phương thức | Hàm trả về gì | Cách kết hợp | Có bộ sưu tập trung gian không? |
|---|---|---|---|
|
Một phần tử | Đơn giản | Không |
|
Luồng (Stream) | Gộp lại | Có (các Stream trung gian) |
|
Consumer (0..n lần) | Gộp lại | Không (thêm trực tiếp) |
6. Các lỗi thường gặp khi làm việc với flatMap và mapMulti
Lỗi số 1: Nhận Stream<Stream<T>> thay vì Stream<T>. Sinh viên thường dùng map thay vì flatMap khi làm việc với các bộ sưu tập của bộ sưu tập. Kết quả là phải viết thêm các vòng lặp dư thừa.
Lỗi số 2: Kiểu trả về không đúng. Với flatMap, hàm phải trả về Stream, chứ không phải List hay mảng.
Lỗi số 3: Kém hiệu quả. Trong các trường hợp đơn giản, flatMap hoạt động rất tốt, nhưng nếu với mỗi phần tử bạn phải tạo Stream.of hoặc Stream.empty(), điều đó có thể là dư thừa. Với các tác vụ như vậy, nên dùng mapMulti.
Lỗi số 4: mapMulti không hoạt động ở các phiên bản Java cũ. mapMulti chỉ xuất hiện từ Java 16. Nếu JDK của bạn cũ hơn, phương thức này sẽ không khả dụng.
Lỗi số 5: Vấn đề với null. Đừng trả về null từ flatMap — luôn trả về Stream.empty() cho các trường hợp “rỗng”.
Lỗi số 6: Bộ sưu tập trung gian. Đừng tạo các danh sách hay luồng dư thừa nếu có thể thêm phần tử trực tiếp qua consumer trong mapMulti.
GO TO FULL VERSION