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
IEnumerableメソッド内に無限ループがあり、その中に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にすれば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<string>型のフィールドStrs
がnullなら初期化する処理を行っていますが、このAdd
メソッドはIEnumerable型を返すので遅延評価です。
戻り値のIEnumerableオブジェクトから値を取り出そうとしない限り、メソッド内のnullチェックは実行されません。
一見すると「Add
メソッドを実行している」→「そのメソッド内の処理が完了した上で次の行の処理が行われる」と勘違いしてしまいやすく、「フィールドのnullチェックおよび初期化も行われているはず」というバグの原因となってしまいます。
IEnumerable型を単に「配列のようなデータを返すメソッド」の感覚で使用すると危険なので注意してください。
同じ理由で、引数のチェック処理なども値を取り出そうとするまで実行されないので注意が必要です。
class SimpleClass
{
//引数arrがnullなら例外をスローする
//ただしメソッドは遅延評価される
public IEnumerable<int> Print(int[] arr)
{
if (arr == null)
throw new Exception("引数arrがnull");
foreach (int n in arr)
{
Console.WriteLine(n);
yield return n;
}
}
}
static void Main(string[] args)
{
int[] arr = null;
SimpleClass sc = new SimpleClass();
IEnumerable<int> iterator = null;
try
{
//ここで例外はキャッチされない
iterator = sc.Print(arr);
}
catch (Exception e) {
Console.WriteLine(e.ToString());
}
//ここでPrintメソッド内から例外がスローされて
//プログラムが停止する
foreach (var item in iterator)
{
//これ以降の行は実行されない
Console.WriteLine(item);
}
Console.WriteLine("プログラム終了");
Console.ReadLine();
}
上記のPrint
メソッドは実行時に引数のnullチェックを行いますが、「メソッドの実行時」はコード上にメソッド名が記述されている行の実行時ではなく、その戻り値のIEnumerableオブジェクトから値を取り出そうとした時です。
上記は例外処理(try-catch文)を行っていますが、例外が捕捉されることなくtry-catch文は終了し、その後に例外が発生してプログラムは停止してしまいます。