Java の語彙で Maybe を説明してみる
java-jaで例外処理の話をしてきました - 西尾泰和のはてなダイアリー
を読んで。
Maybe は値があるかないかを型で表すことができます!そう、直和型なんです!とか言われてもイミフだと思うのです(リンク先のエントリがそう説明してるわけではないですが)。
Java の語彙で Maybe の説明をできたら嬉しい人もいるんじゃないかなぁ、とかなんとか。
ただし、書いてたら結構長くなりました。時間がある人はどうぞ。
Maybe? null より安全に「値がないこと」が扱えるものだよ
スタート地点としてはこれでいいでしょう。
以降で、「なんで安全なの?」という全うな疑問に答えてみたいと思います。
問題点
int で説明すると煙に巻いてしまうような気がしたので、User クラスを見てみます。
import java.util.*; class User { final String name; final int age; User(String name, int age) { this.name = name == null ? "" : name; this.age = age; } } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } // 問題となるメソッド public User findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return u; } return null; } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); User u = us.findByName("foo"); if (u != null) { System.out.printf("name: %s, age: %d%n", u.name, u.age); } } }
main メソッドで null チェックを忘れると、NullPointerException で落ちます。
これを指して「null は安全ではない」と言っています。
こんな小さな例ならまだしも、普通はもっと離れた場所で呼び出しますよね。
このときの態度として、Java プログラマとしては
- ドキュメンテーションコメントに書くべき!
- 例外を使うべき!
- Null Object パターンだ!
あたりを思い浮かべるのではないでしょうか。
ただ、現実を見てみると・・・
- ドキュメンテーションコメント書かないプログラマは普通にいる
- ドキュメンテーションコメント読まないプログラマもいる
- 例外握りつぶすプログラマは普通にいる
- 今回の問題は単純な Null Object パターンできれいに解決できない
最初 3 つは (お察しください) なのでどうしよう・・・規約で縛るしか!でも規約は無視されるものだし、機械的に強制するとコードにノイズが増えてしまいがちです*1。
最後の問題はこういうことです。
import java.util.*; class User { static final User NULL_USER = new User("", -1); // Userを継承して・・・とか面倒なのでこれで final String name; final int age; User(String name, int age) { this.name = name == null ? "" : name; this.age = age; } } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } public User findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return u; } // nullの代わりとなるオブジェクトを返す return User.NULL_USER; } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); User u = us.findByName("foo"); // nullチェックは不要! System.out.printf("name: %s, age: %d%n", u.name, u.age); } }
この変更で findByName は null を返すことはなくなりました。めでたしめでたし。
・・・って、違いますよ!動き変わっちゃってます!
最初の例では、見つからなければ何も出力されませんでした。が、この例では「"name: , age: -1"」って出力されちゃいます。
null チェックしなくとも例外で落ちることはなくなりましたけど、これでは駄目ですね。
かといって if (u != User.NULL_USER) ... と書くのであれば、それ null チェック入れるのと何が違うのさ・・・という。
もうちょっと Null Object パターン
いやいや、さっきのは API がダメだったんだ!
ということで、より柔軟な API を目指してみます。
import java.util.*; class User { interface Case { // NULL_USERの場合に呼び出される処理 void nullCase(); // NULL_USERではない場合に呼び出される処理 // uにはnullではないUserが入ってくる void notNullCase(User u); } static final User NULL_USER = new User("", -1); final String name; final int age; User(String name, int age) { this.name = name == null ? "" : name; this.age = age; } void proc(Case c) { if (this == NULL_USER) c.nullCase(); else c.notNullCase(this); } } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } public User findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return u; } return User.NULL_USER; } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); User u = us.findByName("foo"); // nullチェック不要! u.proc(new User.Case() { public void notNullCase(User u) { System.out.printf("name: %s, age: %d%n", u.name, u.age); } public void nullCase() { /* do nothing */ } }); } }
え、null チェック不要だけど書く量増えてるしこれに何の意味があるの・・・
これが嬉しいのは、「値がないときの処理を書くことを (だいたい) 強制できる」点です。
でも、findByName から返ってくる User オブジェクトをそのまま使ってしまえるため、正直意味ないです。
だいたい強制できる、ではなく強制したい!のです。
あとちょっとだけ Null Object パターン
値がないときの処理を書くことを強制してしまいましょう。
import java.util.*; class User { final String name; final int age; User(String name, int age) { this.name = name == null ? "" : name; this.age = age; } } // このクラスを用意するのがミソ class ResultUser { private final User result; ResultUser(User result) { this.result = result; } interface Case { // ResultUserのresultがnullの場合に呼び出される処理 void nullCase(); // ResultUserのresultがnullではない場合に呼び出される処理 // uにはnullではないUserが入ってくる void notNullCase(User u); } void proc(Case c) { // this.resultの中身に応じて、呼び出すメソッドを振り分ける if (this.result == null) c.nullCase(); else c.notNullCase(this.result); } } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } // ResultUserで包んで返す public ResultUser findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return new ResultUser(u); } return new ResultUser(null); } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); // ResultUserはUserではないので、直接Userとして使うことはできない ResultUser r = us.findByName("foo"); // かならずprocを使って、「値が無い場合の処理」を書くことを強制! r.proc(new ResultUser.Case() { public void notNullCase(User u) { System.out.printf("name: %s, age: %d%n", u.name, u.age); } public void nullCase() { /* do nothing */ } }); } }
これでどうだ!
このバージョンでは、findByName から返ってくるのは User ではなく ResultUser です。
ResultUser から実際の User を使うためには、proc メソッドを使い、Case インターフェイスの notNullCase メソッドの引数として渡してもらうしかありません。
これで、User に関して null の問題で頭を悩ませる必要はなくなりました*2。
長くて面倒だ、と感じるかもしれません。が、安心が手に入る代償だと思えば安いものだと・・・あ、無理ですか。
これに関してはまた後程述べますので、今はスルーしておいてください(解決策があるというわけではないです)。
ResultUser をもっと汎用的にしてみる
null を避けたいクラスに対して、いちいち Result なんとかを作るのは DRY に反します。
ので、これをもっと汎用的にしてみましょう。
色んなクラスで使いたいので、ジェネリクスを使います。
public final class Result<E> { private final E result; public Result(E result) { this.result = result; } public interface Case<E, R> { R nullCase(); R notNullCase(E result); } public <R> R proc(Case<E, R> c) { if (this.result == null) return c.nullCase(); return c.notNullCase(this.result); } }
この Result クラスを使ってさっきの User の例を書き直してみます。
import java.util.*; class User { /* 略 */ } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } public Result<User> findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return new Result<User>(u); } return new Result<User>(null); } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); Result<User> r = us.findByName("foo"); String msg = r.proc(new Result.Case<User, String>() { public String notNullCase(User u) { return String.format("name: %s, age: %d%n", u.name, u.age); } public String nullCase() { return ""; } }); System.out.print(msg); } }
こんな感じでしょうか。
汎用性を持たせるために、処理の戻り値の型も指定できるようにしたため、微妙にロジックを変えました。
Maybe について
やっと Maybe まで来ました。
といっても、さっきの Result クラスが Maybe です。
そう、名前変えただけです。
// ResultからMaybeに変えた(警告を潰すのは読者への課題とします。めんd・・・) public final class Maybe<E> { private final E result; // privateにして直接アクセスできないようにした private Maybe(E result) { this.result = result; } // new Maybe<E>(hoge)の代わりに使うメソッド public static <E> Maybe<E> just(E notNullValue) { return new Maybe<E>(notNullValue); } // new Maybe<E>(null)の代わりに使うメソッド public static <E> Maybe<E> nothing() { return new Maybe<E>(null); } public interface Case<E, R> { // nullCaseからifNothingに変えた R ifNothing(); // notNullCaseからifJustに変えた R ifJust(E result); } // procからmatchに変えた public <R> R match(Case<E, R> c) { if (this.result == null) return c.ifNothing(); return c.ifJust(this.result); } }
これを、ポリモーフィズム使って実現するとこうなります。
package maybe; public final class MaybeModule { private MaybeModule() {} public static <E> Maybe<E> just(E notNullValue) { return new Just<E>(notNullValue); } public static final Maybe NOTHING = new Nothing(); public static <E> Maybe<E> nothing() { return NOTHING; } public interface Maybe<E> { <R> R match(Case<E, R> c); } public interface Case<E, R> { R ifNothing(); R ifJust(E value); } // 値がある時 public static final class Just<E> implements Maybe<E> { private final E value; public Just(E value) { this.value = value; } @Override public <R> R match(Case<E, R> c) { return c.ifJust(this.value); } } // 値がない時 public static final class Nothing<E> implements Maybe<E> { @Override public <R> R match(Case<E, R> c) { return c.ifNothing(); } } }
一つ一つの public な class / interface を別ファイルに切り出すのが面倒なので MaybeModule を static import して使う想定です。
これを最終版として、もう一度 User の例を見てみます。
import java.util.*; import static maybe.MaybeModule.*; class User { /* 略 */ } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } public Maybe<User> findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return just(u); } return nothing(); } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); Maybe<User> r = us.findByName("foo"); String msg = r.match(new Case<User, String>() { public String ifJust(User u) { return String.format("name: %s, age: %d%n", u.name, u.age); } public String ifNothing() { return ""; } }); System.out.print(msg); } }
just やら Just のコンストラクタやらに null を渡すとダメという点で、ポリモーフィズムなし版より残念なことになっていますが、just に関しては notNullValue が null なら nothing 呼び出しに切り替えることで回避可能です。
また、Just のコンストラクタは公開しなければどうということはありませんので、あまり重要な問題ではありません。
重要なのは、「Maybe は汎用的な Null Object と考えることができる」という点です。
恐れることは何もありません。
Java 版 Maybe についての考察
さて、Java で Maybe を作ってみました。
こいつと、他の手法を比較してみましょう。
null | 例外 | Maybe | |
---|---|---|---|
ぬるぽの恐怖 | ある | ない | ない |
チェックの漏れ | ある | 握り潰しの可能性 | ない*3 |
チェックが必要かどうかの判断 | ドキュメンテーションコメント | 同左*4 | 型*5 |
コーディング量 | 少 | 大 | 最大 |
効率*6 | 最速? | まぁまぁ? | 悪そう |
こんな感じでしょうか。
なぜ null より安全に「値が無いこと」を表せるのか、それは ResultUser を導入したバージョンに集約されています*7。
- User を直接触らせない
- 引数で渡ってくる User は null になり得ない
- 値が「ない」場合を考慮することをコンパイラが強制してくれる
ちょっとコード量が多すぎるのは、Java にラムダ式がないからです。
しかし、Java 8 からはラムダ式が乗ります。
これを使った場合どのようなコードになるのか見てみます。
※以降、微妙に難しいかも。ここまでで当初の目的である「なんで安全なの?」までは終わったので、以降はオマケとして読んでもらえれば。
ラムダ式
まず、Maybe はこう書き直します。
// Java8だよ! package functions; public FunctionModule { public enum Unit { _ } public interface Func1<A, R> { R apply(A arg); } }
// Java8だよ! package maybe; import static functions.FunctionModule.*; public final class MaybeModule { private MaybeModule() {} public static <E> Maybe<E> just(E notNullValue) { return new Just<E>(notNullValue); } public static final Maybe NOTHING = new Nothing(); public static <E> Maybe<E> nothing() { return NOTHING; } public interface Maybe<E> { // Caseインターフェイスは削除 // かわりに、Func1を受け取るように変更(Func0作って、それを使ってもよい) <R> R match(Func1<E, R> ifJust, Func1<Unit, R> ifNothing); } public static final class Just<E> implements Maybe<E> { private final E value; public Just(E value) { this.value = value; } // CaseインターフェイスのifJustメソッドではなく、Func1インターフェイスのapplyメソッドを呼び出すように変更 @Override public <R> R match(Func1<E, R> ifJust, Func1<Unit, R> _) { return ifJust.apply(this.value); } } public static final class Nothing<E> implements Maybe<E> { // Justと同じように、Func1インターフェイスのapplyを呼び出すように変更 @Override public <R> R match(Func1<E, R> _, Func1<Unit, R> ifNothing) { return ifNothing.apply(Unit._); } } }
使う側はこうなります。
// Java8だよ! import java.util.*; import static maybe.MaybeModule.*; import static functions.FunctionModule.*; // 必要か分からないけど一応 class User { /* 略 */ } public class Users { private final List<User> users = new ArrayList<User>(); public Users(User...users) { this.users.addAll(Arrays.asList(users)); } public Maybe<User> findByName(String name) { for (User u : this.users) { if (u.name.equals(name)) return just(u); } return nothing(); } // サンプル用mainメソッド public static void main(String[] args) { Users us = new Users(new User("hoge", 10), new User("piyo", 20)); Maybe<User> r = us.findByName("foo"); String msg = r.match( u -> String.format("name: %s, age: %d%n", u.name, u.age), _ -> ""); System.out.print(msg); } }
かなりスッキリしました。
これ、わざわざ変数で受ける必要ないので、
String msg = us.findByName("foo").match( u -> String.format("name: %s, age: %d%n", u.name, u.age) _ -> ""); System.out.print(msg);
でいいです。
ラムダ式版との比較
null チェックと、findByName が例外投げる場合と比べてみましょう。
// nullチェックの場合 User u = us.findByName("foo"); if (u != null) { System.out.printf("name: %s, age: %d%n", u.name, u.age); } // findByNameが例外を投げる場合 try { User u = us.findByName("foo"); System.out.printf("name: %s, age: %d%n", u.name, u.age); } catch (Exception e) { /* do nothing */ } // Maybe(Java8) String msg = us.findByName("foo").match( u -> String.format("name: %s, age: %d%n", u.name, u.age) _ -> ""); System.out.print(msg); // もしくはMaybeにmatchAndActというメソッドを追加して・・・(Java8) us.findByName("foo").matchAndAct( u -> { System.out.printf("name: %s, age: %d%n", u.name, u.age); }, _ -> {});
あれ、これアリじゃないですかね。
toString を実装している場合は、一番安全にもかかわらず、一番短く書けますね。
// nullチェックの場合 User u = us.findByName("foo"); if (u != null) { System.out.println(u); } // findByNameが例外を投げる場合 try { User u = us.findByName("foo"); System.out.println(u); } catch (Exception e) { /* do nothing */ } // Maybe(Java8) us.findByName("foo").matchAndAct(System.out::println, _ -> {});
Maybe の欠点
まだまだ考慮が足りてない部分とかあるとは思いますが、Java8 だと Maybe を使うのは割とアリなんじゃないでしょうか。
今度は、Java8 で書いた Maybe の欠点を見て行きます。
例えば、キーを 2 つ取って、db という Map からそれらに対応する値を取り出し、加算する関数を考えます*8。
// Java8だよ! import java.util.*; import static maybe.MaybeModule.*; import static functions.FunctionModule.*; public class Sample { private static Map<String, Integer> db = new HashMap<String, Integer>() {{ put("x", 1); put("y", 2); }}; private static Maybe<Integer> tryFind(String key) { if (db.containsKey(key)) return just(db.get(key)); return nothing(); } public static Maybe<Integer> plus(String key1, String key2) { // Justの場合は処理を続けるが、Nothingが現れたら以降の処理は実行せず、Nothingを返す return tryFind(key1).match( v1 -> tryFind(key2).match( v2 -> just(v1 + v2), _ -> nothing()), _ -> nothing()); } // サンプル用mainメソッド public static void main(String[] args) { Maybe<Integer> res1 = plus("x", "y"); // Just(3) Maybe<Integer> res2 = plus("x", "z"); // Nothing Maybe<Integer> res3 = plus("a", "y"); // Nothing Maybe<Integer> res4 = plus("a", "b"); // Nothing } }
ちょっとどうインデントすべきか分かりません。
また、加算対象となるキーが増えた場合を考えると・・・やめましょう。
ちなみに、例外を投げる場合は何も考えずに実装できます。
だって失敗したら例外が投げられるんだから、メソッドの最後に到達したらそれは key1 も key2 も対応する値があったということですからね。
null を返す場合は、チェックコードが散乱してしまって嬉しくないです。
バインド!
match のネストを解決するために、Maybe にメソッドを足しましょう。
// Java8だよ! package maybe; import static functions.FunctionModule.*; public final class MaybeModule { // 略 public interface Maybe<E> { <R> R match(Func1<E, R> ifJust, Func1<Unit, R> ifNothing); // bindメソッドを追加 // 「以降の処理」がrestとして渡される <R> Maybe<R> bind(Func1<E, Maybe<R>> rest) default { return match( v -> rest.apply(v), _ -> nothing()); } } // 略 }
このメソッドを使うと、先ほどのコードはこうなります。
// Java8だよ! import java.util.*; import static maybe.MaybeModule.*; import static functions.FunctionModule.*; public class Sample { // 略 public static Maybe<Integer> plus(String key1, String key2) { return tryFind(key1).bind( v1 -> tryFind(key2).bind( v2 -> just(v1 + v2))); } // 略 }
tryFind(key1) が Just ならそれに包まれた値が v1 に入り、さらに tryFind(key2) が Just ならそれに包まれた値が v2 に入り、just(v1 + v2) が実行されます。
途中で一つでも Nothing が返されれば、それ以降の処理は実行されず、全体として Nothing が返されます。
以降の式で使える変数 (v1 や v2) のインデントを合わせることができるため、先ほどよりは読みやすいかと思います。
ただし、通常は変数は
変数 = 値
// 変数を使った処理の続き
のように右から左に流れるイメージですが、bind はすべて左から右に流れます。
値.bind(変数 -> /* 変数を使った処理の続き */);
これを例えば Scala では
val db = Map("x" -> 1, "y" -> 2) def plus(key1: String, key2: String) = for { v1 <- db.get(key1) // getはtryFind相当 v2 <- db.get(key2) } yeild v1 + v2
と、通常の変数と同じ感覚で読める構文を提供しています。
Java も、Maybe に Iterable を実装して
public static Maybe<Integer> plus(String key1, String key2) { for (Integer v1 : tryFind(key1)) for (Integer v2 : tryFind(key2)) return just(v1 + v2); }
とできなくはないんですが、ううむ、ここまでやっていいものか・・・*9
それにこのコード、IDE はおそらく 2 つ目の for をインデントします。
もういっこ
先ほどの例では、Just が返される限り続けました (&& みたいな感じ)。
こんどは、最初の Just を返すものを見てみましょう (|| みたいな感じ)。
例えば、key1 に対応する値があればその値を Just に包んで、key2 に対応する値があればその値を Just に包んで、key3 に対応する値があればその値を Just に包んで、どちらもなければ Nothing を返す関数を考えてみます。
// Java8だよ! import java.util.*; import static maybe.MaybeModule.*; import static functions.FunctionModule.*; public class Sample { private static Map<String, Integer> db = new HashMap<String, Integer>() {{ put("x", 1); put("y", 2); put("z", 3); }}; private static Maybe<Integer> tryFind(String key) { if (db.containsKey(key)) return just(db.get(key)); return nothing(); } public static Maybe<Integer> search3key(String key1, String key2, String key3) { // Nothingの場合は処理を続けるが、Justが現れたら以降の処理は実行せず、Justで包んで返す return tryFind(key1).match( v1 -> just(v1), _ -> tryFind(key2).match( v2 -> just(v2), _ -> tryFind(key3).match( v3 -> just(v3), _ -> nothing()))); } // サンプル用mainメソッド public static void main(String[] args) { Maybe<Integer> res1 = search3key("x", "y", "z"); // Just(1) Maybe<Integer> res2 = search3key("x", "b", "z"); // Just(1) Maybe<Integer> res3 = search3key("a", "y", "z"); // Just(2) Maybe<Integer> res4 = search3key("a", "b", "c"); // Nothing } }
やはりつらいですね。
これ、今度は例外投げる場合がちょっとつらいです。
public static Integer search3key(String key1, String key2, String key3) { // 例外が投げられた場合は処理を続けるが、そうでなければ以降の処理は実行せず、そのまま返す try { return tryFind(key1); } catch (Exception e) { try { return tryFind(key2); } catch (Exception e2) { return tryFind(key3); } } }
逆に、null チェックの場合はガード節になるので非常に読みやすいコードとなります(でも安全ではない点には注意)。
えむぷらす!*10
これを解決するために、Maybe にまたメソッドを足しましょう。
// Java8だよ! package maybe; import static functions.FunctionModule.*; public final class MaybeModule { // 略 public interface Maybe<E> { <R> R match(Func1<E, R> ifJust, Func1<Unit, R> ifNothing); <R> Maybe<R> bind(Func1<E, Maybe<R>> rest) default { return match( v -> rest.apply(v), _ -> nothing()); } // orメソッドを追加 // 「以降の処理」がrestとして渡される Maybe<E> or(Func1<Unit, Maybe<E>> rest) default { return match( v -> just(v), _ -> rest(Unit._)); } } // 略 }
このメソッドを使うと、先ほどのコードはこうなります。
// Java8だよ! import java.util.*; import static maybe.MaybeModule.*; import static functions.FunctionModule.*; public class Sample { // 略 public static Maybe<Integer> search3key(String key1, String key2, String key3) { return tryFind(key1).or(_ -> tryFind(key2).or(_ -> tryFind(key3))); /* または、 return tryFind(key1).or(_ -> tryFind(key2)).or(_ -> tryFind(key3)); でも可 */ } // 略 }
すごいシンプルになりました!
情報が足りない・・・
例外の素晴らしい点の一つは、情報を豊富に保持することができる点にあると思います。
それに対して、Maybe はどうでしょう。失敗時に何が原因で失敗したのか全然分かりませんよね。
ということで、失敗時の情報を持つようにしましょう!
*1:えぇー、その getter、ドキュメンテーションコメントいらないように分かりやすい名前付けたのに!
*2:ただしリフレクションは考慮しないものとする
*3:握りつぶそうにも型を合わせる必要がある
*4:検査例外という手もあるにはある
*5:Maybe かどうかで判断できるうえ、コンパイラにも守ってもらえる
*6:測ったことないのでわからんけど(ぇ
*7:それ以降はより汎用的に使えるようにするための変更なので
*8:この例は http://d.hatena.ne.jp/mzp/20110205/monad からパクリました。このエントリも素晴らしいので後で読んでみてください。
*9:更に、for が文なので値が持てないのも微妙に痛い
*10:気にしないでください