yield文とIEnumerable
yield文
メソッド(関数)の処理は、return文で途中で終了したりgoto文でジャンプしたりといった例外はありますが、基本的に最初から最後まで上から順に実行されます。
C#ではyield文を用いることでメソッドの処理を一時的に中断し、処理を呼び出し元に返すことができます。
中断されたメソッドは次回の呼び出し時に中断した箇所の次の行から再開されます。
yield文を含むメソッドはイテレーターメソッド(イテレーターブロック)といい、通常のメソッドとは扱いが異なります。
yield(イテレーターメソッド)は「System.Collections」と「System.Collections.Generic」名前空間にあるインターフェイスを使用するので、usingディレクティブに追加しておきます。
(後者はVisual Studioの自動生成コードならば最初から記述されていると思います)
using System;
using System.Linq;
using System.Text;
//↓を追加
using System.Collections;
using System.Collections.Generic;
サンプルコード
以下にサンプルを示します。
static IEnumerator Iterator()
{
Console.Write("a");
yield return null;
Console.Write("b");
yield return null;
Console.Write("c");
yield return null;
}
static void Main(string[] args)
{
//ループカウンタ
int count = 0;
IEnumerator iterator = Iterator();
//この時点ではIteratorメソッドは全く実行されていない
//次のyield文まで処理を進める
//進めなくなったら偽を返すので
//真を返す間はループする
while(iterator.MoveNext())
{
//ループ回数を表示
Console.WriteLine(" : {0}回目", ++count);
}
Console.ReadLine();
}
a : 1回目 b : 2回目 c : 3回目
自作メソッドのIterator内でyield文を使用しています。
yield文を使用するメソッドは、戻り値にIEnumerator、IEnumerable、IEnumerator<データ型>、IEnumerable<データ型>のいずれかを指定する必要があります。
後ろ二つは前二つのジェネリックメソッド版です。
ここではIEnumeratorを使用します。
このIteratorメソッドは普通のメソッドのように呼び出しても何もしません。
メソッドを実行するにはまず戻り値を取得します。
この戻り値は列挙子(enumerator)といい、これを通してイテレーターメソッドを操作します。
列挙子からMoveNextメソッドを実行すると、yield文まで処理が進められます。
「yield return null」はメソッドの終了ではなく一時停止を意味し、制御を呼び出し側に戻します。
再度MoveNextメソッドを実行すると中断した箇所から次のyield文まで処理が進められ、これをメソッドの終了まで繰り返します。
MoveNextメソッドの戻り値はbool型で、メソッドの「中断」なら真、メソッドの「終了」なら偽です。
「yield return」が中断なのでそれ以外の場合は偽です。
(メソッドの末尾に到達した場合や後述する「yield break」に到達した場合が偽となる)
引数、ローカル変数
イテレーターメソッドもメソッドの一種なので、引数やローカル変数を使用することができます。
static IEnumerator Iterator(int num)
{
num += 1;
Console.Write(num);
yield return null;
num += 2;
Console.Write(num);
yield return null;
num += 3;
Console.Write(num);
yield return null;
}
static void Main(string[] args)
{
int count = 0;
IEnumerator iterator = Iterator(1);
while(iterator.MoveNext())
{
Console.WriteLine(" : {0}回目", ++count);
}
Console.ReadLine();
}
2 : 1回目 4 : 2回目 7 : 3回目
yield文で呼び出し元に制御を戻した後もローカル変数の値は保持され、再開時に使用することができます。
引数の参照渡しはできない
イテレーターメソッドの引数にはrefやoutを使用して参照渡しすることができません。
値を得る
イテレーターメソッドから値を得るにはyield return文の後ろに戻り値を指定します。
ここで指定した値は列挙子のCurrentプロパティで取得できます。
戻り値を得るだけならばこれだけで可能ですが、そのままではobject型になるのでジェネリックメソッドを利用して型を指定します。
ここではstring型を返したいので「IEnumerator」から「IEnumerator<string>」に変更しています。
static IEnumerator<string> Iterator()
{
yield return "a";
yield return "b";
yield return "c";
}
static void Main(string[] args)
{
int count = 0;
IEnumerator iterator = Iterator();
while(iterator.MoveNext())
{
//Currentプロパティでreturnの値を得る
Console.WriteLine(
"{0} : {1}回目", iterator.Current, ++count);
}
Console.ReadLine();
}
a : 1回目 b : 2回目 c : 3回目
Currentプロパティは現在のyield文のreturnに指定されている値を取得します。
「yield return null」は戻り値が必要ない場合に指定しますが、ジェネリックメソッドの戻り値に値型(int型など)を指定しているとnullは指定できないので注意してください。
一度もMoveNextメソッドを実行していない状態、およびMoveNextメソッドが偽を返す状態でのCurrentプロパティの値は未定義ですので使用してはいけません。
(未定義=環境によって動作が異なる可能性がある)
処理の終了
イテレーターメソッドを途中で終了する場合はyield breakを使用します。
static IEnumerator<int> Iterator(int num)
{
if (num < 0)
{
Console.WriteLine("引数が0未満");
yield break;
}
yield return (num *= 2);
yield return (num *= 2);
yield return (num *= 2);
}
static void Main(string[] args)
{
IEnumerator<int> iterator = Iterator(-1);
while (iterator.MoveNext())
{
Console.Write("{0} ", iterator.Current);
}
Console.ReadLine();
}
引数が0未満
メソッドの「中断」ではなく「終了」なのでMoveNextメソッドは偽を返します。
IEnumeratorとIEnumerable
イテレーターメソッドにはIEnumeratorを使用するものとIEnumerableを使用するものがあります。
「enumerate」は列挙という意味で、いくつかの値を順番に返す機能を提供するのがIEnumeratorです。
その機能の実装がMoveNextメソッドやCurrentプロパティなどです。
IEnumerableはIEnumeratorが返す値すべてを「列挙可能」にします。
列挙に使用するのがforeach文です。
foreach文はコレクションから順に値を取り出す、とだけ説明していましたが、それにはIEnumeratorの機能を使用しています。
つまりforeach文に指定できるコレクションはIEnumerableを継承したオブジェクトです。
配列やListなども内部的にIEnumerableを継承しています。
本来はforeachで列挙可能なクラスを作る場合はこれらのインターフェイスを継承するのですが、これはかなり面倒な実装が必要になります。
それを簡便に書けるようにしたのがyield文です。
イテレーターメソッドをforeach文で列挙可能にするために、戻り値の型をIEnumeratorからIEnumerableに変更してみます。
static IEnumerable<int> Enumerable(int num)
{
yield return (num *= 2);
yield return (num *= 2);
yield return (num *= 2);
}
static void Main(string[] args)
{
IEnumerable<int> enumerable = Enumerable(2);
foreach(var n in enumerable)
{
Console.WriteLine(n);
}
//foreach文に直接
//IEnumerableなメソッドを指定しても良い
//foreach (var n in Enumerable(2))
//{
// Console.WriteLine(n);
//}
Console.ReadLine();
}
4 8 16
foreach文はIEnumerableメソッドからMoveNextメソッドやCurrentプロパティを使用してyield文に到達するたびに値を返します。
GetEnumeratorメソッド
IEnumerableメソッドは内部にIEnumeratorを保持しています。
IEnumerableからGetEnumeratorメソッドを使用することでIEnumeratorを取得できます。
static IEnumerable<int> Enumerable(int num)
{
yield return (num *= 2);
yield return (num *= 2);
yield return (num *= 2);
}
static void Main(string[] args)
{
IEnumerable<int> enumerable = Enumerable(2);
IEnumerator<int> enumerator = enumerable.GetEnumerator();
enumerator.MoveNext();
Console.WriteLine(enumerator.Current);
Console.ReadLine();
}
4
無限リスト
イテレーターメソッドは通常の配列では実現できない無限リストというものを作ることができます。
これは無限個の値を扱うデータの集合です。
//値を取り出す度にカウントアップする
//maxに到達したら0に戻る
static IEnumerable<int> Counter(int max)
{
if (max < 0)
yield break;
int num = 0;
while (true)
{
if (num > max)
num = 0;
yield return num++;
}
}
static void Main(string[] args)
{
const int max = 5;
int count = 0;
foreach(var n in Counter(max))
{
if (n >= max)
{
Console.WriteLine("{0} ", n);
if (++count >= 3)
break;
continue;
}
Console.Write("{0} ", n);
}
Console.ReadLine();
}
0 1 2 3 4 5 0 1 2 3 4 5 0 1 2 3 4 5
yield return文が無限ループしているので、MoveNextメソッドが偽を返すことはなく無限に値を取り出し続けることができます。
無限リストはforeach文で回すと無限ループになるので、適当なところで処理を終了させる必要があります。
同様に、無限リストに対してすべての要素を列挙するメソッド(ToListなど)を使用すると無限ループになるので注意してください。
遅延評価
C#標準ライブラリのメソッドにはデータの集合を返すものが多数あります。
例えば指定のフォルダ内にあるファイル一覧を取得するFile.Directory.GetFilesメソッドがあります。
このメソッドの戻り値はstring型の配列です。
ファイル一覧を取得するメソッドにはFile.Directory.EnumerateFilesというものあります。
機能としては同じですが、こちらはIEnumerable<string>型で返します。
これらのメソッドについて詳しくはファイルとフォルダの列挙を参照してください。
GetFilesメソッドは指定フォルダ内にあるファイル名をすべて取得し、配列にして返します。
100個のファイルがあるならば100個すべてのファイル名を取得するまで呼び出し元に制御は返しません。
数が多いとプログラムが一瞬フリーズしたかのようになることがあります。
EnumerateFilesメソッドはファイル名をひとつ取得する度に呼び出し元に制御を返します。
次のファイル名の取得は実際にそのファイル名が必要になったときに改めて行います。
どれだけファイル数が多くなっても一回の呼び出しに掛かる時間はわずかですから、プログラムがフリーズしたような動作になることはありません。
最終的に同じ数のファイル名を取得するならば、どちらのメソッドでもかかる時間は同じです。
しかし例えば目的のファイルが見つかったら検索を途中で終了する場合、EnumerateFilesメソッドはすべてのファイル名を取得する必要がないため、高速になる可能性があります。
このような「必要になるまで処理をしない」実行方法を遅延評価といいます。
これを容易に記述できるようにするのがyield文です。
ちなみに通常のメソッドは正格評価や即時評価といいます。
取得する要素数が同じでも、IEnumerableは要素を一つずつ返すので配列のように一度に大きなサイズのメモリを占有しません。
これも遅延評価のメリットです。
なお、LINQのToListメソッドを使用すればIEnumerable型をListに変換できますが、この時点でIEnumerableのすべての要素に対する処理が実行されます。
Listにすれば扱いが容易になりますが、遅延評価のメリットがなくなるので注意してください。
注意点
遅延評価はプログラムを高速化する可能性がありますが、注意点もあります。
値はキャッシュされない
IEnumerableによる遅延評価は必要になるまで処理をしないというだけです。
他のプログラム言語では処理後の値を保存(キャッシュ)しておくものがありますが、C#では値が必要になるたびに生成処理を行います。
キャッシュしないということはメモリ使用量が減るというメリットにもなり得ますが、普通の配列と同じ感覚で使いまわしていると何度も生成処理を行うことになり、かえってパフォーマンスが落ちます。
そのような場合は、遅延評価のメリットはなくなりますがToArrayやToListなどで変換しておくと良いでしょう。
エラーチェックも遅延する
IEnumerable型は必要になるまで処理を行わないので、そのことに注意しないと思わぬエラーが発生します。
class SimpleClass
{
public List<string> Strs { get; set; }
public IEnumerable<string> Add(string str)
{
if (Strs == null)
Strs = new List<string>();
Strs.Add(str);
foreach (var s in Strs)
yield return s;
}
}
static void Main(string[] args)
{
SimpleClass sc = new SimpleClass();
sc.Add("abc");
//ここで例外発生
//sc.Strsがnull
Console.WriteLine(sc.Strs.Count);
Console.ReadLine();
}
SimpleClassのAddメソッド内でListがnullなら初期化する処理を行っているのですが、このAddメソッドはIEnumerable型を返すので遅延評価です。
Addメソッドから値を取り出そうとしない限りnullチェックの処理も実行されないので、単に「配列のようなデータを返すメソッド」の感覚で使用するとNullReferenceException例外が発生します。
同じ理由で、引数のチェック処理なども値を取り出そうとするまで実行されないので注意が必要です。