ダブル・ディスパッチ~典型的な関数プログラミング・イディオム~
元ネタはダブル・ディスパッチ~典型的なオブジェクト指向プログラミング・イディオム~ です。 これをF#でやってみるとどうなるかやってみましょう。
レンタルショップの例(レベル1)
商品としてCDやDVDを取り扱うレンタルショップを想像・・・するのは面倒でしょうから、コードで示しますね。
type MemberKind = | Common | Gold type Member = { Kind: MemberKind } type ItemKind = | CD | DVD type Item = { Kind: ItemKind } module RentalShop = let calculateRentalFee (item: Item) (member_: Member) = (* 金額を計算する *)
レンタル料の計算が、商品種別と会員種別の組み合わせによって変わるようです。 一般会員(Common)の場合はどちらのそのままの値段で、ゴールド会員(Gold)の場合はCDが50円引き、DVDが100円引きとかになるんでしょうか。
RentalShop.calculateRentalFeeを何のひねりもなしに実装すると、こうなります(後で明らかにしますが、もっと別の方法があります)。
let calculateRentalFee (item: Item) (member_: Member) = match item.Kind with | CD -> match member_.Kind with | Common -> (* 一般会員がCDを借りる場合の料金計算 *) | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *) | DVD -> match member_.Kind with | Common -> (* 一般会員がDVDを借りる場合の料金計算 *) | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)
matchが入れ子になっていてなんだか「イヤな感じ」がしますね。
レンタル・ショップの例(レベル2)
レンタル・ショップのモデルを少し修正しましょう。 Itemが価格を持っていないわけがないので、Priceを追加します。 ついでに、レンタル料金の計算は商品オブジェクトにやってもらうようにします。
type MemberKind = | Common | Gold type Member = { Kind: MemberKind (* 多分こいつも名前とか色々持ってる *) } type ItemKind = | CD | DVD module CD = let calcRentalFee price = function | Common -> (* 一般会員がCDを借りる場合の料金計算 *) | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *) module DVD = let calcRentalFee price = function | Common -> (* 一般会員がDVDを借りる場合の料金計算 *) | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *) type Item = { Kind: ItemKind Price: int } with member this.CalculateRentalFee(member_: Member) = match this.Kind with | CD -> CD.calcRentalFee this.Price member_.Kind | DVD -> DVD.calcRentalFee this.Price member_.Kind module RentalShop = let calculateRentalFee (item: Item) (member_: Member) = item.CalculateRentalFee(member_)
関数を分けたので、matchのネストがひとつ減りました。 これが「よい感じ」かどうかは置いておきましょう。 さぁさらに複雑にしていきますよ!
レンタル・ショップの例(レベル3)
モデルにさらに手を加え、MemberとItemを相互依存させちゃいましょう!
type MemberKind = | Common | Gold type ItemKind = | CD | DVD type Member = { Kind: MemberKind } with member this.CalculateRentalFeeForCD(item: Item) = match this.Kind with | Common -> (* 一般会員がCDを借りる場合の料金計算 *) | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *) member this.CalculateRentalFeeForDVD(item: Item) = match this.Kind with | Common -> (* 一般会員がDVDを借りる場合の料金計算 *) | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *) and Item = { Kind: ItemKind Price: int } with member this.CalculateRentalFee(member_: Member) = match this.Kind with | CD -> member_.CalculateRentalFeeForCD(this) | DVD -> member_.CalculateRentalFeeForDVD(this) module RentalShop = let calculateRentalFee (item: Item) (member_: Member) = item.CalculateRentalFee(member_)
実際の計算ロジックはすべてMember内に集約されました! これで、会員の種類が増えたとしても手を入れる必要があるのはMemberのみで、Itemなどに手を入れる必要はありません。
しかし、RentalShop.calculateRentalFeeやItem.CalculateRentalFeeは処理を受けて流すだけの内容になってしまい、 これらはノイズとまでは言いませんが、処理を追うのが面倒になりました。
また、扱う商品の種類が増えた場合はItemとMember両方に手を入れなければならないので面倒です。
さらに、例えば新しい軸(曜日によって割引率が変わる、とか)が増えた場合も、どうすればいいんでしょうかこれ。
レンタル・ショップの例(レベル4)
いっそ、モデルの定義とロジックを分割してみましょう。
type MemberKind = | Common | Gold type Member = { Kind: MemberKind } type ItemKind = | CD | DVD type Item = { Kind: ItemKind Price: int } module RentalShop = let calculateRentalFee (item: Item) (member_: Member) = match item.Kind, member_.Kind with | CD, Common -> (* 一般会員がCDを借りる場合の料金計算 *) | CD, Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *) | DVD, Common -> (* 一般会員がDVDを借りる場合の料金計算 *) | DVD, Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)
実はこれ、最初のバージョンのmatchのネストを、タプルのパターンマッチに書き換えただけです。 これでよかったんやー。
共通処理の括りだし
常識的に考えて、一般会員に特別な値引きがあるとは思えません。 おそらく、一般会員は商品のレンタル料金そのままの値段でのレンタルになるでしょう。 レベル3のコードだと、その場合の処理の共通化をしようと思うと、関数に括りだすしかありません。
member private this.CalculateCommonMemberFee(item: Item) = item.Price member this.CalculateRentalFeeForCD(item: Item) = match this.Kind with | Common -> this.CalculateCommonMemberFee(item) | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *) member this.CalculateRentalFeeForDVD(item: Item) = match this.Kind with | Common -> this.CalculateCommonMemberFee(item) | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)
あまり見通しは良くないですね。 それに対して、レベル4のコードだと
let calculateRentalFee (item: Item) (member_: Member) = match item.Kind, member_.Kind with | _, Common -> item.Price (* 一般会員はそのままの値段 *) | CD, Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *) | DVD, Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)
と、見通しの良い記述が可能です。
曜日によって割引率が変わりました
という場合でも、レベル4のコードであればRentalShop.calculateRentalFeeに引数を増やすだけで対応可能です。
let calculateRentalFee (item: Item) (member_: Member) (dayOfWeek: DayOfWeek) = match item.Kind, member_.Kind, dayOfWeek with | CD, Common, Tuesday | CD, Gold, _ -> (* 火曜日は一般会員もCDがゴールド会員と同じ割引ですって *) | DVD, Common, Tuesday | DVD, Gold, _ -> (* 火曜日は一般会員もDVDがゴールド会員と同じ割引ですって *) | _, Common, _ -> item.Price
まぁ複雑になりすぎる場合は、コンビネータを合成する方向性の方がいいと思いますが、 オブジェクト指向プログラミングでのダブルディスパッチよりはかなり簡潔に書けていると思います。
ダブルディスパッチしたくなったら、言語を変更できないか考えてみてもいいかもしれませんね。
なごやまつりでF# Type Providerについて(?)話してきた
してきました。 あれだけの人数が集まって、F# 知らない人が全然いないとかすごい勉強会でしたね。
Excel方眼紙、どうにかしたいものですね。 今回作った(作りかけ)コードは、GitHubに置いてあります。
現実と戦うためのRealWorldsというorganizationを作ったので、「これも入れて!」というのがあれば考えます。 今のところ、みずぴーさんの作ったblockdiagcontrib-excelshapeもRealWorldsでホストしています。
飛ばしたところをちょっと補足つけて説明します。
TypeProviderのつくりかた
37枚目です。
TypeProviderは、 チュートリアル : 型プロバイダーの作成 (F#) をやれば作れるようになります。 このなかで、「ProviderTypes API」と呼ばれているものがありますが、 これはF#3.0 Sample Pack のSampleProviders/Sharedの中の「ProvidedTypes-xxx.fs(i)」です。 これをプロジェクトにコピーして使いましょう。 01~04とheadがありますが、headを使えばいいでしょう。
なぜコピーかというと、様々な型がinternalとなっているためです。 全部publicにしてもいいのですが、他の多くのプロジェクト(FSharpx, FSharp.Dataなどなど)がコピーして使っているため、 それに倣っておくのがいいでしょう。
基本的にはスライドでもある通り、ProvidedTypeDefinitionオブジェクトに対してメソッドやプロパティを追加していくことになります。 今回のコードだと、addCtorやaddMembersが該当箇所になるので、気になる方は追ってみましょう。
TypeProviderのデバッグ
38枚目です。
TypeProviderをVSでデバッグしながら開発する場合は、 TypeProvider用のソリューションとデバッグ用のソリューションを分割する必要があります。 これを同じにしてしまうと、VSがdllをつかみっぱなしになるためにビルドが出来なくなってしまいます。
今回スライドに書いた方法であれば、TypeProvider用のソリューションファイルと同じ場所にデバッグ用ソリューション(Sample.sln)を置くだけで誰でもVSでTypeProviderのデバッグが出来るようになります。 注意点を上げるとすると、デバッグ用ソリューションからはプロジェクト参照ではなく、dllを直接参照する必要がある、という点です。 プロジェクト参照してしまっては結局dll掴みっぱなしになってしまいますからね。
あとは、VSを2つ以上立ち上げても平気な環境がないとたぶん厳しいです。
小技
39枚目です。
HideObjectMethodsプロパティについては、上で紹介したチュートリアルに載ってますが、見落としていたので紹介です。 LangExtでは、EditorBrowsable属性を付けて回って頑張ってるわけですが、TypeProviderでProvideすればこのプロパティをtrueに設定するだけでいいんですね・・・便利。
ファイル存在チェックを導入する前は、GUID生成してファイル名にしてました。 これだと、一回コンパイルを走らせるだけで5ファイルくらい生成され、 リアルタイムでコンパイルが走っているためにその後もどんどんファイルが増えていく、という事態に陥りました。 そこで、コンパイルするファイルの中身をSHA-1ハッシュにかけ、ファイル名をそれにすることで存在チェックできるようにしました。
最後のはまぁ、えっと、その。
.NET基礎勉強会でラムダ計算の発表をしてきた
もう一か月以上も前の話ですが、.NET基礎勉強会で(型無し)ラムダ計算の話をしてきました。 .NETと言えばF#、F#の基礎と言えばラムダ計算!ですよね! 発表資料はこちらです。
当日は2 + 3が分からないと好評(?)でした。 当時の様子はこんな感じです。
2+3の計算が難しい @ラムダ計算 #dotNetbase
ぶれいすさんが人間簡約器になり下がっている #dotNetBase
bleis迷子中 #dotnetbase
人間簡約器がバグっている #dotNetBase
blaisさん、2+3で大苦戦中。 #dotNetBase
ぐるぐるさんも分からない #dotNetbase
料理番組よろしく「ここであらかじめ用意しておいたラムダ項を使って〜」とかやらないと厳しいですね #dotNetBase
2+3の計算についていけないなんて…物心ついて以来初めての経験です。 #dotNetBase
2013-07-20 15:00:01 via web
bleisさんとmzpさんがブツブツ言いながら板書する勉強会 #dotNetBase
結論: 2 + 3 is 難しい #dotnetbase
反省して、発表資料に計算過程を追加しておきました。
掛け算も追加してあるので、追ってみてください。 掛け算は、4から5に行くときに、3を表すラムダ項が増殖しているのがミソです。
実際にノートに書くのがおすすめですけど、カッコの対応が分からなくなる恐れがあるので、 色鉛筆使うのがおすすめです。
引き算や割り算、型付きラムダ計算など気になる方は、
- 作者: Benjamin C. Pierce,住井英二郎,遠藤侑介,酒井政裕,今井敬吾,黒木裕介,今井宜洋,才川隆文,今井健男
- 出版社/メーカー: オーム社
- 発売日: 2013/03/26
- メディア: 単行本(ソフトカバー)
- クリック: 68回
- この商品を含むブログ (8件) を見る
をどうぞ! 型付きラムダ計算以降も楽しいですよ!
NullableとOptionの違い
このエントリの最新版はGithubにあります。
Optionそのものについてのエントリは書く必要ない(世の中に有用なドキュメントが山ほどあるから)かな、 と思っていたのですが、Nullableとの違いについてはそれなりに需要がありそうなので書いておきます。
ちなみに、個人的な嗜好によりOptionを持ち上げ、Nullableを下に扱う感じになっていますが、Nullableも(仕方なく)使うことはあります。 特別な理由がなければNullable使わずにOptionを使う、ということでもありますが、そこは一つよろしくお願いします。
Nullableとは
C#ではnull
は参照型でしか使えませんでした。
Nullableは、この制限がない(ように見えるよう特別扱いされている)唯一の値型です。
ジェネリック型になっており、任意の値型を扱うことが出来ます。
// Nullable<int>はint?と書ける int? x = 42; // 値がある int? y = null; // 値がない // こちらは参照型なので、Nullable型ではない string a = "hoge"; // 値がある string b = null; // 値がない
Optionとは
Optionは、「null
よりも安全に値がないことを表せる」ものです。
ジェネリック型になっており、任意の型を扱うことが出来ます。
Option<int> x = Option.Some(42); // 値がある Option<int> y = Option.None; // 値がない Option<string> a = Option.Some("hoge"); // 値がある Option<string> b = Option.None; // 値がない
このエントリでは、OptionはLangExtのOptionを指すものとします。
NullableとOptionの類似点
NullableもOptionも、structで実装されており、値を持っているかどうかを表すフラグと、値を保持しているという点で、構造は似ています。 しかし、似ているのは構造くらいで、目的や使い方などはかなり異なります。
NullableとOptionの違い
パラメータとして渡せる型の違い
Nullableは値型でもnull
を扱えるようにするために作られたため、値型しか型パラメータに指定できません(参照型はそもそも最初からnull
を扱える)。
Nullable<int> i = null; // これはコンパイルエラー //Nullable<string> s = null; // そもそもこう書ける string s = null;
それに対してOptionは、null
を置き換えるために作られているため、値型であっても参照型であっても型パラメータに指定することができます。
Option<int> i = Option.None; Option<string> s = Option.None; // もちろんこれはコンパイルエラー //string s = Option.None;
また、Nullableはネストさせることができません(Nullable<Nullable<int>>
や、int??
はできない)が、Optionは出来る、という違いもあります。
表記上の違い
Nullableは言語組み込みの機能なので、表記はとてもシンプルです。 また、値がある場合はその値が直接書けます。
それに対してOptionは、値がある場合でもOption.Some
の呼び出しが必須になるため、その分面倒です。
任意の型からそのOption型への暗黙の型変換を提供すればこれは解決できるのですが、
これには問題もあるため、LangExtでは提供していません。
その理由については、 Optionの設計 で述べていますので、後で(NullableとOptionの違いを理解したうえで)読んでみてください。
設計方針の違い
Nullable型は値型でnull
を扱えるようにすることを目的としています。
単に値がない状態を表せればよく、それをどう扱うかには興味がありません。
そのため、Nullableが出来るのは基本的には
null
かどうかのチェック- 値の取り出し
の2つだけです。
それに対して、Option型はどうやって値がない状態を上手に扱うかが最大の興味です。 そのため、Optionに対しては様々な操作が可能です。 しかし、Nullableには用意されている「値の取り出し」は、Optionでは簡単にはできないようになっています。
値が取り出せるのは一見便利に見えます。 しかし、値がない場合に値を取り出そうとした場合にどうすればいいかを考える必要があります。 Nullableの場合、
- 値がない場合に例外(InvalidOperationException)を投げるValueプロパティ
- 値がない場合にデフォルト値を返すGetValueOrDefaultメソッド
- 値がない場合に引数で指定した値を返すGetValueOrDefaultメソッド
の3つの方法を用意しています。
Valueプロパティは一番使うのが簡単なので一番多用されがちな方法となりますが、null
チェックが強制されるわけではないため、気を抜くと簡単に例外が投げられてしまいます。
Nullableは「どうやって値がない状態をうまく扱うか」に興味がないため、
この設計選択は妥当と言えるでしょう(ただ、Nullableの場合と参照型の場合とで投げられる例外が違うのがいいことなのかどうかは場合によると思います)。
static string F(int? x) { return x.Value.ToString(); // xがnullならInvalidOperationExceptionが発生 }
しかし、「どうやって値がない状態をうまく扱うか」が最大の興味であるOptionでは、簡単に例外を投げるわけにはいきません。 そこで、Optionがとる方針はこうです。 「値の取得は禁止して、値を受け取った後の処理と、値がなかった(値が受け取れなかった)場合の処理を渡してもらいOptionの中でそれを振り分ければいい!」
static string F(Option<int> x) { // 値がない場合も考慮することを強制するAPI return x.Match( Some: i => i.ToString(), None: () => { throw new Exception(); }); // 今回は例外を投げるだけでいいので、Nullableより面倒 }
こうすることで、ついうっかり値がない考慮が漏れてしまうという失敗を防ぐことができるのが、OptionとNullableの最も大きな違いです。
値がないことの伝搬
T
と演算可能なU
があった時、T?
とU
も演算可能です。
そのため、以下のプログラムは「42.1」を表示します。
int? x = 42; double? y = x + 0.1; if (y != null) Console.WriteLine(y);
ここで、xがnull
だった場合はどうなるでしょうか。この場合、null
が伝搬することになります(比較演算子の場合はまた違います)。
つまり、yもnull
になります。
int? x = null; double? y = x + 0.1; if (y != null) Console.WriteLine(y); // yがnullなので表示されない
では、以下の例ではどうでしょう?
int? x = null; string y = x + "aaa"; if (y != null) Console.WriteLine(y);
この場合、なんと「aaa」が表示されます。
参照型の場合、(自分で定義しない限り)null
の伝搬は起こらなかったので、null
に文字列を加えたときの動作は特別なものではありませんでした。
しかし、Nullableにnull
の伝搬を導入したせいで、Nullableに関してはnull
に文字列を加えたときの動作が特別なものに見えるようになってしまっています。
Optionでどうなるかを見てみましょう。 Optionで同じことをするためには、Optionのままでは演算できないため、MapとIterを使います。 これらの関数は、内部でMatchを使って実装されています。
public static Option<U> Map<T, U>(this Option<T> self, Func<T, U> f) { // fが呼び出されるのは値がある(Someの時)だけ return self.Match( Some: v => Option.Some(f(v)), None: () => Option.None); // Noneは常に伝搬する(文字列が特別扱いされるなどはない) } public static void Iter<T>(this Option<T> self, Action<T> act) { // actが呼び出されるのは値がある(Someの時)だけ self.Match( Some: v => act(v), None: () => { }); }
これらを使うと、「値があるかどうかわからないものに0.1を加え、値があればその結果を表示する」コードはこう書けます。
Option<int> x = Option.Some(42); x.Map(_ => _ + 0.1) .Iter(y => Console.WriteLine(y)); // 「42.1」と表示される
Option<int> x = Option.None; x.Map(_ => _ + 0.1) .Iter(y => Console.WriteLine(y)); // 何も表示されない
値があるかどうかの判定をユーザーコード側で行っていない(MapとIter内部で行っている)という違いはありますが、ここまでの動作はNullableと同様です。 Nullableと違うのは、文字列の結合も他の場合と同じような動作をする点です。
Option<int> x = Option.None; x.Map(_ => _ + "aaa") .Iter(y => Console.WriteLine(y)); // 何も表示されない
Nullableでは、T
型でできることは、極力T?
型でもできるように考えられています。
しかし、そのせいで統一性が崩れてしまっています。
それに対して、OptionはT
型にできる操作をOption[T]
型に対して提供していません(提供できません)。
T
型とOption[T]
型は完全に別の型であり、Optionはあくまで「値があるかないかの表現」以上のことは行いません。
そのため、Optionが保持する値に対して処理を行いたい場合は、MapやIterのような高階関数を使って値を受け取る関数を渡すことになり、
すべての処理が統一性を保っています。
クエリ式の対象にできるかどうか
クエリ式でOptionを扱うと、「値がない場合」を意識せずに処理が記述でき、非常に便利です。
// Option<int> TryGet(string key); があったとする var res = from x in TryGet("x") from y in TryGet("y") select x + y;
この程度の例なら、Nullableでも問題なく扱えます。
// int? TryGet(string key); があったとする var x = TryGet("x"); var y = TryGet("y"); var res = x + y;
しかし、参照型が混じると途端に厳しくなります。
// Optionの場合は特に何も変わらない var res = from x in TryGetInt("x") from y in TryGetStr("y") select x + y; // x(の文字列表現)とyを連結
// Nullableと参照型との演算ではnullが伝搬しないので、nullチェックが必要になる var x = TryGetInt("x"); var y = TryGetStr("y"); var res = // うっ・・・ x == null || y == null ? null : x + y;
これを回避したくて、Nullableにもクエリ式を提供したくなります。
しかし、これらのケースすべてをカバーできるクエリ式を提供することは、C# のオーバーロード解決能力が低いため不可能です。
実際に実装してみると分かるのですが、T?
だけを扱うようなクエリ式は提供可能です。
例えば、
// int? TryGet(string key); があったとする var res = from x in TryGet("x") from y in TryGet("y") select x + y;
ここまでは可能です。
しかし、そこにT when T : class
where T : class
も扱えるようにしようとすると、オーバーロードが解決できないバージョンのSelectManyを書く必要が出てきてしまいます。
NullableとOptionの違い
Nullableは、null
チェックから逃れることを目的としていない時点で良くないアイディアです。
null参照の考案は10億ドル単位の過ちと、
考案者自らが認める過ちであるnull
の適用範囲を広げてしまうNullableを、言語仕様(それどころかCLRにまで)に組み込んでしまうというのは、
個人的にはなんてセンスのない設計をしたんだ、と思ってしまいます。
それに対してOptionは、OOPの側面からみるとNullObjectをジェネリックの力を借りて汎用化したものであり、 非常に好ましいものです。 シグネチャ上で「値がないかもしれない」ことを表すことができるのも、ありがたいです(Nullableは出来るが、参照型ではできない)。
構造は同じなのに、ここまで正反対の意見になるというのは、ちょっと面白いですね。 この違いは、主に提供するAPIから来ており、「値を(簡単には)取り出せない」という制限がOptionの良さの根源にあるというのは、心に留めておいてほしいです。 何でもかんでもできるAPIが常によいというわけではないのです。
(おまけ)Optionからの値の取り出し
・・・とは言うものの、Optionからさくっと値を取り出したい場合があるのも事実です。 例えば、「ここでは絶対値があることが分かりきっている」というような場合です。 そのような場合でも、通常は値がなかった場合の考慮が必要ですが、LangExt.Unsafe名前空間をusingすることで、Optionから直接値を取り出せる関数が使えるようになります。
LangExtのOptionに限らず、たいていのOptionの実装には、このような「直接値を取り出す」関数が提供されています。 そればかりか、場合によっては他のAPIと一緒にこの関数を紹介してしまっている場合もあります。
しかし、Optionの目的を「安全に値がないことを表す」と置くのであれば、この関数をそのように紹介するのはよくないことです。
これを先に教えてしまえば、null
を知っている人の多くは「使いにくいnull
だ」と思いながらOptionを使ってしまうことでしょう。
そういう事態を避けるためにも、Optionから直接値を取り出す関数は、Optionの目的や利点を語った後で教えるべきです。 場合によっては、教えないというのもアリでしょう。 それくらい、慎重に扱うべきものです。
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になります)。
C# 使いから見てうらやましい Java8 の default 実装の使い方
Java8 から追加されるインターフェイスの default 実装ですが、C# の拡張メソッドに似てますよね。 実際、このどちらも「シンインターフェイス」を定義するだけで「リッチインターフェイス」が手に入ります。
しかし、C# の拡張メソッドと Java のインターフェイスの default 実装には、それぞれの利点と欠点があります。
拡張メソッドの利点
拡張メソッドの利点は、インターフェイスの実装者だけでなく、 インターフェイスの使用者に対してもインターフェイスの拡張が開かれている点です。 既存の型ですら、後付けでメソッドを追加することができるということです。
using System; public static class StringExtension { // インターフェイスでなくても、どんな型に対しても拡張可能 public static int ToInt(this string self) { return int.Parse(self); } }
var num = "100".ToInt();
それに対して Java8 のインターフェイスの default 実装は、 あくまでインターフェイスの提供者しか提供できません。
これは、Java8 の default 実装に対する、拡張メソッドの大きなメリットです。
default 実装の利点
では default 実装には利点がないか、というと、そうではありません。 default 実装は、リッチインターフェイスをシンインターフェイスに変換してしまうこともできるのです。
import java.util.*; // Appendableは3つのメソッドを提供するインターフェイスだが、 // SamAppendable(SamはSingle Abstract Method)はそのうち2つをdefault実装として提供することで、 // 1つの抽象メソッドを実装すればいいようにしている interface SamAppendable extends Appendable { Appendable append(CharSequence csq, int start, int end); default Appendable append(CharSequence csq) { this.append(csq, 0, csq == null ? 0 : csq.length()); return this; } default Appendable append(char c) { this.append("" + c, 0, 1); return this; } } public class Main { static void appendHoge(SamAppendable a) { a.append("hoge"); } public static void main(String[] args) { // (実はあまりいい例ではないのだけれど) // SAMインターフェイスにしたおかげで、ラムダ式が使える! appendHoge((csq, start, end) -> { System.out.printf("csq: %s, start: %d, end: %d%n", csq, start, end); return null; }); } }
それに対して、拡張メソッドではこのようなことは出来ません。
ジェネリックな IEnumerable は非ジェネリックな IEnumerable を継承しているため、 GetEnumerator を 2 種類実装する必要があって面倒なんですが、 「あれ、Java 8 の default 実装だとこの問題解決できるんじゃ?」 ・・・と思ったのがこのエントリを書くことになったきっかけです。 なるほど、拡張メソッドにしなかったのには理由がありそうですね。
そんなことより Scala ですよ
こんな感じで、どちらも一長一短であって優劣つけがたい感じなのですが・・・ なんと、Scala の trait はこのどちらにも対応しています。
既存の型へのメンバの追加
object Extensions { implicit class StringExtension(val value: String) { def toInteger: Int = value.toInt } }
2.10 からかなり簡単に書けるようになりました。
リッチインターフェイスのシンインターフェイス化
trait SamAppendable extends Appendable { def append(csq: CharSequence, start: Int, end: Int): Appendable def append(csq: CharSequence): Appendable = { this.append(csq, 0, if (csq == null) 0 else csq.length) this } def append(c: Char): Appendable = { this.append("" + c, 0, 1) this } }
Scala では Appendable を SAM インターフェイスにする意味はないですが、 リッチでファットなインターフェイスをシンインターフェイスにできるのは、 既存の Java コードとのやり取りを考えるとありがたいかもしれません。
Java8
Java8 に期待するより、今使える Scala 使おう! でも、default 実装が拡張メソッドの劣化コピーとか言っちゃダメ!
LangExt2.0 リリースしました
しました。
メジャーバージョンが上がって、1.xとは互換性のないものになっています。 そこらへんについては、「なぜそうしたのか」等をまとめて公開していくつもりです。
で、リリースしておいてなんですが、使用にあたっていくつかの注意点があります。
- 標準クエリ演算子(SelectとかWhereとか)を捨ててます。ふつうのC#erにはお勧めしません。
- Grouping系のメソッドは未実装です。実装できてないものがいっぱいあって、それを使って実装したいのでまだありません。いつか作ります。
- Join系のメソッドは未実装です。え、これ、必要・・・?
Lenはおとーふさんの要望により、Lengthになるかもしれません。自分としてはLenで十分だと思うのですが・・・→ LenはLenのままで、より汎用的に使える名前であるSizeを追加することになりました- なんとかWithIndex系のメソッドはなくなる可能性があります。結果をMapする関数を受け取る版を作った方が、色々と柔軟なのでは、と思ったので。そっちも用意しつつ、なんとかWithIndex系のメソッドも残す可能性もあります。議論が必要です。
注意点はこんなところです。
LangExtって何?
C#での関数プログラミングをサポートするためのライブラリです。 無理やり感ありますし、F#だったら・・・!と思うこともよくあるのですが、そこは諦める感じでお願いします。無理です。
もっと知りたい人は、コードを読むか、 https://github.com/LangExt/LangExt/blob/master/doc/LangExt.markdown あたりをどうぞ。
あ、一応これ、仕事で使う用のライブラリでもあります。 実際に仕事で発生した問題に対応するためのあれこれが入っています。
なぜ○○は××になってるの?
今後、まとめていく予定です。現在予定している項目としては、
- なぜ標準クエリ演算子を捨てたのか?
- なぜIEnumerable
を捨てたのか? - Optionの実装はなぜああなっているのか?→書いた
- EitherではなくResultにしたのはなぜか?(もしくは、1.xではEitherだったのに、2.0ではなぜResultにしたのか?)
- タプルを独自実装から標準ライブラリのものにしたのはなぜか?
- 拡張メソッドとインスタンスメソッドはどう使い分けているのか?
- OptionやUnitは、F#の型をそのまま使うのではいけなかったのか?
です。 LangExtに限った話だけでなく、C#の言語寄りの話も盛り込むと思います。
今後の予定
GitHubのマイルストーンあたりを見ていただければ大体わかるかと。
あと、LangExt以外にもなんとかExtをいくつか作る予定です。 そのあたりは、LangExt (LangExt) · GitHubからたどって情報を拾ってください。