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