Stream API: map、filter、reduce、collectを例で学ぶ

1. 概要

Stream APIはJava 8から登場し、collectionを処理する際に欠かせないツールになりました。このガイドでは、4つの重要なoperation:map、filter、reduce、collectをOrderとCustomerの具体例を通じてマスターするのを手助けします。読み終わる頃には、各operationをいつ使うべきか、それらを組み合わせる方法、そしてstreamで作業する際の一般的な落とし穴を回避する方法を理解しているでしょう。

2. サンプルデータの準備

各operationを掘り下げる前に、記事全体を通じて操作するデータセットが必要です。

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はrecordです。シンプルでimmutableで、streamのデモに適しています。total()メソッドは各orderの合計金額を計算し、以下で何度も使用されます。

3. filter: 条件に基づいて要素をフィルタリング

filterはPredicate<T>を受け取り、条件を満たす要素を保持します。

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

completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
  • stream()はList<Order>をStream<Order>に変換します。
  • filterはstatusがCOMPLETEDのorderのみを保持します。
  • toList()(Java 16+)は結果をimmutableなListに集約し、collect(Collectors.toList())の代わりになります。
O1 - Laptop
O2 - Mouse
O4 - Monitor

注意: filterは元のデータを変更しません。Streamはlazy - terminal operation(toList()、forEach()など)がない場合、前のfilter/mapは実行されません。

3.1. 複数のfilter条件を組み合わせる

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

連続する2つのfilterはAND条件と同等です。単一のfilterで&&を使用することもできますが、条件が複雑な場合は分割すればコードが読みやすくなります。

4. map: 要素を変換する

mapはFunction<T, R>を受け取り、各要素を型Tから型Rに変換します。

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

System.out.println(productNames);
  • Order::productはmethod referenceで、order -> order.product()と同等です。
  • distinct()はequals()に基づいて重複要素を削除します。
[Laptop, Mouse, Keyboard, Monitor]

4.1. mapが完全に異なる型を返す場合

mapの強力な点は、フィールドを抽出するだけでなくデータ型を変換することです。

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

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

注意: mapとforEachを混同しないでください。mapは新しいstreamを返し副作用がありません。forEachはアクション(出力、DB保存など)を実行するために使用され、何も返しません。

5. reduce: 複数の要素を1つの値に集約する

reduceはstream全体を1つの値に集約し、identity(初期値)と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はidentity - streamが空の場合の初期値です。
  • Double::sumはaccumulatorで、各値を結果に累積します。
1.8950000E7

5.1. identityなしのreduce

identityを提供したくない場合、streamが空の可能性があるためreduceはOptional<T>を返します。

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

注意: max/minを見つけるニーズがある場合、JavaはStream.max(Comparator)とStream.min(Comparator)を提供しており、reduceより簡潔で明確です。単純な比較よりも複雑な集約ロジックがある場合にのみreduceを使用してください。

6. collect: 結果をデータ構造に集約する

collectはtoList()より柔軟で、結果をMap、Setに集約したり、groupBy/partitionを実行できます。

6.1. 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は2つの関数を受け取ります:key extractorとvalue extractorです。
  • keyが重複している場合、toMapはIllegalStateExceptionをスローします。データがkeyの重複の可能性がある場合は、merge functionを追加で渡す必要があります。
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}

6.2. groupingBy – 条件に基づいてグループ化する

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とdownstream collectorの組み合わせ

各statusグループの合計収益を計算でき、グループ化後に再度loopする必要がありません。

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}

downstream collector(summingDouble、counting、averagingDoubleなど)を使ったgroupingByは、各グループに対して手動でreduceを書く必要を回避します。

7. 4つのoperationすべてを1つのpipelineで組み合わせる

これは実際の例です:完了したorder、値が100万を超える、合計金額で降順にソートされた製品名のリストを取得します。

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]

ここでoperationの順序は重要です:処理する要素の数を減らすために最初にfilter、不要なsortを避けるためにフィルタリング後にsorted、最後に必要なフィールドのみを取得するためにmap。

8. pipelineのテストを書く

streamパイプライン内のビジネスロジックに対して常にテストを書き、誰も検証しない長いコード行に「隠れ」たままにしないでください。

<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);
    }
}

最初のテストはreduceの結果が正しい合計金額であることを確認します。2番目のテストはgroupingByがCOMPLETEDグループで正しい数の要素を作成していることを確認します。

9. 一般的なエラー

  • streamの代わりにloopの方が明確なことがあります:ロジックに多くの副作用がある場合(ログ、外部変数の更新、複雑な条件でのexception throw)、従来のforloopはstreamより読みやすいです。
  • すでに消費されたStreamの再利用:streamは1回だけ実行できます。同じstreamに対してcollect()またはforEach()を2回呼び出すと、IllegalStateException: stream has already been operated upon or closedがスローされます。
  • reduceで適切なidentityを忘れる:誤ったidentityを使用する(例えば、合計計算時に0の代わりに1)と、結果は間違いますがエラーメッセージはありません。
  • toMapでkeyが重複する:6.1節で述べたように、merge functionを追加する必要があります:Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1)。
  • mapでの副作用:map/filterに渡される関数内で外部状態を変更するのを避けてください。Streamは並列実行できるため、副作用は競合状態を引き起こし、デバッグが困難になります。

注意: pipelineに4~5個以上の連続したoperationがあり読みにくい場合は、すべてを長いchainに詰め込むのではなく、明確な名前を持つ個別のメソッド(filterCompleted、calculateRevenueなど)に分割してください。

10. 結論

filter、map、reduce、collectはJavaで関数型スタイルでデータを処理するための基本的なツール群です。filterは選別を、mapは変換を、reduceは値への集約を、collectはMapのような複雑な構造やグループ化への柔軟な集約を支援します。operationの順序を習得し、上記の一般的なエラーを回避することで、簡潔で明確で、テストしやすいデータ処理pipelineを書くことができます。

javastream-apifunctional-programmingjava21collections