У чому користь 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()
з якихось причин неможливий?
Відповіді на питання (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: , eager: ");
Результат:
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: ");
counter++;
result.Add(i);
}
return result;
}
finally
{
Console.WriteLine($"Eagerly computed: ");
}
}
IEnumerable Lazy10()
{
Console.WriteLine("Lazy");
int counter = 0;
try
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Adding: ");
counter++;
yield return i;
}
}
finally
{
Console.WriteLine($"Lazily computed: ");
}
}
Беремо тільки 2 елементи з результату:
foreach (var e in Eager10().Take(2))
Console.WriteLine($"Obtained: ");
foreach (var e in Lazy10().Take(2))
Console.WriteLine($"Obtained: ");
foreach (var e in Lazy10())
{
Console.WriteLine($"Obtained: ");
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: ");
if (e > 0)
evilMutableAllowCompute = false;
}
Console.WriteLine("Lazy:");
evilMutableAllowCompute = true;
foreach (var e in LazyGet5WithExternalDependency())
{
Console.WriteLine($"Obtained: ");
if (e > 0)
evilMutableAllowCompute = false;
}
Результат:
Eager:
Obtained: 0
Obtained: 1
Obtained: 2
Obtained: 3
Obtained: 4
Lazy:
Obtained: 0
Obtained: 1
Ми бачимо, що зміна глобальних даних навіть після формального відпрацювання ледачої функції може впливати на обчислення.
(Це ще один аргумент на користь того, що функціональне програмування і мутабельний стан погано поєднуються.)