LangExtでのOptionの設計
このエントリの最新版はGithubにあります。
Optionの意味を理解していることを前提に、直和型をC#で実現する方法についてを説明し、 LangExtではどういう方針を採用しているのかと、その理由について明らかにします。
バリアント型のC#での設計方針
Optionなどのバリアント型(VBのVariant型のことではありません)をC#で実現する場合、大まかに次の2つの方針があります。
- 型の階層で表現する
- タグを判別する値を持つようにする
一つ目の方法は、Option(もしくはMaybe)型の実現方法としてよく使われている方法です。
+-----------+ | Option[T] | +-----------+ △ | +-----+-----+ | | +---------+ +---------+ | Some[T] | | None[T] | +---------+ +---------+ | - value | +---------+
二つ目の方法は、LangExtのOption型が採用している方法です。
+------------+ | Option[T] | +------------+ | - hasValue | | - value | +------------+
これらの特徴を、Option型を実現する場合を例に見ていきます。
型の階層で表現する方法
バリアント型を型の階層で表現する方法は、実際にはOption型をinterfaceとして定義することが多いようです。 この方法では、Option型に対する操作はポリモーフィズムによって実現され、分岐は型階層に隠蔽されます。
public interface IOption<out T> { bool HasValue { get; } IOption<U> Map<U>(Func<T, U> f); IOption<U> Bind<U>(Func<T, IOption<U>> f); T GetOr(T defaultValue); } public sealed class Some<T> : IOption<T> { readonly T value; public Some(T value) { this.value = value; } public bool HasValue { get { return true; } } public IOption<U> Map<U>(Func<T, U> f) { return new Some<U>(f(this.value)); } public IOption<U> Bind<U>(Func<T, IOption<U>> f) { return f(this.value); } public T GetOr(T defaultValue) { return this.value; } } public sealed class None<T> : IOption<T> { public bool HasValue { get { return false; } } public IOption<U> Map<U>(Func<T, U> f) { return new None<T>(); } public IOption<U> Bind<U>(Func<T, IOption<U>> f) { return new None<T>(); } public T GetOr(T defaultValue) { return defaultValue; } }
実際には拡張メソッドで提供するメソッドについては、その中で分岐が発生しますが、
一番コアの部分はif
による分岐を(それがいいことかどうかは置いておいて)完全に排除できます。
この方法は、オブジェクト指向プログラミング的な方法であり、 オブジェクト指向プログラマにとっては自然であり、美しい解決方法でしょう。
タグを判別する値を持つ方法
Option型にはSome
とNone
という2つのラベルがあります。
これを判別するのに必要な情報は1bitでいいため、boolを使えば十分です。
public struct Option<T> { readonly bool hasValue; readonly T value; public Option(T value) { this.hasValue = true; this.value = value; } public bool HasValue { get { return this.hasValue; } } public Option<U> Map<U>(Func<T, U> f) { if (this.hasValue) return new Option<U>(); return new Option<U>(f(this.value)); } public Option<U> Bind<U>(Func<T, U> f) { if (this.hasValue) return new Option<U>(); return f(this.value); } public T GetOr(T defaultValue) { return this.hasValue ? this.value : defaultValue; } }
バリアント型をタグを判別する値を持つことによって表現する方法は、先の方法に比べると泥臭い方法に見えます。 LangExtでは、この2つの方法のメリットとデメリットを考えたうえで、泥臭く思える後者の方法を採用しています。
それぞれの利点と欠点
まずは、型の階層で表現する方法の利点を考えてみます。
None
の場合に余分なフィールドを持つ必要がないSome
やNone
にも型を与えることになるため、「Some
だけを受け取れるような関数」のように、柔軟な関数を定義可能- (Optionを
interface
とすることで)型パラメータにout
が指定できる - 実装が綺麗に書ける
特に、1つ目はラベルが多くなればなるほど効果を感じることができる利点でしょう。
2つ目も、Optionではそれほどメリットを感じないかもしれませんが、 例えばファイルとディレクトリを扱う型階層を作った場合などは大きな利点になります。
3つ目はout
を付けることのできないようなものも考えられますが、Option型の場合には付けることができます。
TがSを継承している場合に、Option[T]がOption[S]として使えるというのは、オブジェクト指向言語においては非常に魅力的です。
4つ目は、実際にミニマムな実装をしたコードを見ても、if
が全く出てこないため、オブジェクト指向プログラミングとして綺麗なコードと言えるでしょう。
では、タグを判別する値を持つことによって実現する方法の利点はどうでしょう。
- Option型の変数に対して
null
を入れることを防げている(struct
のため) - 勝手に独自のラベルを作ることができない(型階層で実現する方法の場合、IOptionを実装することで可能)
の2つです。 後者は一概には利点/欠点と判断できるものではないですが、少なくともOption型には不要な機能であり、それができてしまうと色々面倒です。
また、前者の利点は絶大です。
Option型が達成したいのは、「null
の排除」です。
にもかかわらず、型階層による実現方法では、「IOption[T]
型の変数そのものにnull
を入れられてしまう」という可能性を排除できません。
これは、別に
IOption<string> opt = null;
という直接的なコードが書かれてしまう、ということではありません。
そうではなく、例えばIOption[T]
を受け取るような関数に「とりあえず」null
を渡しておいて、「後で」直そう、と思って忘れていたような、
「うっかりnull
」の方を心配しています。
LangExtの公開以前のかなり初期のバージョンでは、型階層によってOptionを実現していました。 しかし、この手のミスがそれなりに発生したため、設計方針を切り替えたという経緯があります。
もちろん、これを単体テストやDbCによって排除すべきだ、という立場もあるでしょう。 しかし、単体テストで見つけるよりもコンパイル時に見つけた方が発見のコスト(単体テストではテストコードを書いたり、実行したりが必要)も修正のコストも小さくなりますし、 そもそも考慮漏れを起こす心配がありません。
また、DbC(というかCode Contracts)で「この型の変数にはnull
は格納できない」のような契約の書き方がわかりませんでした。
もしかすると方法はあるのかもしれないですが、LangExtではCode Contractsでどうこうする、という方向は諦めています。
どちらを採用するか
これらの利点と欠点を考えたうえで、LangExtではnull
を徹底的に排除するために、タグを判別する値を持つ方法を採用しました。
これにより、LangExtのOptionでは(変数にしろ、Optionの高階関数に渡された関数の引数にしろ)null
が来ることは一切考慮する必要がないという安心感を獲得しています。
Option以外のバリアント型
これまで考えてきたのは、あくまでOption型を実現する場合についてです。 そのため、Option以外のバリアント型もすべてOption型と同じように実装するかどうかは別問題です。
Option型に失敗時の原因も持たせることができるように拡張したResult型では、Option型と同じ方針を採用していますが、
Option型と違ってstruct
にすることができませんので、今後のバージョンで型階層による実現方法に変更される可能性もあります。
ただし、あくまで実現方法のみの変更になるため、変更があった場合でもユーザコードは修正不要です。
Option型の値の生成
Option型の値を生成する方法としては、コンストラクタを呼び出すのが.NETプログラマとしては最もわかりやすいでしょう。 しかし、この方法では常に型パラメータを明示する必要があるため、面倒です。
public void F(Option<string> opt) { ... } F(new Option<string>("hoge")); // Some "hoge" F(new Option<string>()); // None
これを解決するために、ほとんどのライブラリのOptionと同様、staticメソッドを提供しています。
F(Option.Some("hoge"));
さらに、LangExtではこれに加えて、型引数なしでNone
を使うことができるようにしています。
F(Option.None);
Option.None
はOption[Placeholder]
型のNone
を生成するようになっており、
Option[Placeholder]
型から任意のT
型のOption型への暗黙型変換を提供することでこれを実現しています。
それ以外の方法
それ以外の方法として、任意のT
型からOption[T]
への暗黙型変換を提供することも考えられます。
また、null
を任意のT
型のOption型のNone
に暗黙変換を提供するのも便利かもしれません。
LangExtでは、これらの変換を提供することも考えましたが、どちらも却下しました。
任意のT
型からOption[T]
型への暗黙の型変換を提供しない理由
これを提供しないのは、null
の扱いにあります。
public Option<string> F(string str) { return str; }
さて、単純なコードではありますが、str
にはnull
が格納されている可能性があります。
その場合にこのコードは何を返すべきでしょうか?Some null
でしょうか?それとも、None
でしょうか?
これは暗黙の型変換の仕様さえ覚えてしまえばそれでいいのですが、 それを知らない人が「こっちだ」と思い込んでしまう可能性は捨てきれません。
そのため、LangExtではこの変換を提供していません(さらに、LangExtではOption.Some(null)
は例外を投げるようになっており、null
を格納するSome
は作ることができません。これによって、Optionの高階関数に渡す関数の引数にnull
が渡されることがないことも保証しています。もしSome null
のようなことがしたいのであれば、Optionをネストするか、別の型を作るようにしてください。)。
public Option<string> F(string str) { return Option.Some(str); } // もしstrがnullの場合にNoneが返したい場合は、明示的に書く public Option<string> G(string str) { if (str == null) return Option.None; return Option.Some(str); }
null
を任意のT
型のOption型のNone
に暗黙変換しない理由
これには、上の問題以上の問題があります。
例えば、当初はOption[string]
を受け取っていた関数Fの仕様が変わり、string
を受け取ることになったとした場合、
null
をNone
に暗黙変換している個所はコンパイルエラーにならずに単にnull
を渡してしまうことになります。
// 暗黙の型変換を提供していれば、F(Option<string> opt)でもF(string str)でもコンパイルできてしまう F(null);
実はこのような暗黙の型変換を公開前のLangExtは提供していたことがありました。
その当時はまだOption.None
とは書けず、Option.None<string>()
のように書く必要があったこともあり、
手軽に書けるnull
からの変換を多用していました。
しかしというか、やはりというか、上のような問題がそれなりに発生し、
さらにOption.None
という書き方ができるようになったため、null
をNone
として扱う機能は削除しました。
クエリ式への対応
Option型をクエリ式に対応させる最も手軽な方法は、IEnumerable[T]
を実装することでしょう。
そうすれば、IEnumerable[T]
に対して定義されたSelect
やSelectMany
によって、Optionをクエリ式で扱えるようになります。
しかし、この方法では最終的に得られる型がOption[T]
ではなくIEnumerable[T]
になってしまいます。
そのため、LangExtではOption.QueryExpr.csでOption用のクエリ式用メソッドを提供しています。
IEnumerable[T]
を実装していないのは、Option型をIEnumerable[T]
として使いたいことがないからです。
match式の模倣
探した限りはあまり他で同じようなことをやっているライブラリがなかったのですが、 LangExtは(F#のような)match式をある程度模倣するMatchメソッドを提供しています。
int F(Option<string> opt) { return opt.Match( str => int.Parse(str), // 値があった場合の処理 () => -1); // 値がなかった場合の処理 }
また、名前付き引数を使って、以下のように記述することもできます。
int F(Option<string> opt) { return opt.Match( Some: str => int.Parse(str), // 値があった場合の処理 None: () => -1); // 値がなかった場合の処理 }
名前付き引数なので、順番を入れ替えることもできます(Match関数で名前付き引数を使うアイディアは、ころくんのつぶやきから借りています)。 このようなMatchメソッドは、Optionに限らず便利に使えるため、提供できる場合は提供しておくといいでしょう。
LangExtでは、Optionのほかに、ResultやTupleでもMatchメソッドを提供しています。 シーケンスに対しては実験的に実装してみた ものの、このままでは複雑すぎて使い物にならないので、アイディア募集中です。
Optionから値を取り出す方法
Option型のオブジェクトから値を取り出す操作(GetメソッドやValueプロパティ)を提供しているライブラリは多くあります。 しかし、これを提供してしまうと、せっかく「値がないことの考慮」を強制できるOptionの利点を損ねてしまいます。 そのため、LangExtではLangExt名前空間をusingしただけでは、Optionから直接値を取り出すことは出来ないようになっています。
LangExtOptionから値を取り出すには、デフォルト値を指定するGetOrメソッド(もしくはその派生のGetOrElseメソッド)を使うか、 LangExt.Unsafe名前空間をusingして(危険なコードを書くと宣言したうえで)GetValueメソッドを使います。
現在のバージョンのLangExtでは、Unsafe名前空間の中にOptionクラスを格納しているため、 Unsafe名前空間をusingしてしまうとOptionモジュールが解決できなくなってしまうという問題があります。 次のバージョンでは、Unsafe名前空間の中のOptionクラスは、UnsafeOptionという名前に変更予定です。
また、現在のバージョンではNone
の場合に例外を投げずにdefault(T)
を返すものしか提供されていませんが、
今後のバージョンでは例外を投げるメソッドも追加予定です(名前はおそらくGetになります)。