Java ラムダ式、Stream APIについて考える

こんにちは
考える系といいつつ、他の方の説明を読んで
感想を書いているだけですが...

今回はタイトルにある通り、
ラムダ式とStream APIについてやっていこうと思いますが、
一括りにしていいものなんですかね。

参考

いつものやつです、ありがとうございます。

https://qiita.com/sano1202/items/64593e8e981e8d6439d3
https://employment.en-japan.com/engineerhub/entry/2019/04/25/103000
https://www.sejuku.net/blog/22337
https://qiita.com/liguofeng29/items/3e013c7dfa7adf43ea45

ラムダ式とは

ラムダ式というのは、あまり馴染みはないかもしれませんがF#、Scala、Schemeといった関数型言語と呼ばれるプログラミング言語における用語です。
かんたんに言うと、「メソッドを変数と同様に扱う記述様式」になります。
Java8から追加され、使用することができるようになりました。
// (1) 引数と戻り値がある場合
(Integer number) -> {
   return Math.abs(number) >= 5;
}

// (2) 戻り値がない場合
(Integer number) -> {
   System.out.println(number);
}

// (3) 引数も戻り値もない場合
() -> {
   System.out.println("Hello!");
}

引数も戻り値もなかったら書く必要がないですね。
また、引数の型は記述しなくても大丈夫なようです。

ラムダ式が利用されるほとんどの場面では引数の型は明確であるため、基本的にラムダ式では引数の型は記述しません。
そして、引数が1つしかない場合に限り、引数を囲む小括弧( )を省略することができます。引数がない場合や、2つ以上ある場合は省略できません。このルールを(1)と(3)に当てはめると、次のようになります。
// (1) 引数が1つなので ( ) を省略できる
number -> {
   return Math.abs(number) >= 5;
}
// (3) 引数がないため ( ) を省略できない
() -> {
   System.out.println("Hello!");
}

引数が1つであれば、型だけでなく()もいりません。
ただし、引数がない場合は()は必要です。
どんどん書くものがなくなってきました。

処理が1行しかない場合は、中括弧{ }と、returnと、文末のセミコロン;を省略することもできます。
// (1) 引数と戻り値がある場合
number -> Math.abs(number) >= 5
// (2) 戻り値がない場合
number -> System.out.println(number)
// (3) 引数も戻り値もない場合
() -> System.out.println("Hello!")

もう原型を覚えていません...!
個人的にはですが、戻り値がある場合の"return"省略はちょっと分かりづらいかもです。

処理内容がメソッド呼び出し1つの場合、かつ、引数が一意に決まる場合に限り、メソッド参照を利用して、引数そのものを省略することができます。メソッド参照は次のような文法になります。
// メソッド参照
クラス名::メソッド名
System.out::println

もう引数も書いてくれません。。。
コードを読むときは何が引数にくるのか追うように注意が必要です。

こんな感じでコードの記述量を減らせる+視覚的にわかりやすい?コーディングができます。

ラムダ式を分解してみる

ラムダ式はローカルクラスと無名クラスという仕組みを利用しています。

ローカルクラスと無名クラスについてとどのように利用しているのかを詳しくみていきます。

ローカルクラス
ローカルクラスとはメソッドの処理中でクラスを宣言して利用できる仕組みです。
public static void main(String[] args) {
 class Local implements Runnable {
   public void run() {
     System.out.println("Hello Lambda!");
   }
 }
 Runnable runner = new Local();
 runner.run(); // Hello Lambda!
}

別のファイルでクラスを定義するのではなく、
メソッドの途中でクラス定義するやつですね。
インターフェースの実装もいけちゃいます。

無名クラス
無名クラスとはインターフェースを実装したローカルクラスの宣言を省略する仕組みです。
public static void main(String[] args) {
 # Rannableインターフェースを実装した無名クラスのインスタンスを生成
 Runnable runner = new Runnable() {
   public void run() {
     System.out.println("Hello Lambda!");
   }
 };
 runner.run(); //Hello Lambda!
}

Runnableクラスのインスタンス生成の時に
Runnable.run()の実装をしてあげています。

ラムダ式
無名クラスから更に「new Runnable(){}」と「public void run」を省略してラムダ式となります。
public static void main(String[] args) {
 Runnable runner = () -> { System.out.println("Hello Lambda!"); };
 runner.run(); //Hello Lambda!
}

かなりコード量が減りましたね。

最初の()はrunメソッドの引数を表し、->{}の中身はrunメソッドの実装内容になります。runner変数にはRunnableを実装した無名クラスのインスタンスが代入されます。
つまり、ラムダ式とはインターフェースを実装したインスタンスを生成する式といえます。

Runnableインターフェースを実装したrunnerというインスんたんすを生成している式になります。

メソッド参照

--- すこし脱線 ---

メソッド参照とは
メソッドの引数としてメソッドを参照できる仕組みのことです。
呼び出したいメソッド名の直前に「::」句を、さらにその前にはクラス名を記述し、メソッドの引数部分の「()」は省略する型で以下のように記述します。

メソッド参照は、staticなメソッドを記述する場合に使われます。
なお、メソッド参照と同じようにクラスのコンストラクタを参照する仕組みのことをコンストラクタ参照といいます。

でました、static!
https://qiita.com/ue_knnk/items/fa84b8bc30ba319e5744

staticメソッドはインスタンスを生成しなくてもクラスから直接呼び出せるのでstaticを多用する人がいます。
そのような人を「staticおじさん」と呼んだり呼ばなかったりします。

メソッドやメンバ変数にstatic修飾子を付けると、staticメソッド、static変数になります。
static修飾子を付けると何が変わるかというと、newしなくてもそのメソッドやメンバ変数にアクセスできるようになります
つまりstaticとは、
・共通のメソッドを定義する場合
・共通のプロパティを定義する場合
・複数クラス間で共通のプロパティを共有したい場合
に使う修飾子ということです。

シンプルにstaticおじさんって響きがすき。
newする必要があるような処理はメソッド参照できないってことですかね。

--- 終了 --- 

Stream API

(響きがかっこいいかつ強そう。)

「Stream API」とは配列やList、Collectionなどの要素の集合に対して処理を行う場合に便利なAPIです。
要素の直列処理や並列処理、要素の比較並び替え、一致判定などの処理を実行するメソッドが用意されています。

対象は要素の集合です。

Stream APIは「何をしているか(what)」を記述するものであり、既存のfor文を使った処理は「どのように処理するか(how)」を記述するものだと言えます。どちらが読みやすいかは経験によるところもありますが、多くの場合、コードを読む際にはどのように処理するかよりも、何をしているかの方を知りたいでしょうから、目的が明確になりやすいStream APIの方が読みやすく感じると思います。

この文すごくわかりやすい。
ちょっと逸れますが、
開発をする際に「どのように実装するか」に目がいきがちですが、
「何を実装するか」を意識するのが大事だと思います。

Stream APIは「生成する」「操作する」「まとめる」の3段階

Stream APIは名前の通り、ストリーム(Stream)という流れてくるデータやイベントを処理するためのAPI群です。
Stream APIは、データ構造に対してStreamを「生成する」「操作する」「まとめる」の処理を連続して行うことで、目的の結果を得られるよう記述できるAPIです。

Streamが微妙にわかってませんが、
やりたいことは生成する、操作する、まとめると明確ですね。

ストリーム
 Javaの入出力機能はすべて、文字やバイトシーケンスの流れを表す「ストリーム」という概念をもとに設計されている。Javaにおいては、ストリームはデータの読み書きを行う標準化された機構であり、Javaに含まれている可変のデータソースを表すオブジェクトはすべて、ストリームとしてデータを読み書きするメソッドを提供している。

「生成する」API群

Streamは、大きく分けてListやSetなどのCollectionインタフェースから作る場合と、配列から作る場合の2つに分けられます。

ListやSetからStreamを作る場合には、stream()メソッドを使います。
配列からStreamを作る場合には、Arrays.stream()メソッドを使います。
ListからStreamを生成する
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5);
Stream<Integer> stream = numbers.stream();

# 配列からStreamを生成する
int[] array = {3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5};
IntStream stream = Arrays.stream(array);

# MapからStreamを生成する
Map<String, Integer> map = Map.of("key1", 3, "key2", 1, "key3", -4, "key4", 1);
Stream<Entry<String, Integer>> stream = map.entrySet().stream();

# textを読み込んでStremを生成する 
Stream<String> lines = Files.lines(Path.of("/tmp/test.txt"));

まずはStreamを作ってあげるところから、
注意する点は生成元によって異なります。

生成されるStreamの型は、元の配列の型によって決まります。Stream<String>などの通常のStreamの型に加えて、int、long、doubleの配列に限り、IntStream、LongStream、DoubleStreamという3つのStreamの型になります。

「操作する」中間操作のAPI群

このAPI群は中間操作と呼ばれており、Stream APIの一番の肝と言えるものです。この中間操作のうち、特に利用頻度の高い、mapメソッド、filterメソッド、sortedメソッドを紹介します。

・map() : 値や型を別のものに置き換えるメソッド
・filter() : 合致した条件のものだけ抽出メソッド
 ※filterメソッドは条件に一致するものを取り出すことができますが、条件に一致しなかったものも同時に取り出して、処理を分岐させるようなことはできません
・sorted() : 条件で並び替えるメソッド

ilterメソッドは条件に一致するものを取り出すことができますが、条件に一致しなかったものも同時に取り出して、処理を分岐させるようなことはできません

「まとめる」終端操作のAPI群

Streamから別の型に変換したり、副作用ある操作を行って終了する、終端操作について紹介します。終端操作は戻り値がなかったり、Stream以外の型となるため、1つのStreamに対して一種類の終端操作しか利用できません。ここでは終端操作の中でも利用頻度の高い、forEachメソッド、collectメソッドを紹介します。

これまでの処理で生成したStreamを操作した結果を扱う処理になります。

・forEach() : 値に対して行う処理を記述した関数を引数に取り、Streamの各要素に対してその処理を行うメソッド
・collect() : 引数にjava.util.stream.Collectorという変換用のインタフェースを受けて変換処理を行うメソッド

Stream APIとラムダ式を使うことのメリット

なんか便利だとは思うけど、
もう慣れたしいつもの書き方でよくないですか?
と思ってしまう方(自分含め)向けにメリットを整理します。

・記述が簡潔

コード量が減るので読みやすい、かつ視覚的に何の処理をしているのか
理解しやすいのメリットの1つです。

・並列処理が行いやすい

Stream APIでは、Streamに対してparallelメソッドを挟むことで、Streamの処理が複数スレッドで処理されるようになり、マルチコアのCPUを効率よく使って処理されるようになります
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5);
Double result = numbers.stream()
       .parallel()
       .filter(number -> number > 0)
       .collect(Collectors.averagingInt(Integer::intValue));
System.out.println(result);

いいですね、本当にparallel()メソッドを挟むだけです。

これまでのJavaのコードの書き方でマルチスレッド処理を記述する際には、どの部分がマルチスレッドになり、どのオブジェクトに作用するか、またそのオブジェクトがスレッドセーフかどうかを慎重に確認する必要がありました。しかし、関数型の作法であれば、ほとんどの状況でスレッドセーフかどうかを気にしなくてよくなります

楽かつ安全、素晴らしいです。

Stream APIやラムダ式のような関数型のコードでは、基本的に副作用が起きないようにコードを記述します。
ラムダ式を使うことの意義は、
1. 意図が正しく伝わる。
2. 副作用の少ないコードがかける。
リファクタ前のList<String> resultの状態が変わるので、副作用があると言える
ラムダ式で外部状態の変更はコンパイルエラーになる、実質final。
なので、自然と副作用の少ないコードが書ける。

こういった背景まで理解できていると、
より安全に実装できますね。

・リアクティブプログラミングへのステップに

リアクティブプログラミングとは、非同期に変化し続けるデータに対して処理を記述するようなプログラミングの手法です。IoTやビッグデータなどに代表されるような大量のリクエストや大量のデータを、より少ないコンピュータリソースで処理するための手法として注目されています。
〜 中略 〜
Stream APIと似た処理を、ラムダ式を用いて記述していることが分かります。つまり、リアクティブプログラミングのライブラリであるReactorを習得する際に、前もってStream APIやラムダ式を習得しておくと有利だと言えます。

こうした新しい技術・手法への足掛かりになる技術なので
理解を深めておいて損はないですね。

あとがき

さきにThreadについて考えておいてよかった、といのが正直なところですね。
Stream APIの各処理で実際どのようなメソッドで
どのような機能を実装できるのかという経験を
積んでいきたいです。




この記事が気に入ったらサポートをしてみませんか?