はじめに
yield returnとは
なかなか一言での説明が難しいですね。
なにか複数あるものを1個ずつ取得しようとする処理をreturnを使って書けようにするキーワード、といったところでしょうか。
LINQのWhereやOrderByなどの中間操作の内部処理等で多く使用されています。
サンプルコードをみてみましょう。
static void Main(string[] args)
{
Console.WriteLine("Start");
foreach (var x in YieldSample())
{
Console.WriteLine($"loop {x}");
}
Console.WriteLine("End");
}
static IEnumerable<int> YieldSample()
{
Console.WriteLine("yield return 1");
yield return 1;
Console.WriteLine("yield return 2");
yield return 2;
}
このコードの実行結果は次のようになります。
Start
yield return 1
loop 1
yield return 2
loop 2
End
続行するには何かキーを押してください . . .
予想通りでしたか?
YieldSample() を呼び出すと、一見そのメソッドの処理を最初から最後まで実行するように見えてしまいますが、そうではありません。
このように、「値が必要になったときに値を取るために必要な処理をする」という遅延評価の仕組みをreturnに似たキーワードを使って書けるもの、それがyield returnである、と言うことができそうです。
内部挙動を見てみる
逆コンパイル結果を確認する
今ままでの部分の説明はわりとよく見かけるかもしれません。
ただ、実際にどうやって動いているのか、と少しもやもやは残ります。
そこで例によって(前回の記事【図解】C#のasync/awaitの内部挙動を理解すると同じ作戦で)、yieldが定義されていない頃のC#で逆コンパイルした結果を見てみようと思います。yieldはC#2.0で定義されたのでC#1.0で逆コンパイルします。逆コンパイルにはILSpyというツールを使用します。
先ほどのサンプルコードの逆コンパイル結果です。
private static void Main(string[] args)
{
Console.WriteLine("Start");
foreach (int x in YieldSample())
{
Console.WriteLine("Loop");
Console.WriteLine(x);
}
Console.WriteLine("End");
}
[IteratorStateMachine(typeof(<YieldSample>d__1))]
private static IEnumerable<int> YieldSample()
{
return new <YieldSample>d__1(-2);
}
まず、yield returnがあったYieldSampleメソッドの中身は、あるインスタンスをnewしてreturnするのみ、というとてもシンプル内容で置き換わっていることが確認できます。属性のところからStateMachineという文字も見えますね。
次に、この見覚えのない<YieldSample>d__1というクラスの定義部分について見てみましょう。少し長いですが、内容はわりとシンプルなのでそのまま載せてしまいます。
[CompilerGenerated]
private sealed class <YieldSample>d__1 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
private int <>1__state;
private int <>2__current;
private int <>l__initialThreadId;
int IEnumerator<int>.Current
{
[DebuggerHidden]
get { return <>2__current; }
}
object IEnumerator.Current
{
[DebuggerHidden]
get { return <>2__current; }
}
[DebuggerHidden]
public <YieldSample>d__1(int <>1__state)
{
this.<>1__state = <>1__state;
<>l__initialThreadId = Environment.CurrentManagedThreadId;
}
[DebuggerHidden]
void IDisposable.Dispose() { }
private bool MoveNext()
{
switch (<>1__state)
{
default:
return false;
case 0:
<>1__state = -1;
Console.WriteLine("yield return 1");
<>2__current = 1;
<>1__state = 1;
return true;
case 1:
<>1__state = -1;
Console.WriteLine("yield return 2");
<>2__current = 2;
<>1__state = 2;
return true;
case 2:
<>1__state = -1;
return false;
}
}
bool IEnumerator.MoveNext()
{
//ILSpy generated this explicit interface implementation from .override directive in MoveNext
return this.MoveNext();
}
[DebuggerHidden]
void IEnumerator.Reset()
{
throw new NotSupportedException();
}
[DebuggerHidden]
IEnumerator<int> IEnumerable<int>.GetEnumerator()
{
if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId)
{
<>1__state = 0;
return this;
}
return new <YieldSample>d__1(0);
}
[DebuggerHidden]
IEnumerator IEnumerable.GetEnumerator()
{
return System.Collections.Generic.IEnumerable<System.Int32>.GetEnumerator();
}
}
ポイント
細かいところは後においておくとして、この記事で主に伝えたかったポイントは下記の2点です。
-
yield returnは状態遷移機械を作って、どこまで処理を進めてどこまで値を返したかの状態管理を行う。 -
yield returnを書いたYieldSampleメソッドの処理自体は、その状態遷移機械を作ってreturnするだけ。元々YieldSampleメソッドに書いていた処理は何も実行しない。
詳細
インタフェースと実装について
yield returnをするメソッドは公式ドキュメントから
戻り値の型は、
IEnumerable、IEnumerable<T>、IEnumerator、またはIEnumerator<T>であることが必要です。
とあります。
今回のサンプルコードでは戻り値の型をIEnumerable<int>としました。
サンプルコードからコンパイラによって自動生成された<YieldSample>d__1クラスは次のインターフェースを実装しています。
IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
逆コンパイル結果を見てみると、IEnumerableで定義されるメソッドGetEnumeratorでは、自分自身をthisとして返してしまう処理があります。このようにIEnumerableとしてもIEnumeratorとしても使えるように作られていることがわかります。
もし、戻り値の型をIEnumerator<int>とした場合は、
IEnumerator<int>, IEnumerator, IDisposable
となります。同時に、IEnumerableで定義されるGetEnumeratorメソッドの実装もなくなります。
ちなみに、戻り値の型を非ジェネリックにした場合、コンパイラが生成したクラスが実装するインタフェースのジェネリック部分がobjectになって自動生成されました。
最後まで説明しませんでしたが、密かにIDisposableも実装していますね。
これについては次節で説明します。
自動生成されるDisposeメソッド
前節の通り、yield returnを記述した場合、コンパイルが生成するクラスは全てIDisposableを実装していました。そして、上記のサンプルコードの逆コンパイル結果ではDisposeメソッドが空になっていました。
実は、yield returnを記述するメソッドでtry-finallyやusingを使うと、Disposeの中にも処理が記述されます。具体的にはfinally句で記述した処理が実行されるようになります1。
これにより、次のようなプログラムを実行すると、
static void Main(string[] args)
{
Console.WriteLine("Start");
foreach (var x in YieldSample())
{
Console.WriteLine($"loop {x}");
throw new Exception("error");
}
Console.WriteLine("End");
}
static IEnumerable<int> YieldSample()
{
try
{
Console.WriteLine("yield return 1");
yield return 1;
Console.WriteLine("yield return 2");
yield return 2;
}
finally
{
Console.WriteLine("dispose");
}
}
次のような結果となります。
Start
yield return 1
loop 1
Unhandled Exception: System.Exception: error
at ConsoleApp1.Program.Main(String[] args) in C:\Users\mrngsht\source\repos\ConsoleApp1\ConsoleApp1\Program.cs:line 14
dispose
続行するには何かキーを押してください . . .
finally句で記述した処理が、最後にに実行されていることがわかります。
なぜこのような動きになるかと言うと、
-
finally句で記述した処理が、コンパイラが自動生成するクラスのDisposeの中で実行されるようになるから(本節冒頭で説明) -
foreach文は内部的にtry-finally構文を作り出し、finally句でEnumeratorのDisposeを行うようになっているから
です。
例えば、サンプルコードでのforeach文は、内部的に次のように展開されて、
var e = YieldSample().GetEnumerator();
try {
while (e.MoveNext()) {
var x = e.Current;
Console.WriteLine($"loop {x}");
throw new Exception("error");
}
}
finally
{
e.Dispose();
}
e.Dispose()の中身の処理でConsole.WriteLine("dispose")が呼ばれるというイメージです。
stateの数字の意味
サンプルコードの逆コンパイル結果に次のようなメソッドがありました。
private bool MoveNext()
{
switch (<>1__state)
{
default:
return false;
case 0:
<>1__state = -1;
Console.WriteLine("yield return 1");
<>2__current = 1;
<>1__state = 1;
return true;
case 1:
<>1__state = -1;
Console.WriteLine("yield return 2");
<>2__current = 2;
<>1__state = 2;
return true;
case 2:
<>1__state = -1;
return false;
}
}
この中で<>1__stateというフィールドが使用されていますね。
文字通り、状態遷移機械の状態を保持する役割を担っています。
0, 1, 2の数字の場合は、見ての通りyield returnを記述したメソッドの処理がどこまで進んだかを管理するために使用されていることがわかります。
では、-1はどうでしょう?switch文で分岐するときには、defaultに入ってreturn falseをするのみで何もさせないようになっていますね。
case 2:で-1を設定している方については状態遷移機会の終了状態を表していると考えられます。
case 0:やcase 1:で 一時的に設定されているのは何でしょう?これはおそらく排他制御の役割をしているものだと思います。例えば、このEnumeratorを他のスレッドも使って並行的にMoveNextメソッドを実行した場合に、複数スレッドで同じ部分の処理(例えば上の例でのConsole.WriteLine)を重複して行わないように制御しているものだと考えられます。
最後に-2というものも登場していました。
Mainメソッドの隣に自分で記述した(がコンパイラに中身を書き換えられた)メソッドと、
private static IEnumerable<int> YieldSample()
{
return new <YieldSample>d__1(-2);
}
コンパイラが生成したクラス<YieldSample>d__1で定義されるコンストラクタとGetEnumeratorメソッドです。
public <YieldSample2>d__4(int <>1__state)
{
this.<>1__state = <>1__state;
}
// 途中略
IEnumerator<int> IEnumerable<int>.GetEnumerator()
{
if (<>1__state == -2 && <>l__initialThreadId == nvironment.CurrentManagedThreadId)
{
<>1__state = 0;
return this;
}
return new <YieldSample>d__1(0);
}
この部分は、インタフェースと実装についての部分でも簡単に触れましたが、コンパイラに生成された1つのクラス<YieldSample>d__1がIEnumerableとIEnumeratorの役割の両方を担おうとしていて、さらにこのGetEnumeratorの最初の1度の呼び出しだけ自分自身のインスタンスをそのままIEnumeratorとして流用させる目的があるものと思われます2。
一度GetEnumeratorを実行すると<>1__stateの値が0となり、次回以降のGetEnumerator呼び出しでは新しいインスタンスを生成するようになります。
また、今回のサンプルコードとは別にyield returnを記述するメソッドの戻り値をIEnumerator(ジェネリック版も含む)にした場合は、
private static IEnumerator<int> YieldSample()
{
return new <YieldSample>d__1(0);
}
のようにコンストラクタで初期化する際の<>1__stateは0となります。
この場合は、インタフェースと実装についてで記載したように、クラス<YieldSample>d__1はIEnumerableを実装しない(すなわちGetEnumeratorメソッドを持たない)ため、このクラスから複数のIEnumeratorが生成されることを考慮しなくてよいからだと考えられます。
最後に
最後まで読んでいただき、ありがとうございました!
また、長くなってしまった。。。
前回の記事【図解】C#のasync/awaitの内部挙動を理解すると同じく、なんとなく動きはわかるけど、どのように処理されているかがいまいちピンんとこないものをILSpyで覗いてみました。個人的にも新しい発見が多くあり、いろいろ学びながら推測しながら書かせていただきました。
もし誤りやよくない記述、改善コメントがありましたら、是非コメントお願いいたします!