June 2017
Volume 32 Number 6
Essential .NET - Yield によるカスタム反復子
前回 (msdn.com/magazine/mt797654) は、C# の foreach ステートメントが機能する内部のしくみを詳しく掘り下げ、C# コンパイラが共通中間言語 (CIL) で foreach 機能を実装する方法を説明しました。 また、yield キーワードについても例 (図 1 参照) を挙げて簡単に触れました。しかし、その説明はほとんどしませんでした。
図 1 C# キーワードをシーケンシャルに出力
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);
}
}
}
今回は、前回に続きとして yield キーワードを詳しく説明し、その使用方法を紹介します。
反復子と状態
図 1 の GetEnumerator メソッドの先頭にブレーク ポイントを設定すると、foreach ステートメントの開始時に GetEnumerator が呼び出されるのがわかります。 この時点で、反復子オブジェクトが作成され、その状態が、特別な「開始」状態に初期化されます。この状態は、反復子でまだコードが実行されておらず、そのためどのような値も出力されていないことを表します。それ以降、呼び出しサイトで foreach ステートメントが実行され続ける限り、反復子はその状態 (場所) を管理します。ループが次の値を要求するたびに制御が反復子に移り、ループで前回中断された箇所から続行します。反復子オブジェクトに保存された状態情報は、制御を再開する場所を決めるのに使用されます。呼び出しサイトで foreach ステートメントが終了すると、反復子の状態は保存されなくなります。図 2 は、実行内容の大まかなシーケンス図を示しています。IEnumerator<T> インターフェイスに MoveNext メソッドが示されているのがわかります。
図 2 では、呼び出しサイトの foreach ステートメントは、キーワードという CSharpBuiltInTypes インスタンスの GetEnumerator の呼び出しを行います。GetEnumerator は再度呼び出しても常に安全です。必要に応じて「新しい」列挙子オブジェクトが作成されます。(反復子が参照する) 反復子インスタンスがある場合、foreach は MoveNext の呼び出しから各反復を開始します。反復子内で、呼び出しサイトの foreach ステートメントに返す値を出力します。yield return ステートメントの後、次のMoveNext 要求までは GetEnumerator メソッドが停止しているように見えます。ループ本体に戻り、foreach ステートメントによって出力値が画面に表示されます。その後、foreach ステートメントはループに戻り、反復子の MoveNext を再び呼び出します。2 回目には、制御が 2 つ目の yield return ステートメントに移るのがわかります。もう一度、foreach によって CSharpBuiltInTypes の出力が画面に表示され、ループが再開されます。この処理は、反復子内に yield return ステートメントがなくなるまで続きます。ステートメントがなくなった時点で、MoveNext が false を返すため、呼び出しサイトの foreach ループは終了します。
図 2 Yield Return のシーケンス図
反復子の別の例
前回紹介した、BinaryTree<T> を使う例を考えてみましょう。BinaryTree<T> を実装するには、まず、反復子を使用して IEnumerable<T> インターフェイスをサポートするため Pair<T> が必要になります。Pair<T> で各要素を出力する例を図 3 に示します。
図 3 では、Pair<T> データ型の反復処理が 2 回ループします。1 回目は yield return First を、次に yield return Second をループします。GetEnumerator 内で yield return ステートメントが検出されるたびに状態が保存され、実行が GetEnumerator メソッド コンテキストから離れてループ本体に移動します。2 回目の反復が開始されたら、yield return Second ステートメントで、GetEnumerator の実行が再び開始されます。
図 3 Yield を使用した BinaryTree<T> の実装
public struct Pair<T>: IPair<T>,
IEnumerable<T>
{
public Pair(T first, T second) : this()
{
First = first;
Second = second;
}
public T First { get; } // C# 6.0 Getter-only Autoproperty
public T Second { get; } // C# 6.0 Getter-only Autoproperty
#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
}
IEnumerable<T> による IEnumerable の実装
System.Collections.Generic.IEnumerable<T> は System.Collections.IEnumerable から継承します。そのため、IEnumerable<T> を実装するときは、IEnumerable も実装する必要があります。図 3 ではこれが明示的に行われており、実装には単純に IEnumerable<T> GetEnumerator 実装への呼び出しを含みます。この IEnumerable.GetEnumerator から IEnumerable<T>.GetEnumerator への呼び出しは、IEnumerable<T> と IEnumerable の間の (継承を利用した) 型の互換性により、常に機能します。両方の GetEnumerator のシグネチャは同じであるため (戻り値型はシグネチャを区別しない)、一方または両方の実装を明示的なものにする必要があります。IEnumerable<T> バージョンによって提供される追加のタイプ セーフ性を踏まえると、IEnumerable 実装は明示的にすべきです。
以下のコードは Pair<T>.GetEnumerator メソッドを使用して、連続する 2 行に "Inigo” と “Montoya” を表示します。
var fullname = new Pair<string>("Inigo", "Montoya");
foreach (string name in fullname)
{
Console.WriteLine(name);
}
ループ内での Yield Return の配置
CSharpPrimitiveTypes と Pair<T> の両方で行ったように、必ずしも各 yield return ステートメントをハードコードしなくてもかまいません。yield return ステートメントを使用すると、ループ コンストラクトの内部から値を返すことができます。図 4 は foreach ループを使用します。GetEnumerator 内の foreach は実行されるたびに、次の値を返します。
図 4 ループ内での yield return ステートメントの配置
public class BinaryTree<T>: IEnumerable<T>
{
// ...
#region IEnumerable<T>
public IEnumerator<T> GetEnumerator()
{
// Return the item at this node.
yield return Value;
// Iterate through each of the elements in the pair.
foreach (BinaryTree<T> tree in SubItems)
{
if (tree != null)
{
// Because each element in the pair is a tree,
// traverse the tree and yield each element.
foreach (T item in tree)
{
yield return item;
}
}
}
}
#endregion IEnumerable<T>
#region IEnumerable Members
System.Collections.IEnumerator
System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
#endregion
}
図 4 では、最初の反復はバイナリ ツリー内にルート要素を返します。2 回目の反復中に、サブ要素のペアをたどります。サブ要素のペアに null 以外の値が含まれる場合、その子ノードを辿ってその要素を出力します。foreach (ツリーの T 項目) は、子ノードの再帰呼び出しです。
CSharpBuiltInTypes および Pair<T> でわかるように、foreach ループを使用して BinaryTree<T> を反復できるようになります。図 5 に、このプロセスを示します。
図 5 BinaryTree<string> での foreach の使用
// JFK
var jfkFamilyTree = new BinaryTree<string>(
"John Fitzgerald Kennedy");
jfkFamilyTree.SubItems = new Pair<BinaryTree<string>>(
new BinaryTree<string>("Joseph Patrick Kennedy"),
new BinaryTree<string>("Rose Elizabeth Fitzgerald"));
// Grandparents (Father's side)
jfkFamilyTree.SubItems.First.SubItems =
new Pair<BinaryTree<string>>(
new BinaryTree<string>("Patrick Joseph Kennedy"),
new BinaryTree<string>("Mary Augusta Hickey"));
// Grandparents (Mother's side)
jfkFamilyTree.SubItems.Second.SubItems =
new Pair<BinaryTree<string>>(
new BinaryTree<string>("John Francis Fitzgerald"),
new BinaryTree<string>("Mary Josephine Hannon"));
foreach (string name in jfkFamilyTree)
{
Console.WriteLine(name);
}
結果は次のとおりです。
John Fitzgerald Kennedy
Joseph Patrick Kennedy
Patrick Joseph Kennedy
Mary Augusta Hickey
Rose Elizabeth Fitzgerald
John Francis Fitzgerald
Mary Josephine Hannon
反復子の起源
1972 年、Barbara Liskov と MIT の科学者チームはプログラミング方法論の研究を始め、ユーザー定義のデータ抽象化に注目しました。チームは研究成果を示すために、「クラスター (clusters)」という概念を持つ CLU と呼ばれる言語を作成しました (CLU は clusters の先頭 3 文字です)。クラスターは、現在プログラマが使用する主要データ抽象化 (オブジェクト) の先駆け的存在です。研究の間、チームは CLU 言語を使用して、エンドユーザーの型から一部のデータ表現を抽象化できても、その他のユーザーがデータをインテリジェントに利用するには、データの内部構造を明らかにしなければならないことを常に実感していました。チームが持つこの恐れが、反復子と呼ばれる言語コンストラクトの作成につながりました (CLU 言語は、最終的に「オブジェクト指向プログラミング」として普及する概念に多くの洞察を提供しました)。
それ以上の反復の取り消し: Yield Break
それ以上の反復を取り消したい場合もあります。これを行うには、コード内でそれ以上ステートメントが実行されないように、if ステートメントを含めます。ただし、yield break を使用して取り消すこともできます。これにより、MoveNext が false を返し、制御が呼び出し元に即座に戻って、ループが終了します。このようなメソッドの例を以下に示します。
public System.Collections.Generic.IEnumerable<T>
GetNotNullEnumerator()
{
if((First == null) || (Second == null))
{
yield break;
}
yield return Second;
yield return First;
}
このメソッドは、Pair<T> クラスの要素のいずれかが null の場合に反復を取り消します。
yield break ステートメントは、これ以上行う動作がないと判断したときに関数の先頭に return ステートメントを配置するのと同じ考え方です。これは、残りのすべてのコードを If ブロックで囲む必要のない、反復の終了方法です。そのため、この方法は複数の終了を可能にします。コードを何気なく読むと、最初の終了を見過ごす可能性があるため、使用には注意が必要です。
反復子のしくみ
C# コンパイラは反復子を見つけると、対応する列挙子の設計パターンの合わせてコードを適切な CIL に展開します。生成されるコードでは、C# コンパイラは最初に入れ子になったプライベート クラスを作成して、Current プロパティと MoveNext メソッドと共に IEnumerator<T> インターフェイスを実装します。Current プロパティは反復子の戻り値型に対応する型を返します。図 3 に示すように、Pair<T> は T 型を返す反復子を含みます。C# コンパイラは、反復子の内部に含まれるコードを調べ、MoveNext メソッドと Current プロパティ内に必要なコードを作成して動作を模倣します。Pair<T> 反復子の場合、C# コンパイラはほぼ等価なコードを生成します (図 6 参照)。
図 6 反復子に対しコンパイラによって生成される C# コードと等価な C#
using System;
using System.Collections.Generic;
public class Pair<T> : IPair<T>, IEnumerable<T>
{
// ...
// The iterator is expanded into the following
// code by the compiler.
public virtual IEnumerator<T> GetEnumerator()
{
__ListEnumerator result = new __ListEnumerator(0);
result._Pair = this;
return result;
}
public virtual System.Collections.IEnumerator
System.Collections.IEnumerable.GetEnumerator()
{
return new GetEnumerator();
}
private sealed class __ListEnumerator<T> : IEnumerator<T>
{
public __ListEnumerator(int itemCount)
{
_ItemCount = itemCount;
}
Pair<T> _Pair;
T _Current;
int _ItemCount;
public object Current
{
get
{
return _Current;
}
}
public bool MoveNext()
{
switch (_ItemCount)
{
case 0:
_Current = _Pair.First;
_ItemCount++;
return true;
case 1:
_Current = _Pair.Second;
_ItemCount++;
return true;
default:
return false;
}
}
}
}
コンパイラは yield return ステートメントを受け取り、おそらく手作業で作成していたクラスに対応するクラスを生成します。C# の反復子は、列挙子の設計パターンを手作業で実装するクラスと同等のパフォーマンス特性を持ちます。パフォーマンスが向上することはありませんが、プログラマの生産性は大幅に向上します。
1 つのクラスでの複数の反復子の作成
IEnumerable<T>.GetEnumerator を実装した前の反復子の例は、foreach が暗黙に求めるメソッドです。逆順の反復、オブジェクト プロジェクション全体の反復など、既定とは異なる反復シーケンスが必要になることもあります。IEnumerable<T> や IEnumerable を返すプロパティまたはメソッドに反復子をカプセル化することで、クラスで追加の反復子を宣言することができます。Pair<T> の要素全体を逆順に反復処理する場合、たとえば図 7 に示すようにGetReverseEnumerator メソッドを提供することが可能です。
図 7 IEnumerable<T> を返すメソッドでの Yield Return の使用
public struct Pair<T>: IEnumerable<T>
{
...
public IEnumerable<T> GetReverseEnumerator()
{
yield return Second;
yield return First;
}
...
}
public void Main()
{
var game = new Pair<string>("Redskins", "Eagles");
foreach (string name in game.GetReverseEnumerator())
{
Console.WriteLine(name);
}
}
IEnumerator<T> ではなく、IEnumerable<T> を返すことに注意します。これは、IEnumerator<T> を返す Enumerable<T>.GetEnumerator とは異なります。Main のコードは、foreach ループを使用して GetReverseEnumerator を呼び出す方法を示しています。
Yield ステートメントの要件
yield return ステートメントは、IEnumerator<T> または IEnumerable<T> 型、あるいはこれらの非ジェネリックの同等物を返すメンバー内のみで使用できます。本体に yield return ステートメントを含むメンバーの戻り値は、単純ではない可能性があります。メンバーが yield return ステートメントを使用する場合、C# コンパイラは反復子の状態を管理するのに必要なコードを生成します。それに対して、メンバーが yield return の代わりに return ステートメントを使用する場合、プログラマは独自のステート マシンを管理し、反復子インターフェイスの 1 つのインスタンスを返す役割を担います。さらに、戻り値型を持つメソッドのすべてのコード パスは、(例外をスローしないと仮定) 値を伴う return ステートメントを含む必要があり、反復子のすべてのコード パスは、データを返す場合、yield return ステートメントを含む必要があります。
yield ステートメントの以下の追加の制約は、違反した場合コンパイル エラーになります。
- yield ステートメントは、メソッド、ユーザー定義演算子、インデクサーやプロパティの get アクセサーの内部にのみ含めることができます。メンバーは、どのような参照パラメーターも出力パラメーターも受け取ってはなりません。
- yield ステートメントは匿名メソッドまたはラムダ式の内部には含めることができません。
- yield ステートメントは、try ステートメントの catch 句または finally 句 の内部には含めることができません。さらに、yield ステートメントは、catch ブロックがない場合にのみ try ブロックの内部に含めることができます。
まとめ
ジェネリックは C# 2.0 から導入された優れた機能ですが、これは、その当時導入されたコレクション関係の唯一の機能ではありません。その追加機能の中で、もう 1 つ重要なのが反復子です。今回取り上げたように、反復子はコンテキスト キーワード yield を使用します。C# はこの yield を使用して、foreach ループで使用される反復子パターンを実装する、基盤となる CIL コードを生成します。 また、yield 構文についても詳しく説明しました。IEnumerable<T> の GetEnumerator 実装を実現する方法、yield break を使用してループから抜け出す方法、さらに、IEnumeable<T> を返す C# メソッドをサポートする方法について説明しました。
今回の多くの部分は『Essential C#』(IntelliTect.com/EssentialCSharp) から引用しています。現在『Essential C# 7.0』に更新中です。 このトピックの詳細については、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 に心より感謝いたします。