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