mapfilter thì ai cũng hiểu ngay. flatMap thì hầu như ai cũng vấp lần đầu.

Không phải vì nó khó, mà vì tài liệu hay định nghĩa nó bằng chữ "trải phẳng" — không giúp gì cho tới khi bạn gặp đúng tình huống cần.

Nên hôm nay ta đi từ tình huống.

Vấn đề: map cho ra thứ lồng nhau

Có danh sách đơn hàng, mỗi đơn có nhiều mặt hàng. Cần tổng số lượng tất cả mặt hàng.

don.stream().map(DonHang::mon).toList()
  [[Mon[ten=bút, soLuong=2], Mon[ten=vở, soLuong=3]], [Mon[ten=thước, soLuong=1]], []]
  kiểu: List<List<Mon>>

map biến mỗi đơn thành một danh sách, nên kết quả là danh sách của danh sách. Muốn cộng số lượng thì phải lặp hai tầng — đúng thứ Stream sinh ra để tránh.

flatMap giải quyết:

don.stream().flatMap(d -> d.mon().stream()).toList()
  [Mon[ten=bút, soLuong=2], Mon[ten=vở, soLuong=3], Mon[ten=thước, soLuong=1]]
  tổng số lượng: 6

Một tầng. Cộng được ngay.

Khác biệt trong một câu: map biến một phần tử thành một phần tử; flatMap biến một phần tử thành nhiều phần tử (hoặc không phần tử nào).

Chú ý đơn DH-03 có danh sách rỗng — nó đơn giản là biến mất khỏi kết quả. Không cần lọc trước.

Hàm truyền vào phải trả về Stream

Đây là chỗ hay sai cú pháp:

.flatMap(d -> d.mon())          // LỖI: trả về List, không phải Stream
.flatMap(d -> d.mon().stream()) // đúng

Cách nhớ: flatMap nhận một hàm "một phần tử → một stream", rồi nối tất cả các stream đó lại.

Ba tình huống thật

Một: tách chuỗi thành từ

cau.stream().flatMap(s -> Arrays.stream(s.split(" "))).toList()
  map    : 2 mảng
  flatMap: [java, rất, mạnh, stream, tiện, lợi]

split trả về mảng, nên map cho ra stream của mảng. flatMap cộng với Arrays.stream cho ra stream của từng từ.

Đây là bước đầu tiên của mọi bài toán đếm từ, xây chỉ mục, hay tách thẻ.

Hai: bỏ những Optional rỗng

Stream.of("a", "x", "b")
      .map(k -> Optional.ofNullable(kho.get(k)))
      .flatMap(Optional::stream)
      .toList()
  [Anh, Bình]

Optional.stream() cho ra stream 0 hoặc 1 phần tử, nên flatMap vừa mở hộp vừa loại bỏ cái rỗng trong một bước. Ta đã gặp mẫu này ở bài Optional; giờ thì rõ vì sao nó hoạt động.

Cách viết cũ .filter(Optional::isPresent).map(Optional::get) dài hơn và có get().

Ba: đi xuống nhiều tầng dữ liệu

nguoi.stream().flatMap(n -> n.soDienThoai().stream()).toList()
  mọi số: [090, 091, 092]

Bất cứ khi nào dữ liệu của bạn là "một danh sách chứa các đối tượng, mỗi đối tượng lại chứa một danh sách", flatMap là công cụ đúng.

Mẹo: giữ thông tin của cả hai tầng

Nhược điểm hiển nhiên của flatMapmất ngữ cảnh tầng ngoài — có danh sách mặt hàng rồi thì không biết mặt hàng nào thuộc đơn nào.

Cách giải: gọi map bên trong flatMap:

don.stream()
   .flatMap(d -> d.mon().stream().map(m -> d.ma() + " -> " + m.ten()))
   .toList()
  [DH-01 -> bút, DH-01 -> vở, DH-02 -> thước]

Biến d của tầng ngoài vẫn nhìn thấy được bên trong. Đây là mẫu tôi dùng thường xuyên nhất với flatMap, và nó là câu trả lời cho "làm sao ghép hai tầng thành các cặp".

mapMulti: bản thay thế từ Java 16

don.stream().<Mon>mapMulti((d, xuat) -> d.mon().forEach(xuat)).toList()
  [Mon[ten=bút, soLuong=2], Mon[ten=vở, soLuong=3], Mon[ten=thước, soLuong=1]]

Thay vì trả về một Stream, bạn nhận một hàm xuat và gọi nó cho từng phần tử muốn phát ra.

Ưu điểm: không phải tạo một Stream trung gian cho mỗi phần tử. Khi mỗi phần tử chỉ sinh ra 0–1 phần tử con, chi phí dựng stream lấn át phần việc thật, và mapMulti nhanh hơn rõ.

Nhược điểm: khó đọc hơn, và phải chỉ định kiểu tường minh (.<Mon>mapMulti) vì trình biên dịch không suy ra được.

Lời khuyên: mặc định dùng flatMap; đổi sang mapMulti chỉ khi đã đo và thấy đáng.

filter và hai họ hàng ít dùng

  takeWhile : [1, 2, 3]
  dropWhile : [10, 4, 5]
  filter    : [1, 2, 3, 4]

Cùng đầu vào [1, 2, 3, 10, 4, 5] với điều kiện x < 5.

filter giữ mọi phần tử thoả điều kiện, rải rác ở đâu cũng lấy.

takeWhile lấy từ đầu và dừng ngay khi gặp phần tử không thoả — nên số 4 và 5 phía sau bị bỏ.

dropWhile bỏ từ đầu cho tới khi gặp phần tử không thoả, rồi giữ hết phần còn lại.

Hai cái sau có từ Java 9, và chúng đúng thứ bạn cần khi dữ liệu đã có thứ tự: đọc file log cho tới dòng đầu tiên vượt quá một mốc thời gian, bỏ phần tiêu đề của một file CSV, cắt một chuỗi số tăng dần.

Trên nguồn vô hạn thì takeWhile là cách dừng tự nhiên, thay cho limit với một con số đoán mò.

Vài lưu ý khi dùng

Đặt filter trước map khi được — bài hôm qua đã đo, nó giảm hẳn số lần gọi hàm biến đổi.

filter không đổi kiểu, map thì có. Nếu thấy mình viết filter rồi ép kiểu ở map, hãy nghĩ tới mapMulti hoặc instanceof pattern trong một flatMap.

Đừng nhét tác dụng phụ vào map hay filter. Chúng có thể được gọi số lần khác bạn nghĩ — đúng như chuyện peek không chạy khi count() biết trước số phần tử ở bài hôm qua.

flatMap trên stream con cần đóng. Nếu hàm bên trong mở file (Files.lines), flatMap sẽ tự đóng từng stream con sau khi tiêu thụ xong. Đây là một trong số ít chỗ Stream tự quản lý tài nguyên.

Thử ba mươi giây

Lấy một List<List<String>> bất kỳ, thử .stream().map(x -> x).toList() rồi in kiểu ra.

Sau đó đổi thành .flatMap(List::stream) và in lại. Một bên là danh sách của danh sách, một bên là danh sách phẳng — và bạn sẽ nhận ra flatMap chỉ đơn giản là "gỡ bớt một tầng".

Ngày mai: CollectorstoList, joining, groupingBy, partitioningBy, và cách gom nhóm nhiều tầng chỉ bằng cách lồng chúng vào nhau.