Вирішено У чому користь yield?

Alex Alex · 19 · 1

Приклад на С#.

Повертаємо колекцію за допомогою 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: , 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

Ми бачимо, що зміна глобальних даних навіть після формального відпрацювання ледачої функції може впливати на обчислення.

(Це ще один аргумент на користь того, що функціональне програмування і мутабельний стан погано поєднуються.)


Для відповіді на запитання необхідно авторизуватись

Війти / Зареєструватися