Yield: что, где и зачем

Сообщество .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 позволяет избежать вычисления элементов последовательности, которые могут быть отфильтрованы. Это может радикально сократить потребление памяти и уменьшить нагрузку на процессор.

Поделиться публикацией

Комментарии 34

    –11
    может применяться в методах
      +4
      Первый тест абсолютно некорректен.
      1. Метод Array() — не аналог Range и его результаты отличаются от Iterator() при одинаковых входных данных. В том числе и длиной конечного массива.
      2. Скорость Iterator() сильно страдает за счет ToArray(), который будет создавать массивы длиной 4, 8 16, ..., 128 элементов, и потом в конце еще один длиной 90. Если уж замерять «цикл vs. итератор», то хотя бы сумму элементов считать.
        +2
        Спасибо за замечание, исправил.
        Что касается сути теста, то мне хотелось продемонстрировать паттерн, который я постоянно встречаю в коде: разработчик, получив IEnumerable, тут же вызывает ToList() или ToArray(). И я с вами полностью согласен, если сравнивать «цикл vs. итератор», этот тест некорректен.
          +2
          Да, паттерн распространенный, но получается, что сравнивается не «работа yield в сравнении с реализацией „в лоб“, а скорость создания одного массива со скоростью создания и копирования семи, т.е. „manual vs. LINQ“.
          Возможно, то, во что разворачивается yield, в этом эксперименте укладывается в 1% от общего времени выполнения, а 99% съедает создание массива. Хотя, конечно, я не проверял и думаю, что разница все равно будет в разы.
        +3
        Применять же yield целесобразно в случаях обработки длинных последовательностей, когда каждое вычисление коллекции приводит к аллокации больших массивов памяти.

        Применять yield целесообразно тогда, когда это повышает читаемость кода. А "обработка длинных последовательностей" и так далее — это сценарий для применения итераторов вообще, yield тут ни при чем (а без дополнительного класса-итератора можно обойтись так же, как это делают в BCL — сделав структуру).

          0
          Я что-то упустил или в сгенерированном коде отсутствует инициализация последовательности?
            0
            Все правильно, последовательность генерируется по мере запрашивания элементов через MoveNext().
              0
              так, а где указание первого элемента? Там же с 10 должно быть вроде?
                0

                Прошу прощения, что ввел в заблуждение. Для того чтобы метод GetOddNumbers соответствовал сгенерированному итератору, у него не должно быть аргументов. Я поправил пример.
                Когда у метода аргументы есть, они будут добавлены в поля сгенерированного класса, а присваивание значений будет идти напрямую.


                Пример кода
                var iterator = new CompilerGeneratedYield(-2);
                iterator.startingWith = startingWith;
                return iterator;
                
                private sealed class CompilerGeneratedYield // : ...iterfaces
                {
                    public int startingWith;
                    // ...generated code
                }
                  0
                  Вот. Теперь вроде все сходится :)
                  А чем разбирали сборку? У меня dotPeek и что-то он не показывает compiler-generated код…
                    0
                    обновился — заработало.
                      0

                      Тот же dotPeek. В Assembly Explorer'е, в контектном меню, Decompiled Sources.

              0
              Поздравляю, вы открыли для себя Корутины. Хотя Корутины это куда более глубокое понятие. И yield это оператор для создания генераторов.
                +2
                А я вот не уловил, почему о yield говорится в контексте C# 7? Оно же вроде доступно со времен царя Гороха.
                  0
                  Eyecatcher же :). Да и нет там контекста: «Скоро выходит аж C# 7, но некоторые все еще избегают использования старых фич, об одной такой фиче и поговорим».
                  0
                  Хотелось бы понять в чем собственно преимущество возврата yeild return перед IEnumerable или любой другой коллекцией?
                    0
                    Как минимум, у вас не хранится в памяти вся коллекция. Вы получаете порции данных через IEnumerator. Естественно, если вызвать ToList() и иже с ним, то тогда да… пользы мало.
                      0
                      Другими словами это должно дать выигрыш в производительности, если нам нужно вернуть большую по размерам коллекцию?
                        0
                        Выигрыш в потреблении памяти.
                      +1
                      Во-первых, генерируемая коллекция может быть бесконечной. Во-вторых, функция с yield return это сопрограмма, и компилятор сгенерит её конечный автомат за вас.
                      –3

                      Моё мнение, что после появления Linq оператор yield больше не нужен.


                      Всё, что может сделать yield, также может сделать Linq, с сохранением всех плюшек типа ленивости вычислений.


                      Пример из статьи можно переписать одной строчкой.


                      Func<IEnumerable<int>> GetOddNumbers = () => Enumerable.Range(0, int.MaxValue).Where(x => x % 2 == 0);

                      Заодно сделаем функцию конечной до int.MaxValue, а то у вас в примере если в checked всё не обернуть, то тогда после переполнения int отрицательные числа начнут возвращаться.
                      По мне пусть лучше функция будет конечной чем начнёт отрицательные числа возвращать или кидать Arithmetic overflow.


                      Я рекомендую в новом коде не использовать yield совсем, в 2016 году это как бы old style C#.

                        –1

                        Можете реализовать построчное чтение большого текстового файла с O(1) по памяти без дополнительных библиотек?

                          –2

                          Уже всё написано до нас: File.ReadLines — прекрасно работает, возвращает итератор, по итератору можно делать Skip/Take/ToList и радоваться.


                          Если вы хотите меня опровергнуть, то лучше вы приведите пример кода, который написан с использованием yield и который невозможно переписать с использованием linq без потери функциональности.

                            +1
                            Уже всё написано до нас

                            Все?
                            А если надо читать из стрима?
                            Или из базы данных?
                            Или нужен обход дерева?


                            Если вы хотите меня опровергнуть

                            Это совершенно излишне. Гораздо интереснее, как вы сумеете написать генератор без yield там, где нет готового.

                              +1
                              лучше вы приведите пример кода, который написан с использованием yield и который невозможно переписать с использованием linq без потери функциональности.

                              … и читабельности. Легко.


                              public IEnumerable<Identity> GetIdentitiesForCurrentUser()
                              {
                                yield return TryAuthenticateViaProviderA(Context);
                                yield return TryAuthenticateViaProviderB(Context);
                                yield return TryAuthenticateLegacy(Context);
                                yield return Identity.Anonymous;
                              }
                              
                              var identity = GetIdentitiesForCurrentUser().First(u => u != null);

                              Ну или любой пример с валидацией через IEnumerable<ValidationError>.


                              Собственно, ваша ошибка в том, что вы считаете, что yield как-то заменяет/заменяется LINQ, хотя на самом деле, yield нужен тогда, когда вам нужно породить перечисление (enumeration) и лень/неэффективно/нечитабельно писать свой IEnumerator.

                                0

                                Спасибо за пример.


                                Тут классический случай когда мы правы оба.


                                Ваш пример вполне можно переписать без yield через несколько .Concat().


                                return Enumerable.Empty<Identity>().Concat(new[]{Provider1}).Concat(new [] {Provider2});

                                Но я соглашусь с вами про читабельность — конкретно в этом случае она хромает.


                                Любая истина рождается в споре, поэтому я подкорретирую свои слова:


                                Если работаете с потенциально бесконечными последовательностями — только linq и никакого yield.
                                Если работаете с конечными, но ленивыми последовательностями (что само по себе редкий кейс), то да, yield может выглядеть читабельнее.


                                P.S. В вашем примере лучше всего изменить дизайн, чтобы методы были с сигнатурой вида


                                bool TryGetProviderAIdentity(out Identity identity)
                                bool TryGetProviderBIdentity(out Identity identity)

                                И через if(Try1)...elseif(Try2) написать все вообще без yield и проверок на null.
                                Будет выглядеть просто и понятно.

                                  +1
                                  Только до этого аутентификация шла через все провайдеры по-очереди, до первой успешной. А в вашей реализации — пройдут все сразу.

                                  Как бы вы сделали генерацию бесконечной последовательности случайных чисел только на linq? Чтобы можно было написать, например, RandomNumbers().Take(1000)
                                    +1
                                    Ваш пример вполне можно переписать без yield через несколько .Concat().

                                    return Enumerable.Empty<Identity>().Concat(new[]{Provider1}).Concat(new [] {Provider2});

                                    И сколько избыточных массивов здесь было создано? И да, как верно замечено выше, теперь вместо ленивой аутентификации мы получили жадную (вызовы во все провайдеры идут сразу).


                                    А с валидацией (когда некоторые ошибки зависят от других) у вас будет еще больше проблем.


                                    Если работаете с потенциально бесконечными последовательностями — только linq и никакого yield.

                                    Тоже неправда. Я мог бы в конец своего примера с GetIdentity вставить


                                    //в C# до сих пор нет yield!
                                    foreach(var identity from GetLegacyIndentities()) yield return identity; 

                                    и получить потенциально бесконечную последовательность.


                                    В вашем примере лучше всего изменить дизайн, чтобы методы были с сигнатурой вида [...] и через if(Try1)...elseif(Try2)

                                    Нет, не лучше. Во-первых, иногда мне нужны все (успешные) identity. Во-вторых, для большого количества провайдеров код с if заведомо менее читаем (собственно, он там и был изначально, я его рефакторил).

                                      +3
                                      lair, INC_R, спасибо что потратили на меня время.

                                      Я понял что ошибался.
                                      Реально генераторы не покрываются linq никак.
                                      В своей работе у меня нет таких кейсов, поэтому я начал думать что yield вообще никогда не нужен.

                                      Linq нужен для трансформаций последовательностей (map,reduce), а вот как ты последовательность нагенерируешь — это не ответственность linq, для этого как раз yield хорошо подходит.
                                  0
                                  Уже всё написано до нас: File.ReadLines — прекрасно работает, возвращает итератор

                                  Который внутри через yield написан, к слову.
                                    0

                                    Неправда, внутри он написан не через yield а через честную имплементацию IEnumerable, заимствованную из кода linq, в коде в комментариях явно написано "borrowed from Linq"


                                    ссылка на ReferenceSource

                                      0

                                      Раньше был yield: "the previous yield-based iterator in 4.0". А вот "borrowed from Linq" я там что-то не вижу.

                                        0
                                        А вот "borrowed from Linq" я там что-то не вижу.

                                        А, нашел:


                                        Abstract Iterator, borrowed from Linq. Used in anticipation of need for similar enumerables in the future

                                        Обратите внимание, что класс-то internal, и если мне понадобится написать что-то аналогичное, опять придется "заимствовать". Честное слово, yield дешевле.

                                        0
                                        Да, в 4.6 переписали, похоже. В 4.5 был yield

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое