Effective Java 3rd [Chapter 7]
ラムダ式とストリーム
Effective Java 第3版の個人的メモ
- 項目42 匿名クラスよりもラムダ式を選ぶ
- 項目43 ラムダよりもメソッド参照を選ぶ
- 項目44 標準の関数型インタフェースを使う
- 項目45 ストリームを注意して使う
- 項目46 ストリームで副作用のない関数を選ぶ
- 項目47 戻り値型としてStreamよりもCollectionを選ぶ
- 項目48 ストリームを並列化するときは注意を払う
- 参考URL
1. 項目42 匿名クラスよりもラムダ式を選ぶ
1.1. 結論
数行のコードで済む、もしくは、自分への参照が必要、関数型インターフェースの型しか使用しない場合は、ラムダ式を使いましょう。 それ以外の場合は、匿名クラスを使いましょう。
1.2. 匿名クラスでの実装
Anonymous.java
|
|
1行で済んでおり、自分への参照は不要なので、ラムダ式の方が良いですね。
そもそも匿名クラスとは、、
以下のように記述される処理のことですね。
1 2 3変数の型 変数 = new クラス名/インターフェース名( ) { メソッドの処理を記述 };
1.3. ラムダ式での実装
Anonymous.java
|
|
数行で済んでおり、自分への参照はしておらず、Collections.sortの第2引数が関数型インターフェース(Comparator)なので、ラムダ式が向いていますね。
関数型インタフェースとは、、
関数型インターフェースの条件は、大雑把に言って、定義されている抽象メソッドが1つだけあるインターフェース。(staticメソッドやObjectクラスにあるデフォルトメソッドはカウントしない。)
Java8での関数型インタフェース一覧はOracle Java SE 8 (Uses of Class java.lang.FunctionalInterface)にあります。 3rd party製のライブラリで@FunctionalInterfaceが付いているインタフェースも、関数型インタフェースですね。
2. 項目43 ラムダよりもメソッド参照を選ぶ
2.1. 結論
- メソッド参照の利点:ラムダ式よりも簡潔に書ける。
- ただし、同クラスにあるメソッドを用いる場合は、ラムダ式の方が簡潔に書ける場合がある。
2.2. メソッド参照
メソッド参照とは、、
関数型インターフェース(抽象メソッドが1つだけ定義されているインターフェース)の変数にメソッドそのものを代入することが出来る。これを「メソッド参照」と呼ぶ。
MethodRefAndLambdaExpression.java
|
|
メソッド参照の方が、簡潔に記載できていますね。
2.3. メソッド参照について深掘り
メソッド参照には、下記5種類がある。
上記の例は、Systemクラスのstaticフィールドoutのprintlnメソッドなので、静的メソッド参照だったんですね。
| メソッド参照形式 | コード | 等価ラムダ |
|---|---|---|
| 静的メソッド | TypeName::method |
(args) -> TypeName.method(args) |
| 非静的メソッド(インスタンス#1の場合) | instance::method |
(args) -> instance.method(args) |
| 非静的メソッド(インスタンスなし) | TypeName::method |
(instance, args) -> instance.method(args) |
| コンストラクタ#2 | TypeName::new |
(args) -> new TypeName(args) |
| 配列コンストラクタ | TypeName[]::new |
(int size) -> new TypeName[size] |
#1 :
instanceは、インスタンスへの参照を評価する任意の式です。例えば、getInstance()::method、this::methodのように書けます。#2 :
TypeNameが非静的な内部クラスである場合、コンストラクタ参照は外部クラスインスタンスのスコープ内でのみ有効です。(つまり、外からコンストラクタが見れる場合使えますよ。という内容です。)
3. 項目44 標準の関数型インタフェースを使う
基本的には、標準の関数型インタフェースで事足りる。 しかし、特定の場合に限っては独自の関数インタフェースを定義する必要がある。
3.1. 標準の関数型インタフェース
- 使いやすく、すでに組み込まれている様々なメソッドの恩恵にあずかれる。
標準の関数型インタフェースには、43個あり、主な6つは以下である。
性能の観点から、プリミティブ型に対応していない関数型インターフェースには、プリミティブ型をボクシングしたオブジェクトは使わないでおきましょう。
3.2. 独自の関数型インタフェース
以下の場合、独自の関数型インタフェースを作成しましょう。
- 広く使われ、説明的な名前によってメリットがある場合
- 強く関係する契約がある場合(?)
- 独自のデフォルトメソッドによるメリットがある場合
作成するときは、@FunctionalInterfaceを使いましょう。書き方に誤りがある場合、コンパイルエラーにしてくれます。
同じ名前のメソッドで、異なる関数型インタフェースを引数に持つのは、ユーザーにとって曖昧となるのでやめましょう。
例:ExecutorService.submit()は、異なる関数インタフェース「Runnable」と「Callable<T>」を引数に持っているのでよくありません。
3.3. 標準の関数型インタフェースについての余談
各標準の関数型インタフェースについて以下の観点でまとめておきます。
- 従来の書き方
- 匿名クラスでの書き方
- lambda式を使った書き方
- 呼び出し方
3.3.1. Function
従来の書き方
1 2 3R functionName(T arg) { return new R(); }匿名クラスでの書き方
1 2 3 4 5 6Function<T, R> functionalInstance = new Function<T, R>() { @Override public R apply(T arg) { return new R(); } };lambda式を使った書き方
1Function<T, R> functionalInstance = arg -> new R();呼び出し方
1R returnValue = functionalInstance.apply((T) argument);
3.3.2. Consumer
従来の書き方
1 2 3void consumer(T arg) { something(arg); }匿名クラスでの書き方
1 2 3 4 5 6Consumer<T> consumer = new Consumer<T>() { @Override public void accept(T arg) { something(arg); } };lambda式を使った書き方
1Consumer<T> consumer = arg -> something(arg);呼び出し方
1consumer.accept((T) argument)
3.3.3. Supplier
従来の書き方
1 2 3T supplier() { return new T(); }匿名クラスでの書き方
1 2 3 4 5 6Supplier<T> supplier = new Supplier<T>() { @Override public T get() { return new T(); } };lambda式を使った書き方
1Supplier<T> supplier = () -> new T();呼び出し方
1supplier.get();
3.3.4. Predicate
偶奇判定を例に記載します。
従来の書き方
1 2 3boolean predicate(Integer i) { return i % 2 == 0 ? true : false; }匿名クラスでの書き方
1 2 3 4 5 6boolean predicate = new Predicate<T>(){ @Override public boolean test(Integer i) { return i % 2 == 0; } };lambda式を使った書き方
1Predicate<Integer> checker = (i)-> { return i % 2 == 0; };呼び出し方
1boolean result = checker.test(i);
3.3.5. UnaryOperator
Stringを強調させるメソッドを例に記載します。
従来の書き方
1 2 3public String apply(String t) { return "** " + t + " **"; }匿名クラスでの書き方
1 2 3 4 5 6String str = new UnaryOperator<String>(){ @Override public String apply(String t) { return "** " + t + " **"; } }lambda式を使った書き方
1UnaryOperator<String> operator = (String t) -> { return "**" + t + "**"; }呼び出し方
1String newText = operator.apply(text);
3.3.6. BinaryOperator
足し算メソッドを例に記載します。
従来の書き方
1 2 3public Integer binaryOperator(Integer i1, Integer i2) { return i1 + i2; }匿名クラスでの書き方
1 2 3 4 5 6BinaryOperator<Integer> binaryOperator = new BinaryOperator<Integer>() { @Override public Integer apply(Integer i1, Integer i2) { return i1 + i2; } };lambda式を使った書き方
1 2 3BinaryOperator<Integer> binaryOperator = (Integer i1, Integer i2) -> { return i1 + i2; };呼び出し方
1int actual = binaryOperator.apply(1, 2);
4. 項目45 ストリームを注意して使う
4.1. 結論
Stream処理により簡単になる場合は、主に以下である。
いつStreamを使うべきかという、かっちりとしたルールはない。
- 単純な要素の変換
- 要素のフィルタリング処理
- 単純なオペレーションで要素を結びつける処理
- 要素を
Collectionに集約する処理 - 特定の基準を満たす要素を探す処理
次のようなことになるのであれば、使わないでおきましょう。
- Stream処理ばかりで、複雑になる場合
- char型の値のStream処理
- コードブロック内(
{...})で出来ることを捨ててまで、Stream処理にしない。
4.2. Stream処理ばかりで、複雑になる場合
4.2.1. 改善前
ファイル内の単語(行単位)を、アナグラムごとにまとめて、指定数以上含まれている場合は出力するプログラム。
アナグラム(anagram)とは、言葉遊びの一つで、単語または文の中の文字をいくつか入れ替えることによって、全く別の意味にさせる遊びである。Wikipedia
|
|
解説が必要なほど、複雑だと思うので、以下に記載します。。。(なんでもかんでもStream処理が良いという訳ではないということですね。)
|
|
4.2.2. 改善後
ファイル内の単語(行単位)を、アナグラムごとにまとめて、指定数以上含まれている場合は出力するプログラム。
悪い例でややこしかった、文字列ソートの部分をalphabetizeメソッドに切り出しているので、わかりやすくなっていますね。
|
|
4.3. char型の値のStream処理
char型のStream処理は直感的でない動きをしうるので、原則としてchar型の値をStream処理で扱うべきでない。
|
|
4.4. コードブロック内({...})で出来ることを捨ててまで、Stream処理にしない。
関数型オブジェクトを使用したStream処理で出来ず、コードブロック内({...})で出来ること。
- ローカル変数の使用(ラムダ式では
finalでないと使用できない。) - 内部メソッドの
return, ループ内でのbreak/continue, 例外スロー(ラムダ式では出来ない。)
5. 項目46 ストリームで副作用のない関数を選ぶ
5.1. 前提
Streamの最も重要なところは、純粋関数のみによる演算 であること。
純粋関数とは、副作用を持たない関数のことである。副作用とは、変数の再代入や入出力などによってプログラムの状態が変化することを指します。
副作用があることによるデメリット
・全体の流れが分かりづらくなる可能性がある。
・速度が落ち、並列処理時の問題が出る可能性がある。
5.2. 結論
副作用を持たないようにするため、ストリーム処理は以下のようにする。
forEachは、ストリームの演算の結果を示すためだけに利用する。Collectorsを利用する。
5.3. forEachは、ストリームの演算の結果を示すためだけに利用する。
ファイルに含まれる単語の頻度を求めるプログラム。
以下は、ストリーム内で、外部の状態(freq変数)を変更しているため(副作用を持つため)、良くない。
|
|
以下が改善例。
ストリーム内で、外部の状態(freq変数)を変更しておらず、戻り値を代入している。
|
|
5.4. Collectorsを利用する。
ストリームパイプラインの可読性のために、Collectorsのメンバーはstaticインポートしておくべき。
5.4.1. toSet(), toCollection(), toList()
toSet(), toCollection(), toList()を利用した例。
|
|
5.4.2. toMap()
toSet(), toCollection(), toList()以外は、ストリームをMapにするためのものがほとんどである。
toMap()を使ったサンプルを載せる。
|
|
|
|
5.4.3. groupingBy()
groupingBy()は、classifierをもとに要素をカテゴリー分けしたMapを生成する。
classifierには、以下のサンプルのt -> t.length() や 「項目45 ストリームを注意して使う」で紹介したalphabetize(word)が該当する。
|
|
groupingBy()の2つ引数を取るシンプルな例としては以下。
|
|
5.4.4. その他のメソッド
downstream collectorとしての利用に特化した以下のメソッドは、collect(counting())のように利用してはいけない。
Streamに同様の機能があるため。
- counting()
- 要素数を取得する。
- summing*()
- 入力値を型変換して、合算する。
- averaging*()
- 入力値を型変換して、平均値を算出する。 空ストリームの場合は0を返す。
- summarizing*()
- 入力値を型変換して、各種集計値を算出する。
- reducing()
- 値を集約する。
- filtering()
- 条件を満たす値だけ他のコレクターに渡す。
- mapping()
- 値を変換して他のコレクターを呼び出す。
- flatMapping()
- 値をStreamに変換して他のコレクターを呼び出す。
- collectingAndThen()
- 他のコレクターを呼び出した結果を変換する。
- minBy()
- 最小値を取得する。
- maxBy()
- 最大値を取得する。
- joining()
- 結合してStringにする。
CollectorsやStreamの利用方法については、こちらのブログ(Java Collector, Java Stream)が入門として(大まかに把握するのに)良さそうである。
6. 項目47 戻り値型としてStreamよりもCollectionを選ぶ
6.1. 結論
連続した要素の戻り値型として以下のものがあげられる。
- Collection
- Iterable
- 配列
- ストリーム
大抵の場合は、最適な戻り値型は、Collectionか適切なCollectionのサブタイプである。
理由は、Collectionはiterator()やstream()を持っており、イテレーション処理でもストリーム処理でも対応できるからである。
もしイテレーション処理やストリーム処理を使う場合は、使用するユーザーによっては、
ストリームからイテレートできるように変換するコードを作る必要になることを念頭に置いておきましょう。
6.2. Collectionを利用する場合
6.2.1. 返却する連続要素が小さい場合
Collectionを実装したもの(ArrayListやHashSetなど)を返しましょう。
6.2.2. 返却する連続要素が大きい場合
返却する連続要素が大きい場合の例として、与えられた集合のべき集合を返す場合を考える。
この場合、メモリに蓄えるべきではないです。下の例のように、組み合わせ数が膨大になっていくからである。(組み合わせ数 : 2^(要素数))
{a,b,c}のべき集合の例:
{{}, {a}, {b}, {c}, {a, b}, {a, c}, {b, c}, {a, b, c}}
このような場合は、GuavaのSets#powerSet()などを利用しましょう。(もしThirdParty製のライブラリが無ければ、独自実装しましょう。)
中のロジックは、以下のようなSubsetを利用したビット演算である。
|
|
サンプル実装
|
|
メモ:集合が
31以上の要素を持っている場合、GuavaのSets#powerSet()は例外をスローする。べき集合の大きさが2^31以上となり、int size()の最大値(2^31-1)的に対応できないからである。
7. 項目48 ストリームを並列化するときは注意を払う
7.1. 結論
ストリームの並列化により、性能が良くなるのは以下です。
- サブレンジへの分割が容易であること
- 逐次処理される時の参照の局所性
- 終端処理がリダクション処理や短絡評価である場合
また、指標として、(ストリームの要素の数) * (1要素に実行されるコード行数) > 100000 であれば、並列化により性能が向上するかも。
以下の場合、並列化をしても性能は良くならないでしょう。
Stream.iterateを使っている- 中間操作
limitを使っている
並列化により、間違った結果や予想できない動作を起こす可能性があるので気をつけましょう。例えばforEach()とforEachOrded()。
|
|
7.2. 「サブレンジへの分割が容易であること」と「逐次処理される時の参照の局所性」
ArrayList、HashMap、HashSet、ConcurrentHashMap、配列、intの範囲をもったもの、longの範囲を持ったもののストリームであれば、以下。
- サブレンジへの分割ができる
- 逐次処理される時の参照が局所化される
7.3. 「終端処理がリダクション処理や短絡評価である場合」
終端処理で、以下をやっていると並列による性能向上はあまり得られない。
- パイプライン全体に対して大量の処理をする
- 終端処理が内部的に逐次処理を行うものである。
7.3.1. 終端処理がリダクション処理
終端処理が、min、max、count、sumといったリダクション処理であれば、効果を得られる。
リダクション処理とは、ストリーム内のすべての要素を累積関数を使って一つにまとめた結果を返す操作。
7.3.2. 終端処理が短絡評価
終端処理が、anyMatch、allMatch、noneMatchといった短絡評価は並列化の効果を得やすい。
短絡評価とは、
&&や||といった論理演算子において、左辺を評価した時点で、式全体の真偽値が決定し右辺を評価する必要がない場合、右辺を評価しないという機能のこと。(要は無駄な処理をしないで、真偽値を決めれる機能のこと)
7.4. 並列処理による性能向上
並列化が効率的に行える、素数計数関数のプログラムで確認する。
(以下の例では、.parallel()を付けたことにより、36秒 ⇨ 24秒 に短縮できています。)
素数計数関数とは、正の実数にそれ以下の素数の個数を対応させる関数のことであり、π(x) で表す。
7.4.1. 並列化前
|
|
7.4.2. 並列化後
|
|
8. 参考URL
- effective java 3rdまとめ
- Effective Java Third Edition 2版からの更新点 個人的メモ
- Effective Java 第三版、ゲットだぜ!
- Java関数型インターフェース
- Javaメソッド参照・コンストラクター参照
- 【楽チンJava】メソッド参照での呼び出し方
- RIP Tutorial メソッド参照
- Java8のFunction, Consumer, Supplier, Predicateの調査
- [Java]メソッドの引数にメソッドを渡す方法[関数型インターフェース]
- Java8のラムダ式を理解する
- An example of UnaryOperator in functional Lambda expressions
- Java8 Project Lambda (ラムダ式)
- Collectors.toMapで任意のMapクラスを返す
- Java 8 でグルーピング処理 - List
を Map へ変換 - Java 8 | Collectors counting() with Examples
- Java Collector
- Java Stream
- GeeksforGeeks
- Java8 Streamのリダクション操作について
- 短絡評価って本当に処理が速いの? Javaにおける&&と&の違い
- Prime Counting Function
- Stream APIの主なメソッドと処理結果のOptionalクラスの使い方 (3⁄4)