Deep C# vol.5 – yield return

はじめに

この記事はDeep C# vol.4 – switch expressionに続く記事となります。
第5回 Deep C# は yield return を分析します。
C# 2.0 で導入されたものでありながら、実はあまり正しく認識されていないもの。それが yield return です。
そしてこの yield return こそ、C#の発展の祖にあたる言語拡張です。
C#コンパイラー側の観点からはジェネリックに次ぐインパクトだったことは間違いありません。

yield return とは

メソッドで IEnumerable<T> を返し、内部では return の代わりに yield return を使うのが基本形です。
なお、今回は説明のために非同期処理については割愛します。
yield return に関する非同期型の扱いは、Deep C# vol.1 – await foreaach に書いた通り、非同期型の IAsyncEnumerable<T> に対応したものであることは理解していただけることと思います。

同期型では通常このように記述します。

yield return を解体する

今回は珍しく、長い説明が必要となります。
まず、最新C# 11.0 でコンパイルして、それを C# 1.0 でデコンパイルしたものがこれです。

$(string[] args) { foreach (string weekDay in new Foo().WeekDays()) { Console.WriteLine(weekDay); } } } internal class Foo { [CompilerGenerated] private sealed class d__0 : IEnumerable, IEnumerable, IEnumerator, IEnumerator, IDisposable { private int <>1__state; [System.Runtime.CompilerServices.Nullable(1)] private string <>2__current; private int <>l__initialThreadId; public Foo <>4__this; string IEnumerator.Current { [DebuggerHidden] [return: System.Runtime.CompilerServices.Nullable(1)] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public d__0(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; <>2__current = “Sunday”; <>1__state = 1; return true; case 1: <>1__state = -1; <>2__current = “Monday”; <>1__state = 2; return true; case 2: <>1__state = -1; <>2__current = “Tuesday”; <>1__state = 3; return true; case 3: <>1__state = -1; <>2__current = “Wednesday”; <>1__state = 4; return true; case 4: <>1__state = -1; <>2__current = “Thursday”; <>1__state = 5; return true; case 5: <>1__state = -1; <>2__current = “Friday”; <>1__state = 6; return true; case 6: <>1__state = -1; <>2__current = “Saturday”; <>1__state = 7; return true; case 7: <>1__state = -1; return false; } } bool IEnumerator.MoveNext() { return this.MoveNext(); } [DebuggerHidden] void IEnumerator.Reset() { throw new NotSupportedException(); } [DebuggerHidden] [return: System.Runtime.CompilerServices.Nullable(new byte[] { 1, 0 })] IEnumerator IEnumerable.GetEnumerator() { d__0 d__; if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId) { <>1__state = 0; d__ = this; } else { d__ = new d__0(0); d__.<>4__this = <>4__this; } return d__; } [DebuggerHidden] [return: System.Runtime.CompilerServices.Nullable(1)] IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)this).GetEnumerator(); } } [System.Runtime.CompilerServices.NullableContext(1)] [IteratorStateMachine(typeof(d__0))] public IEnumerable WeekDays() { d__0 d__ = new d__0(-2); d__.<>4__this = this; return d__; } }

属性の説明

最近のコンパイラーが自動的に出力する属性を理解しておくのは良い事です。
今回出力されている属性について説明します。

[CompilerGenerated]

このコードがコンパイラーによって生成されたコードであることを意味しています。ユーザーコードと異なることをコンパイラー側に通知します。
これは古くからある属性であり、C# 2.0 から存在しています。
詳細については https://learn.microsoft.com/ja-jp/dotnet/api/system.runtime.compilerservices.compilergeneratedattribute?view=net-7.0 を参照してください。

[DebuggerHidden]

デバッガー上でステップ実行したときにスキップするための属性です。
これも、コンパイラー生成コードである以上はプログラマーがデバッガーで入り込む必要ないものです。そのためには必要な属性といえます。

[Nullable(1)]

コンパイラーが null許容参照型であるかを判定するための属性です。
デコンパイルしたソースには System.Runtime.CompilerServices.Nullable(1)とあります。これはnull非許容であることを表します。引数が 1 の場合は null非許容 です。 2 の場合は null許容です。この属性がないか、0 の場合はC#7.3までの解釈に沿うことを意味しています。
詳細については https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.nullableannotation?view=roslyn-dotnet-4.6.0&viewFallbackFrom=roslyn-dotnet を参照してください。
この属性は C#8.0以降追加されるようになりました。

[IteratorStateMachine(typeof(…)]

VB.NET の同等機能ための属性です。C#においてはそれほど大きな意味はありません。
詳細については https://learn.microsoft.com/ja-jp/dotnet/api/system.runtime.compilerservices.iteratorstatemachineattribute?view=net-7.0 を参照してください。

要点の解説

Fooは内部にコンパイラー生成のクラスを保持しています。
それは以下のようないくつかのインターフェースを実装しています。

IDisposable を実装している意味は簡単です。これを using あるいは foreach 内部に記述するためです。実質的にDispose()で開放すべきものを備えているかは実装次第です。
例えばですが、WeekDays メソッド内にforeachがある場合はDispose()の位置も明確であるため正しく展開されます。usingの場合もすべてを yield return で扱っている限りは正しく展開することが出来ます。
そしてこのクラスは foreach / using 1つに対して1つのステートマシンとして振舞います。ただし、きわめて用心深い実装となっています。
この部分に着目してください。

current を更新するときには一度 state を -1 として、更新処理で例外が生じたときにエラーを捕まえることが出来るようになっています。
イテレーターパターンの中でも実に手堅い実装をコンパイラーが実践しており、これは信用を置けます。
この MoveNext() は foreach を展開したコードで使われています。以下に2つのコードを記載しますが、この2つはC#では等価のものです。

WeekDays()メソッドからは、コンパイラーが生成した IEnumerable<string> が返されることとなります。
foreachのコンパイラー解釈の都合上、戻り値から GetEnumerator() を実行して内部クラスを取得します。
あとは、内部にあるフィールドを読むのみです。それほど難しいコードが展開されるわけではありません。
MoveNext() は次の値を Current に設定してステートを次に進めるという、きわめてシンプルな実装をしています。

このことから、あの短い yield return がかくも大規模なステートマシンとして展開されることが分かります。
コードを書く上での労力は極めて小さく、いろいろな処理を安全性高く実装しています。
一方、最終的に switch – case に展開されるという事実はパフォーマンスについて問題をはらむ可能性があります。
パフォーマンスが必要な局面では同等のクラスをより軽量に実装する方が良いケースも実際にあります。

それと、このコードが async / await を含むようになった場合にどうなるかはまた別の機会に掘り下げたいと考えています。

まとめ

yield return とジェネリック対応の時に、コンパイラーが特定のインターフェースに応じたコードを展開するようになりました。
これが、実はそれ以降の async / await やラムダ式の幅広い解釈にもつながっていきます。
それは.NET というプラットフォームの機能を極力変更せず、コンパイラーとインターフェースの拡張のみでほとんどの変更をやり過ごしてきたことにつながります。
とすると、このDepp C#のシリーズの最終章付近は「ジェネリック」がC#1.0でどのように解釈されるのか、というところまで深堀していくことになると考えています。
またそれに加えて、近年の言語拡張のおかげで実質的に向上したパフォーマンスについても触れる機会があると考えています。

今後のこの記事にご期待ください。

最近の記事

  • 関連記事
  • おすすめ記事
  • 特集記事

アーカイブ

カテゴリー

PAGE TOP