Hình dung một thùng hàng, mở ra thì bên trong là mấy cái túi, mỗi túi lại đựng vài món. map là mở thùng ra và đưa cho bạn mấy cái túi — bạn vẫn phải tự thò tay vào từng túi. flatMap là dốc hết mọi túi ra một cái khay phẳng, rồi đưa bạn cái khay đó. Chỉ vậy thôi. Nhưng phải nhìn thấy cái thùng lồng túi trước, còn nghe chữ "trải phẳng" thì chẳng hình dung ra gì.

map và filter 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 flatMap là mấ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.

Muốn cầm nắm chuyện "gỡ một tầng" cho chắc thì thử ngay bằng tay: lấy một List<List<String>> bất kỳ, chạy .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 — nhìn hai kết quả cạnh nhau là flatMap thôi hết bí ẩn.

Mẫu số chung

flatMap không phải phát minh của Java, và cái tên khó chịu của nó che mất một sự thật đẹp: đây là một trong những phép toán nền tảng nhất của lập trình hàm, có mặt gần như ở mọi ngôn ngữ, chỉ đổi tên.

Nghĩ theo mô hình "hàm sinh ra một tập, rồi gộp mọi tập lại" thì bạn nhận ra nó ở khắp nơi:

  • JavaScript có array.flatMap() và array.flat() từ ES2019 — sinh sau Java, cùng ý.
  • Python không có tên riêng nhưng dùng itertools.chain.from_iterable(map(f, xs)), hoặc một comprehension hai tầng [y for x in xs for y in f(x)] — chính là flatMap viết bằng cú pháp khác.
  • Rust gọi thẳng .flat_map() trên iterator; Scala và Kotlin cũng dùng đúng tên flatMap.
  • Ở cơ sở dữ liệu, họ hàng của nó là CROSS JOIN LATERAL hay unnest: mỗi dòng cha bung ra nhiều dòng con rồi hoà chung vào một bảng phẳng — hệt cái khay ở đầu bài, chỉ nói bằng SQL.

Chỗ sâu hơn: trong lý thuyết, flatMap chính là phép bind của monad — cùng một hình dạng "lấy giá trị trong hộp, áp hàm sinh ra hộp mới, rồi làm phẳng một tầng". Optional.flatMap, Stream.flatMap, và CompletableFuture.thenCompose là đúng một khái niệm mặc ba cái tên. Không cần thuộc chữ "monad" để dùng, nhưng một khi thấy cùng một hình lặp lại ở ba chỗ tưởng chẳng liên quan, bạn sẽ không bao giờ vấp flatMap nữa — và đó mới là thứ đáng mang theo.

Ngày mai: Collectors — toList, 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.