LoginSignup
39
37

More than 5 years have passed since last update.

C#のジェネリクスでできないこと

Last updated at Posted at 2015-12-05

この記事はC# Advent Calendar 2015の5日目の記事です。

C#は、静的型付けのプログラミング言語で、ジェネリクスを持っています。ジェネリクスによって汎用性と型安全性を備えたコードを書けるのですが、それでも「C#のジェネリクスだとうまく書けないな」と思うようなケースもたまにあります。そんな例を2つほど紹介します。そして、他のプログラミング言語ではどうなのか、そしてC#ではこうする/こうしている、という解決法も書いてみます。

ジェネリックなコードで四則演算

C#ではジェネリックなコードで算術演算子を呼べません。

image1.png

これは、演算子は静的メソッドとして型ごとに実装されているため、呼び出したい演算子が型パラメータTに定義されていることを要請する型制約を書く方法がない(だけでなく、コンパイル結果のILにもそのような制約を持たせる方法がない)からです。

Haskellでは

型クラスというものがあって、ある型の値に適用できる関数のシグネチャを定義しておくことができます。むりやりC#っぽく言うなら、「静的メンバーだけを定義した特殊なインターフェース型のようなもの」です。

算術演算については、Numという型クラスがあって、+-*といった演算子のシグネチャが定義されています。ジェネリックな関数を書くときに、引数の型が Num 型クラスに属するという制約をつけると、引数に対して +-*といった演算子を適用できます(逆もできて、引数に対して +-*といった演算子を適用するコードを書くと、引数の型を推論する時にNum型クラスの制約がつきます)。そしてたとえば Haskellの整数型 IntNum 型クラスに属していて、+-*といった演算子が実際に定義されているので、そのようなジェネリックな関数の引数として整数を渡すことができる、という次第です。

F#では

F#では、インライン関数という、コンパイル時にインライン展開される関数を定義できます。そして、ジェネリックなインライン関数を定義する場合は、「静的に解決される型パラメーター」というおもしろい型パラメーターを利用できます。

静的に解決される型パラメーターには、「ある静的メンバーを持っていること」とか「ある演算子を適用できること」といった、C#のジェネリクスでは書けないような型制約をつけることができます(逆もできて、インライン関数の引数に対して +や-や*といった演算子を適用するコードを書くと、引数の型を推論する時に演算子の制約がつきます)。そしてそのような型制約を付けた関数を呼び出すF#コードを書くと、コンパイルするときに制約を満たしているかどうかチェックされます。

コンパイル結果のILにはそのような制約を制約として埋め込むことはできませんので、静的に解決される型パラメータを持った関数をライブラリに含めておいてC#などの言語から呼び出そうとしたときには、呼び出し側のコンパイル時に制約がチェックされることはありません(演算子の適用など、一部の処理は動的に解決され実行されますが、それ以外の処理、たとえばメンバーの呼び出しなどは実行時に例外がスローされます)。

C#ではどうするか

C#でどうするかは、<演算子や>演算子のことを考えるとわかりやすいです。値の大小を比較できるような型はIComparable<T>というインターフェースを実装していたりしますよね。

つまるところ、普通に四則演算をメソッドして宣言するインターフェースを定義して、それぞれの型がそのインターフェースを実装するしかないということです。

もしC#にそのようなインターフェースがあったら……という仮定のコードを書いてみます。

public interface IField<T>
{
    public T Add(T that);
    public T Subtract(T that);
    public T Multiply(T that);
    public T Devide(T that);
}

public struct Int32 : IField<Int32>
{
    public Int32 Add(Int32 that) => this + that;
    public Int32 Subtract(Int32 that) => this - that;
    public Int32 Multiply(Int32 that) => this * that;
    public Int32 Devide(Int32 that) => this / that;
}

public class Program
{
    public static T Double<T>(T x) where T : IField<T>
    {
        return x.Add(x);
    }
}

まあ、そうなるよな……という感じですね。

ただしこのコードだと、Double<T>(T x)のようなメソッドはIField<T>のメソッドを呼び出しますので、Int32のような値型はボックス化されてしまいます。それを避けるには、1 Int32型がIField<Int32>を実装していないと意味がありません。Int32のような組み込み型にはそれを望めませんから、やはり<演算子や>演算子のことを真似して

public class Arithmetic<T>
{
    public static Arithmetic<T> Default { get { ... } }
    public virtual T Add(T x, T y) { ... }
    public virtual T Subtract(T x, T y) { ... }
    public virtual T Multiply(T x, T y) { ... }
    public virtual T Devide(T x, T y) { ... }
}

みたいな型を作って、

public class Program
{
    public static T Double<T>(T x)
    {
        return Arithmetic<T>.Default.Add(x, x);
    }
}

みたいに呼び出す感じになるでしょうか。そして、Arithmetic<T>.Defaultの初回呼び出し時に内部でコード生成する、という。(追記:13日目の記事で、コード生成による対応例が紹介されました。)

ちなみにこのArithmetic<T>.Defaultは、事実上、「型クラスをオブジェクトのインスタンスで代用する」形になっています。Scalaでは、暗黙的な引数や暗黙的な型変換などによって、ある型に関連付けられたオブジェクトのインスタンスを自動的に得る仕組みがあるため、より型クラスっぽいコードを書くことができます。

型引数に別の型引数を渡す

非同期メソッドの戻り値として使うTask<T>ですが、Task<Task<TResult>>という入れ子になったTaskをTask<TResult>に変換してくれる、Unwrapという拡張メソッドがあります。(※ちなみにこのメソッドは普通のインスタンスメソッドとして実装することはできなくて、拡張メソッドとして実装するしかありません。おもしろいですね。)

ところで最近は、C#言語仕様の拡張はオープンに議論されていますが、その中に任意のTask風の型を非同期メソッドの戻り値にできるようにする提案というのがあります。これがもし通れば、ユーザーが独自に定義した型、たとえばFakeTask<T>とかいうクラスを、非同期メソッドの戻り値にしてasync/awaitできるようになるわけです。

さて、そうなればFakeTask<T>型にもUnwrap拡張メソッドを用意しておきたいですよね。

public static class FakeTaskExtensions
{
    public static FakeTask<TResult> Unwrap<TResult>(this FakeTask<FakeTask<TResult>> fakeTask) { ... }
}

で、究極的には、Task<T>FakeTask<T>という個別の型じゃなくて、非同期メソッドの戻り値にできる任意の型で Unwrap できることを表現したくなります。

Haskellでは

さっき上で紹介した型クラスを使って書いてみます。

class Task t where
   unwrap :: t (t a) -> t a

こんな感じで、「Task型クラスに属する任意の型tは型引数を1つとるジェネリックな型であり、unwrap関数は入れ子になった t (t a)型を受け取ってt a型を返す」みたいな宣言をさらっと書けます。

C#ではどうするか

C#には型クラスはないので、インターフェースを定義する方向で考えてみます。

public interface ITask<T>
{
    ITask<TResult> Unwrap<TResult>(ITask<ITask<TResult>> task);
}

(※もとのUnwrapが拡張メソッドだったことを思い出してください。thisが使えるなら引数はいらないのだけど、それではうまく書けないから引数を受け取っています。でもこの記事で扱いたいのはそこじゃないんです。)

こうみればまあ書けなくもない感じですが、入れ子になっているのはインターフェース ITask<T>ですし、戻り値の型もITask<T>です。こいつらを実装型にできないもんでしょうか。

public interface ITask<T>
{
    // コンパイルエラー
    TTask<TResult> Unwrap<TTask<>, TResult>(TTask<TTask<TResult>> task)
        where TTask<T> : ITask<T>;
}

型引数として受け取ったTTask<>を、もうひとつの型引数TResultと組みあわせて、
引数の型TTask<TTask<TResult>>と戻り値の型TTask<TResult>を作り出しています。もちろんこれはコンパイルできません

こういうときC#でたまに見かける解決法に、コンパイラが特別扱いするというものがあります。今回の例でいうと、もともと目指していたことは「非同期メソッドの戻り値にできる任意の型で Unwrap できる」だったわけですが、それをコンパイラが保証する格好になります。

ちょっと何を言っているかぴんと来ない感じでしょうか?では、架空のUnwrapではなく、実際の例を出してみます。LINQのクエリ構文が使える条件は、SelectメソッドとSelectManyメソッドが定義されていることです。

これを、

public interface ILinqable<T>
{
    ILinqable<TResult> Select<TResult>(Func<T, TResult> f);
    ILinqable<TResult> SelctMany<TResult>(Func<T, IMoge<TResult>> f);
}

みたいなインターフェースを実装していること、という条件にしてしまうと、LINQを使うと戻り値の型が必ずILinqable<T>になってしまいますし、そこを実装型にしたいと思うと、さっきの例みたいに「型引数として受け取ったTLinqable<>」みたいなものが登場せざるを得なくなります。

しかし、クエリ構文をコンパイルするときに、コンパイラがSelectメソッドやSelectManyメソッドの呼び出しに機械的に置き換えるのであれば、引数や戻り値の型は自由にできるわけです。(.NET Framework標準ではIEnumerable<T>とその派生インターフェースしかないですが、違う型でもクエリ構文を使うことができます。)


  1. 元の記述は誤りでした。訂正します。こういうジェネリックメソッドに値型を渡した場合、値型のメソッドが呼び出されるわけですが、インターフェース型にボックス化したあとでメソッドが呼び出されるのではなく、値型のメソッドが直接呼び出されます。値型のメソッドを直接呼び出すコードはJITの最適化がかなり効きますので、パフォーマンスの劣化はありません。 

39
37
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
37