Сообщество .Net разработчиков замерло в ожидании выхода C# 7.0 и новых фич которые он принесет. Каждая версия языка которому уже в следующем году исполнится 15 лет принесла с собой что-то новое и полезное. И хотя каждая фича достойна отдельного упоминания, сегодня я хочу поговорить о ключевом слове yield
. Я заметил, что начинающие разрабочики (и не только) избегают его использования. В этой статье я постараюсь донести преимущества и недостатки, а также выделить случаи, когда применение yield
целесообразно.
yield
создает итератор и позволяет нам не писать отдельный класс когда мы реализуем IEnumerable
. C# содержит два выражения использующих yield
: yield return <expression>
и yield break
. yield
может применяться в методах, операторах и свойствах. Я буду говорить о методах, так как yield
работает везде одинаково.
Применяя yield return
мы декларируем, что данный метод возвращает последовательность IEnumerable
, элементами которой являются результаты выражений каждого из yield return
. Причем с возвращением значения, 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 return
не может располагаться в блоке try-catch
, хотя ничто не мешает разместить его в секции try
блока try-finally
. yield break
может располагаться в секции try
как try-catch
так и try-finally
. Причины таких ограничений я приводить не буду, так как они детально изложены Эриком Липертом здесь и здесь.
Давайте посомтрим во что превращается yield
после компиляции. Каждый метод с yield return
представляет собой машину состояний, которая переходит из одного состояния в другое в процессе работы итератора. Ниже приведено простое приложение, которое выводит в консоль бесконечную последовательность нечетных чисел:
Пример программы
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
было заменено сгенерированным классом. Локальные переменные метода превратились в поля класса. Сам класс реализует как IEnumerable
так и IEnumerator
. Метод MoveNext
содержит логику замененного метода с тем лишь отличием, что она представлена в виде машины состояний. В зависимости от реализации изначального метода, сгенерированный класс может дополнительно содержать реализацию метода Dispose
.
Проведем два теста и замерим производительность и потребление памяти. Сразу отмечу — эти тесты синтетические и приводятся только чтоб продемонстрировать работу yield
в сравнении с реализацией "в лоб". Замеры будем делать с помощью BenchmarkDotNet с включеным модулем диагностики BenchmarkDotNet.Diagnostics.Windows
. Первым сравним скорость работы метода получения последовательности чисел (аналог Enumerable.Range(start, count)
). В первом случае будет реализация без итератора, во втором с:
Тест 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;}
Method | Count | Start | Median | StdDev | Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
---|---|---|---|---|---|---|---|---|
Array | 100 | 10 | 91.19 ns | 1.25 ns | 385.01 | - | - | 169.18 |
Iterator | 100 | 10 | 1,173.26 ns | 10.94 ns | 1,593.00 | - | - | 700.37 |
Как видно из результатов, реализация Array на порядок быстрее и потребляет в 4 раза меньше памяти. Итератор и отдельный вызов ToArray
сделали свое дело.
Второй тест будет более сложным. Мы сэмулируем работу с потоком данных. Мы будем сначала выбирать записи с нечетным ключем, а затем с ключем кратным 3-м. Как и в предыдущем тесте, первая реализация будет без итератора, вторая с:
Тест 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");}
Method | Count | Start | Median | StdDev | Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
---|---|---|---|---|---|---|---|---|
List | 100 | 10 | 43.14 us | 0.14 us | 279.04 | - | - | 4,444.14 |
Iterator | 100 | 10 | 43.22 us | 0.76 us | 231.00 | - | - | 3,760.96 |
В данном случае, скорость выполнения оказалась одинаковой, а потребление памяти yield
оказалось даже ниже. Это связано с тем, что в реализации с итератором коллекция вычислилась только единожды и мы сэкономили память на аллокации одного List<Tuple<int, string>>
.
Беря во внимание все сказанное ранее и приведенные тесты, можно сделать краткий вывод: основной недостаток yield
— это дополнительный класс итератор. Если последовательность конечная, а вызывающая сторона не выполняет сложных манипуляций с элементами, итератор будет медленнее и создаст нежелательную нагрузку на GC. Применять же yield
целесобразно в случаях обработки длинных последовательностей, когда каждое вычисление коллекции приводит к аллокации больших массивов памяти. Ленивая природа yield
позволяет избежать вычисления элементов последовательности, которые могут быть отфильтрованы. Это может радикально сократить потребление памяти и уменьшить нагрузку на процессор.