Καλώς ορίσατε στο dotNETZone.gr - Σύνδεση | Εγγραφή | Βοήθεια

Παρουσίαση με Ετικέτες

Όλες οι Ετικέτε... » lambdas   (RSS)

Closures στη C#

Τελικά τα closures είναι ένα απλό concept, που όταν το κατανοήσει κάποιος, το βλέπει τετριμμένο μετά. Αν μπορούσα να δανειστώ από κάπου, δυό λόγια για ορισμό, νομίζω η wikipedia το περιγράφει αρκετά καλά. Τα closures είναι μία ιδέα των γλωσσών προγραμματισμού, που επιτρέπει, σε συναρτήσεις που αντιμετωπίζονται ως first-class objects, τον συσχετισμό ελεύθερων μεταβλητών τους, με τον λεκτικό περιβάλλοντα χώρο της συνάρτησης. Στη συνέχεια θα ήθελα να διατυπώσω το εν λόγω concept όσο πιο σύντομα γίνεται, δανειζόμενος (μερικώς ή πλήρως) κώδικα από το βιβλίο C# in Depth του Jon Skeet και από το Reference της C#.

First – class functions στην C#

First class functions είναι συναρτήσεις που έχουν όλες τις ιδιότητες των αντικειμένων, μπορούν να δημιουργηθούν δυναμικά, μπορούν να συμμετέχουν σε δομές δεδομένων, να ανατεθούν ως παράμετροι, καθώς και να επιστραφούν από κλήσεις άλλων συναρτήσεων. Στην οικογένεια των γλωσσών προγραμματισμού, η C# ανήκει σε εκείνες που υποστηρίζουν first class functions, μέσω του τύπου δεδομένων Delegate και όλης της πραγματικότητας γύρω από αυτό (anonymous methods, lambdas, expression trees). Θυμίζω ότι σε γενικές γραμμές Delegate είναι ένας τύπος δεδομένων (reference type) όπου παρέχει έναν ασφαλή τρόπο για να ενθυλακώσουμε συναρτήσεις (είτε static είτε instance). Ένα Delegate μπορεί να αναφέρεται μόνο σε συναρτήσεις που είναι συμβατές με το signature που περιγράφει.

Η έννοια των πραγματικών closures, ήρθε στην C# με την έκδοση 2.0, υπό τη μορφή των anonymous delegates (και κατεπέκταση στα lambdas), που σε γενικές γραμμές επιτρέπουν την προσπέλαση (read & write) μεταβλητών που δηλώνονται έξω από το scope αυτών. Χωρίς true closure support, είναι π.χ., η Java και ότι πιο κοντινό έχει σε αυτό το concept, είναι τα anonymous inners classes. Ένα simplistic closure support υπολογίζεται ότι θα υποστηριχθεί με την έλευση της νέας έκδοσης 7, το Σεπτέμβρη 2010.

Περιβάλλον ενός lambda

Στο παρακάτω μπλοκ κώδικα θα δούμε διαφορετικούς τύπους μεταβλητών, αναφορικά, με την θέση τους σε σχέση με το lambda.

Outer/free Variables: Οι μεταβλητές που βρίσκονται στο λεκτικό περιβάλλον του delegate (δηλαδή το scope τους περιλαμβάνει το delegate) θεωρούνται ώς πιθανές μεταβλητές που μπορούν να χρησιμοποιηθούν από αυτό. Ελεύθερες μεταβλητές λοιπόν για ένα lambda, είναι όσες δεν δηλώνονται μέσα στο scope του και όσες δεν δηλώνονται στις παραμέτρους τους (τότε θα λέγονταν bound variables). Μαθηματικώς, για παράδειγμα στον Fourier, για κάποιο χ τα a0, a1, a2, .., bn είναι ελεύθερες μεταβλητές (μπορούν να πάρουν τιμές από τον περιβάλλοντα χώρο) :

image

Όταν ένα free variable δεσμευτεί στο περιβάλλον του lambda, λέγεται ότι είναι ένα από τα Captured Variables, τα οποία είναι εξωτερικές μεταβλητές που χρησιμοποιούνται μέσα στο lambda. Ότι δεν είναι free variable, λέγεται bound variable, όπως για παράδειγμα η μεταβλητή Anonymous Local που ανήκει στις μεταβλητές που δηλώνονται και χρησιμοποιούνται μέσα στο lambda.

int outerVariable = 5; string capturedVariable = "captured"; if (DateTime.Now.Hour==23) {     int normalLocalVariable = DateTime.Now.Minute;           Console.WriteLine(normalLocalVariable); } ThreadStart x = delegate()     {         string anonLocal="local to anonymous method";            Console.WriteLine(capturedVariable + anonLocal);     }; x();

Όταν μέσα σε ένα lambda γίνεται αναφορά σε μία εξωτερική μεταβλητή, λέμε ότι η μεταβλητή αυτή, έχει δεσμευτεί (captured) και η ίδια ζει, όσο ζει και το lambda (γίνεται όπως λέμε, επέκταση του χρόνου ζωής της). Τι συμβαίνει στην πραγματικότητα όταν γίνεται capture; Ας το δούμε μέσα από ένα παράδειγμα.

Έστω ότι έχουμε μία συνάρτηση χωρίς capturing μεταβλητών:

static void F() {     D d = () => { Console.Writeline("No captured variables")}; }

όπου:

public delegate void D();

το delegate.

O compiler αντιμετωπίζει το delegate type instantiation (με το lambda που βλέπετε εκεί), κάνοντας allocation μία πραγματική ενδιάμεση μέθοδο που έχει κάνει generate.

static void F() {     D d = new D(generated_Method); } static void generated_Method() {     Console.Writeline("No captured variables") }

Έστω ότι το lambda κάνει capture μία τοπική μεταβλητή:

static void F() {     int y = 1;     D d = () => { Console.Writeline(y)}; }

H C# σε αυτό το σημείο, προσδίδει σημασιολογία closure και λέει: Επειδή το αντικείμενο που γίνεται reference από το d (η μέθοδος που κάνει Writeline το y, δηλαδή),  (μπορεί) να ζήσει περισσότερο από το scope της F (π.χ., αν επιστραφεί από τη συνάρτηση), κρατά τη μεταβλητή y (όχι απλά την τιμή της), για να την χρησιμοποιήσει, όταν το d γίνει invoke και εκτελεστεί. Πριν δείξουμε όμως την περίπτωση όπου το d ζει περισσότερο από το y (προς το παρόν δεν ζει) ας δείξουμε τι γίνεται σε αυτό το παράδειγμα. O compiler under the hood κάνει το εξής:

void F() {     Generated_Class_Keeping_Environment gen = new Generated_Class_Keeping_Environment();     gen.y = 1;     D d = new D(gen.generated_Method); } class Generated_Class_Keeping_Environment {     public int y;     public void generated_Method()     {         Console.Writeline(y);     } }

Ταξιδεύοντας λίγο περισσότερο στο flow, τι γίνεται αν έχω μεταβλητές σε διαφορετικά statement blocks, όπως στο παρακάτω παράδειγμα; Τότε ο compiler λειτουργεί αναλόγως. Στην παρακάτω περίπτωση, έχουμε αναφορά σε μία εξωτερική μεταβλητή (this.x)

class Top {     int x;     static void F()     {         int y = 1;         for (int i =0; i<10;i++)         {             int z = i*2;             D d = () => {Console.Writeline(x+y+z);};         }     } }

Στην παραπάνω μέθοδο F, το lambda κάνει capture μία μεταβλητή που δηλώνεται μία φορά στο this και δεν αλλάζει, μία τοπική μεταβλητή που δεν αλλάζει και μία τοπική μεταβλητή που αλλάζει συνεχώς, με κάθε iteration. O compiler θα κάνει generate δύο κλάσεις. Μία (Locals2) που θα περιέχει τη μέθοδο, την μεταβλητή Z, καθώς και αναφορά στην άλλη generated κλάση. Η δεύτερη generated κλάση (Locals1) θα έχει τη μεταβλητή y και ένα reference σε object τύπου Top οπότε η συνάρτηση F θα μετασχηματιστεί ως εξής:

void F () {     Locals1 locals1 = new Locals1();     locals1._this = this; //H Top     locals1.y = 1;     for (int i =0; i<10;i++)     {         Locals2 locals2 = new Locals2();         locals2.locals1 = locals1;         locals2.z = i*2;         D d = new D(locals2.method);     }    }

Όπως παρατηρούμε, οι μεταβλητές που έχουν γίνει capture, ζουν τώρα πια στο heap καθώς έχουν γίνει dynamic allocated μέσα σε κλάση.

Ας δούμε όμως, μία χρήση των anonymous delegates σαν κανονικά first order functions που είναι. Διαβάστε προσεκτικά τον παρακάτω κώδικα. Έστω ότι θέλω να ορίσω ThreadStart delegates. Φτιάχνω ένα array από δύο anonymous delegates μέσα στο for και αναθέτω σε κάθε ένα από τα δύο κελιά του array, από ένα anonymous delegate. Τα anonymous delegates έχουν κάνει capture δύο free variables, τις outside και inside (και allocate στο heap με τις compiler generated κλάσεις που είδαμε).

static void Main() {     ThreadStart[] delegates = new ThreadStart[2];     int outside = 0;                                               for (int i=0; i < 2; i++)         {             int inside = 0;                                                delegates[ i] = delegate                                        {                 Console.WriteLine ("({0},{1})",                                    outside, inside);                 outside++;                 inside++;             };         }         ThreadStart first = delegates[0];         ThreadStart second = delegates[1];         first();         first();         first();         second();         second();     } }

Σκεφτείτε ότι την ώρα που γίνεται η ανάθεση τιμής στο delegates[ i] γίνονται οι αναθέσεις, έχουν γίνει capture οι μεταβλητές και ο κώδικας εκτελείται παρακάτω. Γίνεται invoke το delegate της θέσης 0 και τυπώνει (0,0). Τη δεύτερη φορά και τα δύο αυξάνονται, όμοια και την τρίτη φορά και έχουμε (1,1) και (2,2). Τώρα εκτελείται το δεύτερο delegate. To outside είναι σε κατάσταση 3, οπότε με την αύξηση γίνεται 4, το inside όμως έχει ΞΑΝΑ-δηλωθεί (int inside = 0), οπότε το delegate έχει διαφορετικό instance της inside, αρχικοποιημένο στο 0. Οπότε η συνέχεια έπεται ως (3,0) και (4,1). Αν γράφατε τον παραπάνω κώδικα δείτε τι θα σας έδειχνε ο Resharper … ;) Πώς μπορώ να το διορθώσω αυτό, αν δεν το επιθυμώ (χωρίς να πατήσω την λάμπα αριστερά :P);

image

Γιατί είναι τόσο σημαντικά τα closure semantics;

Έστω ότι θέλουμε να φιλτράρουμε μία λίστα με βάση κάποια κριτήρια. Ας το υλοποιήσουμε απλά! Τι χρειαζόμαστε; Μία λίστα για να διαβάσουμε, έναν κανόνα που να δείχνει true αν κάνει pass το αντικείμενο και να θα το εισάγει σε μία νέα λίστα. Αφού διατρέξουμε όλη τη λίστα, θα έχουμε στα χέρια μας μία άλλη λίστα μόνο με όσα “πέρασαν”.

public static IList<T> FindAllItemsBasedOnPredicate<T>(IList<T> src, Predicate<T> predicate)     {         List<T> list= new List<T>();         foreach (T item in src)         {             if (predicate(item))              list.Add(item);                }         return list;     }


Όμορφα. Προχωράμε στο filtering. Έστω ότι θέλουμε να βρούμε όλους τους πελάτες ως 18 ετών.

Στη C# 3.0 (λόγω lambdas) μπορούμε να γράψουμε το εξής:

Predicate<Customer> ageRule = customer => customer.age <= 18; IList<Customer> youngCustomers= FindAllItemsBasedOnPredicate(CustomerList, ageRule );

Αν όμως δεν θέλουμε hard coded το 18 και χρειαζόμαστε να το ρυθμίζουμε μέσα από μία μεταβλητή; Έστω ότι δεν έχουμε closures. Τι κάνουμε; Το αλλάζουμε απλά με μία μεταβλητή; Και αν η μεταβλητή είναι static και αλλάξει; Και αν θέλουμε thread safety; Αν αναφερόμαστε σε Threading και θέλουμε να περάσουμε παραμέτρους στο thread μας; H C# έχει την δυνατότητα capturing μεταβλητής και όχι τιμής και αυτό είναι ένα από τα δυνατά της σημεία.

Some food for thought

Τι θα τυπώσει ο παρακάτω κώδικας χωρίς Copy-Paste-F5; ;););)

delegate void Func(); class Program {     static Func[] functionArray = new Func[10];     static void FillFunctionArray(int count)     {         for (int i = 0; i < count; i++)         {             functionArray[ i] = () =>             {                 Console.Write("{0} ", i);             };         }     }     static void Main(string[] args)     {         FillFunctionArray(functionArray.Length);         for (int i = 0; i < functionArray.Length; i++)         {             functionArray[ i]();         }         Console.ReadKey();                } }

Enjoy closures!!!

Για όσους ενδιαφέρονται να διαβάσουν μία ιδέα για το πώς μπορεί να επεκταθεί η IL για να υποστηρίζονται closures και άλλα όμορφα πράγματα σε επίπεδο IL, μπορούν να διαβάσουν μία σχετική δημοσίευση από MSR του Don Syme, δημιουργού της F#.

 

  1. D. Syme, “ILX: Extending the. NET Common IL for Functional Language Interoperability,” Electronic Notes in Theoretical Computer Science 59, no. 1 (2001): 53–72.
Posted: Τρίτη, 9 Μαρτίου 2010 12:13 πμ από George J. Capnias | 0 σχόλια
Δημοσίευση στην κατηγορία: , , ,