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

1,00
р.
Пример на С#.
Возвращаем коллекцию с помощью 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
Для того, чтобы получить «лучшее из обоих миров», то есть, ленивое вычисление, но энергичную проверку аргументов, проще всего разделить функцию на две: энергичную проверку и ленивое вычисление без проверки. Для современных версий 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
Мы видим, что изменение глобальных данных даже после формальной отработки ленивой функции может влиять на вычисления.
(Это ещё один аргумент в пользу того, что функциональное программирование и мутабельное состояние плохо сочетаются.)