Java8以降のStreamAPIとの付き合い方
はじめに
概要
Javaで関数インターフェースを利用できるようになってから随分経ちます。結構最近だと思っていたのですが、5年も前なんですね。そんな関数インターフェースとStreamAPIですが、「分かりやすい」という人と「分かりづらい」という人で結構差があるんじゃないかなと思っています。そんな中でどのようにStreamAPIと付き合っていくかの参考になればと思って書きます。
前提
- チーム全体が若い(全員Javaでの開発経験3年以下)
- Java7ベースでの研修を受けており、Java8で追加された標準APIについては学んでいない
- 開発で使用するのはJava8以降なのでJava8のAPIをガシガシ使うお
要するに関数インターフェースもStreamもOptionalも初めましてな人たちが、Java8で開発してるよという感じです。
結論
- 中間操作(
Stream#map
やStream#filter
)の引数はメソッド参照で書く -
sort
で複数のキーを使って並び替えるなら、java.util.Comparator
を実装して書く -
Stream#collect
のボイラープレート的な処理は他に切り出す
とにかく言いたいことは、メソッドチェーンに何でも詰め込むのはやめようねということです。
StreamAPIで書いたコードがわかりづらくなる要因
ラムダ式とかメソッド参照とか書き方が色々ある
関数型オブジェクトを生成する方法はいくつかありますね。
文字列を受け取り、上で定義したStringUtils#isEmpty
を呼び出して結果を戻す関数オブジェクトを定義します。
Function<String, Boolean> emptyChecker1 = new Function<>{
@Override
public Boolean apply(String s) {
return StringUtils.isEmpty(s);
}
}
Function<String, Boolean> emptyChecker2 = s -> StringUtils.isEmpty(s);
Function<String, Boolean> emptyChecker3 = StringUtils::isEmpty;
特にメソッド参照が難しくて、::
の左側にクラスの名前を書くか変数名を書くかによって、どのメソッドを呼び出すか変わるし、場合によってごにょごにょされる。
引数の関数が複雑すぎてよく分からない
Java開発経験1年目の子がいるところでこんなコード見せられない(懺悔)。
list.getValues().forEach(value -> {
if (CollectionUtils.isEmpty(value.getPropertyList())) {
return;
}
if (!CollectionUtils.isEmpty(value.getPropertyList())) {
Property property = value.getPropertyList().stream()
.findFirst()
.orElseThrow(RuntimeException::new);
service.insert(value, property);
return;
}
switch (value.getType()) {
case TYPE_1:
value.getProperty1().setAmount(1000);
break;
case TYPE_2:
value.getProperty2().setAmount(1000);
break;
case TYPE_3:
value.getProperty3().setAmount(1000);
break;
}
service.insert(value);
});
わたしは処理の塊ごとに段落があることを意識してソースコードを読むようにしているのですが、forEach
の引数がこれほど長いと一息で読みきれなくて結構しんどい思いをします。3年目のわたしがこれだからきっと1年目の子たちは・・・
終端処理を書くのが結構大変
Listに変換するだけであればCollectors.toList()
を使えば一瞬で片付くのですが、ListをMapに変換する単純な処理を書くのが結構しんどいなと思ったりする。重複があったらどうするのか?など初心者からするとハードルが高いし、書くのも面倒です。
Map<Key, List<Value>> map = values.stream()
.collect(Collectors.groupingBy(Value::getKey));
(今後運用したいと思った)StreamAPI周りのルール
経験が浅いメンバーが多く在籍していることを前提にいくつかのルールを策定しました。
中間操作の引数はメソッド参照で書く
目的は、中間操作Stream#map
やStream#filter
の引数をシンプルに保ち、可読性を向上させることです。このルールには以下のメリットがあると考えています。
-
クラス名(変数名)::メソッド名
で呼び出すので、何をしているのかが分かりやすい - 自由に関数オブジェクトを定義できるラムダ式と違って、シンプルさを保つことができる
- コレクション要素に対する操作を要素の型に閉じ込められる
3つ目について少し分かりづらいので、中間試験のクラス内平均点を求めるプログラムの例を使って説明します。
ちなみに中間試験は国語と数学と英語の3科目を想定し、ExaminationScoreSummary#average
の実装について考えます。
public class ExaminationScore {
private final Integer japaneseScore;
private final Integer mathScore;
private final Integer englishScore;
// constractor, getter
}
public class ExaminationScoreSummary() {
private final List<ExaminationScore> values;
// constractor, getter
public Integer average() {
// TODO
}
}
ラムダ式を使う場合
いかようにも実装できます。わたしがいつも書いちゃう感じで書きます。
public class ExaminationScoreSummary() {
private final List<ExaminationScore> values;
// constractor, getter
public Integer average() {
return values.stream()
.mapToInt(score -> score.getJapaneseScore() + score.getMathScore() + score.getEnglishScore())
.average();
}
}
いや、まあこれでもいいんだけど、合計点求める時とかも毎回呼び出し元で点数を足し合わせるのはね…と。
メソッド参照を使う場合
メソッド参照の場合は、そもそも呼び出し側で足し算をするのが不可能なので、ひとまず足し算をする処理をExaminationScore
クラスに書きます。
public class ExaminationScore {
private final Integer japaneseScore;
private final Integer mathScore;
private final Integer englishScore;
// constractor, getter
public Integer getTotalScore() {
return japaneseScore + mathScore + englishScore;
}
}
経験豊富な人からすると「同じクラスのフィールド同士の計算はフィールドが定義されたメソッドに書いて凝集性を高める」ことが当たり前にできるのかもしれません。でもわたしレベルだとそういうの中々難しいんですよ。メソッド参照を使うことをルール付ければオブジェクト指向プログラミングの基本的な考え方も身につきますよというお話です。
呼び出し元はの実装はこんな感じです。
public class ExaminationScoreSummary() {
private final List<ExaminationScore> values;
// constractor, getter
public Integer average() {
return values.stream()
.mapToInt(ExaminationScore::getTotalScore)
.average();
}
}
複数のキーでのsortは、java.util.Comparator
を使う
中間試験の点数高い順に掲示しようと思ったとします。いやいや点数晒すとか問題あるんじゃ…とかは置いておいて笑
3科目あるので、国語の点数が高い順、国語の点数が同じであれば数学の点数が高い順に並び替えます。
Comparator#comparing
やComparator#thenComparing
を使えば、以下のように実装することが可能です。
List<ExaminationScore> values = new ArrayList<>();
values
.stream()
.sorted(Comparator.comparing(ExaminationScore::getJapaneseScore).thenComparing(ExaminationScore::getMathScore())
.collect(Collectors.toList());
並び替えもこんなに簡単にできるなんて便利です。ところで、並び替え順が複雑になったら全部ここに書くんだろうか?いやいやいや、それ結構しんどいですよ。中学校になったら5教科だったり実技科目も試験があったりしたらめちゃくちゃ長いコードになりますよ。
「どんな順番でソートするか」という定義を別の場所に書く2つの方法を紹介します。
1. コレクションの要素にComparableインターフェースを実装させる。
並べ替えの仕方をコレクション要素の型に定義するやり方です。まずは並び替えの仕方を定義します。
手順は以下の2つだけ。
- 要素のクラス宣言に
implements Comparable<要素のクラス>
を追記 - 要素のクラスで
public int compareTo(要素のクラス o)
を実装
今回は国語の点数、数学の点数、英語の点数の順に並び替えますから、以下のように実装しました。
- 国語の点数が等しくなければ、国語の点数の比較結果を
ExaminationScore
の比較結果として扱う - 国語の点数が等しく、数学の点数が等しくなければ、数学の点数の比較結果を
ExaminationScore
の比較結果として扱う - 国語、数学の点数が等しく、英語の点数が等しければ、英語の点数の比較結果を
ExaminationScore
の比較結果として扱う
// 1. 要素のクラス宣言に`implements Comparable<要素のクラス>`を追記
public class ExaminationScore implements Comparable<ExaminationScore> {
private final Integer japaneseScore;
private final Integer mathScore;
private final Integer englishScore;
// constractor, getter
// 2. 要素のクラスで`public int compareTo(要素のクラス o)`を実装
public int compareTo(ExaminationScore o) {
if (japaneseScore.compareTo(o.japaneseScore) != 0) {
return japaneseScore.compareTo(o.japaneseScore);
}
if (mathScore.compareTo(o.mathScore) != 0) {
return mathScore.compareTo(o.mathScore);
}
return englishScore.compareTo(o.englishScore);
}
}
わたしはComparable#compareTo
の戻り値で何を返せばよいのか忘れてしまうので、できるだけ並び替えのキーにする変数の比較結果を返すだけの簡単な実装を心がけています。
並び替えは以下のように行います。点数の高い順に並び替えるのでComparator#reverseOrder()
を呼び出しています。
List<ExaminationScore> values = new ArrayList<>();
values
.stream()
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
Comparable#compareTo
メソッドで「点数が高ければ-1」というふうに実装すればStream#sorted
の引数を省略できるのですが、分かりづらいので避けました。
2. Comparatorインターフェースを実装したクラスを別に作る
1と違って並び替えの順番を別のクラスに定義します。手順は以下のとおりです。
-
Comparator<要素のクラス>
を実装したクラスを作成 -
public int compare(要素のクラス o1, 要素のクラス o2)
を実装
class ExaminationScoreComparator implements Comparator<ExaminationScore> {
@Override
public int compare(ExaminationScore o1, ExaminationScore o2) {
if (Integer.compare(o1.getJapaneseScore(), o2.getJapaneseScore()) != 0) {
return Integer.compare(o1.getJapaneseScore(), o2.getJapaneseScore());
}
if (Integer.compare(o1.getMathScore(), o2.getMathScore()) != 0) {
return Integer.compare(o1.getMathScore(), o2.getMathScore());
}
return Integer.compare(o1.getEnglishScore(), o2.getEnglishScore());
}
}
並び替えは以下のように行います。Stream#sorted
の引数に上で定義したComparatorのインスタンスを渡します。
List<ExaminationScore> values = new ArrayList<>();
values
.stream()
.sorted(new ExaminationScoreComparator().reverseOrder())
.collect(Collectors.toList());
Comparable vs Comparator
結局どっちを使うのかという話ですが、基本的にComparator
を使って実装しましょう。
自然順序付けでは、equalsと一貫性があることは、必須ではありませんが強く推奨されます。これは、明示的なコンパレータを指定しないソート・セットやソート・マップを、自然順序付けがequalsと一貫性のない要素またはキーと一緒に使用すると、セットとマップの動作が保証されなくなるからです。特に、このようなソート・セットまたはソート・マップは、セットまたはマップの一般的な規約に違反します。この規約は、equalsメソッドの用語を用いて定義されています。
公式ドキュメントにも書いてありますが、equals
とcompareTo
に矛盾があると、Map
などでの動作が保証されなくなります。Comparable
を実装したクラスがキーのMap
では、Map#get
でキーのequals
ではなく、compareTo
の結果を用いるので、変なバグを踏むことになります。
Java ComparableとComparator どちらを使うかにも同様のことが書いてありました。
Stream#collect
のボイラープレート的な処理は他に切り出す
コレクション要素内の特定のフィールドを取り出して新しいコレクションを作ったり、コレクション内の特定のフィールドをキーにしてMapにしたりすることはよくあることだと思います。こういうのはいちいちStreamAPIに触れさせずとも使えるようにしといたほうが良いと考えています。
/**
* リスト要素から別のインスタンスを生成し、生成したインスタンスのリストを返却する。<br>
* インスタンスの生成ロジックは第二引数で与えられた関数オブジェクトに従う。<br>
* @param list リスト
* @param generator リストの要素から別の型のインスタンスを生成する関数オブジェクト
* @param <S> 元のリスト要素の型。
* @param <R> 新しいリスト要素の型。
* @return 生成したインスタンスのリスト
*/
public static <S, R> List<Property> collect(List<S> list, Function<S, R> extractor){
return list.stream()
.map(extractor)
.collect(Collectors.toList());
}
/**
* リストを特定のキーでグルーピングし、キーとリストが対になったMapを返却する。<br>
* キーの生成ロジックは第二引数で与えられた関数オブジェクトに従う。<br>
* @param list グルーピング対象のリスト
* @param keyExtractor リストの要素からリストのキーを取得する関数オブジェクト
* @param <K> キーの型。Comparableインターフェースを実装したクラスである必要がある
* @param <V> リストの要素の型
* @return グルーピング結果
*/
public static <K, V> Map<K, List<V>> groupingBy(List<V> list, Function<V, K> keyExtractor) {
return list.stream().collect(Collectors.groupingBy(keyExtractor));
}
最後に
関数インターフェースやStreamAPIはとても便利なのですが、経験の浅いメンバーが多いチームでは逆にソースコードの可読性が下がり、生産性が下がるかもしれません。この記事では3年目のわたしが考えた戯言についてつらつらと書きましたが、同じようにチームの中で書き方を工夫して、新しいJavaの機能を使ってチームの生産性が上がったみたいなことがあったら嬉しいなって思ったりしています。