C#に型クラスを入れる実装の話

この記事はC# Advent Calendar 2016の12日目のものです。 昨日(今日)書いた、F# Advent Calendar 2016 11目C#版です。

今日のリポジトリはこちら。

github.com

実は、F#版だけじゃなくてC#版の実装もあります。 ということで、そのざっくりした紹介です。

型クラス?コンセプト?

F#版では「型クラス(type class)」と呼んでいますが、C#版では「コンセプト(concept)」と呼んでいるようです。 で、コンセプトがあると何がうれしいかですが、例えばC#には現在3つの2要素タプルがあります。

  • System.Collections.KeyValuePair<TKey, TValue>
  • System.Tuple<T1, T2>
  • (T1, T2)

これらの型すべてに対応するためには、現在のC#ではオーバーロードを3つ書く必要があります。 例えば、「2要素タプルの IEnumerable から1番目の要素を取り出した IEnumerable にしたい」という場合を考えてみましょう。

public static IEnumerable<T1> FirstAll<T1, T2>(IEnumerable<KeyValuePair<T1, T2>> xs)
    => xs.Select(kvp => kvp.Key);
public static IEnumerable<T1> FirstAll<T1, T2>(IEnumerable<Tuple<T1, T2>> xs)
    => xs.Select(t => t.Item1);
public static IEnumerable<T1> FirstAll<T1, T2>(IEnumerable<(T1, T2)> xs)
    => xs.Select(t => t.Item1);

面倒ですね。 ここで、提案されているコンセプトを使った場合にどうなるか見てみましょう。

// 新しいキーワードconceptを使ってコンセプトを定義
public concept Tuple2<Tpl, [AssociatedType] T1, [AssociatedType] T2>
{
    T1 First(Tpl t);
    T2 Second(Tpl t);
    Tpl Make(T1 item1, T2 item2);
}

// 新しいキーワードinstanceを使ってコンセプトのインスタンスを定義
// ここではKeyValuePairをTuple2のインスタンスにしている
public instance KeyValuePairTuple2<T1, T2> : Tuple2<KeyValuePair<T1, T2>, T1, T2>
{
    T1 First(KeyValuePair<T1, T2> t) => t.Key;
    T2 Second(KeyValuePair<T1, T2> t) => t.Value;
    KeyValuePair<T1, T2> Make(T1 item1, T2 item2) => new KeyValuePair<T1, T2>(item1, item2);
}

// System.TupleをTuple2のインスタンスにする
public instance TupleTuple2<T1, T2> : Tuple2<Tuple<T1, T2>, T1, T2>
{
    T1 First(Tuple<T1, T2> t) => t.Item1;
    T2 Second(Tuple<T1, T2> t) => t.Item2;
    Tuple<T1, T2> Make(T1 item1, T2 item2) => Tuple.Create(item1, item2);
}

// System.ValueTupleをTuple2のインスタンスにする
public instance ValueTupleTuple2<T1, T2> : Tuple2<(T1, T2), T1, T2>
{
    T1 First((T1, T2) t) => t.Item1;
    T2 Second((T1, T2) t) => t.Item2;
    (T1, T2) Make(T1 item1, T2 item2) => (item1, item2);
}

こういう定義をしておけば、あとは一つだけ実装を書くだけです。

// 型パラメータにimplicitを付けて、その型パラメータがTuple2でなければならないことを制約で書く
public static IEnumerable<T1> FirstAll<T1, T2, implicit Tpl2>(IEnumerable<Tpl2> xs) where Tpl2 : Tuple2<T1, T2>
    => xs.Select(x => First(x)); // 本体部分では何の修飾もなしにメソッドを呼び出す

// 当然、SecondAllも同様に定義可能
public static IEnumerable<T2> SecondAll<T1, T2, implicit Tpl2>(IEnumerable<Tpl2> xs) where Tpl2 : Tuple2<T1, T2>
    => xs.Select(x => Second(x));

これで、定義した FirstAllSecondAll には IEnumerable<KeyValuePair<TKey, TValue>>IEnumerable<Tuple<T1, T2>>IEnumerable<(T1, T2)> も渡せます。 このように、既存の型に対して後付けで新たな抽象を追加できるのがコンセプトの便利なところの一つです。

ここからは未確認ですが、おそらく戻り値オーバーロードのようなこともできるようです。

public static IEnumerable<Tpl2> Singleton<T1, T2, implicit Tpl2>(T1 x, T2 y) where Tpl2 : Tuple2<T1, T2>
    => Enumerable.Repeat(Make(x, y), 1);

IEnumerable<KeyValuePair<string, int>> res1 = Singleton("aaa", 0);
IEnumerable<Tuple<int, int>> res2 = Singleton(10, 20);
IEnumerable<(string, string)> res3 = Singleton("hoge", "piyo");

他にも、例えば今は Enumerable.SequentialEqualIEnumerable<T> どうしの比較をしていますが、比較不可能なもの((Equals をオーバーライドしていないとかとか))でもコンパイルが通ってしまいますが、コンセプトが導入されれば Eq コンセプトの要素を持つ場合のみに有効な比較演算子みたいなものも定義出来てうれしい、とかがあったりします。

この実装方法の利点・欠点

この実装方法は、既存のランタイムに全く手を加える必要がないのが利点です。

欠点は、この実装方法でどこまでやるかという話になりますが、例えば == 演算子Eq コンセプトで置き換えるとなると、互換性を犠牲にする必要が出てきてしまう点です。 全部作り直してしまえるタイミングはとうの昔に過ぎ去っているので、別の演算子を導入するとか何らかのスイッチで切り替えられるようにしておくとかしないといけません(そんなの知るか、全部作り直しじゃー!ってのも面白いんですけどまずないでしょう)。

C#にコンセプトはいつ乗るの?

この実装が乗ることはまずないです。 ですが、こういう「今ここにない機能」が実際に動作するコードとともに公開されているというのは、いい時代になったものです。 コンセプト(≒型クラス)は、Haskellはもちろん似たような機能がSwiftやRust、Scalaといった今をときめく言語たちに乗っていますので、この実装そのままではなくても、いつかはC#にも乗ったりする日が来るかもしれませんね。