この記事は エムスリー Advent Calendar 2015 の10日目の記事です。
はじめに
最近、かなり古くからある Java6 ベースの巨大サービスを Java8 に移行しました!(パチパチ)
lombok や RetroLambda を入れるのは怖くてできなかったのと、一度 Java7 移行に失敗していたため、メンバーの喜びもひとしおです。Java8 向けのコーディングスタイルを決めるミーティングでも、心なしかチームメンバーからワクワク感が感じられます。
早速ラムダや Optional を使いまくりの日々でしたが、しばらくするとソースコードレビューで長々と議論が始まってしまったため、一度、皆でどうしたらよいか話し合うことになりました。
この記事はそこで出てきた関数の戻り値の返し方パターンについてまとめたものです。
余談ですが、Guava の Optional は Java8 の Optional と混在すると大変見づらくなるため、非推奨になりました。さようなら Guava、楽しかったよ……。一方で DateTime API は非推奨になりました。jodatime の勝ち!
お題
ここでは、以下のような処理をどう書くかについて整理していきます。
- 失敗する可能性のある処理 process1 と process2 がある。
- process2 は process1 の結果を使う
- 失敗したときにログを出すなど何らかの処理が必要
例外パターン
// 関数側
Result process1() throws Process1Exception {
if (success) {
return new Result.success();
} else {
throw new Process1Exception();
}
}
// 呼び出し側
try {
final Result result1 = process1();
try {
final Result result2 = process2(result1);
// 後続処理
} catch (Process2Exception e) {
// process2 失敗時の処理
} catch (Process1Exception e) {
// process1 失敗時の処理
}
Java の設計思想としてはこれが固いと思います。設計がしっかりしているフレームワークの中での利用は確かに安心感があります。
ただ、しょっちゅう作っては捨てる軽いビジネスロジックのためにいちいち例外を定義したり catch したりしているとかなり重みを感じるのも事実です。catch した後も、return していいんだっけ、再 throw すべきかな、何でラップするのがよいか、など考えることが結構増えます。
現実的にはちゃんと例外返してくるケースはほとんどありません。(APIのクライアントなどに限られる)
null返却パターン
// 関数側
Result process1() {
return success? Result.success() : null;
}
// 呼び出し側
Result result1 = process1();
if (result1 != null) {
Result result2 = process2();
if (result2 != null) {
// 後続処理
} else {
// process2 の失敗時の処理
}
} else {
// process1 の失敗時の処理
}
// result1 は null かもしれない
Javaっぽいコードで、よく出現します。このパターンの難点は、ネストが深くなりがちなことと、null になってしまった変数が null 判定の if の後もずっと参照できてしまい、毎回 null チェックが必要と感じてしまうことです。
また、失敗時の処理が呼び出しと離れてしまい、構造を頭に入れるのにワーキングメモリが多めに必要です。(1メソッド1000行とかあるとね……)
early return パターン
上記のパターンの改良として見られるのが以下のパターンです。
// 呼び出し側
Result result1 = process1();
if (result1 == null) {
// process1 失敗時の処理
return;
}
Result result2 = process2(result1);
if (result2 == null) {
// process2 失敗時の処理
return;
}
// 以降は result1, result2 とも Nonnull が保証されている
これは視覚的にも読みやすく書き手の意図も読み取りやすいです。ただ、リファクタで extract method が非常にやりにくくなりますが……
Java7以前の世界では、基本的にはこれがおすすめです。
「失敗値」パターン
null を返すのではなく、失敗を表す値 を返すパターンです。
x.isSuccess()
や x == Result.SUCCESS
や x instanceof SuccessResult
などで判断するバリエーションがあると思います。
ここでは戻り値の型が isSuccess()
を持つという例にしました。
// 関数側
Result process1() {
return success ? Result.sucess() : Result.failure();
}
// 呼び出し側
Result result1 = process1();
if (result1.isSuccess()) {
Result result2 = process2(result1) {
if (result2.isSuccess()) {
// 後続処理
} else {
// process2 失敗時の処理
}
} else {
// process1 失敗時の処理
}
こうすると確かに null はなくなり NPE の恐怖からは逃れられますが、結局、result が失敗を表すものなのか成功を表すものなのかを毎回気にしなければならないので、本質的にはあまり変わっていないように思います。むしろ NPE が出ない分、検出しにくいバグを生みそうです。
このパターンでももちろん early return パターンにすることはできます。
戻り値の型をプリミティブにして、int で -1 は失敗、というのもこれのバリエーションですね。C言語っぽい。
今回はあまり推奨案にしていませんが、この考え方を拡張した結果が、後で触れる Optional や Either (この記事では触れません)だと考えることもできます。
isPresent パターン
満を持して Optional です。最初にやりがちなのがこのパターンです。
// 関数側
Optional<Result> process1() {
return success? Optional.of(Result.success()) : Optional.empty();
}
// 呼び出し側
Optional<Result> result1 = process1();
if (result1.isPresent()) {
Optional<Result> result2 = process2(result1.get());
if (result2.isPresent()) {
// 後続処理
} else {
// process2 失敗時の処理
}
} else {
// process1 失敗時の処理
}
これは各所でも dis られている通り、null を全然駆逐していません。さらに result1 が optional なのかどうかをいつも気にしなければならなくなります。null かどうかも気にしなければならないうえに、(コンパイラが助けてくれるとはいえ)Optional でくるまれているのか、中身だけの変数なのかも気にしなければならず、つらいです。禁止パターンですね。
といいつつ私も最近書いてしまいました。ゴメンナサイゴメンナサイ
Optional#get なんてなければいいのに
拡張 for パターン
scala をやったことがあると、for で書きたくなります。
実際、Java8 移行前は Guava 全盛だったので、Optional#asSet と組み合わせれば for の内部では Nonnull が保証できる! と少し流行りました。
Java8 の Optional は お手軽に Iterable に変換できないので、無事(?)このパターンは廃止になりました。
for (Result result1 : process1().asSet()) {
for (Result result2 : process2(result1).asSet()) {
// 後続処理
}
}
そもそも失敗時の処理がかけなーい!
ifPresent パターン
Optional#ifPresent(Consumer) というのがあります。
isPresent と似ていますが、イズではなくイフです。処理の中身をラムダで渡します。
process1().ifPresent(result1 -> {
process2(result1).ifPresent(result2 -> {
// 後続処理
});
});
これも失敗時の処理、どうやって書きましょうか? 汎用的ではないです。
型宣言をいちいち書かなくて済むのでそれは良いのですが・・・惜しい。
map/flatMap パターン
Optional を導入したら必ずおすすめされるのがこの書き方です。
process1()
.flatMap(result1 -> process2(result1))
.map(result2 -> {
// 後続処理
return true;
})
.orElseGet(() -> {
// process1 または process2 失敗時の処理
return false;
};
今風で Nonnnul 保証できているし型宣言も冗長にならなくてかっこいい!しかも if を使った場合と違って戻り値があって Immutable な感じ!
null を返してくる人が混じっていても、Optional#ofNullable でくるんでやればええんやで!
// 例えば process2 は 失敗時 null な人だとすると
process1()
.flatMap(result1 -> Optional.ofNullable(process2(result1))) // <-- ココ
.map(result2 -> {
// 後続処理
return true;
})
.orElseGet(() -> {
// process1 または process2 失敗時の処理
return false;
};
あれ?でも、失敗時の処理が共通になってしまっています。
Optional Early empty パターン
失敗時の処理をそれぞれ別にやろうとすると、優しかった Optional が急に牙をむきます。
※2015.12.10追記:以下のコード、間違いがありました。コメント欄に修正版を書きました
process1()
.map(result1 -> Optional.of(result1)) // ここでは Optional<Optional<Result>> 型
.orElseGet(() -> {
// process1 失敗時の処理
return Optional.empty();
}) // ここで Optional<Result> に戻る(中身は result1)
.map(result1 -> process2(result1)) // ここでは Optional<Optional<Result>> 型
.orElseGet(() -> {
// process2 失敗時の処理
return Optional.empty();
}) // ここで Optional<Result> に戻る (中身は result2)
.ifPresent(result2 -> {
// 後続処理
});
えーと…… 大分苦労しました。flatMap と map を行ったり来たり。
「この程度もすぐにわからぬとは愚か者め!」という、偉い人からのマサカリを待ちます。
どうも null 大魔王を倒して表の世界は平和になったものの、裏の世界では住民がモナドに苦しめられ、Optional を攻略できたとしても「フフフ……やつはまだモナド四天王の中では最弱……」とかいわれている感じです。ううむ。
とはいえ、タイムリーに届いたこういう記事 などを読ませていただくとやっぱり挑戦したくなるんですけど……
まとめ
関数型のブームに真っ向からぶつかるようですが、現実的なところで以下に落ち着きました。
- null が避けられないならさっさとあきらめて early return パターン
- 全てが Optional な世界になっているなら map/flatMap パターン
- Optional#get ダメ、ゼッタイ
2016年は null とも Optional とも仲良く過ごしたいです。やはり魔改造 javac + Elvis Operator の出番か!