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

в 22:40, , рубрики: C#, yield

Сообщество .Net разработчиков замерло в ожидании выхода C# 7.0 и новых фич которые он принесет. Каждая версия языка которому уже в следующем году исполнится 15 лет принесла с собой что-то новое и полезное. И хотя каждая фича достойна отдельного упоминания, сегодня я хочу поговорить о ключевом слове yield. Я заметил, что начинающие разрабочики (и не только) избегают его использования. В этой статье я постараюсь донести преимущества и недостатки, а также выделить случаи, когда применение yield целесообразно.

yield создает итератор и позволяет нам не писать отдельный класс когда мы реализуем IEnumerable. C# содержит два выражения использующих yield: yield return <expression> и yield break. yield может применятся в методах, операторах и свойствах. Я буду говорить о методах, так как yield работает везде одинаково.

Применяя yield return мы декларируем, что данный метод возвращает последовательность IEnumerable, элементами которой являются результаты выражений каждого из yield return. Причем с возвращением значения, yielf 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 Main1() {
        foreach (var number in GetOddNumbers(10))
            Console.WriteLine(number);
    }

    private static IEnumerable<int> GetOddNumbers(int staringWith) {
        var previous = staringWith;
        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[this.count];
    for (var i = start; i < this.count; i++)
        numbers[i] = this.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 = start; 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 позволяет избежать вычисления элементов последовательности, которые могут быть отфильтрованы. Это может радикально сократить потребление памяти и уменьшить нагрузку на процессор.

Автор: superhomek

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js