ラムダ式とStreamAPI[Java]

Javaでのラムダ式とStreamAPIについて知識を整理します.
ほぼ過去にqiitaに投稿した記事の焼き直しですが….

緒言

Javaでスレッドを実行するためには,Threadクラスを継承したサブクラスを用いる方法と,
Runnableインタフェースを実装したサブクラスを用いる方法があります.
今回,ラムダ式の簡易的な説明のために後者を例にします.

public class Function implements Runnable{
    @Override
    public void run() {
        // スレッドで処理する内容
        System.out.println("hello!");
    }
}
public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new Function());
        thread.start();
        System.out.println("finish!");
    }
}

出力はこんな感じになると思います.

finish!
hello!

ThreadクラスとRunnableインタフェースの関係は
GoFデザインパターンのStrategyパターン設計が使われています.
Strategyパターンは大まかにいえば「付け替えできるアルゴリズムを実現する」設計で,
もともと1つだったクラスを処理の全体の流れを担当するクラスと,
具体的なアルゴリズムを担当するクラスに分けて考えます.
Strategyパターンを使うことで,使う側(Threadクラス)と
使われる側(Runnableインタフェースを実現したクラス)を直接関係せずに済むため,
あとから付け替えできるアルゴリズム実現することができます.
(Strategyパターンを詳しく!という方はこちらを見ると良いかも.いずれ記事投稿します.)

f:id:xrdnk:20200103194913p:plain

しかしながら,インタフェースを実現したクラスには複雑なもの(UML図のFunctionHardクラス)と
簡単なコードで済むクラス(UML図のFunctionEasyクラス)のようなものがあります.

簡単なコードで済むのに,逐一新しいクラスを
定義しなくてはならないのめんどくないですか?ということです.

ラムダ式の効力

緒言で提示した問題点を解決するために,Java SE8から導入されたラムダ式を利用します.
以下のコードはFunctionEasyクラスを作る代わりにラムダ式に置換したものです.

public class Lambda {
    public static void main(String[] args) {
        Runnable r = () -> System.out.println("hello!");
        Thread thread = new Thread(r);
        thread.start();
        System.out.println("finish!");
    }
}

このように1行で済みます.スッキリ!
ラムダ式は「関数型インタフェースをインスタンス化するときに
めんどい記述しなくても済むような記法」と思えばよいです.

実装が必要なメソッドを1つだけ持つインタフェースを「関数型インタフェース」や
「SAM(Single Abstarct Method)インタフェース」と呼んだりします.

SAMということからわかる通り,抽象メソッドを1つだけ持つため,
ラムダ式がどのメソッドを実装するのか?引数と戻り値はどうなのか?
推論が可能になります.逐一メソッド名を決める必要がないです.
インタフェースを実現したクラスを用意せずに,ポリモーフィズムを実現できます.

匿名クラスとラムダ式

Java8以前は匿名クラスで実現されていました.
匿名クラスは無名クラスとも呼ばれることがあります.

        Runnable r = new Runnable() {
            @Override
            public void run() {
                System.out.println("hello!");
            }
        }

長くて可読性が悪いですね.

Java8でラムダ式が登場し,これをもっと簡単に記述できるようになりました.

      Runnable r = () -> {
            System.out.println("hello!");
        }

ラムダ式の宣言は,以下のように引数の変数宣言と処理ブロックで構成されます.

     ( 引数 ) -> { 処理; };

「->」をアロー演算子と呼びます.

ラムダ式省略記法

ラムダ式では省略できるものがあります.
以下の原型を例にたどってみましょう.

        Sample sample = (String a) -> { System.out.println(a);};

省略記法1

引数が1つのときだけの場合,カッコ「()」を省略できます.
また,引数の型は型推論できるため,省略することができます.

      Sample sample = a -> { System.out.println(a);};

省略記法2

また,メソッド本体が1行の場合は,中カッコ「{}」と文末のセミコロン「;」を省略できます.
return文の場合はreturnも省略できます.

       Sample sample = a -> System.out.println(a);

省略記法3

さらに,引数が推論できる場合,メソッド呼び出しは
 クラス名::メソッド名
 オブジェクト名::メソッド名
のように省略することができてしまいます.

       Sample sample = System.out::println;

ラムダ式の変数スコープ

注意1

メソッド内で宣言したローカル変数と同じ名前の変数をラムダ式の引数名として使えない. 以下はコンパイルエラーになります.

public class Sample {
    public static void main(String[] args) {
        String a = "sample";
        Function f = a -> System.out.println(a); // エラー
        // 略
    }
}

すでにaという変数名がローカル変数として宣言されているので,ラムダ式の引数名として使えません.

注意2

ラムダ式外で宣言されたローカル変数にラムダ式内からアクセスするには、
ローカル変数がfinalでなければいけない.final修飾子が付いていない場合は実質的finalでなければいけない.

ラムダ式からその式を囲むメソッドのローカル変数にアクセスすることはできます.
(ラムダ式に変換前のことを考えればそれはそうという感じです.)
以下のように,ラムダ式内から,式を宣言しているメソッドのローカル変数にアクセスできます.

「実質的にfinal」というのは,finalで修飾されていなくても,変更されない変数という意味です.
以下のように,ラムダ式内でローカル変数の値を変更してしまうとコンパイルエラーになります.

public class Sample {
    public static void main(String[] args) {
        String a = "sample";
        Function f = () -> {
            a = "change"; // エラー
            System.out.println(a);
        };
        // 略
    }
}

java.util.functionパッケージとCollection API

頻繁に使われる関数型インタフェースはjava.util.functionパッケージにあります.
特に,有名どころは以下の5つの関数型インタフェースです.

関数型インタフェース メソッド 引数 戻り値 説明
Consumer<T> void accept(T) あり なし 値を返さない「消費者」
Supplier<T> T get() なし あり 値を返す「供給者」
Predicate<T> boolean test(T) あり あり 真偽値を返す「断定」
Function<T, R> R apply(T) あり あり 引数を受け取り指定の型の結果を返す「処理」
UnaryOperator<T> T apply(T t) 2つあり あり 演算結果を返す「操作」

Collection APIにはListやMapを操作するデフォルトメソッドがたくさんあります.

例えば,java.util.ListではremoveIf(Predicate filter)というのメソッドがあり,filterに一致する要素を削除します.
ラムダ式を使えば1行で済んでしまいます.

import java.util.ArrayList;
import java.util.Arrays;

public class RemoveIf {
    public static void main(String[] args) {

        ArrayList<String> list = new ArrayList<String>(Arrays.asList("aaaa", "bbb", "cc"));

        list.removeIf(v -> v.length() > 3);

        System.out.println(list); // 結果: [bbb, cc]
    }
}

他にもラムダ式を指定できるメソッドがたくさん用意されています.

Stream API

Stream API はDataをPipeLine形式で処理するためのAPIです.
Collection,配列,ファイルなどデータの集合体(Data Source)から,
個々の要素を取り出して「処理の流れ」(Stream)に引き渡すための仕組みを提供します.

Streamに対して関数操作を行った結果をStreamで返す「中間操作」と
処理結果をDataとして返す「終端操作」があります.

中間操作も終端操作もメソッド引数は関数型インターフェースを取るものが多いため,
ここでラムダ式の知識が利用していくとスマートということになります.

f:id:xrdnk:20200103195106p:plain

Stream…?I/O Streamとは違う?

Javaにはjava.ioパッケージにI/O Streamが提供されていますが,
こちらのStreamの意味は入出力をStreamになぞらえた概念です.
Stream APIのStreamはDataのPipeLine処理をStreamに見立てた概念です.

Stream API の基本

import java.util.ArrayList;
import java.util.Arrays;

public class Main {

    public static void main(String[] args) {

        // ①Data Sourceを準備する
        var list = new ArrayList<String>(
                Arrays.asList("tokyo", "nagoya", "osaka", "fukuoka", "hokkaido", "okinawa"));

        // ②ストリームを作る
        list.stream().
            filter(s -> s.length() > 5).         // ③中間処理を行う
            map(String::toUpperCase).
            forEach(System.out::println);        // ④終端処理を行う

        // 結果:NAGOYA FUKUOKA HOKKAIDO OKINAWA
    }
}

Stream APIの処理は以下で構成されます.
①Data Source準備する→②Stream生成→③抽出/加工などの中間処理→④出力/集計などの終端処理

上の例でいえば, ①まずArrayListのData Sourceを作る

②Streamを生成する.ここではArrayList<String>をもとにしているため,
 Streamメソッドも,Stream<String>オブジェクトを返します.

※与えられるData Sourceの型によって型引数は変動することと,
 Streamの途中で値が加工されることで,型が変化していく場合もある.

③filterメソッドで「文字数が5より大きい値だけを取り出す」
 mapメソッドで「大文字に変換する」.中間処理は複数あってもよし.省略してもよし.

④forEachメソッドで,得られた値をSystem.out::printlnメソッドで出力
 終端処理は省略できません.

中間処理の戻り値はいずれもStream<T>です.
Stream APIでは,Streamの生成から中間処理/終端処理までを「.」演算子
ひとまとめに連結でき,スマートな記述ができます.
(メソッドの連鎖という意味でメソッドチェーンと呼ばれる)

Streamの一連の処理が実行されるのは,終端処理のタイミングです.
中間で抽出/加工などの演算が呼び出されていても,それは一旦ストックされて,
その場では実行されず,終端処理まで処理の実施を待ちます.これを遅延処理といいます.

Stream の作り方

Collection/配列から生成

Collectionから Collection.stream()
配列から Arrays.stream(T[])
Mapから Map.entrySet().stream()

stream()メソッドの並列版として,parallelStream()メソッドもあります.
streamをparallelStreamに置換するだけで,並列処理が可能になります.(強い…)
扱う要素数が多い場合,並列処理を有効にすることで,効率的に処理できる場合があります.
(勿論並列化のオーバーヘッドがあるため,必ずしも高速にはなりません.)
既存のStreamを並列化,あるいは直列化することもできます.

StreamクラスからStream生成

Streamクラスでは,Stream生成するためのFactory Methodがあります.
最も基本的なのは,指定された可変数引数をStreamに変換するofメソッドです.

var stream = Stream.of("tokyo","osaka","nagoya");
stream.forEach(System.out::println); // tokyo, osaka, nagoya

他にもgenerate(),builder(),concat(),iterator()があります.ここでは割愛します.

プリミティブ型のStream生成

IntStream intで特殊化されたストリーム
LongStream longで特殊化されたストリーム
DoubleStream doubleで特殊化されたストリーム

IntStream.range(int start, int endExclusive) [第2引数は範囲外:開空間]
IntStream.rangeClosed(int start,int endInclusive) [第2引数は範囲内:閉空間]

IntStreamを使った繰り返し処理の例は以下のようになります.
for文を使った場合と比較します.ちょっとオサレですね.

for(int i = 1; i <= 5 ; i++){
   System.out.println(i);
}
IntStream.rangeClosed(1, 5).forEach(System.out::println);

Javaジェネリクスの型引数では,プリミティブ型は使えないため,
Stream<int>のような書き方はエラーになります.

中間処理

Streamに流れる値を抽出/加工する役割を持ちます.
中間処理が実行されるのは,あくまで終端処理が呼び出されたタイミングであり,
呼び出しのたびに実行されるわけではないです.

filter

指定された条件で値を抽出します.

Stream.of("tokyo", "nagoya", "osaka").filter(s -> s.startsWith("t")).forEach(System.out::println); //tokyo

map

与えられた値を加工します.

Stream.of("tokyo", "nagoya", "osaka").map(s -> s.length)).forEach(System.out::println); //5, 6, 5

生成直後はStream<String>だったのが,mapメソッドの後はStream<Integer>になってることに注意.

sorted

要素を並べ替えます.

Stream.of("tokyo", "nagoya", "osaka").sorted().forEach(System.out::println); // nagoya, osaka, tokyo
Stream.of(2,1,3).sorted().forEach(System.out::println); // 1, 2, 3

デフォルトの動作は自然順序によるソートです.文字列ではれば辞書順,数値であれば大小でのソートです.
独自のソート規則を指定したい場合は,ソート規則をラムダ式で設定しましょう.
sorted()の引数はComparatorインターフェイスです.

Stream.of("tokyo", "nagoya", "osaka").
  sorted((str1, str2) -> str1.length() - str2.length()).forEach(System.out::println); // tokyo, osaka, nagoya

skip/limit

skip: m番目までの要素を切り捨てる
limit: n+1番目以降の要素を切り捨てる

IntStream.range(1, 10).skip(3).limit(5).forEach(System.out::println); // 4, 5, 6, 7, 8

skipメソッドで最初の4要素をスキップし,
limitメソッドでそこから5個分の要素を取り出しています.
limitメソッドではすでに先頭が切り捨てたStreamを操作するため,引数に注意しましょう.

peek

Streamの途中状態を確認します.
peekメソッドそのものはStreamに影響を与えないため,主にデバッグ用に使われます.

Stream.of("tokyo", "nagoya", "osaka").peek(System.out::println).sorted().forEach(System.out::println);
//ソート前の結果:tokyo, nagoya, osaka ← peekのprintln
//ソート後の結果:nagoya, osaka, tokyo ← forEachのprintln

distinct

値の重複を除去します.

Stream.of("tokyo", "nagoya", "osaka", "osaka", "nagoya", "tokyo").distinct().forEach(System.out::println);
// tokyo, nagoya, osaka

終端処理

Streamに流れる値を最終的に出力/集計する役割を持ちます.
Streamは終端処理の呼び出しをトリガーにして最終的にまとめて処理されるため,
中間処理と異なり,終端処理は省略できません.

終端処理したStreamを再利用することはできないため,
再度Stream処理を行いたい場合は,StreamそのものをData Sourceから再生成する必要があります.

forEach

個々の要素を順に処理します.

Stream.of("tokyo", "nagoya", "osaka").forEach(v -> System.out.println(v)); // tokyo, nagoya, osaka
Stream.of("tokyo", "nagoya", "osaka").forEach(System.out::println); // tokyo, nagoya, osaka

findFirst

最初の値を取得します.

System.out.println(Stream.of("tokyo", "nagoya", "osaka").filter(s -> s.startsWith("t")).findFirst().orElse("empty"));
// tokyo

空Streamの場合があるため,findFirstメソッドの戻り値はOptional型です.

System.out.println(Stream.of("tokyo", "nagoya", "osaka").filter(s -> s.startsWith("a")).findFirst().orElse("empty"));
// empty

anyMatch/allMatch/noneMatch

値が特定の条件を満たすか判定します.
順に「条件式がtrueになる要素が存在するか」,「条件式がすべてtrueになるか」,
「条件式がすべてtrueにならないか」になります.

System.out.println(Stream.of("tokyo", "nagoya", "osaka").anyMatch(v -> v.length() == 5)); // true
System.out.println(Stream.of("tokyo", "nagoya", "osaka").allMatch(v -> v.length() == 5)); // false
System.out.println(Stream.of("tokyo", "nagoya", "osaka").noneMatch(v -> v.length() == 5)); // false

toArray

Stream処理の結果を文字列配列として変換します. w

var list = Stream.of("tokyo", "nagoya", "osaka").filter(s -> s.startsWith("t")).toArray();

collect

Stream処理の結果をCollectionとして変換します.
collectメソッドにはCollectorsクラスで提供されている変換メソッドを渡します.
Listへの変換はtoList,Setへの変換はtoSet,マップへの変換はtoMapを使います.

var list = Stream.of("tokyo", "nagoya", "osaka").filter(s -> s.startsWith("t")).collect(Collectors.toList());

collectメソッドはコレクション変換専用のメソッドというより,
リダクション処理を行うメソッドでもあります.

min/max

最小値/最大値をもとめます.引数には比較規則(Comparator)を指定する必要があります.
戻り値がOptional型であるため,orElse経由になります.(ここではないことを意味する-1としています.)

System.out.println(Stream.of(1, 3, 2).min((int1, int2) -> int1 - int2).orElse(-1)); // 1
System.out.println(Stream.of(1, 3, 2).min((int1, int2) -> int2 - int1).orElse(-1)); // 3
System.out.println(Stream.of(8, 7, 9).max((int1, int2) -> int1 - int2).orElse(-1)); // 9
System.out.println(Stream.of(8, 7, 9).max((int1, int2) -> int2 - int1).orElse(-1)); // 7

count

要素の個数を求めます.

System.out.println(Stream.of("tokyo", "nagoya", "osaka").filter(s -> s.length() > 5).count()); // 1

reduce

Streamの値を一つにまとめます(Reduction).
reduceメソッドは3種類のオーバーロードを提供しています.

引数1個の場合

Optional reduce(BinaryOperator<T> accumulator); 戻り値がOptional型であるため,orElse経由になります.

引数は演算結果を格納するための変数result,個々の要素を受け取るための変数strがあります.

System.out.println(
    Stream.of("tokyo", "nagoya", "osaka").sorted()
        .reduce((result, str) -> { return result + "," + str;}).orElse("")); // nagoya,osaka,tokyo

引数2個の場合

T reduce(T identity, BinaryOperator<T> accumulator);
第一引数で初期値を受け取ることができます.
結果は非nullであることが明らかなため,非Optional型になります.OrElse経由は不要です.

System.out.println(
    Stream.of("tokyo", "nagoya", "osaka").sorted()
        .reduce("hokkaido",  (result, str) -> { return result + "," + str;})); //hokkaido,nagoya,osaka,tokyo

引数3個の場合

<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);
少々難しいかもしれません.Streamの要素型と,最終的な要素型が異なる場合に使います.ここでは例を割愛します.
詳細を知りたい方はこちらの記事を見るといいかもしれません.

collect(リダクション操作)

Stream内の要素をCollectionなどにまとめます.
reduceがStream内の要素をint,Stringのような単一値にリダクション処理するのに対し,
collectはCollection/StringBuilderのような可変な入れ物に対して値を蓄積してから返します.
今回はjava.util.streamパッケージにあるCollectorsクラスを利用した典型的なリダクション処理を紹介します.

joining

文字列を結合します.

        System.out.println(
                Stream.of("tokyo", "nagoya", "osaka")
                .sorted()
                .collect(Collectors.joining(",", "<", ">"))
                ); // <nagoya,osaka,tokyo>

groupingBy

指定されたキーで値をグループ化します.

        System.out.println(
                Stream.of("tokyo", "nagoya", "osaka")
                .sorted()
                .collect(Collectors.groupingBy(str -> str.length()))
                ); // {5=[osaka, tokyo], 6=[nagoya]}

partitioningBy

groupingByと同じような処理ですが,真偽値で2分割してグループ化します.

        System.out.println(
                Stream.of("tokyo", "nagoya", "osaka")
                .sorted()
                .collect(Collectors.partitioningBy(str -> str.length() > 5))
                ); // {false=[osaka, tokyo], true=[nagoya]}

結言

今月中にJava Silver SE 11,3月までにJava Gold SE 11を会社のお金が受けるのですが
前者はまだしも,後者はちょっと受験勉強が大変な気がする….
Java SE 11版の資格は19年6月頃に出来たばかりで,Goldの方は書籍がまだ出版されてないです.

Java SE 11 認定資格 | オラクル認定資格制度 | Oracle University
この時期に未だにSE 7/8版を受けたりしてる人は何考えてるんだろう.