Пример на С#.
Возвращаем коллекцию с помощью 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
Ответы
Ответ 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;
Если кратко, то значение элемента вычисляет в тот момент, когда происходит обращени
к нему.
Комментариев нет:
Отправить комментарий