Closures v C#

Často opomíjenou, o to zajímavější a užitečnější, vlastností jazyka C# je podpora closures. A co že to vlastně closure (uzávěr) je? Wikipedie říká, že je to first-class function s volnými proměnnými, které jsou vázány k lexikálnímu prostředí. A co že to tedy je? :-)

Začněme tím, co je to first-class function (vhodný český překlad neznám). Mezi námi programátory – není to nic jiného než obyčejný ukazatel na funkci. To znamená, že s funkcí můžeme pracovat jako s jakoukoliv jinou proměnnou – přiřazovat do ní, předávat si ji jako parametry…
Možná vám přijde zbytečné, že se takový pojem vůbec zavádí, ale některé jazyky (třeba Java) ukazatel na funkci nemají. To ale nesnižuje vyjadřovací schopnost takového jazyka a stejné konstrukce jako s pomocí first-class function lze zapsat i jinak, jak je ukázáno např. v tomto výborném článku z knihy C# in Depth. V C# máme ale first-class function dostupné jako delegáty.

Closure si tedy můžeme představit jako delegáty, které si sahají někam mimo své tělo, čímž do delegáta vnáší kontext prostředí, ve kterém byly vytvořeny.

Anonymní metody

Do verze C# 2.0 jsme sice měli k dispozici delegáty, ale mohli jsme do nich přiřazovat jen celé metody.

public class Test
{
  public delegate string TestDelegate(int i); // deklarace typu

  static string Test(int i)
  {
    return i.ToString();
  }

  public static void Main()
  {
    TestDelegate d = new TestDelegate(Test);
    // nebo TestDelegate d = Test;
    Console.WriteLine(d(1));
  }
}

Od C# 2.0 byly ale zavedeny anonymní metody, což je konstrukt, který nám umožní vytvořit metodu uvnitř jiné metody.

public class Test
{
  public delegate string TestDelegate(int i); // deklarace typu
  // to same jako Func<int, string>

  public static void Main()
  {
    TestDelegate d = delegate(int i) { return i.ToString(); };
    Console.WriteLine(d(1));
  }
}

No a konečně od C# 3.0 máme k dispozici lambda funkce:

public class Test
{
  public static void Main()
  {
    Func<int, string> d = i => { return i.ToString(); };
    Console.WriteLine(d(1));
  }
}

Pokud máme v těle anonymní metody jen jeden příkaz, můžeme si odpustit i složené závorky:

public class Test
{
  public static void Main()
  {
    Func<int, string> d = i => i.ToString();
    Console.WriteLine(d(1));
  }
}

A nyní se podívejme Reflectorem, co nám leze z kompilátoru:

public class Test
{
  [CompilerGenerated]
  private static string <Main>b__0(int i)
  {
    return i.ToString();
  }

  public static void Main()
  {
    Func<int, string> d = new Func<int, string>(Test.<Main>b__0));
    Console.WriteLine(d(1));
  }
}

Vidíme, že kompilátor udělal prakticky to samé, co jsme museli dělat ručně do příchodu C#2.0 – tedy vyextrahoval tělo do nové metody.
Jen jsem z originálního kódu odstranil cachování, které zajišťuje, aby se při vícenásobném volání nemusel neustále vytvářet stejný objekt Func<int, string>.

Closures

A teď zpátky ke closures, tedy delegátům, které si sahají mimo své tělo. Všimněte si, že to výše uvedené příklady nedělaly – pracovaly jen se svými parametry. Další příklad ale closure již obsahuje:

public class Test
{
  public static void Main()
  {
    string prefix = GetPrefix();
    Func<int, string> d = i => prefix + i.ToString();
    Console.WriteLine(d(1));
  }
}

Nyní máme proměnnou prefix, na kterou si saháme z těla delegáta do nadřazené metody.
Důležité je si uvědomit, že s delegátem d můžeme dělat cokoliv, tj. třeba ho vrátit jako návratovou hodnotu nebo ho někam uložit – tedy životnost delegáta může být delší než život lokální proměnné metody. Jak se s tímto vypořádal kompilátor?

public class Test
{
  [CompilerGenerated]
  private sealed class <>c__DisplayClass1
  {
    public string prefix;
    public string <Main>b__0(int i)
    {
       return (this.prefix + i.ToString());
    }
  }

  public static void Main()
  {
    <>c__DisplayClass1 CS$<>8__locals2 = new <>c__DisplayClass1();
    CS$<>8__locals2.prefix = GetPrefix();
    Func<int, string> d = new Func<int, string>(CS$<>8__locals2.<Main>b__0);
    Console.WriteLine(d(1));
  }
}

Proměnná prefix nemůže být v tomto případě nadále lokální proměnnou (umístěnou na stacku), ale je třeba ji umístit do pomocné, automaticky vygenerované třídy. Spolu s lokální proměnnou prefix se do ní přesunula i anonymní metoda. Instance této třídy je vytvořena při každém volání metody Main.
Automaticky vygenerovaná třída <>c__DisplayClass1 je tedy jakýmsi zhmotněním pojmu closure (uzávěr) :-)

Vhnízděné closures

Začněme složitejším příkladem – vytvoříme pole delegátů, kde každý delegát vrátí jedno číslo:

public class Test
{
  public static void Main()
  {
    var delegates = new Func<string>[5];
    for(int i = 0; i < delegates.Length; i++)
    {
      delegates[i] = () => i.ToString();
    }
    foreach(var d in delegates)
    {
      Console.WriteLine(d());
    }
  }
}

Tento kód nefunguje tak, jak by se mohlo na první pohled zdát. Nevypíšou se totiž čísla 0 až 4, ale vypíše se pěkrát číslo 5. Lokální proměnná i je totiž takto deklarována se scopem na celé metodě. A jak jsme si ukázali, delegáti mají živou vazbu na lokální proměnné, takže v okamžiku, kdy je voláme z foreach cyklu, pracují s aktuální hodnotou proměnné i.
Pokud chceme, aby kód pracoval podle našich představ (tj. vypsal čísla 0 až 4), musíme zajistit, aby si tělo delegátu sahalo na proměnnou, která se nebude dále měnit:

public class Test
{
  public static void Main()
  {
    var delegates = new Func<string>[5];
    for(int i = 0; i < delegates.Length; i++)
    {
      var scopedI = i;
      delegates[i] = () => scopedI.ToString();
    }
    foreach(var d in delegates)
    {
      Console.WriteLine(d());
    }
  }
}

Zavedli jsme pomocnou proměnnou scopedI, která již nemá scope na celé metodě, ale je deklarována na úrovni bloku. Můžeme si to tedy představit tak, že proměnná scopedI vznikne znovu při každém vstupu do bloku (v našem případě do těla for cyklu). A co na to kompilátor?

public class Test
{
  [CompilerGenerated]
  private sealed class <>c__DisplayClass1
  {
    public int scopedI;
    public string <Main>b__0()
    {
        return this.scopedI.ToString();
    }
  }
  public static void Main()
  {
    var delegates = new Func<string>[5];
    for (int i = 0; i < delegates.Length; i++)
    {
        <>c__DisplayClass1 CS$<>8__locals2 = new <>c__DisplayClass1();
        CS$<>8__locals2.scopedI = i;
        delegates[i] = new Func<string>(CS$<>8__locals2.<Main>b__0);
    }
    foreach (var d in delegates)
    {
        Console.WriteLine(d());
    }
  }
}

Jak vidno, kompilátor správně detekoval, že je třeba vytvořit instanci pomocné třídy (držící hodnotu lokální proměnné) v každé iteraci cyklu. Delegát opět ukazuje na metodu v nově vytvořené instanci.

Předchozí příklady byly příprava pro la grande finale – vhnízděné closures. Nejzajímavější kód totiž kompilátor vygeneruje, když máme více vnořených bloků:

public class Test
{
  public static void Main()
  {
    string prefix = GetPrefix();
    var delegates = new Func<string>[5];
    for(int i = 0; i < delegates.Length; i++)
    {
      var scopedI = i;
      delegates[i] = () => prefix + scopedI.ToString();
    }
    foreach(var d in delegates)
    {
      Console.WriteLine(d());
    }
  }
}

Opět se zaměřme hlavně na tělo delegáta. To si nyní nesahá na vnitřní closure, obhospodařující lokální proměnnou scopedI, ale sahá i o blok výše – na proměnnou prefix.

public class Test
{
  [CompilerGenerated]
  private sealed class <>c__DisplayClass1
  {
    public string prefix;
  }

  [CompilerGenerated]
  private sealed class <>c__DisplayClass3
  {
    public Test.<>c__DisplayClass1 CS$<>8__locals2;
    public int scopedI;
    public string <Main>b__0()
    {
        return (this.CS$<>8__locals2.prefix + this.scopedI.ToString());
    }
  }

  public static void Main()
  {
    <>c__DisplayClass1 CS$<>8__locals2 = new <>c__DisplayClass1();
    CS$<>8__locals2.prefix = GetPrefix();
    var delegates = new Func<string>[5];
    for (int i = 0; i < delegates.Length; i++)
    {
      <>c__DisplayClass3 CS$<>8__locals4 = new <>c__DisplayClass3();
      CS$<>8__locals4.CS$<>8__locals2 = CS$<>8__locals2;
      CS$<>8__locals4.scopedI = i;
      delegates[i] = new Func<string>(CS$<>8__locals4.<Main>b__0);
    }
    foreach (Func<string> d in delegates)
    {
      Console.WriteLine(d());
    }
  }
}

Jak vidno, kompilátor se s nastalou situací hrdinně vypořádal. Vytvořil si pomocnou třídu, která obsahuje lokální proměnné deklarované na úrovni celé metody. Stejně jako v předchozích příkladech si vytvořil další pomocnou třídu, která se stará o proměnné deklarované v těle cyklu. A to nejdůležitější – tato třída obsahuje odkaz na první jmenovanou (viz přiřazení na druhém řádku v cyklu).

Shrnutí

Kompilátor C# od verze 2.0 nabízí možnost sahat si z anonymních metod (resp. lambda funkcí) mimo jejich tělo – podporuje tzv. closures. Ty jsou implementovány celkem přímočaře – každý uzávěr je reprezentovaný pomocnou třídou a při každém vstupu do bloku je vytvořena instance odpovídající pomocné třídy (reprezentující closure).
Je to vlastnost, bez které bychom se jistě dokázali v jazyce obejít, ale mít možnost zanést do delegáta kontext prostředí, ve kterém vznikl, je velmi užitečný a překvapivě častý. Např. i tento, na první pohled primitivní kód, využívá closures!

public class Test
{
  public static void Main()
  {
    var toShow = int.Parse(Console.ReadLine());
    foreach(var number in GetArray().Where(i => i == toShow))
    {
      Console.WriteLine(number);
    }
  }
}

P.S.: Na toto téma jsem již blogoval, ale tentokrát jsem to chtěl vzít trošku podrobněji.


Příspěvek byl publikován v rubrice Programování a jeho autorem je Augi. Můžete si jeho odkaz uložit mezi své oblíbené záložky nebo ho sdílet s přáteli.

10 komentářů u “Closures v C#

  1. Pěkně shrnuto :) )

    Jen těm first-class function: prvotřídní funkce bych nenazýval ukazatelem na funkci. Vyjadřuje to trochu jinou vlastnost a to, že funkce jsou také plnohodnotné objekty.

  2. Ja bych jen doplnil, ze

    public class Test
    {
    public delegate string TestDelegate(int i); // deklarace typu
    // to same jako Func

    public static void Main()
    {
    Func d = delegate(int i) { return i.ToString(); };
    Console.WriteLine(d(1));
    }
    }

    v c# 2.0 neprelozis, neb Func je az v 3.5 frameworku.. Nebo se da starsi kompilator nastavit proti novymu FW? :)

  3. Však verze CLR je pro FW 2.0 a FW 3.5 stejná (2.0), tak proč by ne? :-) Ale pro klid tvé duše jsem to upravil ;-)

  4. Moc pěkné shrnutí.
    Jen pár poznámek:
    1)Když už ukazuješ, jak vypadají třídy generované kompilátorem, možná by stálo za to ukázat, za jakých okolností je generována metoda ve stávající třídě, kdy v samostatné třídě, proč dostaneme občas warning o neverifikovatlném kódu při volání base.xyz atd.
    2) U anonymních metod by stálo za zmínku, že mají oproti lambdám jedinou výhodu. Když argumenty metody nepoužíváme, nenemusíme je u anonymní metody ani deklarovat. Tose dá využít třeba u událostí, kdy se přiřadí události výchozí null handler a není nutné testovat událost při vyvolávání, jestli má hodnotu null.
    public event EventHandler StateChanged = delegate{};
    3) Když budeš psát další verzi článku, myslím, že člověk skutečně pochopí lambdy, když si napíše YKombinator a parciálni aplikaci metod (i currying). :D :D

  5. Díky za doplnění!
    1) Máš pravdu. Nechtěl jsem ale zabíhat do zbytečných detailů, protože jsem zjistil, že většina (i jinak velmi dobrých) programátorů nemá ponětí o tom, že se něco takového na pozadí vůbec děje.
    2) Toho jsem si všiml u nějakého tvého blogpostu – moc pěkný trik :-) Ale to si schovávám do připravovaného článku „C# – tipy a triky“ (ano, konečně jsem dočetl CLR via C# :) ).
    3) O tom asi radši blogovat nebudu, protože bych měl strach, že hodně čtenářů by vyvolalo StackOverflowException už při vysvětlování y-combinatoru :-) (sám jsem měl na krajíčku)

    Ale pro odvážné nabízím zajímavé zdroje k nastudování:
    Y-combinator – sice v JavaScriptu, ale přijde mi to nejsrozumitelnější
    Currying a aplikace parciálních metod v C#
    Y-combinator v C#

  6. Je to tak – funkce, coby (pseudo)kod kdesi v pameti, je vzdy z principu „ukazatelovita“. Pouze pokud jazyk explicitne pointery nezavadi, neni to hmatatelne, ale k firstclassovitosti to nestaci, pokud funkci nejde pouzivat ala datovy typ s hodnotou (nemusi to byt primo objekt).

  7. Jen drobnost, řídící proměnná for cyklu nemá scope na celé metodě,alespoň dle MSDN:

    The scope of a local variable declared in a for-initializer of a for statement (Section 8.8.3) is the for-initializer, the for-condition, the for-iterator, and the contained statement of the for statement.

    Tuším, že to mělo co dělat s životnosti dané proměnné.

  8. Je třeba podotknout, že chování closures nad iteračními proměnnými (zde popsáno na začátku sekce „Vhnízděné closures“) se bude v další verzi C# měnit.

    Viz http://blogs.msdn.com/b/ericlippert/archive/2009/11/16/closing-over-the-loop-variable-part-two.aspx

    Jsem za to rád. Případů, kdy se něčí kód, napsaný intuitivně, leč bez 100% znalosti chování closures, byl díky tomuto chybný, jsem zaregistroval několik. Já sám jsem si tohoto chování všiml až teprve v momentě, kdy mě na něj upozornil Resharper, a to jsem se do té doby považoval za relativně „poučeného“ :-)

Napsat komentář

Vaše emailová adresa nebude zveřejněna.

Můžete používat následující HTML značky a atributy: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>