次の方法で共有



April 2017

Volume 32 Number 4

Essential .NET - C# の foreach の内部と yield を使ったカスタム反復子を理解する

Mark Michaelis

Mark Michaelis今月は、あらゆるプログラムでよく使われる C# の重要なコンストラクトである foreach ステートメントの内部を詳しく見ていきます。foreach の内部の動作を理解したら、後ほど説明するように、yield ステートメントを使って foreach コレクション インターフェイスを実装する方法を調べます。

foreach ステートメントのコーディングは簡単ですが、内部のしくみを理解している開発者が非常に少ないのは驚きです。たとえば、foreach は IEnumberable<T> コレクション以外の配列で機能することに気付いているでしょうか。 IEnumerable<T> と IEnumerator<T> の関係を理解しているでしょうか。 列挙可能なインターフェイスを理解しているなら、yield を使ってこのインターフェイスを実装できますか。 

クラスをコレクションにするもの

定義によると、Microsoft .NET Framework 内のコレクションとは、最低でも、IEnumerable<T> (非ジェネリック型の IEnumerable) を実装するクラスです。このインターフェイスは重要です。それは、コレクション全体の反復処理をサポートするには、最低限 IEnumerable<T> のメソッドを実装する必要があるためです。

foreach ステートメント構文はシンプルです。要素数を把握する必要をなくして、複雑さを取り除いています。ただし、ランタイムは foreach ステートメントを直接サポートしません。代わりに、この後説明するように、C# コンパイラがコードを変換します。

foreach と配列: 次の例は、整数の配列全体を反復処理し、コンソールに各整数値を出力する簡単な foreach ループを示しています。

int[] array = new int[]{1, 2, 3, 4, 5, 6};
foreach (int item in array)
{
  Console.WriteLine(item);
}

このコードから、C# コンパイラは for ループに相当する次の CIL を作成します。

int[] tempArray;
int[] array = new int[]{1, 2, 3, 4, 5, 6};
tempArray = array;
for (int counter = 0; (counter < tempArray.Length); counter++)
{
  int item = tempArray[counter];
  Console.WriteLine(item);
}

この例では、foreach が Length プロパティとインデックス演算子 ([]) のサポートを利用していることに注意してください。この Length プロパティにより、C# コンパイラは配列内の要素全体の反復処理に for ステートメントを使用できます。

foreach と IEnumerable<T>: 先ほどのコードは、長さが固定でインデックス演算子が常にサポートされている配列ではうまく機能します。ただし、すべての種類のコレクションで要素数が分かるわけではありません。さらに、Stack<T>、Queue<T>、Dictionary<TKey, TValue> など、多くのコレクション クラスはインデックスによる要素の取得はサポートしません。そのため、コレクションの要素全体を反復する一般的な方法が必要です。反復子パターンにより、これが可能になります。先頭の要素、次の要素、末尾の要素を判断できれば、要素数の把握も、インデックスによる要素の取得のサポートも必要ありません。

System.Collections.Generic.IEnumerator<T> インターフェイスと非ジェネリック System.Collections.IEnumerator インターフェイスは、先ほどの length プロパティとインデックスを使用するパターンではなく、要素のコレクション全体の反復処理に反復子パターンを使用できるように設計されています。これらの関係を示すクラス図を図 1 に示します。

IEnumerator<T> インターフェイスと IEnumerator インターフェイスのクラス図
図 1 IEnumerator<T> インターフェイスと IEnumerator インターフェイスのクラス図

IEnumerator<T> の派生元の IEnumerator には 3 つのメンバーがあります。まずは、bool MoveNext メソッドです。このメソッドを使用して、コレクション内のある要素から次の要素へ移動しながら、同時に全項目を列挙したことを検出できます。2 つ目のメンバーは、Current という読み取り専用のプロパティで、現在処理中の要素を返します。この Current を IEnumerator<T> でオーバーロードし、型固有の実装を提供します。コレクション クラスのこの 2 つのメンバーにより、while ループを使用してコレクション全体の反復処理を簡単に実現できます。

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
int number;
// ...
// This code is conceptual, not the actual code.
while (stack.MoveNext())
{
  number = stack.Current;
  Console.WriteLine(number);
}

上記のコードの MoveNext メソッドは、コレクションの末尾を超えて移動すると false を返します。これにより、while ループで要素を数える必要がなくなります。

Reset メソッドは通常 NotImplementedException をスローするため、呼び出すべきではありません。列挙を再開する必要がある場合は、新しい列挙子を作成するだけです。

前述の例では C# コンパイラの出力の要点だけを示しました。ですが、実際にはこのようにコンパイルされません。ここではインターリーブとエラー処理という実装に関する 2 つの重要な詳細を省略しています。

状態の共有: 前述の例のような実装に関する問題は、そうした 2 つのループが相互にインターリーブする場合に発生します。インターリーブとは、1 つの foreach がもう 1 つの foreach の内部にあり、双方が同じコレクションを使用することを指します。このようにインターリーブされる場合、MoveNext が呼び出されたときに次の要素を決定できるように、コレクションは現在要素の状態インジケーターを管理する必要があります。このような場合、インターリーブされる一方のループが他方のループに影響する可能性があります (ループが複数スレッドで実行される場合も同じことが当てはまります)。

この問題に対処するには、コレクション クラスで IEnumerator<T> インターフェイスと IEnumerator<T> インターフェイスを直接サポートしないようにします。代わりに、IEnumerable<T> というもう 1 つのインターフェイスを使用します。このインターフェイスにはメソッドが GetEnumerator しかありません。このメソッドは、IEnumerator<T> をサポートするオブジェクトを返すのが目的です。コレクション クラスで状態を管理する代わりに、別のクラスを用意して、IEnumerator<T> インターフェイスをサポートし、反復ループの状態を管理します。このクラスは、通常、コレクションの内部にアクセスするように入れ子になったクラスです。列挙子は、シーケンス内での「カーソル」や「ブックマーク」のようなものです。ブックマークは複数用意できます。その中の 1 つを動かして、他のブックマークとは独立してコレクション全体を列挙します。このパターンを使用した、foreach ループに相当する C# コードは図 2 に示すようになります。

図 2 反復中に状態を個別に管理する列挙子

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
int number;
System.Collections.Generic.Stack<int>.Enumerator
  enumerator;
// ...
// If IEnumerable<T> is implemented explicitly,
// then a cast is required.
// ((IEnumerable<int>)stack).GetEnumerator();
enumerator = stack.GetEnumerator();
while (enumerator.MoveNext())
{
  number = enumerator.Current;
  Console.WriteLine(number);
}

反復後のクリーンアップ: IEnumerator<T> インターフェイスを実装するクラスが状態を管理するとしたら、ループを抜けた後に状態をクリーンアップすることが必要になる場合もあります (すべての反復が完了したか、例外がスローされたため)。これを実現するには、IEnumerator<T> インターフェイスを IDisposable から派生します。IEnumerator を実装する列挙子は IDisposable を実装する必要はありませんが、実装する場合は、Dispose も呼び出されます。これにより、foreach ループを抜けた後で Dispose の呼び出しが可能になります。最終的な CIL に相当する C# コードは図 3 のようになります。

図 3 コレクションでの foreach のコンパイル結果

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
System.Collections.Generic.Stack<int>.Enumerator
  enumerator;
IDisposable disposable;
enumerator = stack.GetEnumerator();
try
{
  int number;
  while (enumerator.MoveNext())
  {
    number = enumerator.Current;
    Console.WriteLine(number);
  }
}
finally
{
  // Explicit cast used for IEnumerator<T>.
  disposable = (IDisposable) enumerator;
  disposable.Dispose();
  // IEnumerator will use the as operator unless IDisposable
  // support is known at compile time.
  // disposable = (enumerator as IDisposable);
  // if (disposable != null)
  // {
  //   disposable.Dispose();
  // }
}

IDisposable インターフェイスは IEnumerator<T> によってサポートされるため、using ステートメントを使えば、図 3 のコードを図 4 のように単純化できます。

図 4 using を使ったエラー処理とリソースのクリーンアップ

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
int number;
using(
  System.Collections.Generic.Stack<int>.Enumerator
    enumerator = stack.GetEnumerator())
{
  while (enumerator.MoveNext())
  {
    number = enumerator.Current;
    Console.WriteLine(number);
  }
}

ただし、CIL は using キーワードを直接サポートしません。したがって、実際には図 3 のコードの方が foreach CIL コードの正確な C# 表現です。

IEnumerable なしの foreach: C# では、あるデータ型を foreach を使用して反復処理するために IEnumerable も IEnumerable<T> も実装する必要はありません。コンパイラはダック タイピングという考え方を使用します。つまり、コンパイラは Current プロパティと MoveNext メソッドを含む型を返す GetEnumerator メソッドを探します。ダック タイピングは、このメソッドへのインターフェイスや明示的なメソッド呼び出しを利用するのではなく、名前による検索を利用します (「ダック タイピング」という名前は、アヒルとして扱うためには、オブジェクトが単にアヒルのように鳴く Quack メソッドを実装しなければならない、という風変わりな考え方に由来しています。つまり、IDuck インターフェイスを実装する必要はありません)。 ダック タイピングによって列挙可能なパターンの適切な実装が見つからない場合、コンパイラはコレクションがインターフェイスを実装するかどうかをチェックします。

反復子の導入

これで foreach の実装内部を理解しました。ここからは、カスタム コレクションの IEnumerator<T>、IEnumerable<T>、および対応する非ジェネリック インターフェイスのカスタム実装を作成するために反復子を使用する方法を説明します。反復子は、特に foreach ループを使用して、コレクション クラスのデータ全体を反復する方法を指定する簡潔な構文を提供します。これにより、コレクションのエンド ユーザーはコレクションの構造に対する知識なしに、内部構造にアクセスできるようになります。

列挙型パターンの問題は、手作業で実装するのが面倒になる可能性があることです。それは、コレクション内の現在位置を表すために必要な状態をすべて管理しなければならないためです。この内部状態はリスト コレクション型クラスの場合は単純になります。現在位置のインデックスがあれば十分です。これに対して、バイナリ ツリーなどの再帰的な走査が必要となるデータ構造の場合、状態はかなり複雑になることがあります。こうしたパターンの実装に関連する課題を軽減するため、C# 2.0 では状況依存型の yield キーワードが追加され、クラスが foreach ループのコンテンツを反復処理する方法を簡単に指示できるようになりました。

反復子の定義: 反復子はクラスのメソッドを実装する手段であり、より複雑な列挙子パターンの構文ショートカットです。C# コンパイラが反復子を見つけると、反復子のコンテンツを列挙子パターンを実装する CIL コードに展開します。このように、反復子を実装する場合、ランタイムに依存しません。C# コンパイラは CIL コードを生成しながら実装を処理するため、反復子を使用しても実際の実行時のパフォーマンスにはメリットがありません。ただし、手作業で列挙子パターンを実装する際に反復子を選択すると、プログラマの生産性が大きく向上します。この生産性向上について理解するために、まず、反復子がコード内で定義される方法を考えてみます。

反復子構文: 反復子により、反復子のインターフェイス、つまり、IEnumerable<T> インターフェイスと IEnumerable<T> インターフェイスを簡単に実装できるようになります。図 5 では、GetEnumerator メソッドを作成することで (まだ実装していません)、ジェネリック BinaryTree<T> 型の反復子を宣言しています。

図 5 反復子インターフェイス パターン

using System;
using System.Collections.Generic;
public class BinaryTree<T>:
  IEnumerable<T>
{
  public BinaryTree ( T value)
  {
    Value = value;
  }
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    // ...
  }
  #endregion IEnumerable<T>
  public T Value { get; }  // C# 6.0 Getter-only Autoproperty
  public Pair<BinaryTree<T>> SubItems { get; set; }
}
public struct Pair<T>: IEnumerable<T>
{
  public Pair(T first, T second) : this()
  {
    First = first;
    Second = second;
  }
  public T First { get; }
  public T Second { get; }
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    yield return First;
    yield return Second;
  }
  #endregion IEnumerable<T>
  #region IEnumerable Members
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
  #endregion
  // ...
}

反復子から値を yield: 反復子インターフェイスは関数に似ていますが、単一の値を返すのではなく、値のシーケンスを一度に 1 つずつ生み出します。BinaryTree<T> の場合、反復子は T で指定される型引数の値のシーケンスを生み出します。非ジェネリック版の IEnumerator を使用する場合、生み出される値は型オブジェクトになります。

反復子パターンを正しく実装するには、コレクションを列挙中に現在位置を監視するためになんらかの内部状態を管理する必要があります。BinaryTree<T> の場合、ツリー内のどの要素を既に列挙し、次にどの要素を返すかを監視します。反復子は、コンパイラによって「状態マシン」に変換されます。状態マシンは、現在位置を監視し、次の位置に「自身を移動する」方法を把握します。

yield return ステートメントは、反復子が値を見つけるたびに値を生み出します。制御は項目を要求した呼び出し元に即座に戻ります。呼び出し元が次の項目を要求すると、前に実行された yield return ステートメント直後のコードの実行が始まります。図 6 では、C# 組み込みのデータ型キーワードがシーケンシャルに返されます。

図 6 C# キーワードをシーケンシャルに出力

using System;
using System.Collections.Generic;
public class CSharpBuiltInTypes: IEnumerable<string>
{
  public IEnumerator<string> GetEnumerator()
  {
    yield return "object";
    yield return "byte";
    yield return "uint";
    yield return "ulong";
    yield return "float";
    yield return "char";
    yield return "bool";
    yield return "ushort";
    yield return "decimal";
    yield return "int";
    yield return "sbyte";
    yield return "short";
    yield return "long";
    yield return "void";
    yield return "double";
    yield return "string";
  }
    // The IEnumerable.GetEnumerator method is also required
    // because IEnumerable<T> derives from IEnumerable.
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    // Invoke IEnumerator<string> GetEnumerator() above.
    return GetEnumerator();
  }
}
public class Program
{
  static void Main()
  {
    var keywords = new CSharpBuiltInTypes();
    foreach (string keyword in keywords)
    {
      Console.WriteLine(keyword);
    }
  }
}

図 6 の結果の C# 組み込み型のリストを図 7 に示します。

図 7 図 6 のコードからの C# キーワード出力のリスト

object
byte
uint
ulong
float
char
bool
ushort
decimal
int
sbyte
short
long
void
double
string

明らかに説明が足りませんが、今月はここまでです。残りは次回以降までお待ちください。今回は、反復子を使用すれば、図 8 に示すように、プロパティとしてコレクションを魔法のように作成できるというところまでです。この例では、ただ楽しむために C# 7.0 のタプルを使用しています。詳しく知りたい方は、ソース コードをチェックするか、書籍『Essential C#』の 16 章をご覧ください。

図 8 yield return を使用した IEnumerable<T> プロパティの実装

IEnumerable<(string City, string Country)> CountryCapitals
{
  get
  {
    yield return ("Abu Dhabi","United Arab Emirates");
    yield return ("Abuja", "Nigeria");
    yield return ("Accra", "Ghana");
    yield return ("Adamstown", "Pitcairn");
    yield return ("Addis Ababa", "Ethiopia");
    yield return ("Algiers", "Algeria");
    yield return ("Amman", "Jordan");
    yield return ("Amsterdam", "Netherlands");
    // ...
  }
}

まとめ

今回は、バージョン 1.0 以来 C# の一部となり、C# 2.0 でのジェネリックの導入以来大きく変化していない機能に立ち返りました。しかし、この機能はよく使用されるにもかかわらず、内部の詳しいしくみはあまり理解されていません。そこで、反復子パターン (yield return コンストラクトを利用) を表面的に取り上げ、例を示しました。

今回の多くの部分は『Essential C#』(IntelliTect.com/EssentialCSharp) から引用しています。現在『Essential C# 7.0』に更新中です。 詳細については、14 章と 16 章をご覧ください。


Mark Michaelis は、IntelliTect の創設者で、同社でチーフ テクニカル アーキテクト兼トレーナーを務めています。彼は約 20 年間 Microsoft MVP に認定され、2007 年から Microsoft Regional Director を務めています。Michaelis は、C#、Microsoft Azure、SharePoint、Visual Studio ALM など、マイクロソフト ソフトウェアの設計レビュー チームにも所属しています。開発者を対象としたカンファレンスで講演を行い、多数の書籍を執筆しています。最近では、『Essential C# 6.0 (5th Edition)』(Addison-Wesley Professional、2015 年) を執筆しました (itl.tc/EssentialCSharp、英語)。連絡先は、Facebook (facebook.com/Mark.Michaelis、英語)、ブログ (IntelliTect.com/Mark、英語)、Twitter (@markmichaelis、英語)、または電子メール mark@IntelliTect.com (英語のみ) です。

この記事のレビューに協力してくれた IntelliTect 技術スタッフの Kevin Bost、Grant Erickson、Chris Finlayson、Phil Spokas、および Michael Stokesbary に心より感謝いたします。