Страницы

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

понедельник, 1 октября 2018 г.

В чем польза 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() по каким-то причинам невозможен?


Ответ

Ну, отличие на самом деле кардинальное.
Дело в том, что в первом случае у вас ленивое, а во втором — энергичное вычисление ответа. Это значит, что элементы выходной последовательности в энергичном случае вычисляются все и сразу, а в ленивом случае — только когда запрошены и только те, что запрошены.
Давайте посмотрим, где с практической стороны есть разница.
Для случая ленивого вычисления вся последовательность не присутствует полностью в памяти. Это значит, что при поэлементной обработке у нас не выделяется память, и сохраняется 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

Ещё один случай различия — зависимость от внешних данных в процессе вычисления. Следующий код пытается влиять на вычисления, изменяя глобальное состояние. (Это не очень хороший код, не делайте так в реальных программах!)
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
Мы видим, что изменение глобальных данных даже после формальной отработки ленивой функции может влиять на вычисления.
(Это ещё один аргумент в пользу того, что функциональное программирование и мутабельное состояние плохо сочетаются.)

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

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