収量:何、どこで、なぜ

開発者の.Netコミュニティは、C#7.0のリリースとそれがもたらす新機能を待っていました。 来年15歳になる言語の各バージョンには、何か新しい便利なものが付属していました。 すべての機能に個別の言及が必要ですが、今日はyieldキーワードについてお話したいと思います。 初心者の開発者(だけでなく)が使用を避けていることに気付きました。 この記事では、長所と短所を伝えるとともに、 yieldの使用yield適切な場合を強調します。


yieldはイテレータを作成し、 IEnumerableを実装するときに別のクラスを記述しないようにします。 C#には、 yieldを使用する2つの式が含まれますyield return <expression>yield breakです。 yieldは、メソッド、演算子、およびプロパティで使用できます。 yieldはどこでも同じように機能するため、メソッドについて説明します。


yield returnを使用して、このメソッドが要素が各yield return式の結果であるIEnumerableシーケンスを返すことを宣言します。 さらに、戻り値を使用して、 yield returnは制御を呼び出し元に渡し、次の要素を要求した後、メソッドの実行を継続します。 yieldを含むメソッド内の変数の値は、リクエスト間で保存されます。 yield breakは、ループ内で使用される既知のbreak役割を果たします。 以下の例は、0から10までの一連の数値を返します。


Getnumbers
 private static IEnumerable<int> GetNumbers() { var number = 0; while (true) { if (number > 10) yield break; yield return number++; } } 

知っておく必要があるyield使用にはいくつかの制限があることに言及することが重要です。 イテレータでResetを呼び出すと、 NotSupportedExceptionスローされます。 匿名メソッドやunsafeコードを含むメソッドでは使用できません。 また、 yield returntry-catchに配置できませんが、 try-finallyブロックのtryセクションに配置されるのを止めるものはありません。 yield breakは、 try-catchtry-finally両方のtryセクションにあります。 Eric Lipertがここここで詳細に説明しているため、このような制限の理由は述べません。


コンパイル後にyieldが変わることを見てみましょう。 yield returnを使用する各メソッドは、イテレーター中に1つの状態から別の状態に移行する状態マシンです。 以下は、奇数の奇数列をコンソールに出力する単純なアプリケーションです。


プログラム例
 internal class Program { private static void Main() { foreach (var number in GetOddNumbers()) Console.WriteLine(number); } private static IEnumerable<int> GetOddNumbers() { var previous = 0; while (true) if (++previous%2 != 0) yield return previous; } } 

コンパイラーは次のコードを生成します。


生成されたコード
 internal class Program { private static void Main() { IEnumerator<int> enumerator = null; try { enumerator = GetOddNumbers().GetEnumerator(); while (enumerator.MoveNext()) Console.WriteLine(enumerator.Current); } finally { if (enumerator != null) enumerator.Dispose(); } } [IteratorStateMachine(typeof(CompilerGeneratedYield))] private static IEnumerable<int> GetOddNumbers() { return new CompilerGeneratedYield(-2); } [CompilerGenerated] private sealed class CompilerGeneratedYield : IEnumerable<int>, IEnumerable, IEnumerator<int>, IDisposable, IEnumerator { private readonly int _initialThreadId; private int _current; private int _previous; private int _state; [DebuggerHidden] public CompilerGeneratedYield(int state) { _state = state; _initialThreadId = Environment.CurrentManagedThreadId; } [DebuggerHidden] IEnumerator<int> IEnumerable<int>.GetEnumerator() { CompilerGeneratedYield getOddNumbers; if ((_state == -2) && (_initialThreadId == Environment.CurrentManagedThreadId)) { _state = 0; getOddNumbers = this; } else { getOddNumbers = new CompilerGeneratedYield(0); } return getOddNumbers; } [DebuggerHidden] IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable<int>)this).GetEnumerator(); } int IEnumerator<int>.Current { [DebuggerHidden] get { return _current; } } object IEnumerator.Current { [DebuggerHidden] get { return _current; } } [DebuggerHidden] void IDisposable.Dispose() { } bool IEnumerator.MoveNext() { switch (_state) { case 0: _state = -1; _previous = 0; break; case 1: _state = -1; break; default: return false; } int num; do { num = _previous + 1; _previous = num; } while (num%2 == 0); _current = _previous; _state = 1; return true; } [DebuggerHidden] void IEnumerator.Reset() { throw new NotSupportedException(); } } } 

この例からわかるように、 yieldメソッドの本体は生成されたクラスに置き換えられyieldいます。 メソッドのローカル変数がクラスフィールドに変わりました。 クラス自体はIEnumerableIEnumerator両方を実装します。 MoveNextメソッドには、置き換えられたメソッドのロジックが含まれていますが、唯一の違いは、ステートマシンとして表されていることです。 元のメソッドの実装に応じて、生成されたクラスにはDisposeメソッドの実装が追加で含まれる場合があります。


2つのテストを実行して、パフォーマンスとメモリ消費を測定してみましょう。 これらのテストは総合的なものであり、「額」の実装と比較したyieldの操作を示すためにのみ提供されていることをすぐに言わなければなりません。 付属の診断モジュールBenchmarkDotNet.Diagnostics.Windows BenchmarkDotNetを使用して測定を行います。 数値のシーケンスを取得する方法の速度を比較する最初の(アナログEnumerable.Range(start, count) )。 最初のケースではイテレータなしの実装があり、2番目のケースでは以下があります。


テスト1
 public int[] Array(int start, int count) { var numbers = new int[count]; for (var i = 0; i < count; ++i) numbers[i] = start + i; return numbers; } public int[] Iterator(int start, int count) { return IteratorInternal(start, count).ToArray(); } private IEnumerable<int> IteratorInternal(int start, int count) { for (var i = 0; i < count; ++i) yield return start + i; } 

方法カウント開始する中央値StddevGen 0Gen 1Gen 2割り当てられたバイト数/ Op
配列1001091.19 ns1.25 ns385.01--169.18
イテレータ100101,173.26 ns10.94 ns1,593.00--700.37

結果からわかるように、配列の実装は1桁高速であり、4分の1のメモリしか消費しません。 イテレーターと別個のToArray呼び出しがトリックを行いました。


2番目のテストはより困難になります。 データストリームを使用して作業をエミュレートします。 最初に奇数のキーを持つエントリを選択し、次に3つのキーの倍数を持つエントリを選択します。 前のテストと同様に、最初の実装にはイテレータがなく、2番目の実装には次のものがあります。


テスト2
 public List<Tuple<int, string>> List(int start, int count) { var odds = new List<Tuple<int, string>>(); foreach (var record in OddsArray(ReadFromDb(start, count))) if (record.Item1%3 == 0) odds.Add(record); return odds; } public List<Tuple<int, string>> Iterator(int start, int count) { return IteratorInternal(start, count).ToList(); } private IEnumerable<Tuple<int, string>> IteratorInternal(int start, int count) { foreach (var record in OddsIterator(ReadFromDb(start, count))) if (record.Item1%3 == 0) yield return record; } private IEnumerable<Tuple<int, string>> OddsIterator(IEnumerable<Tuple<int, string>> records) { foreach (var record in records) if (record.Item1%2 != 0) yield return record; } private List<Tuple<int, string>> OddsArray(IEnumerable<Tuple<int, string>> records) { var odds = new List<Tuple<int, string>>(); foreach (var record in records) if (record.Item1%2 != 0) odds.Add(record); return odds; } private IEnumerable<Tuple<int, string>> ReadFromDb(int start, int count) { for (var i = start; i < count; ++i) yield return new KeyValuePair<int, string>(start + i, RandomString()); } private static string RandomString() { return Guid.NewGuid().ToString("n"); } 

方法カウント開始する中央値StddevGen 0Gen 1Gen 2割り当てられたバイト数/ Op
一覧1001043.14 us0.14 us279.04--4,444.14
イテレータ1001043.22 us0.76 us231.00--3,760.96

この場合、実行速度は同じであることが判明し、メモリ消費yieldはさらに低くなりました。 これは、イテレータを使用した実装では、コレクションが一度だけ計算され、1つのList<Tuple<int, string>>割り当て時にメモリを節約したためです。


上記および上記のすべてのテストを考慮すると、簡単な結論を出すことができます。 yieldの主な欠点は、追加のイテレータクラスです。 シーケンスが有限であり、呼び出し元が要素を使用して複雑な操作を実行しない場合、反復子は遅くなり、GCに望ましくない負荷がかかります。 コレクションの各計算がメモリの大きな配列の割り当てにつながる場合、長いシーケンスを処理する場合にyieldを適用することが適切です。 yieldのレイジーな性質により、フィルター処理可能なシーケンス要素の計算が回避されます。 これにより、メモリ消費が大幅に削減され、プロセッサの負荷が軽減されます。



Source: https://habr.com/ru/post/J311094/


All Articles