Sulla Programmazione

Quattro chiacchere sulla programmazione e sulle bit-tecnologie con Fabrizio Cipriani

Le closures e C#

Chi è abituato ad usare internet per tenersi aggiornato, o chi legge gli ultimi libri sui linguaggi di programmazione, avrà sicuramente già incontrato il termine "closure".

Che cos'è una closure? Se siete arrivati qua con una ricerca su Google vi sarete già imbatutti in mille definizioni (un pò come mi accadde quando fui io a cercarle per la prima volta). Un esempio di closure dovrebbe facilitare le cose:

1
2
3
4
5
List<Video> FindAllShorterThen(List<Video> videos, int maxMinutes)
{  
 return lessons.FindAll(delegate(VideoLesson lesson)    
     { return lesson.Duration < maxMinutes; } );  // <-- closure
}

Per chi conosce il C# l'esempio potrebbe essere familiare, visto che applica un comune pattern per filtrare i contenuti di una lista.

FindAllShorterThen() ritorna l'elenco di tutti i video di durata inferiore a maxMinutes. Il metodo FindAll() della classe List<T> prende come parametro una funzione che viene invocata per ogni elemento della lista, filtrandola in base alla durata.

La particolarità è che questa funzione usa una variabile esterna al suo ambito di visibilità (scope): la variabile maxMinutes.

Una closure è proprio questo:

una funzione che può essere assegnata ad una variabile e che è in grado di interagire con le variabili presenti nell'ambiente che la definisce**

Provate a riesaminare l'esempio: il metodo FindAll() prende come parametro una funzione (che come vedremo può essere tranquillamente assegnata ad una variabile), che al suo interno usa una variabile esterna, ovvero maxMinutes.

Per semplicità chiamiamo le variabili esterne "variabili catturate".

Vediamo un paio di caratteristiche interessanti delle closures:

Una variabile catturata dalla closure rimane sempre una variabile esterna.

Se una closure cattura una variabile definita al suo esterno, essa è realmente quella variabile, e non un suo clone con una vita indipendente. La variabile catturata potrà ancora essere modificata sia all'interno che all'esterno della closure, la quale in fase di esecuzione ha a disposizione sempre il suo valore corrente.

Vediamo un esempio (ricordiamo che Action (necessita link) è una classe delegato con la quale possiamo assegnare un metodo ad una variabile ed eseguirlo in seguito):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
string sequence = "due";

Action printSequenceThenReset = delegate() 
{ 
    Console.WriteLine(sequence);    
    sequence = "uno"; 
};

sequence = "tre"; 
printSequenceThenReset();

Console.WriteLine(sequence);

sequence = "quattro"; 
printSequenceThenReset();

Eseguendo il codice, l'output è:

tre
uno
quattro

Vediamo passo passo cosa succede:

  1. inizializzo la variabile sequence con il valore "2".
  2. definisco una closure che stampa e modifica la variabile catturata
  3. cambio il contenuto della variabile in "3"
  4. invoco la closure. 
  5. La closure stampa il valore corrente della variabile, "3" (e non "2", come si potrebbe pensare), poi la imposta a "1"
  6. stampo il valore corrente della variabile, che essendo stata modificata dalla closure è "1"
  7. cambio il contenuto della variabile in "4"
  8. invoco la closure, che di nuovo stampa il valore corrente della variabile, "4".

Quindi la closure, quando viene eseguita, stampa il valore corrente della variabile sequence, e non il valore che ha la variabile al momento in cui la closure viene definita.

Inoltre, quando la closure imposta la variabile, le modifiche sono visibili a tutti quella che la usano.

Questi sono due concetti base che vi saranno estremamente utili quando dovrete analizzare un comportamento imprevisto delle closure che utilizzerete, e non sono del tutto intuitivi. Al punto che è frequente trovare su internet chi sbaglia a prevedere il risultato di una cosa di questo tipo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
List<Action> actions = new List<Action>();
for (int i=0; i<10; i++)
{  
    actions.Add(delegate() { Console.WriteLine(i); });
}

foreach (Action action in actions)
{  
    action();
}

Quale pensate sia l'output? Vi lascio un momento per riflettere.

...

Riflettuto? Chi ha risposto: 0, 1, 2, 3, ... 9? Risposta errata, il programma stampa 10 volte 10. 10.

Naturalmente è sufficiente rileggere cosa abbiamo detto sopra per accorgersi che nel momento in cui le dieci closure sono eseguite nell'ultimo loop, tutte stampano il valore corrente di "i" il quale appunto è 10 :).

Chi si chiede come fanno le closures del secondo loop ad usare la variabile i, che in teoria non è più definita perchè fuori dall'ambito di visibilità nel quale è stata creata, ovvero il primo loop, legga il prossimo paragrafo.

Una variabile catturata vive tanto a lungo quanto l'ultima closure che la usa

Definizione criptica? Mi sembra quasi di vedere la vostra espressione perplessa... Vediamo se va meglio con un altro esempio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static Action CreateAgingClosure()
{  
    int age = 30;  
    Action aging = delegate {     
        age++;    
        Console.WriteLine("La tua età è: " + age + " anni.");  
    };  
    return aging;
}

public void Main()
{  
    Action doAge = CreateAgingClosure();  
    doAge();  
    doAge();
}

Il programma mostra:

La tua età è 31 anni  
La tua età è 32 anni.

Niente di particolare, la funzione CreateAgingClosure() crea una closure che incrementa la variabile age e la stampa sullo schermo...

Se non fosse per il fatto che quando la closure viene invocata nel metodo main(), la variabile age non dovrebbe più esistere, visto che chi la definisce è andato out of scope. O per lo meno, questo è quello che succede in un normale programma C# quando NON usiamo una closure.

Nel nostro caso, il compilatore fa in modo che la variabile age continui ad esistere nell'istanza della closure doAge(), e farà in modo che venga deallocata solo quando la closure non esiste più. Ovvero, in questo caso quando la variabile doAge andrà out of scope. 

Questo ci permette di continuare ad utilizzare le variabili catturate nelle closures in qualsiasi momento e situazione, anche se ci siamo lasciati da tempo alle spalle l'ambiente in cui sono state definite.

Un esempio pratico

Bene, un uso pratico delle closure ve l'ho già mostrato all'inizio di questo articolo. Vediamone un altro. Prendiamo una situazione che si verifica frequentemente nelle applicazioni dove l'accesso ai dati è denso e costante:

List<Videos> GetVideosOfCategory(string category)
{  
    VideoDataContext ctx = new VideoDataContext();  
    return (from v in ctx.Videos 
        where v.category == category select v).ToList();
}

Questo metodo ritorna tutti i video di una particolare categoria attraverso una query LINQ to SQL usando Entity Framework. Se le categorie sono composte da molti video, un uso frequente del metodo potrebbe incidere sulle prestazioni. In questi casi normalmente si decide di inserire il risultato nella cache:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
List<Videos> GetVideosOfCateogry(string category)
{  
    string key = "VideoOfCategory_" + category;  
    if (HttpContext.Current.Cache[key] != null)    
        return (List<Videos>)HttpContext.Current.Cache[key];

    VideoDataContext ctx = new VideoDataContext();  
    List<Videos> videos =  (from v in ctx.Videos    
        where v.category == category select v).ToList();

    HttpContext.Current.Cache.Add(key, videos, null, 
        Cache.NoAbsoluteExpiration, new TimeSpan(0, 0, 300),
        CacheItemPriority.Normal, null);

    return videos; 
}

Il metodo verifica prima se i dati sono presenti nella cache, e solo in caso contrario esegue l'accesso al database.

Immaginate ora che questa situazione si verifichi frequentemente con molti altri metodi che eseguono una operazione simile, ma con differenti query e tipi di dati, e che decidiamo di usare la cache anche per quelli.

Per tutti dovrei inserire le linee 3,4,5 e le linee 11,12,13, con poche variazioni.

C'è un modo per generalizzare? Ora sappiamo di si. Creiamo questo metodo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public T GetFromCacheIfPresent<T>(Func<T> calcValueMethod, string cacheKey)
{  
    if (HttpContext.Current.Cache[cacheKey] != null)    
        return (T)HttpContext.Current.Cache[cacheKey];

    T retValue = calcValuetMethod();

    HttpContext.Current.Cache.Add(cacheKey, retValue, null, 
        Cache.NoAbsoluteExpiration, new TimeSpan(0, 0, 300),
        CacheItemPriority.Normal, null);

    return retValue;
}

Anche qui vi ricordo che Func (necessita link) è una classe delegato con la quale possiamo assegnare un metodoad una variabile ed eseguirlo in seguito. L'unica differenza con la classe Action (necessita link) è che il metodo ritorna un valore di tipo .

Abbiamo così creato un metodo generico che possiamo inserire in una delle nostre librerie. Il metodo GetFromCacheIfPresent\<T\>() fa questo:

  • Verifica se il valore è presente nella cache con il nome cachekey. Se è presente, lo ritorna immediatamentre.
  • Esegue la closure calcValueMethod() inserendo in una variabile il valore di tipo ritornato.
  • Salva il valore di ritorno nella cache con il nome cacheley ed infine lo ritorna al chiamante.

Il metodo ha due particolarità: 

  1. Non abbiamo bisogno di sapere in che modo vengono recuperati i dati. E' sufficiente eseguire la closure calcValueMethod.
  2. Alla closure non abbiamo bisogno di passare parametri, visto che possiamo sfruttare il meccanismo delle variabili catturate. 

Vediamo come usarlo:

1
2
3
4
5
6
7
8
9
List<Videos> GetVideosOfCateogry(string category)
{  
    return GetFromCacheIfPresent<List<Videos>>(delegate()   
        {    
            VideoDataContext ctx = new VideoDataContext();    
            return (from v in ctx.Videos      
                where v.category == category select v).ToList();  
        }, "VideoOfCategory_" + category);
}

Abbiamo potuto usare il nostro metodo generalizzato di uso della cache col nostro codice ed il nostro tipo di dati. Notare come il codice passato al metodo GetFromCacheIfPresent() usi la variabile esterna category. Lo stesso metodo potrà essere usato in qualsiasi situazione sia necessario utilizzare la cache per velocizzare l'accesso ai dati, semplicemente passando il codice necessario a recupare i valori.

Conclusione

Per chi volesse approfondire l'argomento, non è facile trovare dei buoni articoli sulle closures. C'è ancora un pò di confusione sull'argomento, che tuttavia si sta velocemente diradando. Se volete un consiglio, evitate di partire dalla definizione di Wikipedia, dove l'argomento viene affrontato in modo troppo accademico e poco pratico.

Per quanto mi riguarda, il miglior trattato sulle closures l'ho trovato nel capitolo a loro dedicato nel libro C# in Depth di Jon Skeet. 

Quelli che seguono sono alcuni articoli sulle closures, se ne avete altri sentitevi liberi di segnalarmeli nei commenti:

http://www.codethinked.com/c-closures-explained http://stackoverflow.com/questions/111102/how-do-javascript-closures-work?rq=1 

Con la loro capacità di rendere più snello ed elegante il codice, e di risolvere velocemente le situazioni intricate, le closures sono oramai supportate da quasi ogni linguaggio di programmazione (addirittura per alcuni linguaggi non è praticamente possibile farne a meno, come javascript) e stanno diventando un importante bagaglio di conoscenza per ogni programmatore.

Comments