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からたどって情報を拾ってください。
Java 8を関数型っぽく使うためのおまじないをF#でやってみた
Java 8を関数型っぽく使うためのおまじない - きしだのはてな
Java 8を関数型っぽく使うためのおまじないをC#でやってみた - ぐるぐる~
Java も C# も大変ですね。 F# さんは、ラムダ式も関数型も最初から使えたので、似たようなことはすでにできます。 上記の記事のパクリなので、上記の記事をまずは読んでから読むことをおすすめします。
関数型(関数を表す型の方)
F# では FSharpFunc という型があります。名前空間や型パラメータまで含めると、Microsoft.FSharp.Core.FSharpFunc<'T, 'U>
です。
ただ、この型を直接使うことはありませんし、見ることもそうそうないです。
その代わりに、'T -> 'U
という表記が使えます。「'T
を受け取って 'U
を返す関数」と読みます。
ちなみに、型パラメータの最初に「'」が付いているのが割と大事なことなのですが、
それはまた機会があればということで今回は「型パラメータの一文字目は「'」が必要」くらいに思っておいてください。
こんな感じで使います。
(* F#では//形式の一行コメントも使えるけど、 はてなブログがF#のシンタックスハイライトに対応してないので、 この記事ではブロックコメントを使います(OCamlのシンタックスハイライトを使ってます)。 *) (* Java: Function<String, String> enclose = s -> "[" + s + "]"; *) (* C#: Func<string, string> enclose = s => "[" + s + "]"; *) let enclose = fun s -> "[" + s + "]"
型を全然書いてませんね。変数に型を明記すると、こうなります。
let enclose: string -> string = fun s -> "[" + s + "]"
これを呼び出そうとすると、こんな感じになります。
(* 引数を()で囲む必要はない *) System.Console.WriteLine(enclose "foo") (* System.Console.WriteLineの引数をカッコで囲っているように思えるけど、 どちらかというとenclose "foo"全体をカッコで囲っている感じ *)
こうすると、次のような表示になります。
[foo]
もうひとつ関数を定義してみましょう。最初の文字を大文字にする関数です。
(* sの型を明示しないと、メソッド呼出しが出来ないので明示している *) let capitalize = fun (s: string) -> s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower()
2文字未満の文字列を与えると死にます。
呼び出してみます。
System.Console.WriteLine(capitalize "foo") (* => Foo *)
この2つを順に呼び出して、capitalize して enclose しようとすると、こんな感じになりますね。
System.Console.WriteLine(enclose (capitalize "foo")) (* => [Foo] *)
こういう場合、Java では andThen を使って連結し、C# では拡張メソッドを定義しましたが、
F# では >>
という関数が用意されています。
使ってみましょう。
System.Console.WriteLine((capitalize >> enclose) "foo");
これは、関数合成ですね。
let capEnc = capitalize >> enclose System.Console.WriteLine(capEnc "foo")
F# は関数型っぽいことが簡単にできます。べんり!
関数
ところで、F# では let f = fun x -> ...
は let f x = ...
と書くことが出来ます。
これを使うと、今までのコードはこう書けます。
let enclose s = "[" + s + "]" let capitalize (s: string) = s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower() let capEnc = capitalize >> enclose System.Console.WriteLine(capEnc "foo")
そして、この let f x = ...
は関数定義です。
関数型は 'T -> 'U
でしたが、上で定義した enclose 関数のシグネチャも、string -> string
のように表します。
ラムダ式と関数がシームレスにつながっていて、とても良い感じです。
別名を使う?
'T -> 'U
がすでに Microsoft.FSharp.Core.FSharpFunc<'T, 'U>
の別名のようなものなので、必要ないですね。
さらに、型推論が効くので型を書く必要はほとんどの場合でありません。
unit
Java でも C# でも、戻り値を返さない関数は Function/Func とは別のものが必要でした。
これは、void というのが特殊なものなのが原因です。
しかし、F# で void に相当する unit は、他の型同様値を持つ(「()」という唯一の値を持つ)ので、特別扱いは不要です。
何の問題もなく、string -> unit
が使えます。
(* let writeLine (str: string) = System.Console.WriteLine(str) とかでも可 *) let writeLine: string -> unit = System.Console.WriteLine let enclose s = "[" + s + "]" writeLine (enclose "foo") let capitalize (s: string) = s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower() writeLine (capitalize "foo")
ここまでで、1引数の関数型しか使っていませんが、2引数以上の関数は甘えなので、1引数の関数が定義できれば問題ありません。
あと F# の関数である FSharpFunc<'T, 'U>
には BiFunction
やら Func<TArg1, TArg2, TResult>
なんてものはありません。何の問題もありません。
関数合成
関数合成は次のように書けます。
writeLine ((capitalize >> enclose) "foo")
writeLine も特別扱いしなくていいので、さらに合成できますね。
(capitalize >> enclose >> writeLine) "foo"
さらにもうひとつ関数を用意して、次のように書いてみます。真ん中あたりを取り出す関数です。
let middle = (fun s: string) -> s.Substring(s.Length / 3, s.Length - s.Length * 2 / 3) (middle >> capitalize >> enclose >> writeLine) "yoofoobar"
関数合成を使わないと、次のようになりますね。
writeLine (enclose (capitalize (middle "foobaryah")))
このように、実際に呼び出す順と記述が逆になります。middle して capitalize して enclose して writeLine するのに、先に writeLine から書く必要があります。 また、カッコがたまっていくので、最後にまとめてカッコを閉じる必要があります。ちょっと関数呼び出しが増えると、閉じカッコの数が分からなくなってコンパイルエラーがなくなるところまで「)」を増やしたり減らしたりなんてこと、ありますよね?
F# ではさらに、パイプライン演算子というものがあるので、こう書くこともできます。
"yoofoobar" |> middle |> capitalize |> enclose |> writeLine
「"yoofoobar"をmiddleしてcapitalizeしてencloseしてwriteLineする」ことがコードで自然に表せていますね。 これを改行すると・・・
"yoofoobar" |> middle |> capitalize |> enclose |> writeLine
おぉ。
カリー化形式
さて、2引数以上の関数は甘えと書きましたが、実際2つ以上のパラメータを渡したいときはどうすればいいんでしょう? こういうときに使うのがカリー化です。カリー化は、ひとつの引数を取って関数を返すことで、複数のパラメータに対応します。
例えば、はさむ文字列とはさまれる文字列を指定して、文字列をはさむ関数は、C# の2引数関数であらわすならこうなるでしょう。
string Sandwich(string tag, string str) { return tag + str + tag; }
これをカリー化形式の関数で書くと、次のようになります。
(* 一部型指定をしてますが、気にしたら負けです *) let sandwich (tag: string) = fun str -> tag + str + tag
sandwich 自体は、『文字列を取って、「文字列を取って文字列を返す関数」を返す関数』になっています。
呼び出しは次のようになります。
writeLine (sandwich "***" "sanded!") (* => ***sanded!*** *)
3引数だとこんな感じですね。
let encloseC (openStr: string) = fun closeStr -> fun str -> openStr + str + closeStr
encloseC は、【文字列を取って、『文字列を取って、「文字列を取って文字列を返す関数」を返す関数』を返す関数】になっています。
呼び出しはこんな感じで。
writeLine (encloseC "{" "}" "enclosed!") (* => {enclosed!} *)
ところで、このカリー化形式の encloseC、引数を部分的に渡しておくことが出来ます。
let encloseCurly = encloseC "{" "}" writeLine (encloseCurly "囲まれた!")
こうやって部分適用することで、新しい関数が作れるわけです。 ちなみに Curly は、波カッコ=カーリーブラケット「{~}」のことで、カリー化とは関係ないのであしからず。
ところでところで、F# ではネストした fun をまとめることができます。 例えば、encloseC はこんな定義でした。
let encloseC (openStr: string) = fun closeStr -> fun str -> openStr + str + closeStr
2つも fun がありますね。まとめてしまいましょう。
let encloseC (openStr: string) = fun closeStr str -> openStr + str + closeStr
スッキリしましたね!
ところでところでところで、F# では let f = fun x -> ...
は let f x = fun x -> ...
と書けるのでした。
fun をまとめる前の定義に戻ると、
let encloseC (openStr: string) = fun closeStr -> fun str -> openStr + str + closeStr
でしたので、「f」の部分が「encloseC (openStr: string)」だと思ってみると・・・
let encloseC (openStr: string) closeStr = fun str -> openStr + str + closeStr
となって、さらに
let encloseC (openStr: string) closeStr str = openStr + str + closeStr
こうですね!
まとめ
Action 以降のソース、こんな感じです。
let writeLine (str: string) = System.Console.WriteLine(str) let enclose s = "[" + s + "]" writeLine (enclose "foo") let capitalize (s: string) = s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower() writeLine (capitalize "foo") (* 関数合成 *) (capitalize >> enclose >> writeLine) "foo" let middle (s: string) = s.Substring(s.Length / 3, s.Length - s.Length * 2 / 3) (middle >> capitalize >> enclose >> writeLine) "yoofoobar" writeLine (enclose (capitalize (middle "foobaryah"))) (* カリー化形式 *) let sandwich (tag: string) str = tag + str + tag writeLine (sandwich "***" "sanded!") let encloseC (openStr: string) closeStr str = openStr + str + closeStr writeLine (encloseC "{" "}" "enclosed!") (* 部分適用 *) let encloseCurly = encloseC "{" "}" writeLine (encloseCurly "囲まれた!")
おまけ
Java や C# では、ラムダ式と通常のメソッドの間には大きな差がありました。 その一例として、ジェネリックな関数を定義できない、というものがありました。 しかし、F# ではラムダ式と通常の関数はシームレスにつながっています。 そのため、
(* 関数 *) let id x = x
はもちろんできるし、
(* ラムダ式 *) let id = fun x -> x
Java 8を関数型っぽく使うためのおまじないをC#でやってみた
Java 8を関数型っぽく使うためのおまじない - きしだのはてな
Java は大変ですね。 C# さんは、ラムダ式も Func 型(Java の Function 型に大体対応)も Visual Studio 2008 時代(5年前)から使えたので、似たようなことはすでにできます。 上記の記事のパクリなので、上記の記事をまずは読んでから読むことをおすすめします。
Func 型
C# では Func デリゲートというものがあります。名前空間名や型パラメータまで含めると、System.Func<TArg, TResult>
です。
こんな感じで使います。
Func<string, string> enclose = s => "[" + s + "]";
これを呼び出そうとすると、こんな感じになります。
// Javaだとenclose.apply("foo")と、applyが必要 System.Console.WriteLine(enclose("foo"));
こうすると、次のような表示になります。
[foo]
もうひとつ関数を定義してみましょう。最初の文字を大文字にする関数です。
Func<string, string> capitalize = s => s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower();
2文字未満の文字列を与えると死にます。
呼び出してみます。
// applyは不要 System.Console.WriteLine(capitalize("foo")); // => Foo
この2つを順に呼び出して、capitalize して enclose しようとすると、こんな感じになりますね。
System.Console.WriteLine(enclose(capitalize("foo"))); // => [Foo]
こういう場合、Java では andThen を使って連結できるみたいですが、C# にはそんなものはありません。 拡張メソッドで追加しましょう。
using System; public static class Func { public static Func<T, V> AndThen<T, U, V>(this Func<T, U> f, Func<U, V> g) { return t => g(f(t)); } }
使ってみましょう。
System.Console.WriteLine(capitalize.AndThen(enclose)("foo"));
これは、関数合成ですね。
Func<string, string> capEnc = capitalize.AndThen(enclose); System.Console.WriteLine(capEnc("foo"));
C# でも関数型っぽいことができることがわかりました。やったね!
別名を使う?
Func はそんなに長くなくてそれなりにいい感じだと思います。 あと、Func オブジェクトをすでに持っているのであれば、var が使えます。
// これは怒られちゃう // var enclose = s => "[" + s + "]"; Func<string, string> enclose = s => "[" + s + "]"; Func<string, string> capitalize = s => s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower(); // Funcオブジェクトに対する操作の結果であれば、引数の型も戻り値の型も不要! var capEnc = capitalize.AndThen(enclose);
Action
戻り値を返さない関数は、C# では Func が使えません(Func<string, void>
がエラー)。
仕方ないので Action を使います。
Action を使うと、System.Console.WriteLine も扱えます。
Action<string> writeLine = System.Console.WriteLine; var enclose = (string s) => "[" + s + "]"; writeLine(enclose("foo")); var capitalize = (string s) => s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower(); writeLine(capitalize("foo"));
ここで、1引数の Func しか使ってませんが、C# は型パラメータの数が違えば同じ名前で型が定義できます。 それを利用して、標準で 16 引数まで対応した Func/Action が用意されています。 が、2引数以上の関数は甘えなので、1引数の Func/Action さえ定義されていれば問題ありません。
関数合成
関数合成は次のように書けます。
writeLine(capitalize.AndThen(enclose)("foo"));
さらにもうひとつ関数を用意して、次のように書いてみます。真ん中あたりを取り出す関数です。
// Substringの第二引数の意味が、Javaとは違うので注意! var middle = (string s) => s.Substring(s.Length / 3, s.Length - s.Length * 2 / 3); writeLine(middle.AndThen(capitalize).AndThen(enclose)("yoofoobar"));
関数合成を使わないと、次のようになりますね。
writeLine(enclose(capitalize(middle("foobaryah"))));
このように、実際に呼び出す順と記述が逆になります。middle して capitalize して enclose するのに、先に enclose から書く必要があります。 また、カッコがたまっていくので、最後にまとめてカッコを閉じる必要があります。ちょっと関数呼び出しが増えると、閉じカッコの数が分からなくなってコンパイルエラーがなくなるところまで「)」を増やしたり減らしたりなんてこと、ありますよね?
元記事には日本語名のメソッド定義してますが、省略します。
カリー化形式
さて、2引数以上の関数は甘えと書きましたが、実際2つ以上のパラメータを渡したいときはどうすればいいんでしょう? こういうときに使うのがカリー化です。カリー化は、ひとつの引数を取って関数を返すことで、複数のパラメータに対応します。
例えば、はさむ文字列とはさまれる文字列を指定して、文字列をはさむ関数は、通常の2引数関数であらわすならこうなるでしょう。
string Sandwich(string tag, string str) { return tag + str + tag; }
これをカリー化形式の関数で書く(もしくは、1引数関数のみを使って書く)と、次のようになります。
Func<string, Func<string, string>> sandwich = tag => str => tag + str + tag;
sandwich 自体は、『文字列を取って、「文字列を取って文字列を返す関数」を返す関数』になっています。
呼び出しは次のようになります。
writeLine(sandwich("***")("sanded!")); // => ***sanded!***
3引数だとこんな感じですね。
Func<string, Func<string, Func<string, string>>> encloseC = open => close => str => open + str + close;
encloseC は、【文字列を取って、『文字列を取って、「文字列を取って文字列を返す関数」を返す関数』を返す関数】になっています。
呼び出しはこんな感じで。
writeLine(encloseC("{")("}")("enclosed!")); // => {enclosed!}
ところで、このカリー化形式の encloseC、引数を部分的に渡しておくことが出来ます。
var encloseCurly = encloseC("{")("}"); writeLine(encloseCurly("囲まれた!"));
こうやって部分適用することで、新しい関数が作れるわけです。 ちなみに Curly は、波カッコ=カーリーブラケット「{~}」のことで、カリー化とは関係ないのであしからず。
まとめ
Action 以降のソース、こんな感じです。
Action<string> writeLine = System.Console.WriteLine; Func<string, string> enclose = s => "[" + s + "]"; writeLine(enclose("foo")); Func<string, string> capitalize = s => s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower(); writeLine(capitalize("foo")); // 関数合成 writeLine(capitalize.AndThen(enclose)("foo")); Func<string, string> middle = (string s) => s.Substring(s.Length / 3, s.Length - s.Length * 2 / 3); writeLine(middle.AndThen(capitalize).AndThen(enclose)("yoofoobar")); writeLine(enclose(capitalize(middle("foobaryah")))); // カリー化形式 Func<string, Func<string, string>> sandwich = tag => str => tag + str + tag; writeLine(sandwich("***")("sanded!")); Func<string, Func<string, Func<string, string>>> encloseC = open => close => str => open + str + close; writeLine(encloseC("{")("}")("enclosed!")); // 部分適用 var encloseCurly = encloseC("{")("}"); writeLine(encloseCurly("囲まれた!"));
おまけ:限界について
ラムダ式を変数で受けるスタイルは、書き方の問題を除けば、このくらいであれば問題なく使えます。 ですが、メソッドで実現できることをすべて実現できるわけではありません。 最も大きいのが、ジェネリックな関数を定義できない、というものです。 例えば、こんな簡単なメソッドを考えてみましょう。
T id<T>(T x) { return x; }
引数をそのまま返すだけの関数ですが、ジェネリックな定義になっており、string だろうが int だろうが使えます。
string s = id("hoge"); int i = id(42);
これを、ラムダ式を変数で受け取るスタイルで書こうとしても出来ません。
Func<T, T> id = x => x; // Tって何
無理やり回避するなら、クラスを作る必要があります。
public static class Id<T> { public static readonly Func<T, T> Apply = x => x; }
var str = Id<string>.Apply("hoge"); var i = Id<int>.Apply(42);
・・・面倒ですね。 そこで F# ですよ!
並列/並行基礎勉強会でasync/awaitをDisってきた
3/23 に開催された、並列/並行基礎勉強会で「async/await 不要論」という発表をしてきました。
一番言いたかったこと
一番言いたかったことは、実は並列とかとは全く関係ないことです。 それは、言語への機能追加に関することです。
C# は 5.0 で非同期処理のための専用の構文として、async/await を導入しました。 これは、F# が計算一般という抽象度の高いものための汎用的な構文として、コンピュテーション式を採用しているのとは対照的です。 専用の構文を用意するかしないかは、その言語を取り巻く環境次第です。
専用の構文の利点と欠点
専用の構文の利点は、その使用用途が明確であるというところにあります。 そのため、書き方さえ覚えてしまえば、その裏で何がどうなるかなどを一切気にせずに使えてしまえたりします。
欠点は、専用の構文なのでそれ以外には使いにくくなるところです。 例えば、クエリ式はその名の通り、クエリのための構文として導入されており、SQL に近い書き方をしますが、 これをクエリ以外のために使う場合は、どうしても不自然になってしまいます。
拡張メソッドがLINQだけじゃなく様々に使えるように、クエリ式もクエリとしての構文にこだわらず汎用のモナド用の構文として入れて欲しかった
これには完全に同意です。 クエリ式が専用の構文としてではなく、汎用的な構文として導入されていれば、async/await は不要だったでしょう。
クエリ式をクエリ式として導入しなければ成功はしなかった、という考え方もあるでしょう。 しかし、クエリ式は単純な構文であれば SQL に似ている、と言えなくもないですが、 複雑なクエリを「SQL に似ている」とするのは辛いものがあり、 クエリ式はクエリの専用構文としての導入でなくとも成功したのでは、という思いがあります。
また、専用の構文をどんどん追加していくと、その言語にはどんどんゴミの山が積みあがっていきます。 言語自体の複雑さもどんどん向上していくのも問題です。 これが限界まで進むと、「便利な機能を追加したいのに既存文法とバッティングして追加できない」ようになります。 そして、C# はかなり限界に近づいてきている言語ではないか、と思うのです。
確かに async/await は便利な機能です。 もう導入されてしまったのだから、使える環境であればどんどん使っていけばいいです。 ですが、これからもこういう機能追加を「是」とし続けると、いずれどこかで限界が来ます。 もうちょっと、ストッパー的な立場の人が必要なのではないかな、というのが個人的な思いです。
async/awaitが死ぬとき
async は Task と強く結びついています。 async を付けたメソッド (やラムダ式) は、Task を返すことしかできません(もしくは何も返さない)。 そのため、async/await は Task が古い方法となったその時に死にます。 Task や現状の async/await で対応できなくなった場合、また新たに構文を導入するのでしょうか?
async/await と互換性を保ったまま、より汎用的な構文を入れる、ということも考えられなくはないです。
以下妄想です。
現状の async/await が Task と結びついているのは、Awaitable パターンが要求するシグネチャに戻り値の型に対する規定がないことから来ています。 つまり、Awaitable パターンを実装する Awaitable/Awaiter に「Task」が出てこないのに、裏で勝手に「Task」にラップされてしまうのです。
// Awaitableパターンが要求するAwaiterの例 public class SomeAwaiter<T> : INotifyCompletion { public bool IsCompleted { get; set; } public void OnCompleted(Action cont) { ... } // Task<T>ではなく、単なるTを返す public T GetResult() { ... } }
この問題を解決するには、どこかに async キーワードと Task を関連付ける仕組みを用意する必要があります。 例えば、
[ContinuationKeyword( "async", "await", Builders = new[] { typeof(AsyncVoidMethodBuilder), typeof(AsyncTaskMethodBuilder), typeof(AsyncTaskMethodBuilder<>) })] public class AsyncKeyword { }
のようなイメージです(AsyncなんとかMethodBuilder
がGetResult
の結果と Task を関連付けています)。
こんな感じで、async/await の位置にくるキーワードをユーザ定義できるようにするわけです。
それなんてコンピュテーション式?という感じですね。 まぁ、実現されることはないでしょうが・・・
それReactive Extensionsでできるよ
こんな感じで、一番言いたかったことは実は構文のお話なのでした(そういう意味では、「async/await 不要論」というタイトルは釣りです)。 なので、「それ Rx でできるよ!」というのは、今回はほとんど言ってません。
一応、40 枚目のスライドで名前を出してはいるんですが、正直 Rx は全然調べてなかったので、名前しか出してません。 その後ちょっと調べてみたら、APM がベースで、TAP も IObservable に変換して使えるみたいです。 なので、F# や async/await は使えないけど、Rx が使える場合は、独自で実装せずに Rx を使う、というのが現実的でしょうか。
マルチコア時代とasync/await
これから「どんどん」コア数が増えていくのか、というと、「それは違うのではないか」とスライドに書きました。 しかし、緩やかにはコア数は増えていくでしょうし、現状、複数コアが使える環境というのは多くあるでしょう。 これにより、「マルチコア時代だから async/await だ!」という意見を見かけることも多くなっていくような気がしています。
これについてはスライド中でも「マルチコア化の流れとは無関係に async/await は重要だ」という話をしました。 たとえ 1コアしかなくても、async/await に意味はあるのです。
今回の発表では、この「マルチコア時代だから async/await だ!」という意見が出てくるのを防ぎたかった、 というのが、二番目に言いたかったことでした。 杞憂であるならそれに越したことはないのですが、一応ここでも明言しておきます。
次回以降
アクターに関する話が最初にちょろっと出ただけだったので、次回があるならそのあたりも聞きたいですね。 それか、CPU 内部の並列の話とか。