はじめに
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で覗いてみました。個人的にも新しい発見が多くあり、いろいろ学びながら推測しながら書かせていただきました。
もし誤りやよくない記述、改善コメントがありましたら、是非コメントお願いいたします!