読者です 読者をやめる 読者になる 読者になる

re:僕にとってMaybe / Nullable / Optional が、どうしてもしっくりこないわけ。

C# Java etc

元ネタ: 僕にとってMaybe / Nullable / Optional が、どうしてもしっくりこないわけ。 - 亀岡的プログラマ日記

OOPの文脈で見ると、元の記事が言っていることもわからなくはないのですが、対象が広すぎていろいろと不正確になってしまっているので、ちょっとまとめてみます。

元の記事が対象にしているのは、Maybe / Optional / Nullableの3つです。 対応する型を持つ言語を見てみると、下記のようになります。

これらは、「値がないこと」を表すもの、という見方では同じですが、それぞれ異なる価値観の元に作られています。

Maybe/OptionalとNullable

これらはすべて型パラメータを取ります*1。 しかし、この中でNullableだけは型パラメータに値型のみという制約が付きます。 これは、C#Nullableは他のものとかなり性質が違うことを意味しています。 元の記事では並べて書かれていましたが、そもそもNullableに関してはここに並べるべきものではありません。

C#のNullable

C#Nullableは、値型にもnullが使いたいという動機で導入された機能です。 そのため、NullableMaybeOptionalと違って、むしろnullを積極的に使えるようにするための機能と言えるでしょう。 これによってC#の上ではNullableを使うことでコード上の見た目だけに関しては、値型でもnullが使えるように見せています。 見た目だけの話であれば、SwiftOptionalと同じように型名の後ろに?が付くため、同じような言語機能に見えますが、実際には全くの別物と考えたほうがいいでしょう。

JavaのOptional

JavaOptionalは、メソッドの戻り値として使うことが想定されているらしく、引数やフィールドに使うことは想定されていないそうです。 JavaOptionalは参照型なので、Optional自体がnullになりうるという点を考えると、引数に使うべきではないというのもある程度納得できます*2

Javaでは現状、型パラメータに指定できるのは参照型だけであり、nullになりうるのも参照型だけです。 そのため、JavaOptionalC#Nullableと違って、(戻り値としての)nullを排除しよう(少なくとも、減らそう)という動機で導入されたとみていいでしょう。

SwiftのOptional

SwiftOptionalは、Javaと名前は同じですが、より言語の仕組みに根差しています。 Javaでは、Optional<T>型の変数にT型の値を代入できません。

Integer i = 42;
Optional<Integer> x = i; // コンパイルエラー

それに対して、Swiftではそれが可能です。

var i: Int = 42
var x: Optional<Int> = i // OK
// Optional<Int>はInt?とも書ける

SwiftではOptionalではない型にnull*3は代入できません。 ですが、Optionalという仕組みによって今までの知識からあまり外れない書き方でnullを扱えるようになっています。

HaskellのMaybe

HaskellMaybeは実装としてはSwiftOptionalに一番近くなっています。 ただ、他の言語と違ってHaskellではそもそも他の言語のようなnullはなく、Swiftのように既存言語ユーザーを取り込むために「今までの知識からあまり外れない書き方」というのも求めていません。 結果だけ見ると、JavaOptionalと同じように、Maybe t型の変数にt型の値は代入できません。

Nullのダメな理由

元記事では、「Nullは何がダメなんだっけ?」について、

  1. Nullは型ではない
  2. あるメソッドがNullになるかどうかの判断ができない

が挙げられています。 細かい点ですが、ここにも誤解があります。

(少なくともJavaでは)Nullは型を持つ

まず、Javaの話であればnullは型を持っています*4。 そのため、「ClassHogenull」という表現は、少なくともJavaにおいては正確ではなく、正確には「ClassHoge型に暗黙変換されたnull」とでもなるでしょうか。 詳しくは言語仕様を参照してください。

ここで言いたかったのはおそらく、「nullはどんな型にも変換できてしまう」ということでしょう。

あるメソッドの結果(もしくは変数)がNullかどうかの判断が型だけからできない

これも細かいですが、あるメソッド(の結果)がNullになるかどうかの判断ができないというのは、正確には「型を見ただけでは」できないのであり、元記事中にあるように、if文によって実行時にはチェックできます。

本題

さて、ここからが本題です。 元記事では、Optionalが解決するのは2だけ、としていますが・・・

Javaの場合

JavaOptionalでは、Optional<T>型とT型は全くの別物であり、相互に暗黙変換できません。 そのため、

  1. (nullに相当する)Optional.empty()Optional型の変数にしか入れることはできない*5
  2. あるメソッドの結果がOptional.empty()になりうるかどうか型だけで判断できる

となります。

Swiftの場合

SwiftOptionalは、Optional<T>型の変数にT型の値を代入できますが、その逆はできません。 そのため、

  1. (nullに相当する)nilOptional型の変数にしか入れることはできない
  2. あるメソッドの結果がnilになりうるかどうか型だけで判断できる

となります。

Haskellの場合

HaskellMaybeは、Maybe t型とt型は全くの別物であり、相互に暗黙変換できません。 そのため、

  1. (nullに相当する)NothingMaybe型の変数にしか入れることはできない
  2. ある関数の結果がNothinsになりうるかどうか型だけで判断できる

となります。

元記事が言いたかったこと

このように、OptionalもしくはMaybeは、1も2も解決できています。 これは十分なメリットであり、このメリットだけでもOptional/Maybeは有用です。

ただ、元記事を見ると、

本当に解決したいのは「Nullに型独自の処理をさせたい」なので、残ったままです

とあります。 つまり、本当に問題にしていたのは、「型Aの値がない場合と型Bの値がない場合で別の処理をさせたい」となります。

if文相当の処理を書く必要性

Optional/Maybeを使ったコードで頻繁にif相当の処理を書いてしまっている場合、それはそもそもOptional/Maybeの使い方を間違っている可能性が大きいです。 リストを操作する関数にリストを返す関数が多く含まれるのと同じように、あるいはTaskを返す関数を内部で呼び出している関数がTaskを返す関数になるのと同じように、Optional/Maybeを返す関数を内部で呼び出している関数は、その関数の戻り値もOptionalになることがよくあります。 この場合、if相当の処理を書くのは間違っています。

// Javaだと思う(Swiftは書いたことないので)
Optional<User> user = findUser(pred);
if (user.isPresent()) {
    // 何かする
    return Optional.of(何か処理の結果);
} else {
    return Optional.empty();
}

例えば、「何かする」の部分がuserの中身を使った関数を呼び出すような処理の場合、

// 値がなかった場合のことは気にしない
return findUser(pred).map(user -> 何かする関数(user)); // ラムダ式を使わずに直接関数を渡す、でも可

のようになるでしょう。 mapの他にも、様々なメソッドが用意されているため、ifによる分岐に頼る場面は少なくなります。 Haskellの場合、do構文によってさらに複雑な例でもすっきり書けますが、それはまた別の話。

手続き的には見えない

さて、上のコードが手続き的に見えるでしょうか? 呼び出し側はOptionalのチェックを行っておらず、コードも短く簡潔です。

ではオブジェクト指向プログラミングではどのようになるでしょうか? Userに対してNullObjectを用意して、このようになるでしょう。

public interface User {
    戻り値の型 何かする処理();
}
public class UserImpl implements User {
    @Override
    public 戻り値の型 何かする処理() {
        // 何かする
        return 何かした結果;
    }
    // ...
}
public class EmptyUser implements User {
    @Override
    public 戻り値の型 何かする処理() {
        return 戻り値の型のNullObject;
    }
    // ...
}
// 呼び出し側は意識しない(意識できない)
return findUser(pred).何かする処理();

呼び出し側だけ見ると、Optionalのときとそんなに変わってませんよね*6。 つまり、元のコードも手続き的には見えないと言っていいでしょう。

NullObjectをちゃんと使いたい、というよりは、Optionalをどう使うか考えたい

OOPの文脈で、どこまでOptionalを使えばいいのかというのはよくわかりません。 Optionalを使うと、どうしてもコードはOOPから離れて行ってしまうという感覚は確かにあります。 どのあたりでバランスを取るのがいいのかはケースバイケースでしょうし、一般化できるものではないと思っています。

コメント欄では id:kyon_mm が(やはりOOPの文脈で)

Optionを返していいのはむしろprivateやprotected的なものだけだと思っていて、それ以外はオブジェクトだったり、バリアント(判別共用体)的な感じで返すのが「オブジェクトとやり取りをしていて、それぞれに責務が割あたっている」といえるのではないだろうか。と思っています。

と言っています。 これはこれで一つの態度ではありますが、基本的なライブラリでは Optional を返す関数*7というのはもっと積極的に作られることになると思います。

FPに軸足を置くか、OOPに軸足を置くかは、対象の性質やメンバーのスキル等によって決めていくしかないでしょう。 ただし、間違ったOptionalの使い方をもって「しっくりこない」というのであれば、まずはOptionalの正しい使い方を学び、実践すべきです。 そのための言語としては、F#なんていかがでしょうか。

*1:Javaは取らなくても許されるとかいう細かい話は置いといて。

*2:フィールドに使うべきではない理由はよくわかりませんが

*3:Swiftではnilだが、この記事ではnullで統一

*4:ただし、その型の変数やフィールドを作ることはできない

*5:Objectは無視します

*6:findUserがnullを返す可能性は考慮しなくていいのかという点はここでは考慮しない

*7:もしくは、Optionalを返す関数を受け取る関数