Страницы

Поиск по вопросам

воскресенье, 24 ноября 2019 г.

В чем польза yield?


Пример на С#.

Возвращаем коллекцию с помощью yield.

public static class Foo
{
    public static IEnumerable Test()
    {
        var rand = new Random().Next(1, 3);
        if (rand == 1)
            yield return 1;


        if (rand == 2)
            yield return 2;

        yield return 3;
        yield return "foo";
        yield return true;
    }
}


Пример 2. Возвращаем коллекцию с помощью обычного листа.

public static class Foo1
{
    public static IEnumerable Test()
    {
        var list = new List();
        var rand = new Random().Next(1, 3);
        if (rand == 1)
            list.Add(1);

        if (rand == 2)
            list.Add(2);

        list.Add(3);
        list.Add("foo");
        list.Add(true);

        return list;
    }
}


Результат равнозначен, вопрос - зачем тогда вообще нужен yield, если можно обойтис
таким кодом? Или yield используется там, где код с new List() по каким-то причинам невозможен?
    


Ответы

Ответ 1



Ну, отличие на самом деле кардинальное. Дело в том, что в первом случае у вас ленивое, а во втором — энергичное вычислени ответа. Это значит, что элементы выходной последовательности в энергичном случае вычисляютс все и сразу, а в ленивом случае — только когда запрошены и только те, что запрошены. Давайте посмотрим, где с практической стороны есть разница. Для случая ленивого вычисления вся последовательность не присутствует полностью памяти. Это значит, что при поэлементной обработке у нас не выделяется память, и сохраняетс cache locality: IEnumerable GenerateHugeSequenceLazy() { for (int i = 0; i < 1000000; i++) yield return 13 * i; } IEnumerable GenerateHugeSequenceEager() { var result = new List(); for (int i = 0; i < 1000000; i++) result.Add(13 * i); return result; } Вычисляем функцию на всей последовательности, сравниваем расход памяти: var seqLazy = GenerateHugeSequenceLazy(); // вычисляем максимум вручную var max = 0; foreach (var v in seqLazy) if (v > max) max = v; var memLazy = GC.GetTotalMemory(forceFullCollection: false); var seqEager = GenerateHugeSequenceEager(); // вычисляем максимум вручную max = 0; foreach (var v in seqEager) if (v > max) max = v; var memEager = GC.GetTotalMemory(forceFullCollection: false); Console.WriteLine($"Memory footprint lazy: {memLazy}, eager: {memEager}"); Результат: Memory footprint lazy: 29868, eager: 6323088 Затем, у нас довольно большие различия в смысле операций. Энергичные вычисления производятс в момент вызова функции, в то время как ленивые вычисления происходят в момент, когд вы пользуетесь результатом. А значит, для реального вычисления ленивой последовательност состояние аргументов будет взято на момент перечисления. Вот пример: IEnumerable DoubleEager(IEnumerable seq) { var result = new List(); foreach (var e in seq) result.Add(e * 2); return result; } IEnumerable DoubleLazy(IEnumerable seq) { foreach (var e in seq) yield return e * 2; } Смотрим на отличия: var seq = new List() { 1 }; var eagerlyDoubled = DoubleEager(seq); var lazilyDoubled = DoubleLazy(seq); Console.WriteLine("Eager: " + string.Join(" ", eagerlyDoubled)); Console.WriteLine("Lazy : " + string.Join(" ", lazilyDoubled)); // выводит оба раза 2, покамест различий нет seq.Add(2); // модифицируем *исходную* последовательность Console.WriteLine("Eager: " + string.Join(" ", eagerlyDoubled)); // 2 Console.WriteLine("Lazy : " + string.Join(" ", lazilyDoubled)); // 2 4 Поскольку ленивое вычисление происходит при перечислении, мы видим, что при изменени последовательности ленивая версия подхватывает изменения. Другой пример. Посмотрим, что будет, если мы не вычисляем всю последовательность Вычислим одну и ту же последовательность энергично и лениво: IEnumerable Eager10() { Console.WriteLine("Eager"); int counter = 0; try { var result = new List(); for (int i = 0; i < 10; i++) { Console.WriteLine($"Adding: {i}"); counter++; result.Add(i); } return result; } finally { Console.WriteLine($"Eagerly computed: {counter}"); } } IEnumerable Lazy10() { Console.WriteLine("Lazy"); int counter = 0; try { for (int i = 0; i < 10; i++) { Console.WriteLine($"Adding: {i}"); counter++; yield return i; } } finally { Console.WriteLine($"Lazily computed: {counter}"); } } Берём только 2 элемента из результата: foreach (var e in Eager10().Take(2)) Console.WriteLine($"Obtained: {e}"); foreach (var e in Lazy10().Take(2)) Console.WriteLine($"Obtained: {e}"); foreach (var e in Lazy10()) { Console.WriteLine($"Obtained: {e}"); if (e == 1) break; } Получаем такой вывод на консоль: Eager Adding: 0 Adding: 1 Adding: 2 Adding: 3 Adding: 4 Adding: 5 Adding: 6 Adding: 7 Adding: 8 Adding: 9 Eagerly computed: 10 Obtained: 0 Obtained: 1 Lazy Adding: 0 Obtained: 0 Adding: 1 Obtained: 1 Lazily computed: 2 Lazy Adding: 0 Obtained: 0 Adding: 1 Obtained: 1 Lazily computed: 2 Видите разницу? Ленивый вариант прогнал цикл всего два раза, и не вычислял «хвост последовательности. Ещё одна разница между случаями — когда сообщаются ошибки. В случае энергичного вычислени они сообщаются сразу. В случае ленивого — лишь при перечислении результата. Пример: IEnumerable CheckEagerly(int value) { if (value == 0) throw new ArgumentException("value cannot be 0"); return new List { value }; } IEnumerable CheckLazily(int value) { if (value == 0) throw new ArgumentException("value cannot be 0"); yield return value; } Применяем try/catch: Console.WriteLine("Eager:"); IEnumerable seqEager = null; try { seqEager = CheckEagerly(0); } catch (ArgumentException) { Console.WriteLine("Exception caught"); } if (seqEager != null) foreach (var e in seqEager) Console.WriteLine(e); Console.WriteLine("Lazy:"); IEnumerable seqLazy = null; try { seqLazy = CheckLazily(0); } catch (ArgumentException) { Console.WriteLine("Exception caught"); } if (seqLazy != null) foreach (var e in seqLazy) Console.WriteLine(e); Получаем результат: Eager: Exception caught Lazy: Unhandled Exception: System.ArgumentException: value cannot be 0 at Program.d__3.MoveNext() in ...\Program.cs:line 59 at Program.Run() in ...\Program.cs:line 45 at Program.Main(String[] args) in ...\Program.cs:line 13 Для того, чтобы получить «лучшее из обоих миров», то есть, ленивое вычисление, н энергичную проверку аргументов, проще всего разделить функцию на две: энергичную проверк и ленивое вычисление без проверки. Для современных версий C# удобно использовать вложенны функции: IEnumerable CheckEagerlyEnumerateLazily(int value) { if (value == 0) throw new ArgumentException("value cannot be 0"); return Impl(); IEnumerable Impl() { yield return value; } } Проверяем: Console.WriteLine("Recommended way:"); IEnumerable seqLazy = null; try { seqLazy = CheckEagerlyEnumerateLazily(0); } catch (ArgumentException) { Console.WriteLine("Exception caught"); } if (seqLazy != null) foreach (var e in seqLazy) Console.WriteLine(e); и получаем Recommended way: Exception caught Ещё один случай различия — зависимость от внешних данных в процессе вычисления. Следующи код пытается влиять на вычисления, изменяя глобальное состояние. (Это не очень хороши код, не делайте так в реальных программах!) bool evilMutableAllowCompute; IEnumerable EagerGet5WithExternalDependency() { List result = new List(); for (int i = 0; i < 5; i++) { if (evilMutableAllowCompute) result.Add(i); } return result; } IEnumerable LazyGet5WithExternalDependency() { for (int i = 0; i < 5; i++) { if (evilMutableAllowCompute) yield return i; } } Используем: Console.WriteLine("Eager:"); evilMutableAllowCompute = true; foreach (var e in EagerGet5WithExternalDependency()) { Console.WriteLine($"Obtained: {e}"); if (e > 0) evilMutableAllowCompute = false; } Console.WriteLine("Lazy:"); evilMutableAllowCompute = true; foreach (var e in LazyGet5WithExternalDependency()) { Console.WriteLine($"Obtained: {e}"); if (e > 0) evilMutableAllowCompute = false; } Результат: Eager: Obtained: 0 Obtained: 1 Obtained: 2 Obtained: 3 Obtained: 4 Lazy: Obtained: 0 Obtained: 1 Мы видим, что изменение глобальных данных даже после формальной отработки лениво функции может влиять на вычисления. (Это ещё один аргумент в пользу того, что функциональное программирование и мутабельно состояние плохо сочетаются.)

Ответ 2



Да, в данном случае результат одинаковый, но "стоимость" его получения разная. Н используя yield вы создаёте дополнительный объект List, который сначала наполняете объектами дополнительно расходуя при этом ресурсы. С yield же объекты возвращаются "напрямую". В первом случае, если в foreach (object obj in Foo.Test()), например, я решу, чт мне нужны всего два элемента, и сделаю break, то на этом всё и закончится. Во второ случае, если мне нужно всего два элемента из итератора, мне придётся подождать пок внутри Foo1.Test() внутренний список заполнится всеми элементами, из которых уже пото я возьму только два. Также с yield я могу возвращать элементы бесконечно или почти бесконечно. Вот, например итератор, который возвращает последовательность треугольных чисел: IEnumerable Triangle() { long t = 0; long next = 0; while (true) { t += ++next; yield return t; } }

Ответ 3



В книге Джона Скита по C# очень хорошо раскрыт принцип работы оператора yield. При наличии оператора yield return внутри метода компилятор строит на основе данног метода конечный автомат. Для реализации итератора конечный автомат обладает следующим свойствами: Он имеет некое начальное состояние При вызове MoveNext() выполняется код из метода GetEnumerator() по тех по, пока н будет достигнут оператор yield return; Когда используется свойство Current, он возвращает последнее выданное значение. Он должен знать, когда выдача значений завершена, чтобы метод MoveNext() мог возвратить false; Если кратко, то значение элемента вычисляет в тот момент, когда происходит обращени к нему.

Комментариев нет:

Отправить комментарий