У чому користь 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
Ми бачимо, що зміна глобальних даних навіть після формального відпрацювання ледачої функції може впливати на обчислення.
(Це ще один аргумент на користь того, що функціональне програмування і мутабельний стан погано поєднуються.)