Instrukcja yield return – tworzenie leniwych kolekcji danych

2012-08-18

W tym wpisie zajmę się omówieniem polecenia yield, udostępnionego w wersji 2.0 języka C#. Do czego służy ta instrukcja? Dzięki niej możemy tworzyć tzw. leniwe kolekcje, do których poszczególne elementy dodawane są dopiero w momencie zgłoszenia na nie zapotrzebowania. Żeby zaprezentować działanie polecenia yield, najpierw zobaczmy standardowy sposób tworzenia kolekcji. Poniższa metoda zwraca kolekcję pięciu elementów typu int:

static IEnumerable<int> GetData()
{
    Console.WriteLine("Rozpoczęcie metody GetData.");

    List<int> list = new List<int>();

    for (int i = 1; i <= 5; i++)
    {
        Console.WriteLine("Przygotowanie wartości: " + i.ToString());
        list.Add(i);
    }

    Console.WriteLine("Zakończenie metody GetData.");

    return list;
}

Oto zastosowanie utworzonej metody:

static void Main(string[] args)
{
    Console.WriteLine("Pobranie danych.");
    IEnumerable<int> data = GetData();

    Console.WriteLine("Rozpoczęcie przetwarzania.");
    foreach (int i in data)
    {
        Console.WriteLine("Odczyt wartości: " + i.ToString());
        if (i == 3)
            break;
    }

    Console.WriteLine("Zakończenie przetwarzania.");
    Console.ReadLine();
}

Po uruchomieniu powyższego kodu otrzymamy następujące wyniki:

Pobranie danych.
Rozpoczęcie metody GetData.
Przygotowanie wartości: 1
Przygotowanie wartości: 2
Przygotowanie wartości: 3
Przygotowanie wartości: 4
Przygotowanie wartości: 5
Zakończenie metody GetData.
Rozpoczęcie przetwarzania.
Odczyt wartości: 1
Odczyt wartości: 2
Odczyt wartości: 3
Zakończenie przetwarzania.

Jak widać wszystko działa zgodnie z oczekiwaniami. Można jednak zauważyć, iż mimo że metoda GetData zwróciła pięć elementów, my wykorzystaliśmy tylko trzy z nich. Oznacza to, że niepotrzebnie straciliśmy czas i zasoby na przygotowanie dwóch ostatnich elementów kolekcji. Ale skąd metoda GetData może wiedzieć, z których elementów będzie korzystał dalszy kod programu? Jej działanie kończy się przed rozpoczęciem przetwarzania danych, co z kolei wymusza na niej utworzenie i zapisanie w pamięci wszystkich elementów wynikowej kolekcji. Takie działanie można określić jako zachłanne. Jego przeciwieństwem jest działanie leniwe, i właśnie do tego celu służy polecenie yield. Poniżej znajduje się nowa, „leniwa” wersja metody GetData:

static IEnumerable<int> GetData()
{
    Console.WriteLine("Rozpoczęcie metody GetData.");

    for (int i = 1; i <= 5; i++)
    {
        Console.WriteLine("Przygotowanie wartości: " + i.ToString());
        yield return i;
    }

    Console.WriteLine("Zakończenie metody GetData.");
}

W obecnej wersji nie ma już potrzeby tworzenia lokalnej zmiennej dla wynikowej kolekcji. Poszczególne elementy zwracane są z metody poprzez instrukcję yield return, ale jedynie w przypadku gdy nastąpi do nich odwołanie.

Poniżej znajduje się wynik działania programu z nową wersją metody GetData:

Pobranie danych.
Rozpoczęcie przetwarzania.
Rozpoczęcie metody GetData.
Przygotowanie wartości: 1
Odczyt wartości: 1
Przygotowanie wartości: 2
Odczyt wartości: 2
Przygotowanie wartości: 3
Odczyt wartości: 3
Zakończenie przetwarzania.

Jak widać, przebieg programu jest teraz zupełnie inny. Metoda GetData wykonywana jest dopiero po rozpoczęciu przetwarzania i zwraca poszczególne elementy tylko w momencie ich odczytu (de facto po poleceniu yield return sterowanie przekazywane jest z metody do kodu, który ją wywołał i powraca do niej w momencie odczytu kolejnego elementu). W tym miejscu należy zwrócić uwagę na jedną rzecz – brak komunikatu „Zakończenie metody GetData”. Jest to spowodowane przerwaniem pętli foreach po odczycie trzech elementów, co z kolei wymusiło także przerwanie działania metody GetData. Aby komunikat pojawił się, w metodzie należy zastosować blok try finally, a jego wyświetlenie umieścić w sekcji finally. Z powyższym faktem wiąże się jeszcze jedna cecha polecenia yield – nie może ono znajdować się w sekcji try jeżeli po niej występuje sekcja catch. A co dzieje się z enumeratorem zawierającym pobrane elementy w przypadku przerwania metody? Na szczęście pętla foreach o to dba, a więc po jej przerwaniu zostanie on prawidłowo zwolniony.
Dzięki zastosowaniu instrukcji yield pozbyliśmy się dwóch problemów jednocześnie: nie musimy tworzyć elementów, z których nie skorzystamy, nie musimy także buforować całej kolekcji w pamięci.
W celu sprawdzenia w jakim stopniu zastosowanie yield wpływa na poprawę wydajności, utworzyłem dwie wersje metody odczytującej plik tekstowy i zwracającej jego ponumerowane linie w postaci kolekcji string-ów:

Wersja 1 (bez użycia yield):

static IEnumerable<string> GetDataFromFile()
{
    List<string> data = new List<string>();
    string fileLine;
    int lineNumber = 0;

    using (StreamReader sr = File.OpenText(@"d:\Dane.txt"))
    {
        while ((fileLine = sr.ReadLine()) != null)
        {
            data.Add((++lineNumber).ToString() + ' ' + fileLine);
        }
    }
    return data;
}

Wersja 2 (z użyciem yield):

static IEnumerable<string> GetDataFromFile()
{
    string fileLine;
    int lineNumber = 0;

    using (StreamReader sr = File.OpenText(@"d:\Dane.txt"))
    {
        while ((fileLine = sr.ReadLine()) != null)
        {
            yield return (++lineNumber).ToString() + ' ' + fileLine;
        }
    }
}

Poniżej znajduje się kod zapisujący zwrócone przez utworzoną metodę dane w nowym pliku tekstowym, dodatkowo mierzony jest czas wykonania całej operacji:

static void Main(string[] args)
{
    Stopwatch sw = new Stopwatch();
    sw.Start();

    File.WriteAllLines(@"d:\Dane1.txt", GetDataFromFile());

    sw.Stop();
    Console.WriteLine(sw.ElapsedMilliseconds.ToString());
    Console.ReadLine();
}

Plik, na którym przeprowadziłem test miał rozmiar ok. 190MB i nieco ponad milion linii. W przypadku pierwszej wersji metody program wykonywał się 4 sekundy i zajął 430MB pamięci. Przy drugiej wersji metody czas wyniósł 2,9 sekundy, natomiast program w pamięci zajął jedynie 6MB. Jak widać zastosowanie instrukcji yield znacznie poprawiło wydajność rozwiązania, szczególnie w zakresie zapotrzebowania na pamięć. Efekt ten został osiągnięty dzięki temu, że nie było potrzeby generowania i umieszczania w pamięci całej kolekcji przed rozpoczęciem jej przetwarzania.

Na zakończenie kilka uwag odnośnie stosowania polecenia yield:

  • może być używane jedynie w metodach zwracających IEnumerable lub IEnumerator;
  • nie może znajdować się w sekcji try jeżeli po niej występuje sekcja catch;
  • jeżeli chcemy przerwać zwracanie kolejnych elementów kolekcji używamy polecenia yield break;

Więcej informacji na ten temat: MSDN.

Reklamy

Posted on 2012-08-18, in .NET/C# and tagged , , . Bookmark the permalink. 10 komentarzy.

  1. Fajny post.

    Warto też dodać że yield jest instrukcją pre procesora kompilatora więc tak naprawdę dla yield generowany jest kod, co pokaże dlaczego jest szybszy w pokazanym przez ciebie przykładzie.

    Przykład tego co zostało wygenerowane przez kompilator dla twojego przykładu:

    [CompilerGenerated]
    private sealed class d__0 : IEnumerable, IEnumerable, IEnumerator, IEnumerator, IDisposable
    {
    private string 2__current;
    private int 1__state;
    private int l__initialThreadId;
    public string 5__1;
    public int 5__2;
    public StreamReader 5__3;
    string IEnumerator.Current
    {
    [DebuggerHidden]
    get
    {
    return this.2__current;
    }
    }
    object IEnumerator.Current
    {
    [DebuggerHidden]
    get
    {
    return this.2__current;
    }
    }
    [DebuggerHidden]
    IEnumerator IEnumerable.GetEnumerator()
    {
    Program.d__0 result;
    if (Thread.CurrentThread.ManagedThreadId == this.l__initialThreadId && this.1__state == -2)
    {
    this.1__state = 0;
    result = this;
    }
    else
    {
    result = new Program.d__0(0);
    }
    return result;
    }
    [DebuggerHidden]
    IEnumerator IEnumerable.GetEnumerator()
    {
    return this.System.Collections.Generic.IEnumerable.GetEnumerator();
    }
    bool IEnumerator.MoveNext()
    {
    bool result;
    try
    {
    switch (this.1__state)
    {
    case 0:
    this.1__state = -1;
    this.5__2 = 0;
    this.5__3 = File.OpenText(„d:\\Dane.txt”);
    this.1__state = 1;
    break;

    case 1:
    goto IL_A6;

    case 2:
    this.1__state = 1;
    break;

    default:
    goto IL_A6;
    }
    if ((this.5__1 = this.5__3.ReadLine()) != null)
    {
    int num = ++this.5__2;
    this.2__current = num.ToString() + ‚ ‚ + this.5__1;
    this.1__state = 2;
    result = true;
    return result;
    }
    this.m__Finally4();
    IL_A6:
    result = false;
    }
    catch
    {
    this.System.IDisposable.Dispose();
    throw;
    }
    return result;
    }
    [DebuggerHidden]
    void IEnumerator.Reset()
    {
    throw new NotSupportedException();
    }
    void IDisposable.Dispose()
    {
    switch (this.1__state)
    {
    case 1:
    case 2:
    try
    {
    }
    finally
    {
    this.m__Finally4();
    }
    return;

    default:
    return;
    }
    }
    [DebuggerHidden]
    public d__0(int 1__state)
    {
    this.1__state = 1__state;
    this.l__initialThreadId = Thread.CurrentThread.ManagedThreadId;
    }
    private void m__Finally4()
    {
    this.1__state = -1;
    if (this.5__3 != null)
    {
    ((IDisposable)this.5__3).Dispose();
    }
    }
    }

    Analiza tego kodu oraz metody używającej Enumeratora pokazuje że tak naprawdę nigdy nie gromadzimy niczego w pamięci oraz czytamy i wyświetlamy tylko małe kawałki co przyśpiesza znacznie sam proces, kolejnym argumentem za przemawiającym za szybszą wydajnością jest fakt że enumeratory używają zoptymalizowanej pętli foreach.

    Jako podsumowanie dodam że developer może napisać identyczny kod (prawie), jak generwuje instrukcja yield przez co wydajność jak i zużycie pamięci będzie bardzo podobne natomiast nie ma to większego sensu jako że używając yeild kompilator dba o wszelkie wyjątki i edge casy jakie mogą się nam przytrafić przy pisaniu takiego kodu, co więcej jest to tzw „synactic sugar”, który uprzyjemnia pracę z kodem i czyni język C# płynnym i eleganckim 🙂

    • Dzięki za te dodatkowe informacje 🙂 W 100% zgadzam się z Twoim ostatnim zdaniem, wiele rozwiązań można zaimplementować „na piechotę” ale jest to pozbawione sensu gdy mamy sprawnie działające mechanizmy udostępniane przez samą platformę.

  2. Fajny artykuł 🙂 bardzo spodobał mi się wykonany test na czas i zużycie pamieci bo są to ważne rzeczy i powinny być dodawane do takich artykułów 🙂

  3. Bardzo dobry artykuł. Wygląda na to, że dotychczas nie do końca rozumiałem ideę leniwości udostępnianą przez yield. Super przykład, od razu widzę zastosowanie.

  4. Arkadiusz Bal

    Duzo tu skrótów myślowych.
    Wszystko to prawda ale wypadałoby zaznaczyć że za tą „leniwą” kolekcją stoi po prostu odpowiedni enumerator, z którego w standardzie korzysta yield.
    Nie raz, czy dwa warto własny enumerator zaimplementować. Choćby dla tych dwóch „featurów”: „Lazy” i „Cache”. Czyli czasami chcemy cos realizować dopiero na next… na przykład wczytywać strumień. A czasami po jednokrotnym przejechaniu kolekcji chcemy zapamętać rezultaty i je zwracać, zamiast od nowa dociągać, czy generować dane.

    • Skrótów? Wpis jest o yield i zawiera meritum w tym temacie. Gdyby zatytułowany był „Co się kryje za yield” – wówczas można by wymagać informacji o enumeratorze. Gdyby zaś był zatytułowany „Enumerator zamiennikiem yield”, można by oczekiwać opisu implementacji takiego enumeratora. Koniec końców, gdyby był zatytułowany „Czego yield nie umożliwia”, można by oczekiwać, że opisze inne metody na uzyskanie „Lazy” i „Cache”. Ale on jest o yield jako takim i uważam, że ma dokładnie tyle treści ile trzeba. A czytelnicy wpisu bezinteresownie uzupełnili go o garść ekstra informacji. Informacji wartościowych, choć niekoniecznie istotnych z punktu widzenia konsumenta „yield”.

  5. True.

    Ale Enumeratory/Iteratory to podstawa. Nikomu takie wywody w głowach nie namieszały.

    • Podstawą są również instrukcje if, while, switch, itd., ale tak samo jak enumeratory i iteratory nie mają one związku z instrukcją yield, więc ich poruszenie w ramach tego tematu nie ma sensu.

      Dzięki temu dowiadujemy się czym jest yield i niczego ponad to.

      To co proponujesz przypomina mi szkolenie z Worda, kiedy przy okazji omawiania czynności edycyjny podawano skróty je wywołujące. Nikt tych skrótów nie zapamiętał, a zakres zapamiętanych czynności edycyjnych był mniejszy niż wówczas, gdy ze szkolenia wycofano informację o skrótach. To jest właśnie sedno dawkowania informacji.

  6. Ciekawy tekst, dzięki! 🙂

  1. Pingback: Podsumowanie 2012 « Developer notes

Skomentuj

Wprowadź swoje dane lub kliknij jedną z tych ikon, aby się zalogować:

Logo WordPress.com

Komentujesz korzystając z konta WordPress.com. Log Out / Zmień )

Zdjęcie z Twittera

Komentujesz korzystając z konta Twitter. Log Out / Zmień )

Facebook photo

Komentujesz korzystając z konta Facebook. Log Out / Zmień )

Google+ photo

Komentujesz korzystając z konta Google+. Log Out / Zmień )

Connecting to %s

%d blogerów lubi to: