Java8におけるラムダ式と関数型プログラミング

導入

2014年にリリースされたJava8は、多くの新機能を導入しました。その中でも代表的なのがラムダ式、メソッド参照、関数型インターフェース、Stream APIなどの新機能です。これらの機能は互いに連携して使用されることが多く、コードをより簡潔にします。

1、新機能の概要

1.1、ラムダ式:

ラムダ式は、関数をメソッドのパラメータとして渡すことを可能にします。ラムダ式の構文は以下の通りです:

(parameters) -> expression または (parameters) ->{ statements; }

以下に簡単な例を示します:

1.パラメータなしで値5を返す:() -> 5

2.1つのパラメータを受け取り、値を出力する:(x) -> System.out.println(x);

3.1つのパラメータを受け取り、その値の2倍を返す:x -> 2 * x;

4.2つのパラメータを受け取り、その合計を返す:(x, y) -> x + y;

ラムダ式では、外部でfinalとして宣言された変数、またはラムダ式の後に変更されない非final変数のみを参照できます。言い換えれば、ラムダ式で参照される変数は外部から変更できなくなります。

1.2、関数型インターフェース

関数型インターフェース(Functional Interface)とは、抽象メソッドが1つだけ持つインターフェースです(非抽象メソッドは複数持てます)。本質的には通常のインターフェースですが、制約が追加されています。

関数型インターフェースと通常のインターフェースを区別するため、インターフェース定義には@FunctionalInterface注解が追加されることがあります。例えば、よく知られているRunnableインターフェースは関数型インターフェースです:

@FunctionalInterface
public interface Runnable {
 
    public abstract void run();
}

また、コードを簡潔にするため、インターフェースに抽象メソッドが1つしか定義されていない場合、@FunctionalInterface注解がなくてもコンパイラはそれを関数型インターフェースとして扱います。一方、インターフェースに複数の抽象メソッドが定義されている場合、@FunctionalInterface注解が付いていても関数型インターフェースとは見なされず、コンパイルエラーになります。

1.3、メソッド参照

メソッド参照は、既存のJavaクラスやオブジェクトのメソッドやコンストラクタを直接参照するための便利な構文を提供します。ラムダ式と組み合わせることで、コードの構造をよりコンパクトで簡潔にし、冗長なコードを減らすことができます。

メソッド参照はメソッド名を使用してメソッドを指し示します。メソッド参照は、言語の構造をよりコンパクトで簡潔にし、冗長なコードを減らします。メソッド参照はコロン2つ::を使用して表現されます。以下に例を示します:

1 list.forEach(System.out::println);

このコードは、メソッド参照を直接使用してSystem.outのprintlnメソッドを実行します。

1.4、Stream API(java.util.stream)

Stream APIは、コレクションデータをストリーム処理する方法を提供し、Javaに真の関数型プログラミングスタイルを導入します。ラムダ式、関数型プログラミング、メソッド参照はコード構文の最適化に過ぎませんが、Streamはこれらの基盎の上でコレクション操作を最適化し、ストリーム処理によりコレクションを一度の反復だけで多くの操作を実行できるようにします。これにより、コレクション操作を簡素化すると同時にパフォーマンスも向上します。

例えば、Userクラス(userName、score、gender属性を持つ)があり、多くのUserオブジェクトをListに格納しているとします。このリストから男性の中でスコアが高い3人の名前を取得するには、どのように実装すればよいでしょうか?

おおよそ以下の手順が必要です:

1.すべての男性をフィルタリングする 2.男性をスコアでソートする 3.上位3人の男性情報を取得する 4.男性の名前を取得する

従来の実装コードは以下のようになります:

List<String> resultList = new ArrayList<>();
       Collections.sort(userList);
       for (Person person : userList){
            if(person.getGender()==0){
                resultList.add(person.getName());
            }
        }
      resultList = resultList.subList(0,3);
      System.out.println(JSON.toJSONString(resultList));

しかし、Stream APIを使用すれば、1行のコードで実装できます:

1 userList.stream().sorted().filter(p->p.getGender()==0).map(p->p.getName()).limit(3).forEach((p)->System.out.println(p));

この1行のコードには、ソート、フィルタリング、プロパティ抽出、数量制限、反復出力など、一連の操作が含まれており、コードの記述を大幅に簡素化し、全体的に一度の反復操作のみで実行できます。

2、Streamの実装原理

Streamの最も単純な実装方法は、各関数呼び出し時に一度の反復処理を実行し、結果を次の関数に渡すことです。上記の例では、sorted()を呼び出す時にまずすべてのデータをソートし、一時的なコレクションlist1に格納します。次に、filter関数を呼び出すと、ソートされたlist1を渡して反復処理とフィルタリングを行い、一時的なコレクションlist2を生成します。map関数を呼び出すと、list2を処理してlist3を生成し、このように最後の関数が実行されるまで続きます。この方法は単純ですが、2つの欠点があります。1つは、各関数を実行するたびにすべてのデータを反復処理する必要があることです。もう1つは、各関数の実行後に一時的なコレクションを作成して結果を保存する必要があることです。

Streamの本質はパイプラインです。パイプラインの特徴は、データを一度だけ反復し、各データがパイプラインの頭から流れて尾部へと流れ、計算が行われて結果が得られることです。

Streamの操作は主に中間操作と終端操作の2つに分けられます。中間操作は操作を記録するだけで実際のデータ処理は行いません。一方、終端操作は実際の計算操作をトリガーし、これは遅延計算の一形態です。これがStreamが効率的な理由の一つです。

中間操作はさらに、状態を持つ操作(StatefulOp)と状態を持たない操作(StatelessOp)の2種類に分けられます。状態を持つ操作とは、要素を処理するためにすべての要素を取得する必要がある操作です(例:sorted()、limit()、distinct()など)。状態を持たない操作とは、要素の処理が前の要素の影響を受けない操作です(例:filter()、map()など)。

終端操作はさらに、短絡操作と非短絡操作の2種類に分けられます。短絡操作とは、特定の条件に達したらパイプラインを終了できる操作です(例:findFirst()、findAny()など)。非短絡操作とは、すべてのデータを処理しないと結果が得られない操作です(例:collect()、max()、count()、foreach()など)。

Streamパイプラインの各操作には、データソース + 中間操作 + コールバック関数が含まれ、これらは完全なプロセスStageとして抽象化できます。Streamパイプラインは複数のStageで構成され、各Stageは前のStageと次のStageへの参照を保持し、双方向リンクリストを形成します。

Streamの抽象実装クラスはAbstractPipelineで、これをStageと見なすことができます。最初のStageはHeadであり、Collection.stream()メソッドでHeadオブジェクトを取得できます。HeadはStreamの実装クラスでもあり、操作を含まず、パイプラインの先頭です。

Headから始めて、各中間操作を実行するたびに新しいStreamが生成されます。Streamオブジェクトは双方向リンクリストで構成され、完全なパイプラインを形成します。この双方向リンクリストのStreamは、ソースデータと実行する必要のあるすべての操作を記録しています。

Stream双方向リンクリストを使用してすべての操作を記録した後、各Streamを重ね合わせる必要があります。つまり、前の関数の実行が完了したら次の関数を実行する方法です。各Stageは自身の操作のみを知っており、次のStageの具体的な操作を知らないため、前の操作後に次の操作を呼び出すための連携メカニズムが必要です。ここでSinkインターフェースが使用されます。

Sinkインターフェースの定義は以下の通りです:

 1 interface Sink<T> extends Consumer<T> {
 2         /**
 3          * 要素の反復処理を開始する前に呼び出されるメソッド。Sinkが準備を整えることを通知します。
 4          */
 5         default void begin(long size) {}
 6 
 7         /**
 8          * すべての要素の反復処理が完了した後に呼び出されるメソッド。これ以上要素がないことを通知します。
 9          */
10         default void end() {}
11 
12         /**
13          * 操作を終了できるかどうか。短絡操作が早期に終了できるようにします。
14          */
15         default boolean cancellationRequested() {
16             return false;
17         }
18 
19         /**
20          * 要素を反復処理する際に呼び出され、処理対象の要素を受け取り、要素を処理します。
21          * Stageは自身が含む操作とコールバックメソッドをこのメソッドにカプセル化します。
22          * 前のStageは現在のStage.accept(T t)メソッドを呼び出すだけで十分です。
23          */
24         default void accept(int value) {
25             throw new IllegalStateException("wrong accept method called");
26         }
27 
28     }

Stageは自身の操作をSinkにカプセル化し、前のStageは次のStageに対応するSinkのacceptメソッドを呼び出すだけです。状態を持つ操作では、beginメソッドとendメソッドも実装する必要があります。例えば、sorted操作では、beginメソッドは結果保存用のコンテナを作成し、endメソッドはコンテナのデータをソートします。

短絡操作では、cancellationRequested()を実装する必要があります。cancellationRequestedがtrueを返すと、操作が終了したことを示します。したがって、Streamの核心はSinkインターフェースの実装方法です。

Streamパイプライン全体の流れは、Headから始めて順に次のStageに対応するSinkのbegin、end、accept、cancellationRequestedメソッドを呼び出すことです。acceptメソッド内に次のStageがある場合、acceptメソッド内で次のStageのacceptメソッドをさらに呼び出します。

ソートのSink実装クラスのソースコードは以下の通りです:

 1 private static final class RefSortingSink<T> extends AbstractRefSortingSink<T> {
 2         private ArrayList<T> dataList;
 3 
 4         RefSortingSink(Sink<? super T> nextSink, Comparator<? super T> comparator) {
 5             super(nextSink, comparator);
 6         }
 7 
 8         @Override
 9         public void begin(long size) {
10             if (size >= Nodes.MAX_ARRAY_SIZE)
11                 throw new IllegalArgumentException(Nodes.BAD_SIZE);
12             /** リストの初期化 */
13             dataList = (size >= 0) ? new ArrayList<T>((int) size) : new ArrayList<T>();
14         }
15 
16         @Override
17         public void end() {
18             /** リストのソート */
19             dataList.sort(comparator);
20             /** 次のStreamのbeginメソッドを呼び出す */
21             downstream.begin(dataList.size());
22             if (!cancellationWasRequested) {
23                 /** 短絡でない場合、次のStreamのacceptメソッドを反復して呼び出す */
24                 dataList.forEach(downstream::accept);
25             }
26             else {
27                 for (T item : dataList) {
28                     if (downstream.cancellationRequested()) break;
29                     downstream.accept(item);
30                 }
31             }
32             /** 次のStreamのendメソッドを呼び出す */
33             downstream.end();
34             dataList = null;
35         }
36 
37         @Override
38         public void accept(T item) {
39             /** リストにデータを追加 */
40             dataList.add(item);
41         }
42     }

1.まず、begin()メソッドはソートに参加する要素の数をSinkに伝え、中間結果コンテナのサイズを決定します。

2.その後、accept()メソッドを使用して要素を中間結果に追加します。最終的な実行時には、呼び出し側がこのメソッドを継続的に呼び出し、すべての要素を反復処理します。

3.最後に、end()メソッドはSinkにすべての要素の反復処理が完了したことを伝え、ソート手順を開始します。ソートが完了したら、結果を下流のSinkに渡します。

4.下流のSinkが短絡操作の場合、結果を下流に渡す際に継続的に下流のcancellationRequested()を問い合わせ、処理を終了できるかどうかを確認します。

Sinkは複数のStreamの操作を連携させます。次に、パイプライン全体の操作を実行する必要があります。操作の実行は終端操作がトリガーされるときに行われます。終端操作のSinkはデータを処理するだけで、下流に渡す必要はありません。終端操作が実行されると、パイプライン全体の実行がトリガーされます。

もう1つの問題は、終端操作のSinkが最上位のSinkを見つける方法です。ここでAbstractPipelineのonWrapSinkメソッドが使用されます。このメソッドの役割は、現在のStage操作と結果を下流のStageに渡すことを新しいSinkにカプセル化することです。つまり、現在の操作と下流のSinkを新しいSinkに結合します。これにより、すべての操作を含むSinkを取得できます。終端操作からonWrapSinkメソッドを呼び出すと、終端操作のSinkメソッドを実行するのと同等になり、パイプライン上のすべてのSinkの処理ロジックを実行することになります。

タグ: Java8 ラムダ式 関数型インターフェース StreamAPI

5月23日 12:20 投稿