Java Goldへの道:紫本「JavaストリームAPI」学習備忘録

はじめに

このブログ記事は”Oracle Certified Java Programmer, Gold SE 11認定資格(Java Gold SE11)”合格に向けた学習備忘録のまとめです。

< この学習備忘録について >
内容や重要な点が抜けていた場合には適宜更新していきます

Java Gold合格において重要な単元

Java Goldの学習においては、下記の単元が非常に重要となってきます。

  • 関数型インタフェース
  • ラムダ式
  • JavaストリームAPI
  • 並列処理

今回は太字になっている部分、つまり「JavaストリームAPI」について私がこの章の学習で特に重要だと感じたポイントや、つまずきやすかった箇所を、具体的なコード例とともに備忘録としてまとめていきます。

私と同じようにJava Goldの合格を目指して勉強されている皆様にとって、少しでもお役に立てれば幸いです。

Stream APIの基本概念:中間操作と終端操作

Stream APIの核となるのは、「中間操作」と「終端操作」という概念です。これらを理解することが、Stream APIを使いこなす第一歩となります。

中間操作

filter()map()distinct()peek()などが該当します。

  • これらの操作は**遅延評価(Lazy Evaluation)**されます。つまり、中間操作が呼び出された時点では実際の処理は行われず、終端操作が呼び出された時点で初めて一連の処理が実行されます。
  • 戻り値は常に**Streamオブジェクト**を返します。そのため、複数のをチェーン(メソッド連結)して記述できます。
  • 一度終端操作が呼び出された後には、中間操作を呼び出すことはできません。

peek()メソッドの役割

中間操作の一つであるpeek()は、その名の通り、ストリームの要素がパイプラインを流れる途中で、その要素を「のぞき見する」ようなイメージのメソッドです。
デバッグ用途で、中間操作が期待通りに動いているかを確認したい場合などに便利です。

コード例(peek()メソッドの使用方法)

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class PeekExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

        names.stream()
             .filter(s -> s.startsWith("A")) // 中間操作: "A"で始まる要素をフィルタリング
             .peek(s -> System.out.println("フィルタリング後: " + s)) // 中間操作: のぞき見
             .map(String::toUpperCase) // 中間操作: 大文字に変換
             .peek(s -> System.out.println("大文字変換後: " + s)) // 中間操作: のぞき見
             .collect(Collectors.toList()); // 終端操作: リストに収集
    }
}
// 出力例:
// フィルタリング後: Alice
// 大文字変換後: ALICE

forEach()と似ていますが、forEach()は終端操作であるため、ストリームの最後にしか使えず、その後に別の中間・終端操作を続けることはできません。一方peek()は中間操作なので、パイプラインの途中に挟み込んで、処理の過程を確認することができます。

終端操作

forEach()collect()count()reduce()anyMatch()などが該当します。

  • 終端操作はストリームパイプラインを「閉じ」、中間操作で定義された処理を実際に実行します。
  • 戻り値はStreamオブジェクトではなく、処理結果(例: ListMapbooleanlongOptionalなど)を返します。
  • 終端操作が実行されると、そのストリームは消費され、再利用できなくなります(詳細は後述)。

ストリームの生成と無限ストリームへの注意点

Stream APIでは様々な方法でストリームを生成できますが、特にgenerate()iterate()を使う際には注意が必要です。

主なストリーム生成メソッド

  • Stream.generate(Supplier<T> s)
    ┗ 引数にサプライヤー(引数を取らず戻り値を返す関数型インタフェース)を取り、無限に要素を生成し続けます。
     ┗ 例: Stream.generate(() -> "orange") は、常に”orange”という文字列を生成し続けます。
  • Stream.iterate(T seed, UnaryOperator<T> f)
  • Stream.iterate(T seed, Predicate<? super T> hasNext, UnaryOperator<T> next)
    ┗ 初期値(seed)と、次の要素を生成するための関数(f)または条件(hasNext)を指定して、無限に要素を生成し続けます。
     ┗ 例: Stream.iterate("1", n -> n + "1") は、”1″, “11”, “111”, …と文字列を生成し続けます。

コード例(無限生成)

import java.util.function.*;
import java.util.stream.*;
public class Test {
  public static void main(String[] args) {
    Predicate<? super String> p = s -> s.length() < 5;
    Stream<String> stream = Stream.generate(() -> "orange");
    System.out.print(stream.anyMatch(p) + " ");
  }
}
// 出力結果:コンパイルは成功するが無限ループでfalseが出力され続ける
// 理由:
// 1. streamは"orange"という文字列を終端操作がないと永遠に生成し続ける。
// 2. stream.anyMatch(p)のpで文字数を判断(p.test()メソッド)するが、"orange"は6文字なのでfalseを返す。
// 3. generateされ続ける"orange"はanyMatchでtrueを返さないため終端処理が終わらない。

この例では、anyMatch()trueを返す要素を見つけるまで無限に要素を生成し続けるため、プログラムが終了しません。anyMatch()は短絡評価(Short-circuiting)を行う終端操作なので、もし条件を満たす要素が一つでも見つかればそこで処理を終了しますが、このケースでは見つからないため無限に探索し続けることになります。

コード例(終端操作がない)

import java.util.stream.*;

public class Test {
  public static void main(String[] args) {
    var stream = Stream.iterate("1", n -> n + "1");
    System.out.println(stream.limit(5).filter(s -> s.length() < 3));
  }
}
// 出力結果:Streamオブジェクト
// 理由:終端操作がないため、中間操作の戻り値であるStreamオブジェクトが返されて、それがsysoutされる。

この例は無限ループには陥りませんが、limit()filter()は中間操作であるため、最終的な結果を得るためにはforEach()collect()などの終端操作を呼び出す必要があります。終端操作がなければ、単にStreamオブジェクトが返されるだけです。

その他のストリーム生成メソッド

  • IntStream.range(int startInclusive, int endExclusive)
     startInclusiveからendExclusive一つ前までint値を生成します。
     ┗ 例: IntStream.range(1, 10) → 1, 2, …, 9
  • IntStream.rangeClosed(int startInclusive, int endInclusive)
    ┗ startInclusiveからendInclusiveまでを生成します(endInclusiveも含む)。
     ┗ 例: IntStream.rangeClosed(1, 10) → 1, 2, …, 10
  • Stream.empty(): 空のストリームを生成します。
    ┗ データが存在しない可能性があるメソッドの戻り値としてnullを返す代わりに利用することで、NullPointerExceptionのリスクを軽減し、コードの堅牢性を高めることができます。

Streamの再利用不可とIllegalStateException

Stream APIで特に注意が必要なのが、一度終端操作を実行したストリームインスタンスは再利用できないという点です。再利用しようとするとIllegalStateExceptionがスローされます。

コード例(一つのストリームに複数の終端操作)

import java.util.stream.*;
import java.util.function.*;
public class Test {
  public static void main(String[] args) {
    var stream = Stream.iterate(10, s -> s <= 50, n -> n + 10);
    boolean ans = stream.anyMatch(s -> s%30 == 0); // 一つ目の終端操作
    long count = stream.count(); // 一つのストリームに対する二つ目の終端操作、NG
    System.out.println(ans + " " + count);
  }
}
// 出力結果:IllegalStateExceptionがスローされる
// 理由:一つのストリームインスタンスに対して複数の終端操作を実行しようとしたため。

この問題を回避するためには、終端操作を実行するたびに新しいストリームを生成する必要があります。

コード例(修正版)

import java.util.stream.*;
import java.util.function.*;

public class TestFixed {
    public static void main(String[] args) {
        // stream1で最初の終端操作を実行
        Stream<Integer> stream1 = Stream.iterate(10, s -> s <= 50, n -> n + 10);
        boolean ans = stream1.anyMatch(s -> s % 30 == 0); // true (30が見つかるため)

        // stream2を新たに生成して2つ目の終端操作を実行
        Stream<Integer> stream2 = Stream.iterate(10, s -> s <= 50, n -> n + 10);
        long count = stream2.count(); // 5 (10, 20, 30, 40, 50)

        System.out.println(ans + " " + count); // 出力: true 5
    }
}

プリミティブ型ストリームとOptional

Stream<T>の他に、intlongdoubleといったプリミティブ型に特化した
IntStream
・LongStream
DoubleStream
があります。これらはオートボクシング/アンボクシングのオーバーヘッドを避けるために用意されています。

これらのプリミティブ型ストリームの終端操作には、
OptionalInt
OptionalLong
OptionalDouble
といったOptionalのプリミティブ型特化版が戻り値として使われることがあります。

OptionalXXX型とそのメソッド

OptionalIntOptionalDoubleOptionalLongは、結果が「存在しない可能性がある」場合にnullを返す代わりに、その状態を明示的に示すためのラッパークラスです。これにより、NullPointerExceptionのリスクを減らし、コードの堅牢性を向上させることができます。

  • getAsXXX()メソッド
    Optionalオブジェクトが保持している値を取り出すためのメソッドです。
    Optional<T>の場合: get()
    OptionalIntの場合: getAsInt()
    OptionalLongの場合: getAsLong()
    OptionalDoubleの場合: getAsDouble()
    注意:これらのメソッドを安全に使うためには、必ず事前にisPresent()で値の存在を確認する必要があります。値がない状態で呼び出すとNoSuchElementExceptionがスローされます。
  • ifPresent()メソッド
    もしOptionalオブジェクトが値を保持しているならば、その値を使って指定されたアクション(Consumer)を実行するメソッドです。値がない場合は何も実行しません。
    これにより、if (op.isPresent()) { ... } のような記述をせずに済むため、より簡潔で安全なコードを書くことができます。

コード例(穴埋め)

import java.util.stream.*;
import java.util.*;
public class Test {
  public static void main(String[] args) {
    LongStream stream = LongStream.of(1, 2, 3);
    OptionalLong op = stream.map(n -> n * 2) // 2, 4, 6
                             .filter(n -> n < 5) // 2, 4
                             .findFirst(); // 2
    // 【 1 】
  }
}

【 1 】に入るのは、以下のいずれか

  1. if(op.isPresent()) System.out.println(op.getAsLong()); (安全な値の取得)
  2. op.ifPresent(System.out::println); (より簡潔な値の存在チェックと処理)
    どちらも正しい出力結果をもたらしますが、ifPresent()の方が簡潔に記述できるため、推奨されることが多いです。

collect()メソッドとコレクタの活用

collect()メソッドは、ストリームの要素をリストやマップなどのコレクションに集約する強力な終端操作です。特にCollectorsクラスの静的メソッドは、多様な集約処理をサポートします。

Collectors.groupingBy()Collectors.partitioningBy()

これらのコレクタは、ストリームの要素を特定の基準でグループ化するために使われます。

Collectors.groupingBy(Function classifier)

指定された分類関数(classifier)に基づいて要素をグループ化し、Map<分類キー, List<要素>>として結果を返します。

コード例
import java.util.stream.*;
import java.util.*;
public class Test {
  public static void main(String[] args) {
    Set<Double> set = IntStream.of(2, 2, 2) // IntStream [2, 2, 2]
                             .mapToDouble(x -> x) // DoubleStream [2.0, 2.0, 2.0]
                             .boxed() // Stream<Double> [2.0, 2.0, 2.0]
                             .collect(Collectors.groupingBy(x -> x)) // Map<Double, List<Double>> {2.0 = [2.0, 2.0, 2.0]}
                             .keySet(); // Set<Double> [2.0]
    System.out.println(set);
  }
}
// 出力結果:[2.0]

この例では、groupingBy(x -> x)Double型の値2.0をキーとして要素をグループ化し、そのMapkeySet()を取得することで、重複が取り除かれた[2.0]というSetが得られます。

Setのオブジェクトを System.out.println() で出力すると、通常はSetの toString() メソッドが呼び出され、その要素が角括弧 [] で囲まれて表示されます。

Collectors.partitioningBy(Predicate predicate)

指定された条件(Predicate)に基づいて要素をtruefalseの2つのグループに分割します。
戻り値の型は常にMap<Boolean, List<T>>です。

コード例
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class PartitioningByExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
        Map<Boolean, List<Integer>> partitionedNumbers = 
            numbers.stream()
                   .collect(Collectors.partitioningBy(n -> n % 2 == 0)); // 偶数か否かで分割

        System.out.println("分割結果: " + partitionedNumbers); // 出力: {false=[1, 3, 5], true=[2, 4, 6]}
        List<Integer> evenNumbers = partitionedNumbers.get(true); // 偶数: [2, 4, 6]
        List<Integer> oddNumbers = partitionedNumbers.get(false); // 奇数: [1, 3, 5]
    }
}

partitioningBy()は特定の真偽値に基づいた二分化に特化しており、シンプルな条件でのグループ化に非常に便利です。

reduce()メソッドによる集約

reduce()メソッドは、ストリームの要素を単一の結果に集約するための終端操作です。例えば、要素の合計値や最大値などを計算する際に使われます。

reduce()にはいくつかのオーバーロードがあります。

  • Optional reduce(BinaryOperator accumulator)
    初期値なしでreduceを実行。ストリームが空の場合、結果はOptional.empty()となります。
    戻り値がOptional<T>になるため、結果を取得するにはget()ifPresent()などのOptionalメソッドを使用する必要があります。(後述のリスト2)
  • T reduce(T identity, BinaryOperator accumulator)
    初期値(identity)を指定してreduceを実行します。ストリームが空の場合でも、初期値が返されるため、結果はOptionalではありません。(後述のリスト1)

コード例(穴埋め)

import java.util.*;
import java.util.stream.*;
import java.util.function.*;
class User {
  String name;
  double point;
  User(String name, double point) {
    this.name = name;
    this.point = point;
  }
  public String getName() { return name; }
  public double getPoint() { return point; }
}

public class Test {
  public static void main(String[] args) {
    List<User> users = Arrays.asList(
                       new User("tanaka", 10.0),
                       new User("sato", 15.0));
    BinaryOperator<Double> ope = (a, b) -> a + b;
    // 【 1 】
    System.out.println(total);
  }
}

【 1 】に入るのは、以下のいずれか

  1. double total = users.stream().map(u -> u.getPoint() * 2).reduce(0.0, ope);
    初期値0.0を指定しているため、戻り値は直接double型になります。
  2. double total = users.stream().map(u -> u.getPoint() * 2).reduce(ope).get();
    初期値なしでreduceを実行するため、戻り値はOptional<Double>になります。そのため、get()メソッドで値を取り出す必要があります

flatMap()によるストリームの平坦化

flatMap()は、ストリームの各要素を別のストリームに変換し、それらのストリームをすべて結合して一つの新しいストリームを生成する中間操作です。ネストされたコレクションの要素を一つのストリームに「平坦化」したい場合に非常に役立ちます。

コード例(2つのリストを1つのリストに)

import java.util.stream.*;
import java.util.*;
public class Test {
  public static void main(String[] args) {
    List<Character> list1 = Arrays.asList('a', 'c');
    List<Character> list2 = Arrays.asList('y', 'a');
    Arrays.asList(list1,list2).stream()
                          .flatMap(List::stream) // List<Character>をCharacterのStreamに平坦化
                          .distinct() // 'a', 'c', 'y', 'a' -> 'a', 'c', 'y'
                          .forEach(System.out::println);
// 出力結果:
// a
// c
// y

flatMap(List::stream)とすることで、List<Character>のストリームがCharacterのストリームに変換され、
その結果distinct()が個々の文字の重複を取り除きます。

まとめ

紫本の第6章「Stream API」を通して、Javaにおけるデータ処理の新しい形を深く学ぶことができました。中間操作と終端操作の区別、遅延評価の概念、そしてストリームが一度しか消費できない特性など、Java Goldの試験だけでなく、実務でも非常に重要な知識ばかりです。
ちなみに、繰り返し処理といえばfor文やwhile文が一番初めに想像されますが、イテレータを使って繰り返し処理させることもできるんです!!!

引き続き、Stream APIの奥深さを探求し、より効率的で読みやすいコードを書けるよう精進していきます。Java Gold合格を目指す皆さん、一緒に頑張りましょう!

櫟原侑祐

 櫟原侑祐

スキル:Java/SpringBoot/Bootstrap/Thymeleaf/HTML/CSS/JavaScript/TypeScript/Angular/MySQL/Eclipse

コメント

この記事へのコメントはありません。

関連記事