Java Stream API 完全ガイド:もうforループには戻れない!

この記事から得られる知識

  • Java Stream APIの基本的な概念とメリット
  • Streamの生成方法、中間操作、終端操作の具体的な使い方
  • 従来のforループと比較した際のStream APIの優位性
  • `filter`, `map`, `sorted`など、データを加工する様々な中間操作
  • `collect`, `reduce`, `forEach`など、処理結果を取得する終端操作の詳細
  • `Collectors`ユーティリティクラスを活用した高度な集約方法
  • パフォーマンス向上に繋がる並列ストリームの概念と注意点

はじめに:Java Stream APIとは?

Java 8(2014年3月リリース)で導入されたStream APIは、Javaにおけるコレクション(リスト、セット、マップなど)や配列のデータ処理を大きく変革しました。 従来のforループやイテレータを使った命令的なコードに比べ、Stream APIは宣言的で、より直感的かつ簡潔にデータ処理を記述することができます。

例えば、リストの中から特定の条件を満たす要素だけを抽出し、その結果を新しいリストとして受け取りたい場合を考えてみましょう。

従来のforループ

List<String> list = Arrays.asList("apple", "apricot", "banana", "blueberry");
List<String> result = new ArrayList<>();
for (String s : list) { if (s.startsWith("a")) { result.add(s.toUpperCase()); }
}

Stream API

List<String> list = Arrays.asList("apple", "apricot", "banana", "blueberry");
List<String> result = list.stream() .filter(s -> s.startsWith("a")) .map(String::toUpperCase) .collect(Collectors.toList());

一目瞭然なように、Stream APIを使うことで「何をしたいか」が明確になり、コードの可読性が大幅に向上します。このAPIは、データソース、0個以上の中間操作、1つの終端操作の3つの要素から構成されるパイプラインを構築して処理を行います。

  • データソース: コレクション、配列、I/Oチャネルなど。
  • 中間操作: データのフィルタリング、変換、ソートなどを行い、新しいStreamを返します。 中間操作は遅延評価され、終端操作が呼び出されるまで実行されません。
  • 終端操作: パイプラインの処理を実際に開始し、最終的な結果(コレクション、値、または副作用)を生成します。 終端操作が実行されると、そのStreamは消費され、再利用できなくなります。

第1章: Streamの生成方法

Stream APIを利用するには、まずデータソースからStreamを生成する必要があります。生成方法は多岐にわたります。

1. コレクションから生成

ListSetなど、java.util.Collectionインターフェースを実装するクラスはstream()メソッドを持ちます。これが最も一般的な生成方法です。

List<String> list = Arrays.asList("Java", "Stream", "API");
Stream<String> stream = list.stream(); // 逐次ストリーム
Stream<String> parallelStream = list.parallelStream(); // 並列ストリーム

2. 配列から生成

java.util.Arraysクラスのstream()メソッドを使用します。

String[] array = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(array);
int[] intArray = {1, 2, 3};
IntStream intStream = Arrays.stream(intArray); // プリミティブ型用のIntStream

3. Streamクラスのstaticファクトリメソッド

Streamクラス自体も、Streamを生成するための便利なstaticメソッドを提供しています。

メソッド説明コード例
Stream.of(T... values)任意個数の要素からStreamを生成します。
Stream<String> stream = Stream.of("one", "two", "three");
Stream.iterate(T seed, UnaryOperator<T> f)初期値(seed)から始まり、関数を繰り返し適用して無限シーケンスを生成します。limit()と組み合わせて使うことが一般的です。
Stream<Integer> numbers = Stream.iterate(0, n -> n + 2).limit(5); // 0, 2, 4, 6, 8
Stream.generate(Supplier<T> s)指定されたSupplierから値を取得して無限シーケンスを生成します。
Stream<Double> randoms = Stream.generate(Math::random).limit(3);
Stream.empty()空のStreamを生成します。
Stream<String> emptyStream = Stream.empty();
IntStream.range(int start, int end)指定された範囲の整数シーケンスを生成します(終端を含まない)。
IntStream.range(1, 5); // 1, 2, 3, 4
IntStream.rangeClosed(int start, int end)指定された範囲の整数シーケンスを生成します(終端を含む)。
IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5

第2章: 中間操作 – Streamの加工

中間操作は、Streamの要素をフィルタリングしたり、変換したりして、新しいStreamを返します。メソッドチェーンで複数の操作を連結できます。

filter(Predicate<T> predicate)

引数として渡されたPredicate(条件式)がtrueを返す要素のみを含む新しいStreamを返します。

List<String> names = List.of("Alice", "Bob", "Charlie", "Anna");
names.stream() .filter(name -> name.startsWith("A")) .forEach(System.out::println); // Alice, Anna

map(Function<T, R> mapper)

引数として渡されたFunction(関数)を各要素に適用し、その結果からなる新しいStreamを返します。要素の型を変更することも可能です。

List<String> words = List.of("java", "stream", "api");
words.stream() .map(String::length) // .map(s -> s.length()) と同じ .forEach(System.out::println); // 4, 6, 3

flatMap(Function<T, Stream<R>> mapper)

mapと似ていますが、各要素がStreamに変換される場合に使います。結果として得られる複数のStreamを一つのフラットなStreamに統合します。

List<List<Integer>> listOfLists = List.of(List.of(1, 2), List.of(3, 4, 5));
// [,] を に変換
List<Integer> flatList = listOfLists.stream() .flatMap(Collection::stream) .collect(Collectors.toList());
System.out.println(flatList);

sorted() / sorted(Comparator<T> comparator)

要素をソートします。引数なしの場合は自然順序で、Comparatorを渡すと指定した順序でソートされます。

List<String> fruits = List.of("Banana", "Apple", "Cherry");
// 自然順序(アルファベット順)でソート
fruits.stream().sorted().forEach(System.out::println); // Apple, Banana, Cherry
// 文字列の長さでソート
fruits.stream() .sorted(Comparator.comparingInt(String::length)) .forEach(System.out::println); // Apple, Banana, Cherry (順序は変わる可能性がある)
注意: sorted()はステートフルな中間操作です。すべての要素を内部バッファに保持してからソートを行うため、非常に大きなデータセットに対してはメモリを大量に消費する可能性があります。

distinct()

重複した要素を排除します。要素のequals()メソッドに基づいて重複を判定します。

Stream.of("A", "B", "A", "C", "B") .distinct() .forEach(System.out::println); // A, B, C (順序は保証される)

limit(long maxSize) / skip(long n)

limitはStreamを最初のmaxSize個の要素に切り詰め、skipは最初のn個の要素をスキップします。

IntStream.range(1, 11) // 1から10までのストリーム .skip(3) // 最初の3つをスキップ -> 4, 5, 6, 7, 8, 9, 10 .limit(4) // 先頭から4つを取得 -> 4, 5, 6, 7 .forEach(System.out::println);

Java 9で追加された中間操作

2017年9月にリリースされたJava 9では、さらに便利な中間操作が追加されました。

  • takeWhile(Predicate<T> predicate): 条件がtrueである間の要素を取得し、falseになった時点で処理を打ち切ります。
  • dropWhile(Predicate<T> predicate): 条件がtrueである間の要素を破棄し、falseになった時点以降のすべての要素を返します。
// takeWhile: 最初の偶数でない要素が出てくるまで取得
List<Integer> numbers1 = List.of(2, 4, 6, 7, 8, 10);
numbers1.stream() .takeWhile(n -> n % 2 == 0) .forEach(System.out::println); // 2, 4, 6
// dropWhile: 最初の偶数でない要素が出てくるまで破棄
List<Integer> numbers2 = List.of(2, 4, 6, 7, 8, 10);
numbers2.stream() .dropWhile(n -> n % 2 == 0) .forEach(System.out::println); // 7, 8, 10

第3章: 終端操作 – 結果の取得

中間操作によって加工されたStreamから最終的な結果を取り出すのが終端操作の役割です。終端操作を実行すると、パイプライン全体の処理がトリガーされます。

ループ処理: forEach(Consumer<T> action)

各要素に対して指定された処理を実行します。戻り値はなく、副作用(画面出力など)を目的として使用されることが多いです。

List.of("One", "Two", "Three").stream().forEach(System.out::println);
ヒント: 並列ストリームで順序を保証してループ処理したい場合は forEachOrdered() を使用します。

集約: collect(Collector<T, A, R> collector)

Streamの要素を集約して、コレクションや単一の値にまとめる、非常に強力で柔軟な終端操作です。通常、java.util.stream.Collectorsクラスのファクトリメソッドと組み合わせて使用します。

Collectorsのメソッド説明コード例
toList() / toSet()要素をListやSetに集約します。
List<String> list = Stream.of("a", "b").collect(Collectors.toList());
toMap(keyMapper, valueMapper)要素をMapに集約します。キーと値をどのように生成するかを関数で指定します。
Map<String, Integer> map = Stream.of("a", "bb", "ccc") .collect(Collectors.toMap(s -> s, String::length)); // {"a"=1, "bb"=2, "ccc"=3}
joining(delimiter)文字列のStreamを連結して一つの文字列にします。
String joined = Stream.of("A", "B", "C").collect(Collectors.joining(", ")); // "A, B, C"
groupingBy(classifier)指定した分類関数に基づいて要素をグループ化し、Mapを生成します。SQLのGROUP BYに似ています。
Map<Integer, List<String>> byLength = Stream.of("apple", "banana", "kiwi") .collect(Collectors.groupingBy(String::length)); // {5=[apple], 6=[banana], 4=[kiwi]}
partitioningBy(predicate)述語(Predicate)に基づいて要素をtrueとfalseの2つのグループに分割します。
Map<Boolean, List<Integer>> partition = IntStream.range(1, 6).boxed() .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=, true=}
counting() / summingInt() / averagingInt()要素数、合計値、平均値を計算します。
long count = Stream.of(1,2,3).collect(Collectors.counting()); // 3

Java 16以降では、より簡潔にリストを生成できる .toList() という終端操作も追加されました。

// Java 16+
List<String> list = Stream.of("a", "b").toList(); // .collect(Collectors.toList()) と同等

集約: reduce()

要素を順次結合して、単一の結果を生成します。合計値や最大値の計算などに使われます。

// 合計値を計算
Optional<Integer> sumOpt = Stream.of(1, 2, 3, 4, 5).reduce(Integer::sum);
System.out.println(sumOpt.get()); // 15
// 初期値ありの合計値計算
int sum = Stream.of(1, 2, 3, 4, 5).reduce(0, Integer::sum);
System.out.println(sum); // 15
注意: 初期値なしでreduceを実行した場合、Streamが空の可能性があるため、結果はOptionalでラップされます。

マッチング

Streamの要素が特定の条件に一致するかどうかを判定します。短絡評価され、結果が確定した時点で処理を停止します。

  • anyMatch(predicate): いずれか1つでも条件に一致すればtrue。
  • allMatch(predicate): すべての要素が条件に一致すればtrue。
  • noneMatch(predicate): すべての要素が条件に一致しなければtrue。
List<Integer> numbers = List.of(2, 4, 6, 8, 9);
boolean hasOdd = numbers.stream().anyMatch(n -> n % 2 != 0); // true
boolean allEven = numbers.stream().allMatch(n -> n % 2 == 0); // false

検索: findFirst() / findAny()

Streamから要素を1つ見つけます。結果はOptionalで返されます。

  • findFirst(): Streamの最初の要素を返します。順序が重要な場合に使用します。
  • findAny(): いずれかの要素を返します。並列ストリームではパフォーマンス上有利になることがあります。
Optional<String> firstA = List.of("Banana", "Apple", "Apricot") .stream() .filter(s -> s.startsWith("A")) .findFirst();
firstA.ifPresent(System.out::println); // Apple

第4章: プリミティブ型Streamの活用

Javaには、int, long, doubleといったプリミティブ型に対応した専用のStream(IntStream, LongStream, DoubleStream)が用意されています。 これらを使用することで、ラッパークラス(Integer, Long, Double)への変換(ボクシング)とプリミティブ型への変換(アンボクシング)に伴うパフォーマンス上のオーバーヘッドを避けることができます。

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// Stream<Integer> を IntStream に変換
IntStream intStream = numbers.stream() .mapToInt(Integer::intValue);
// IntStream には便利な集約メソッドが用意されている
int sum = intStream.sum(); // 15
OptionalDouble avg = numbers.stream().mapToInt(Integer::intValue).average();
OptionalLong max = numbers.stream().mapToLong(Integer::longValue).max();

プリミティブ型Streamには、sum(), average(), max(), min(), summaryStatistics() といった便利な集約メソッドが用意されており、簡潔に統計計算を行えます。

IntSummaryStatistics stats = IntStream.of(10, 20, 30, 40, 50).summaryStatistics();
System.out.println(stats);
// IntSummaryStatistics{count=5, sum=150, min=10, average=30.000000, max=50}

第5章: 並列Stream (Parallel Streams)

Stream APIの強力な機能の一つが、簡単な記述で処理を並列化できることです。コレクションのparallelStream()メソッドを呼び出すか、既存の逐次ストリームに対してparallel()中間操作を呼び出すだけで、並列Streamを取得できます。

// parallelStream() で最初から並列ストリームを生成
long count = largeList.parallelStream() .filter(n -> n % 2 == 0) .count();
// 既存のストリームを並列化
long count2 = largeList.stream() .parallel() .filter(n -> n % 2 == 0) .count();

並列Streamは、内部的にJava 7で導入されたFork/Joinフレームワークを使用して、処理を複数のスレッドに分割して実行します。 これにより、マルチコアCPUの性能を最大限に引き出し、特に大規模なデータセットに対する計算量の多い処理を高速化できる可能性があります。

並列Streamの注意点

並列処理は常に高速化を保証するものではなく、いくつかの重要な注意点があります。
  • オーバーヘッド: データを分割し、スレッドを管理し、結果を結合するのにはコストがかかります。データ量が少なかったり、個々の処理が非常に軽量だったりすると、逐次処理よりもかえって遅くなることがあります。
  • スレッドセーフティ: 並列Streamの操作内で共有された可変状態(例: 外部のリストに要素を追加する)を扱う場合、その状態へのアクセスがスレッドセーフであることを保証しなければなりません。 `ArrayList`のようなスレッドセーフでないコレクションへの副作用は、予期せぬ結果やエラーを引き起こす可能性があります。 最も良い方法は、副作用を避け、collectなどのステートレスな終端操作を使用することです。
  • 処理順序: forEachなどの操作では、要素の処理順序は保証されません。 順序が重要な場合はforEachOrderedを使用しますが、パフォーマンスは低下します。
  • ステートフルな中間操作: sorted()distinct()のようなステートフルな操作は、並列実行の効率を下げる可能性があります。

並列Streamは強力なツールですが、その特性をよく理解し、パフォーマンス測定を行った上で、適切に利用することが重要です。


まとめ

Java Stream APIは、現代的なJavaプログラミングに不可欠な要素です。従来のforループによる命令的な処理から、より宣言的で、読みやすく、保守しやすいコードスタイルへと移行することを可能にします。

最初はラムダ式やメソッド参照に戸惑うかもしれませんが、一度慣れてしまえば、その表現力の高さと簡潔さに魅了されるはずです。フィルタリング、マッピング、ソート、集約といった一般的なデータ処理タスクを、流れるようなメソッドチェーンでエレガントに記述できるStream APIをぜひ活用してみてください。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です