列挙可能から完全なるモノまで – IEnumerableの探索 – C# Advent Calendar 2014

この投稿は、C# Advent Calendar 2014 の14日目の記事です。

IEnumerableにまつわるアレやアレ

こんにちは! いつもはAsyncとLINQの事をしゃべったり書いたりしています。「列挙可能から完全なるモノまで – IEnumerableの探索」というお題で書いてみようと思います。

.NETでは、LINQにまつわるインターフェイスがかなり多く含まれています。その中でも、IEnumerableインターフェイスの継承グラフに存在する様々なインターフェイスを、どのように使い分けるべきか、と言うのが分かりにくいかも知れません。沢山のインターフェイスが定義されているのは、歴史的な事情もあります。

LINQ to ObjectやLINQ to Entitiesなど、一般的に使用されているLINQの背景には、「列挙可能である」という性質があります。この事は、以前にLINQの勉強会でも取り上げましたが、もっと分かりやすく掘り下げたいと思います。


Back to the IEnumerable – .NET 1.0

LINQにおいて「列挙可能」であるとは、つまり「IEnumerable<T>」インターフェイスを実装している事、という事です。例えば、次のような簡単なLINQを考えてみます。

// データを格納するintの配列
int[] sourceDatas = new int[] { 123, 456, 789 };

// データ配列から、偶数の値を抽出する
IEnumerable<int> evens = sourceDatas.Where(data => (data % 2) == 0);

.NETの配列は、System.Arrayクラスを暗黙に継承しています。このリファレンスを見ると、以下のようなインターフェイスを実装していることが分かります。

// (無関係なインターフェイスは除外)
public abstract class Array : IList, ICollection, IEnumerable

これを図示すると、こういう事です。

enumeration1

  • IEnumerableインターフェイスは、「列挙可能」である事を示します。「示す」と言っても、ただ表明しているわけではありません。列挙可能にするための「GetEnumerator」メソッドを実装しています。ここではその詳細に踏み込みませんが、C#の「foreach」を使用する時、C#のコンパイラは暗黙に「GetEnumerator」メソッドを呼び出して、要素の列挙を実行します。そのため、「IEnumerable」を実装している=列挙可能、と見なせるわけです。
    だから、配列に対して、直接foreachが使えます。
    (厳密には異なるのですが、このように解釈していて問題ありません)
  • ICollectionインターフェイスを実装しているクラスは、要素数を把握可能である事を示します(他にも機能はあるのですが、省略)。要素数は、皆さんおなじみの「Count」プロパティで取得できます。
    ICollectionはIEnumerableを継承しています。そのため、ICollectionにキャスト出来た場合は、勿論「列挙が可能」と言う事になります。
  • IList インターフェイスは、リスト形状のコレクションに対する、追加・変更・削除も含めた、全ての操作が可能である事を示します。このインターフェイスは、ICollectionを継承しているので、要素数の把握も可能(つまり、Countプロパティが使える)で、更にIEnumerableも継承していることになるので、列挙も可能です。

さて、これらのインターフェイスが登場したのは、.NET 1.0、つまり最初から存在するインターフェイスです。気づいたかもしれませんが、これらのインターフェイスには、要素の型が含まれていません。例えば、IEnumerableインターフェイスで列挙しようとすると、System.Objectが要素の型となります。これでは列挙した後、適切な型へのキャストを行わなければなりません。

実際、「foreach」を使用して列挙する場合、ループ変数の型を明示的に指定する事で、暗黙にキャストしていました。意味的には以下のようになります。

// .NET 1.0世代での列挙

// 明示的にIEnumerable型の変数に代入
IEnumerable sourceDatas = new int[] { 123, 456, 789 };

// foreachでループ(一般的な記述)
foreach (int value in sourceDatas)
{
  // ...
}

// 実際にはこういう事
foreach (object untypedValue in sourceDatas)
{
  int value = (int)untypedValue;

  // ...
}

ところで、配列の基底型であるSystem.Arrayが、IListインターフェイスを実装しているのはおかしいと思うかもしれません。配列の要素数は、一度生成されたら「不変」な筈です。そのため、ICollectionインターフェイスの実装までが妥当と思われますが、そうなっていません。実際、配列のインスタンスをIListインターフェイスにキャストし、Addメソッドを呼び出して要素を追加しようとしても、例外がスローされます。

このような設計になっている理由は公式には明らかになっていないと思いますが、IListインターフェイスには「インデクサ」が定義されています。このインデクサを使用可能にするため、敢えてIListインターフェイスを実装しているのではないかと推測しています。

(しかし、結局これは誤った設計だったと個人的には思っています。理由については後述)


ジェネリック引数で要素の型を伝搬 – .NET 2.0

上図では、Arrayを継承してintの配列を生成しています。Arrayは列挙可能であったり、要素数の把握が可能と言う事になっています。しかし、その要素の型はSystem.Objectであり、intへのキャストが必要である事は説明した通りです。では、配列を対象とした場合、IEnumerable<int>のような、要素型が明示されたインターフェイスでは使用出来ないという事でしょうか?

enumeration2

そんな事はありません。.NET 2.0でジェネリック型がサポートされ、このように配列の実装インターフェイスが拡張されました。「Array<T>」クラスが導入されたわけではなく、配列を定義すると自動的に「IEnumerable<T>」インターフェイス「ICollection<T>」インターフェイス「IList<T>」インターフェイスが、直接配列の型に実装されます。

「IEnumerable<T>」は「IEnumerable」も継承しているので、根本的な「列挙可能」という性質は受け継いでいると言えます。しかし、「ICollection<T>」は「ICollection」を継承しておらず、「IList<T>」も「IList」を継承していません。

この理由についても推測するしかありませんが、「ICollection<T>」は設計上、ICollectionのジェネリックバージョンではないと考えられます。ICollectionには存在しない「Add」等の要素数の変化を伴う操作メソッドが追加されています。「IList」があまりに多くの役割を担い過ぎている事への反省でしょうか。

しかし、これが原因で、ICollectionやIListの対比が分かりにくくなっています。IList<T>を実装するクラスを見た場合、暗黙的にIListも実装しているのではないか?と考えてしまいますが、そうなっていません(ここで示した配列や、List<T>クラスは、わざわざIList「も」実装する事で、この問題に対処しています)。

それにしても、普段コードを書いていると、「ICollection<T>」ほど「触らない」インターフェイスも珍しいです。一番の問題はやはり「インデクサ」が定義されていない事で、わざわざICollection<T>を操作する機会がほとんどありません。対照的に、「IList<T>」は良く使いました。


LINQの登場 – .NET 3.5

さて、このようなコレクション操作の抽象化を行う下積みがあり、とうとうLINQが登場します。LINQは、IList<T>のような高機能なインターフェイスではなく、List<T>クラスのような個々のコレクションクラスへの直接拡張でもなく、「IEnumerable<T>」インターフェイスだけを要求してきました。

ここに「拡張メソッド」のサポートが加わり、「列挙可能な要素群」を包括的に操作可能となったのです。LINQが.NETやC#と切り離せない関係にあるのは、ここで改めて説明するまでも無いでしょう。

IEnumerable<T>しか要求しないので、ここまでに解説したほとんどのインターフェイスは、実装さえしていれば即LINQで応用が可能と言う事になります。IEnumerable・ICollection・IListの「非ジェネリック」版インターフェイスについては、残念ながらLINQでそのまま使う事が出来ません。大きな問題は、列挙可能な要素の「型」が分からない事です。

しかし、型を明示的に与えれば、LINQ演算子を適用する事が出来ます。

// 明示的にIEnumerable型の変数に代入
IEnumerable sourceDatas1 = new int[] { 123, 456, 789 };

// LINQで使うには、Cast演算子を使って型を明示すればよい
IEnumerable<int> evens1 = sourceDatas1.Cast<int>().Where(data => (data % 2) == 0);

// 様々なインスタンスが入っている状態
IEnumerable sourceDatas2 = new object[] { 123, "ABC", 456, Guid.NewGuid(), 789 };

// 何が入っているか不明な場合は、OfType演算子を使って型で絞り込みを行う事も可能
// (指定された型以外のインスタンスが入っていた場合は、そのインスタンスは無視される)
IEnumerable<int> evens2 = sourceDatas2.OfType<int>().Where(data => (data % 2) == 0);

IEnumerableは、列挙される要素の型が分からないため、必ず想定されるインスタンスが取得されるとは限りません。OfType演算子はそのような場合に使用することが出来ます。Cast演算子を使用しながら、異なる型が取得された場合は、例外がスローされます。

今でも、古いWindows Formsクラスのコレクションで、IEnumerableやIListだけが実装されているクラスがあります。それらのインスタンスに対しても、この演算子を使えばLINQで操作可能となります。

.NET 3.5の世代では、一連のインターフェイスに新たに付け加えられたインターフェイスはありませんでした。次の拡張は.NET 4.5を待つ事になります。


読み取り専用とインデクサ – .NET 4.5

enumeration3

.NET 4.5がリリースされ、突如として「列挙可能」インターフェイスに新顔が追加されました。それが「IReadOnlyCollection<T>」インターフェイス「IReadOnlyList<T>」インターフェイスです。

名前から想像出来るように、これらのインターフェイスは、要素の読み取りのためのインターフェイスで、IReadOnlyCollection<T>は要素数「だけ」が取得可能、IReadOnlyList<T>は読み取り専用インデクサが定義されています。

このインターフェイスを初見した時の率直な感想としては、「遅すぎた」でした。.NET 1.0世代で書きましたが、すでにこの時「読み取りしか行わないけど、インデクサが欲しい」という状態は露呈していました。世代が進んで初めて必要となったのであれば分かるのですが、.NET 1.0においても既に分かっていた事のように思います。理由として、「IsReadOnly」プロパティの存在が挙げられます。このプロパティを追加する際に、当然IReadOnlyCollectionやIReadOnlyListを検討する事も可能だった、と思うと残念なのです。

残念な出自もさることながら、残念な現実もあります(だから余計に残念)。今更、ICollection<T>やIList<T>インターフェイスの継承階層の適切な位置に、これらの新しいインターフェイスを潜り込ませる事は「出来ません」。そんな事をすれば、既存のICollection<T>やIList<T>等のインターフェイスを実装しているクラスが破綻してしまいます。インターフェイスは契約の一種です。後から変更されるとバイナリ互換性が失われます。

.NET内部ではこのインターフェイスをどう扱っているのでしょうか? 図に示したように、新しいインターフェイスは配列の具象型が「直接」実装する事になります。これまでのインターフェイスとは全く融合せず、唯一IEnumerable<T>を継承する事で「型明示で列挙可能」である事は表明しています。同様に、List<T>クラスも、クラスが直接新しいインターフェイスを実装する事で、互換性を失わないようにしています。


メモ:先人を擁護するなら、.NET 1.0の頃はまだJavaとの対比が強く、「マルチ言語」に強くこだわっていました。インデクサという機能は、様々な言語で再現出来ない可能性があり、C#やVB.net特有の機能とみなされており、IL上でも特別な扱いを受けていました。そのような機能を、mscorlib内のスタンダードなインターフェイスとして定義するのははばかられたのかも知れません。
ぶっちゃけ、素でインデクサを表現できないなら、「Item」メソッドで良いじゃん?と思わなくもないんですがw


インターフェイスバリエーションの物理的な側面

IEnumerableにまつわるインターフェイスの歴史的な側面を挙げてきました。物理的な側面についても触れておきます。

多くのインターフェイス定義の、どれを選択するのか?

単に列挙するだけならIEnumerable<T>、リストの参照や操作を行う場合はIList<T>型を指定させ、意図の違いをコード上に表現します。

// 与える引数は列挙しかしないので、int[]ではなく、IList<int>ではなく、IEnumerable<int>として意図を推測しやすくする
public static int SumEvens(IEnumerable<int> datas)
{
  // (わざとベタアルゴリズムです)
  int result = 0;
  foreach (var data in datas)
  {
    if ((data % 2) == 0)
    {
      result += data;
    }
  }
  return result;
}

// 配列の指定はOK
int[] sourceDatas = new int[] { 123, 456, 789 };
SumEvents(sourceDatas);

// List<int>の指定もOK
List<int> sourceDatas = new List<int> { 123, 456, 789 };
SumEvents(sourceDatas);

基底となるインターフェイスで引数を定義する、と言うのは意図の明確化でもあり、応用性を高める点でも重要です。上記の例では、引数にはあらゆる「列挙可能」なインスタンスを指定する事が出来ます。しかし、仮に具象クラスや具象クラスに近いインターフェイスを指定した場合はどうなるでしょうか。

// IList<int>を指定した場合
public static int SumEventsFromIList(IList<int> datas) { ... }

// 配列の指定はOK
int[] sourceDatas = new int[] { 123, 456, 789 };
SumEventsFromIList(sourceDatas);

// List<int>の指定もOK
List<int> sourceDatas = new List<int> { 123, 456, 789 };
SumEventsFromIList(sourceDatas);

// しかし、内部では列挙しているだけなのだから、IList<T>である必要はない。
// そして、IList<T>で渡していると、リストが書き換えられる可能性がある、ように見える。

//////////////////////////////////////

// 配列を指定した場合
public static int SumEventsFromArray(int[] datas) { ... }

// 配列の指定はOK
int[] sourceDatas = new int[] { 123, 456, 789 };
SumEventsFromArray(sourceDatas);

// List<int>の指定はNG
List<int> sourceDatas = new List<int> { 123, 456, 789 };
SumEventsFromArray(sourceDatas);   // 型不一致エラーでコンパイル出来ない

//////////////////////////////////////

// List<int>を指定した場合
public static int SumEventsFromList(List<int> datas) { ... }

// 配列の指定はNG
int[] sourceDatas = new int[] { 123, 456, 789 };
SumEventsFromList(sourceDatas);   // 型不一致エラーでコンパイル出来ない

// List<int>の指定はOK
List<int> sourceDatas = new List<int> { 123, 456, 789 };
SumEventsFromList(sourceDatas);

大したことではない、と思うかもしれません。一年後、コードのメンテナンスをしなければならない時に、内部実装を読む事無く、すばやくメソッドの「意図」を把握出来るのはどちらでしょうか? IEnumerable<T>のように、より制限された型を指定しておくと、そのような負担から解放され、少しでも楽になります。

.NET 4.5以上であれば、IReadOnlyList<T>も使い分けの対象に入れると良いでしょう。このインターフェイスはまだまだ実装クラスが少ないですが、見ると安心感が違います(少なくともIList<T>を引数に渡す時には、書き換えの可能性が頭をよぎります)。

共変性と読み取り専用インターフェイス

読み取り専用として定義されたインターフェイスが残念な事になっているのは説明した通りです。このインターフェイスがわざわざ遅れて導入された理由と思われるものとして、要素型の「共変性」があります。共変性については「ジェネリクスの共変性・反変性 – 未確認飛行 – ++C++;// 未確認飛行 C」が詳しいので参照してください。

読み取り専用であれば(或は、読み取り専用として定義する事により)、インスタンスの代入互換性が高まるという事が趣旨にあります。ちなみに、この共変性の性質は.NET 1.0からありました。配列についてだけは、要素型の暗黙的なキャストが成立する場合において、代入互換があることになっていました。

// stringの配列
string[] values = new string[] { "ABC", "DEF", "GHI" };

// objectの配列は、要素型がstring→objectへの暗黙のキャストが成立するので代入可能(暗黙の共変性・但し、値型は不可)
object[] objectValues = values;

実行時判定

今までの話は、全てコンパイル時に型を特定する「型安全」についてフォーカスしたものです。LINQのパフォーマンスについての疑念の話をしましたが、実際にはより最適なアルゴリズムを採用するために、実行時のインスタンスの型を調査しています。

去年書いた、Advent LINQ:Countや、Advent LINQ:ElementAtのように、実行時に様々なインターフェイスにキャスト可能かどうかを判定して、より効率の高いアルゴリズムを採用する、という手法です。

Countの例はIReadOnlyCollection<T>に対応していなかったので、書き直してみます。

public static int Count<T>(this IEnumerable<T> enumerable)
{
  // 列挙子がIReadOnlyCollection<T>を実装していれば
  var rocollection = enumerable as IReadOnlyCollection<T>;
  if (rocollection != null)
  {
    // 直接Countプロパティを参照する
    return rocollection.Count;
  }
 
  // 列挙子がICollectionを実装していれば
  var collection = enumerable as ICollection;
  if (collection != null)
  {
    // 直接Countプロパティを参照する
    return collection.Count;
  }
 
  // 仕方が無いので、列挙してカウントする
  var count = 0;
  foreach (var value in enumerable)
  {
    count++;
  }
  return count;
}

残念なインターフェイスの通り、IReadOnlyCollection<T>とICollectionは、互いに全く関係がありません。そのため、キャストが成立するかどうかを別々に確かめる必要があります。


あとがき

ちょっと、頭の中を整理したい事もあって、詳しく書き出してみたつもりだったのですが、要所でコード書いて確認してみないと自身が無い部分とかあり、良いリハビリになったなと思いました。

今年もあと少し、実際にはC# Advent Calendarは次週も担当です。進捗はダメです (;´Д`)
それではまた。

次は「a_taihei」さんです。よろしく!

投稿者:

kekyo

A strawberry red slime mold. Likes metaprogramming. MA. Bicycle rider. http://amzn.to/1SeuUwD