Stream API: map, filter, reduce và collect qua ví dụ

1. Tổng quan

Stream API xuất hiện từ Java 8 và trở thành công cụ không thể thiếu khi xử lý collection. Bài này giúp bạn nắm chắc 4 operation quan trọng nhất: map, filter, reduce và collect thông qua ví dụ cụ thể với Order và Customer. Đọc xong, bạn sẽ biết khi nào dùng operation nào, cách kết hợp chúng, và tránh được vài lỗi phổ biến khi làm việc với stream.

2. Chuẩn bị dữ liệu mẫu

Trước khi đi vào từng operation, bạn cần một bộ dữ liệu để thao tác xuyên suốt bài viết.

public record Order(String id, String product, int quantity, double price, String status) {
    public double total() {
        return quantity * price;
    }
}
import java.util.List;

public class OrderData {
    public static List<Order> sample() {
        return List.of(
            new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
            new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
            new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
            new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
            new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
        );
    }
}

Order là một record – gọn, immutable, phù hợp để demo stream. Phương thức total() tính tổng tiền của từng order, sẽ dùng lại nhiều lần bên dưới.

3. filter: lọc phần tử theo điều kiện

filter nhận vào một Predicate<T>, giữ lại những phần tử thỏa điều kiện.

List<Order> completedOrders = OrderData.sample().stream()
    .filter(order -> order.status().equals("COMPLETED"))
    .toList();

completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
  • stream() chuyển List<Order> thành một Stream<Order>.
  • filter chỉ giữ lại các order có status là COMPLETED.
  • toList() (Java 16+) gom kết quả thành một List immutable, thay cho collect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor

Lưu ý: filter không thay đổi dữ liệu gốc. Stream là lazy – nếu bạn không có bước terminal operation (như toList(), forEach()), các filter/map phía trước sẽ không chạy.

3.1. Kết hợp nhiều điều kiện filter

List<Order> expensiveCompleted = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .toList();

Hai filter liên tiếp tương đương với điều kiện AND. Bạn có thể viết thành một filter với &&, nhưng tách riêng giúp code dễ đọc khi điều kiện phức tạp.

4. map: biến đổi phần tử

map nhận vào một Function<T, R>, biến mỗi phần tử kiểu T thành một phần tử kiểu R.

List<String> productNames = OrderData.sample().stream()
    .map(Order::product)
    .distinct()
    .toList();

System.out.println(productNames);
  • Order::product là method reference, tương đương order -> order.product().
  • distinct() loại bỏ phần tử trùng lặp (dựa trên equals()).
[Laptop, Mouse, Keyboard, Monitor]

4.1. map trả về kiểu khác hoàn toàn

Điểm mạnh của map là biến đổi kiểu dữ liệu, không chỉ lấy ra một field.

List<Double> totals = OrderData.sample().stream()
    .map(Order::total)
    .toList();

System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]

Lưu ý: Đừng nhầm map với forEach. map trả về stream mới, không có side effect; forEach dùng để thực thi hành động (in ra, lưu DB…) và không trả về gì.

5. reduce: gộp nhiều phần tử thành một giá trị

reduce tổng hợp toàn bộ stream thành một giá trị duy nhất, dựa trên identity (giá trị khởi đầu) và một BinaryOperator.

double totalRevenue = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .map(Order::total)
    .reduce(0.0, Double::sum);

System.out.println(totalRevenue);
  • 0.0 là identity – giá trị khởi đầu khi stream rỗng.
  • Double::sum là accumulator, cộng dồn từng giá trị vào kết quả.
1.8950000E7

5.1. reduce không có identity

Nếu không muốn cung cấp identity, reduce trả về Optional<T> vì stream có thể rỗng.

Optional<Order> mostExpensive = OrderData.sample().stream()
    .reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);

mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7

Lưu ý: Với nhu cầu tìm max/min, Java cung cấp sẵn Stream.max(Comparator) và Stream.min(Comparator) – ngắn và rõ ý hơn reduce. Chỉ dùng reduce khi logic gộp phức tạp hơn so sánh đơn giản.

6. collect: thu thập kết quả vào cấu trúc dữ liệu

collect linh hoạt hơn toList(), cho phép gom kết quả vào Map, Set, hoặc thực hiện groupBy/partition.

6.1. Gom vào Map

import java.util.Map;
import java.util.stream.Collectors;

Map<String, Double> totalByOrderId = OrderData.sample().stream()
    .collect(Collectors.toMap(Order::id, Order::total));

System.out.println(totalByOrderId);
  • Collectors.toMap nhận hai hàm: key extractor và value extractor.
  • Nếu có key trùng, toMap sẽ throw IllegalStateException – cần truyền thêm merge function nếu dữ liệu có khả năng trùng key.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}

6.2. groupingBy – nhóm theo điều kiện

Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(Order::status));

ordersByStatus.forEach((status, orders) ->
    System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)

6.3. groupingBy kết hợp downstream collector

Bạn có thể tính tổng doanh thu theo từng nhóm status, không cần nhóm xong rồi loop lại.

Map<String, Double> revenueByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(
        Order::status,
        Collectors.summingDouble(Order::total)
    ));

System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}

groupingBy với downstream collector (summingDouble, counting, averagingDouble…) giúp bạn tránh phải tự viết reduce thủ công cho từng nhóm.

7. Kết hợp cả bốn operation trong một pipeline

Đây là ví dụ thực tế: lấy danh sách tên sản phẩm của các order đã hoàn thành, có giá trị > 1 triệu, sắp xếp giảm dần theo tổng tiền.

List<String> topProducts = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .sorted(Comparator.comparingDouble(Order::total).reversed())
    .map(Order::product)
    .toList();

System.out.println(topProducts);
[Laptop, Monitor]

Thứ tự operation ở đây quan trọng: filter trước để giảm số phần tử cần xử lý, sorted sau khi đã lọc để tránh sort dư thừa, map ở cuối để chỉ lấy field cần.

8. Viết test cho pipeline

Luôn viết test cho logic nghiệp vụ nằm trong stream pipeline, đừng để nó “ẩn” trong một dòng code dài mà không ai kiểm chứng.

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderAnalyticsTest {

    @Test
    void shouldCalculateTotalRevenueForCompletedOrders() {
        double totalRevenue = OrderData.sample().stream()
            .filter(o -> o.status().equals("COMPLETED"))
            .map(Order::total)
            .reduce(0.0, Double::sum);

        assertThat(totalRevenue).isEqualTo(18_950_000.0);
    }

    @Test
    void shouldGroupOrdersByStatus() {
        var grouped = OrderData.sample().stream()
            .collect(java.util.stream.Collectors.groupingBy(Order::status));

        assertThat(grouped)
            .containsKey("COMPLETED")
            .extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
            .hasSize(3);
    }
}

Test đầu tiên kiểm tra kết quả reduce đúng tổng tiền. Test thứ hai kiểm tra groupingBy tạo ra đúng số lượng phần tử trong nhóm COMPLETED.

9. Lỗi hay gặp

  • Dùng stream khi loop thường rõ hơn: nếu logic có nhiều side effect (log, update biến ngoài, throw exception có điều kiện phức tạp), vòng for truyền thống dễ đọc hơn stream.
  • Tái sử dụng một Stream đã consume: stream chỉ chạy được một lần. Gọi collect() hoặc forEach() hai lần trên cùng một stream sẽ throw IllegalStateException: stream has already been operated upon or closed.
  • Quên identity phù hợp trong reduce: dùng sai identity (ví dụ 1 thay vì 0 khi tính sum) sẽ cho kết quả sai nhưng không báo lỗi gì.
  • toMap với key trùng: như đã nói ở mục 6.1, cần thêm merge function: Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1).
  • Side effect trong map: tránh thay đổi state bên ngoài trong hàm truyền vào map/filter. Stream có thể chạy parallel, side effect sẽ gây race condition khó debug.

Lưu ý: Nếu pipeline của bạn có hơn 4-5 operation nối tiếp và khó đọc, hãy tách thành các phương thức riêng có tên rõ nghĩa (filterCompleted, calculateRevenue…) thay vì nhồi tất cả vào một chain dài.

10. Kết luận

filter, map, reduce và collect là bộ công cụ nền tảng để xử lý dữ liệu theo phong cách functional trong Java. filter giúp bạn chọn lọc, map biến đổi, reduce tổng hợp thành một giá trị, còn collect linh hoạt gom kết quả vào cấu trúc phức tạp hơn như Map hay nhóm theo điều kiện. Nắm vững thứ tự đặt operation và tránh các lỗi phổ biến ở trên, bạn sẽ viết được pipeline xử lý dữ liệu ngắn gọn, rõ ý và dễ test.

javastream-apifunctional-programmingjava21collections