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ểnList<Order>thành mộtStream<Order>.filterchỉ giữ lại các order cóstatuslàCOMPLETED.toList()(Java 16+) gom kết quả thành mộtListimmutable, thay chocollect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor
Lưu ý:
filterkhô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ácfilter/mapphí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::productlà method reference, tương đươngorder -> order.product().distinct()loại bỏ phần tử trùng lặp (dựa trênequals()).
[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
mapvớiforEach.maptrả về stream mới, không có side effect;forEachdù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.0là identity – giá trị khởi đầu khi stream rỗng.Double::sumlà 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ơnreduce. Chỉ dùngreducekhi 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.toMapnhận hai hàm: key extractor và value extractor.- Nếu có key trùng,
toMapsẽ throwIllegalStateException– 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
fortruyề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ặcforEach()hai lần trên cùng một stream sẽ throwIllegalStateException: stream has already been operated upon or closed. - Quên identity phù hợp trong reduce: dùng sai identity (ví dụ
1thay vì0khi 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.