😺

ストリームAPIまとめ(前編)[Java入門]

に公開

はじめに

こんにちは。
プログラミング初心者wakinozaと申します。
Java勉強中に調べたことを記事にまとめています。

十分気をつけて執筆していますが、なにぶん初心者が書いた記事なので、理解が浅い点などあるかと思います。
間違い等あれば、指摘いただけると助かります。

記事を参考にされる方は、初心者の記事であることを念頭において、お読みいただけると幸いです。

対象読者

  • Javaを勉強中の方
  • Java SE11 Gold試験を勉強中の方
  • JavaのStream APIについてざっくり知りたい方

目次

1. ストリームAPIとは
2. 「生成」「中間操作」「終端操作」
3. ストリームAPIの記述方法
4. プリミティブ型を扱う
5. ストリームAPIの注意点

本文

1. ストリームAPIとは

ストリームAPIとは、配列やコレクションなどのデータソースから目的のデータを得るための仕組みです。

その特徴は、大きく分けて2つあります。
1、複数の処理を連結させるパイプライン処理を行えること
2、パイプライン処理を簡潔に記述できること

まず、1つ目の特徴は「パイプライン処理」です。
ストリームAPIは、内部的には、データソースの要素を1つずつ取り出し、「パイプライン処理」に送り込みます。「パイプライン処理」とは、複数の処理を連結する仕組みです。ある処理の出力が次の処理の入力となるように処理を連結することで、複数の処理を組み合わせた複雑な処理が実現できます。

もう1つの特徴は、簡潔に記述できるという点です。
従来の命令的な記述では、「どのように処理を行うか(How)」を記述していました。これらの命令的な記述の欠点は、コードが冗長になることと、コードを読まなければどのような処理が行われているか理解しにくいことです。
しかし、ストリームAPIでは関数型プログラミングの考えを取り入れることで、宣言的な記述が可能になりました。宣言的な記述とは、「どのように処理を行うか(How)」ではなく、「何の処理を行うか(What)」を記述する手法です。例を挙げると、特定のものだけ抽出したい場合は「filter()」、数を数えたい場合は「count()」などのように、「何の」処理を行うかを記述するだけで処理を実行できます。
宣言的に記述することで、処理内容が理解しやすくなり、コードが簡潔になるのです。

また、ストリームAPIと混同しやすい概念として、ファイル入出力(I/O)などで用いられる「I/Oストリーム」があります。
「ストリームAPI」と「I/Oストリーム」は、どちらも「データの流れ」を抽象化したものであるため、概念が非常に似ています。しかし、その利用目的は大きく異なります。

「ストリームAPI」は、データ処理のロジックを簡潔に記述するための仕組みで、プログラム内部でのデータ処理に焦点を当てています。
一方、「I/Oストリーム」は、ファイルやネットワークなどの物理的なデバイスとの間で、データの読み書きを行うための仕組みで、プログラムと外部リソース間のデータ交換に焦点を当てています。
このように、「ストリームAPI」と「I/Oストリーム」は似た概念ではありますが、全く別の仕組みです。そのため、両者を区別して理解することが重要です。

2. 「生成」「中間操作」「終端操作」

ストリームAPIは、「ストリームの生成」「中間操作」「終端操作」という3種類の操作を組み合わせます。
まず、java.util.stream.Streamのインスタンスを「生成」し、次にそのストリームインスタンスに対する「中間操作」を任意の回数行い、最後に「終端操作」を1回行って、処理結果を取り出します。

操作 処理内容
生成 1つ コレクションや配列からストリームインスタンスを生成する
中間操作 0つ以上の任意の数 ストリームインスタンスを受け取り、処理を行い、その結果をストリームインスタンスで返す
終端操作 1つ ストリームインスタンスを配列やコレクションに変換したり、任意の形で処理結果を出力する

0つ以上の中間操作と1つの終端操作を組み合わせたものを「ストリーム・パイプライン」と呼びます。ストリームAPIは、このストリーム・パイプラインを扱うためのAPIとも言えます。

3. ストリームAPIの記述方法

次に、ストリームAPIの記述方法について説明していきます。
中間処理に特定の要素を抽出するfilter() を利用し、終端処理に各要素に指定した処理を実行するforEach()を利用して、コレクションの中から5文字以下の要素を抽出するストリームAPIを記述します。メソッドの詳細は、別の記事でまとめる予定です。

まずは、一つ一つの処理を確認するために、あえて冗長に記述してみます。

//冗長版

List<String> fruits = List.of("apple","banana","orange","peach");

Stream<String> stream1 = fruits.stream();
//①生成:stream()メソッドで、Listからストリームインスタンスを生成

Stream<String> stream2 = stream1.filter(s -> s.length() <= 5);
//②中間操作:ストリームインスタンスから5文字以下の要素を抽出

stream2.forEach(s -> System.out.println(s));
//③終端操作:ストリームインスタンスの要素に出力処理を行う

まず①で、Listからストリームインスタンスを生成するため、変数の型はStreamとなります。<>内には、ストリームパイプラインを流れる「要素の型」を表すため、この場合はStringと記述します。
この記述では最初から最後までStringですが、「要素の型」は処理の途中で変わることもあります。

次に②で、中間操作を行っています。中間操作は、受け取ったストリームインスタンスに対して何らかの処理を実行し、新しいストリームインスタンスで返します。
なお、中間操作や終端操作のメソッドの多くは、関数型インタフェースを引数に取ります。そのため、それに応じたラムダ式やメソッド参照を引数に記述します。

最後に③で、終端操作を行っています。終端操作とは、ストリームインスタンスから目的とするデータを取り出す操作です。終端操作によってストリーム以外の型が返されるため、一連のストリームパイプラインは終了します。
上の記述では、forEach()で各要素を取り出し、出力処理を行っています。

以上が、冗長版のストリームAPIの記述の流れです。
実際には、処理の途中で変数に代入せず、メソッドの戻り値の参照を使って、さらに次のメソッドを呼び出す「メソッドチェーン」を使って記述します。

以下に、上の冗長版の記述をメソッドチェーンで記述します。

//メソッドチェーン版

List<String> fruits = List.of("apple","banana","orange","peach");

fruits.stream()
      .filter(s -> s.length() <= 5)
      .forEach(s -> System.out.println(s));

冗長版と比べて、コードが簡潔になっていることがわかると思います。

4. プリミティブ型を扱う

これまで紹介してきたjava.util.stream.Streamは参照型を扱うストリームAPIです。
プリミティブ型のストリームを扱う際は、専用のインターフェースが必要です。

インターフェース 用途
IntStream int型の配列を扱うためのストリーム
LongStream long型の配列を扱うためのストリーム
DoubleStream double型の配列を扱うためのストリーム

例として、IntStream型を利用したコードを記述してみましょう。

int[] numbers = {1,2,3,4,5};
IntStream stream = Arrays.stream(numbers); 

int型・long型・double型の配列からストリームインスタンスを形成すると、参照型のストリームではなく、プリミティブ型のストリームインスタンスを返します。
参照型のストリームとプリミティブ型のストリームとは互換性がないため、もし下のコードのように記述すると、コンパイラエラーになります。

//注意:コンパイラエラーとなる
int[] numbers = {1,2,3,4,5};
Stream<Integer>  = Arrays.stream(numbers); 

5. ストリームAPIの注意点

ストリームAPIを扱う場合に、4つ注意点があります。

①遅延実行
ストリームAPIでは複数の中間操作が記述される事があります。中間操作のたびに新しいストリームインスタンスが返されているため、中間操作がその都度行われているように考えがちですが、実は中間操作の時点では処理は一切行われていません。
ストリームインスタンスは処理内容を保持するためのインスタンスであり、実際にパイプラインに要素が流れ始めるのは終端操作の実行時です。すべての中間操作が終端操作の呼び出し時にまとめて実行される仕組みを「遅延実行」と呼びます。
遅延実行という仕組みがあるため、ストリームAPIには必ず終端操作が必要です。終端操作を記述しないと、中間操作をいくら記述しても、処理は実行されません。

②再利用禁止
ストリームAPIには、パイプライン処理を巻き戻すという概念がありません。そのため、終端操作を終えたストリームに対して、再び処理を行おうとすると実行例外IllegalStateExceptionがスローされます。

③ローカル変数は、finalか実質finalしか扱えない
ストリームAPI内からは、ローカル変数はfinalか実質finalのものしか扱えません。これは、ラムダ式に対する制限と同様の理由です。

④データソースへの追加や削除はできない
ストリーム処理中に、配列やコレクションなどのデータソースに要素の追加や削除はできません。追加や削除をしようとすると、実行時例外ConcurrentModificationExceptionがスローされます。

まとめ

  • ストリームAPIは、コレクションや配列などのデータソースに対し、宣言的な「パイプライン処理」を行うための仕組みです。「何を(What)」するかを記述することで、コードが簡潔で読みやすくなります

  • ストリームAPIは「①ストリームの生成」→「②中間操作」→「③終端操作」という3ステップで構成されます。中間操作は、0以上の任意の数出会うが、生成と終端操作は必ず1つずつ必要です

  • 中間操作はすぐに実行されず、終端操作が呼び出された時点で初めて全ての処理が実行されます(遅延実行)。そのため、パイプラインの最後に終端操作を記述しなければ、処理は一切実行されません


記事は以上です。
次回は、ストリームAPIのメソッドについてまとめる予定です。

最後までお読みいただき、ありがとうございました。

参考情報一覧

この記事は以下の情報を参考にして執筆しました。

GitHubで編集を提案

Discussion