C# 3.0
L'evoluzione di LINQ e l'impatto sulla progettazione di C#
Anson Horton
Questo articolo si basa su una versione preliminare di Visual Studio con nome in codice "Orcas". Tutte le informazioni qui contenute sono soggette a modifica.
In questo articolo verranno discussi i seguenti argomenti:
- C# e LINQ
- L'evoluzione di LINQ
- Query SQL da codice
|
In questo articolo verranno utilizzate le seguenti tecnologie:
LINQ, C#
|

Sommario
Ero un grande appassionato della serie Connections di James Burke, quando veniva trasmesso da Discovery Channel. La premessa di base della serie era la seguente: alcune scoperte, in apparenza del tutto scollegate, hanno influenzato altre scoperte, portando in ultima analisi alla scoperta di alcuni irrinunciabili oggetti o comodità moderne. La morale, per così dire, è che non è possibile conseguire alcun progresso in condizioni di isolamento. Non sorprende che lo stesso concetto possa essere applicato a Language Integrated Query (LINQ).
In poche parole LINQ è una serie di estensioni del linguaggio che supporta l'esecuzione di query sui dati in modo indipendente dal tipo; sarà rilasciato con la prossima versione di Visual Studio, con nome in codice "Orcas". I dati su cui si eseguono le query possono essere in formato XML (LINQ a XML), database (ADO.NET compatibile con LINQ, che comprende LINQ a SQL, LINQ ai dataset e LINQ alle entità), oggetti (LINQ agli oggetti) e così via. L'architettura LINQ è illustrata nella Figura 1.
Figura 1 Architettura LINQ (Fare clic sull'immagine per ingrandirla)
A questo punto è opportuno iniziare a esaminare alcune stringhe di codice. Una query di LINQ di esempio nella nuova versione "Orcas" di C# potrebbe somigliare alla seguente:
var overdrawnQuery = from account in db.Accounts
where account.Balance < 0
select new { account.Name, account.Address };
Quando i risultati di questa query vengono ripetuti più volte con un foreach, ciascun elemento restituito è costituito dal nome e l'indirizzo di un account con un saldo minore di 0.
L'esempio rende evidente la similitudine con la sintassi SQL. Alcuni anni fa, Anders Hejlsberg (progettista capo di C#) e Peter Golde pensarono di estendere C# in modo da integrare meglio le funzionalità di query sui dati. Peter, che a quel tempo era il responsabile dello sviluppo del compilatore C#, stava cercando il modo per rendere estensibile il compilatore C#, in particolare per supportare i componenti aggiuntivi utili per verificare la sintassi di linguaggi specifici del dominio, come SQL. Anders, invece, stava progettando un livello di integrazione più profondo e specifico. Pensava a una serie di "operatori di sequenza" da utilizzare su qualsiasi insieme che implementasse IEnumerable, oltre a query remote per i tipi con implementazione di IQueryable. In definitiva l'idea di operatore di sequenza ha riscosso maggior successo e all'inizio del 2004 Anders ha prodotto un documento da presentare al Thinkweek di Bill Gates. Il commento fu estremamente positivo. Nelle prime fasi del progetto, una query semplice era caratterizzata dalla sintassi seguente:
sequence<Customer> locals = customers.where(ZipCode == 98112);
La sequenza, in questo caso, era un alias per IEnumerable<T> e la parola "where" era un operatore speciale compreso dal compilatore. L'implementazione dell'operatore where era un normale metodo statico di C# che includeva un delegato di predicato (vale a dire, un delegato di Pred<T>(T item)). L'idea di fondo consisteva nell'attribuire al compilatore una conoscenza speciale dell'operatore. In tal modo il compilatore avrebbe potuto chiamare correttamente il metodo statico e creare il codice per collegare il delegato all'espressione.
Si supponga che l'esempio riportato sopra sia la sintassi ideale per una query in C#. Come sarebbe la stessa query in C# 2.0, senza estensioni del linguaggio?
IEnumerable<Customer> locals = EnumerableExtensions.Where(customers,
delegate(Customer c)
{
return c.ZipCode == 98112;
});
Questo codice è esageratamente dettagliato e, ancora peggio, richiede ricerche onerose per trovare il filtro pertinente (ZipCode == 98112). L'esempio è molto semplice, ma è facile immaginare quanto sarebbe illeggibile con più filtri, proiezioni e così via. La causa principale del dettaglio eccessivo è la sintassi richiesta per i metodi anonimi. Nella query ideale l'espressione non richiederebbe altro che l'espressione da valutare. Il compilatore tenterebbe quindi di dedurre il contesto; ad esempio, che ZipCode si riferisce realmente all'elemento ZipCode definito su Customer. Come risolvere il problema? Una conoscenza di operatori specifici hardcoded nel linguaggio non sembrava la soluzione ideale al team di progettazione del linguaggio, che invece era più propenso ad adottare una sintassi alternativa per i metodi anonimi. Il team desiderava che le espressioni risultassero estremamente concise, senza necessariamente richiedere una conoscenza maggiore di quella già necessaria al compilatore per i metodi anonimi. Il risultato finale furono le espressioni lambda.
Espressioni lambda
Le espressioni lambda sono una funzionalità del linguaggio simile per molti aspetti ai metodi anonimi. In effetti, un'adozione più tempestiva delle espressioni lambda nel linguaggio avrebbe reso inutili i metodi anonimi. Il concetto di base consisteva nella possibilità di trattare il codice come i dati. In C# 1.0 è comune passare stringhe, valori interi, tipi di riferimento e così via ai metodi, in modo che questi possano agire sui valori. Metodi anonimi ed espressioni lambda estendono la gamma di valori fino a includere i blocchi di codice. Questo concetto è comune nella programmazione funzionale.
Se si assume l'esempio precedente e si sostituisce il metodo anonimo con un'espressione lambda, si ottiene:
IEnumerable<Customer> locals =
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822);
È possibile notare alcune particolarità. La concisione dell'espressione lambda può essere attribuita a numerosi fattori. Primo, la parola chiave del delegato non viene utilizzata per introdurre il costrutto. Esiste invece un nuovo operatore, =>, che suggerisce al compilatore che questa non è un'espressione normale. Secondo, il tipo Customer viene dedotto dall'uso. In questo caso la firma del metodo Where ha un aspetto simile al seguente:
public static IEnumerable<T> Where<T>(
IEnumerable<T> items, Func<T, bool> predicate)
Il compilatore è in grado di dedurre che "c" si riferisce a un cliente, perché il primo parametro del metodo Where è IEnumerable<Customer>, in modo che T deve, in effetti, essere Customer. Grazie a questa conoscenza, il compilatore verifica inoltre che in Customer esista un membro ZipCode. Infine, non è specificata alcuna la parola chiave di restituzione. Nella forma sintattica il membro di restituzione viene omesso, ma per semplice comodità sintattica. Il risultato dell'espressione viene tuttavia considerato come il valore restituito.
Le espressioni lambda, come i metodi anonimi, supportano anche l'acquisizione delle variabili. È, ad esempio, possibile fare riferimento a parametri o variabili locali del metodo che contiene l'espressione lambda nel corpo dell'espressione lambda:
public IEnumerable<Customer> LocalCusts(
IEnumerable<Customer> customers, int zipCode)
{
return EnumerableExtensions.Where(customers,
c => c.ZipCode == zipCode);
}
Infine, le espressioni lambda supportano una sintassi più dettagliata che consente di specificare i tipi ed eseguire più istruzioni. Ad esempio:
return EnumerableExtensions.Where(customers,
(Customer c) => { int zip = zipCode; return c.ZipCode == zip; });
La buona notizia è che la sintassi ideale proposta nel documento originale è molto più vicina e che ciò è stato possibile grazie a una funzionalità del linguaggio, in genere utile all'esterno degli operatori di query. È opportuno fare il punto:
IEnumerable<Customer> locals =
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822);
Qui c'è un problema ovvio. Invece di pensare alle operazioni che è possibile eseguire su Customer, l'utilizzatore deve acquisire conoscenza della classe EnumerableExtensions. Inoltre, nel caso di più operatori, l'utilizzatore deve invertire il proprio modo di pensare per scrivere la sintassi corretta. Ad esempio:
IEnumerable<string> locals =
EnumerableExtensions.Select(
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822),
c => c.Name);
Si noti che Select è il metodo esterno, anche se opera sul risultato del metodo Where. La sintassi ideale sarebbe più simile alla seguente:
sequence<Customer> locals =
customers.where(ZipCode == 98112).select(Name);
È possibile avvicinarsi alla sintassi ideale con un'altra funzionalità del linguaggio?
Metodi di estensione
Una sintassi migliore l'ha prodotta una funzionalità del linguaggio nota come metodi di estensione. I metodi di estensione sono dei metodi fondamentalmente statici che sono richiamabili con una sintassi di istanza. La radice del problema per la query precedente consiste nella volontà di aggiungere metodi a IEnumerable<T>. Tuttavia, se si aggiungono degli operatori come Where, Select e così via, tutti gli implementatori esistenti e futuri sarebbe obbligati a implementare tali metodi. Tuttavia, la maggior parte di queste implementazioni sarebbero identiche. L'unico modo per condividere l'"implementazione di interfaccia" in C# consiste nell'utilizzare metodi statici, come in precedenza con la classe EnumerableExtensions.
Si supponga invece di scrivere il metodo Where come un metodo di estensione. La query potrebbe essere riscritta come segue:
IEnumerable<Customer> locals =
customers.Where(c => c.ZipCode == 91822);
Per questa semplice query, la sintassi è molto vicina a quella ideale. Ma che significa con precisione scrivere il metodo Where come un metodo di estensione? È piuttosto semplice. La firma del metodo statico viene in sostanza modificata come se si aggiungesse un modificatore "this" al primo parametro:
public static IEnumerable<T> Where<T>(
this IEnumerable<T> items, Func<T, bool> predicate)
Inoltre, il metodo deve essere dichiarato all'interno di una classe statica. Per statica si intende una classe che può contenere solo membri statici e che è caratterizzata dal modificatore statico sulla dichiarazione della classe. Tutto qui. Questa dichiarazione richiede al compilatore di consentire la chiamata di Where con la stessa sintassi di un metodo di istanza su qualsiasi tipo che implementa IEnumerable<T>. Il metodo Where deve, tuttavia, essere accessibile dall'ambito corrente. Un metodo è nell'ambito quando il tipo che lo contiene è nell'ambito. Quindi, è possibile portare i metodi di estensione nell'ambito tramite l'istruzione Using (per ulteriori informazioni, vedere la barra laterale "Metodi di estensione").
La sintassi che ne risulta è molto vicina all'ideale per la clausola filtro, ma i vantaggi della versione "Orcas" di C# si esauriscono qui? Non proprio; si può estendere un po' l'esempio proiettando all'esterno solo il nome del cliente, invece dell'intero oggetto cliente. Come già anticipato, la sintassi ideale sarebbe simile alla seguente:
sequence<string> locals =
customers.where(ZipCode == 98112).select(Name);
Con le sole estensioni del linguaggio già esaminate, espressioni lambda e metodi di estensione, è possibile riscrivere il codice in questo modo:
IEnumerable<string> locals =
customers.Where(c => c.ZipCode == 91822).Select(c => c.Name);
Si noti come il tipo restituito sia diverso per questa query: IEnumerable<string> invece di IEnumerable<Customer>. Questo accade perché si restituisce solo il nome del cliente dall'istruzione Select.
Quando la proiezione è costituita da un solo campo, il codice funziona particolarmente bene. Tuttavia, si supponga di dover restituire, oltre al nome, anche l'indirizzo del cliente. La sintassi ideale sarebbe simile alla seguente:
locals = customers.where(ZipCode == 98112).select(Name, Address);
Tipi anonimi
Se si utilizza la sintassi esistente per restituire il nome e l'indirizzo, si dovrà presto affrontare un problema: non esiste infatti alcun tipo che contenga solo un nome e un indirizzo. È tuttavia ancora possibile scrivere questa query, introducendo tale tipo:
class CustomerTuple
{
public string Name;
public string Address;
public CustomerTuple(string name, string address)
{
this.Name = name;
this.Address = address;
}
}
È quindi possibile utilizzare il tipo, qui CustomerTuple, per costruire il risultato della query:
IEnumerable<CustomerTuple> locals =
customers.Where(c => c.ZipCode == 91822)
.Select(c => new CustomerTuple(c.Name, c.Address));
Il codice sembra davvero troppo complicato per la proiezione di un sottoinsieme di campi. Spesso non è facile denominare il tipo. CustomerTuple è un buon nome? Se invece fosse necessario proiettare nome ed età? CustomerTuple sarebbe ancora accettabile? I problemi sono dunque costituiti da un pezzo di codice scadente e dalla difficoltà di trovare un buon nome per i tipi che si creano. Inoltre, potrebbero essere necessari molti tipi diversi e la gestione diventerebbe rapidamente un incubo.
Ecco a cosa servono i tipi anonimi. Questa funzionalità consente in pratica di creare tipi strutturali senza specificarne il nome. Se si riscrive la query precedente utilizzando i tipi anonimi, si ottiene:
locals = customers.Where(c => c.ZipCode == 91822)
.Select(c => new { c.Name, c.Address });
Questo codice crea implicitamente un tipo con i campi Name e Address:
class
{
public string Name;
public string Address;
}
Non è possibile utilizzare un nome per fare riferimento al tipo, poiché non esiste alcun nome. I nomi dei campi possono essere dichiarati in modo esplicito nella creazione di un tipo anonimo. Se, ad esempio, il campo creato è derivato da un'espressione complessa oppure il nome non è semplicemente utile, è possibile modificare il nome:
locals = customers.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + “ “ + c.LastName,
HomeAddress = c.Address });
In questo caso il tipo generato contiene dei campi denominati FullName e HomeAddress.
In questo modo ci si avvicina all'ideale, ma si pone un problema. Si noterà che è stato strategicamente omesso il tipo di variabili locali in tutti i casi in cui si è utilizzato un tipo anonimo. È ovvio che non è possibile dichiarare il nome di tipi anonimi, dunque come si utilizzano?
Variabili locali tipizzate implicitamente
Esiste un'altra funzionalità del linguaggio nota come variabili locali tipizzate in modo implicito (o var in breve) che richiede al compilatore di dedurre il tipo da una variabile locale. Ad esempio:
In questo caso il tipo del valore intero è int. È importante comprendere che questa tipizzazione è molto forte. In un linguaggio dinamico, il tipo del valore intero potrebbe essere modificato in seguito. Per illustrare questa caratteristica, il codice seguente non viene compilato:
var integer = 1;
integer = “hello”;
Il compilatore C# riferirà un errore sulla seconda riga, dichiarando che non è possibile convertire implicitamente una stringa in un int.
Nel caso della query precedente, ora è possibile scrivere come segue:
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + “ “ + c.LastName,
HomeAddress = c.Address });
Il tipo delle variabili locali alla fine corrisponde a IEnumerable<?>, dove "?" è il nome di un tipo che non può essere scritto (dal momento che è anonimo).
Le variabili locali tipizzate in modo implicito non sono altro che questo: variabili locali all'interno di un metodo. Non possono sfuggire ai limiti di un metodo, di una proprietà, di un indicizzatore o di un altro blocco, perché non è possibile dichiarare il tipo in modo esplicito e "var" non è legale per i tipi di campi o parametri.
Le variabili locali tipizzate in modo implicito si rivelano utili all'esterno del contesto di una query. Consentono, ad esempio, di semplificare le creazioni di istanze generiche complesse:
var customerListLookup = new Dictionary<string, List<Customer>>();
La query è a buon punto; è molto prossima alla sintassi ideale e la sua forma attuale è stata raggiunta solo con funzionalità del linguaggio generiche.
È interessante come si sia scoperto, con collaborazione di un numero crescente di persone all'elaborazione di questa sintassi, che era spesso necessario consentire a una proiezione di superare i limiti imposti da un metodo. Come già esposto in precedenza, questa operazione è possibile tramite la costruzione di un oggetto con la chiamata del costruttore corrispondente dall'interno di Select. Tuttavia, cosa accade se manca un costruttore che porti esattamente i valori che è necessario impostare?
Inizializzatori di oggetti
Per casi come questo, esiste una funzionalità del linguaggio C# nella nuova versione "Orcas" nota come inizializzatori di oggetti. Gli inizializzatori di oggetti consentono di assegnare più proprietà o campi in una sola espressione. Ad esempio, un modello comune per la creazione di oggetti è:
Customer customer = new Customer();
customer.Name = “Roger”;
customer.Address = “1 Wilco Way”;
In questo caso non esiste alcun costruttore di Customer che porti un nome e indirizzo; tuttavia, esistono due proprietà, Name e Address, che è possibile impostare dopo la creazione di un'istanza. Gli inizializzatori di oggetto consentono di eseguire la stessa creazione con la sintassi seguente:
Customer customer = new Customer()
{ Name = “Roger”, Address = “1 Wilco Way” };
Nel precedente esempio di CustomerTuple, si è creata la classe CustomerTuple chiamando il relativo costruttore. È possibile ottenere lo stesso risultato con gli inizializzatori di oggetto:
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c =>
new CustomerTuple { Name = c.Name, Address = c.Address });
Si noti come gli inizializzatori di oggetto consentano di omettere le parentesi del costruttore. Inoltre, è possibile assegnare sia i campi che le proprietà impostabili all'interno del corpo dell'inizializzatore di oggetto.
La nuova sintassi per creare query in C# ora è abbastanza concisa. Tuttavia, si è giunti anche a una modalità estendibile per aggiungere nuovi operatori (Distinct, OrderBy, Sum e così via) tramite i metodi di estensione e una serie distinta di funzionalità del linguaggio altrettanto utili.
Il numero di prototipi prodotti dal team di progettazione del linguaggio per ottenere commenti e suggerimenti iniziava a essere significativo. Dunque si è organizzato uno studio di utilizzabilità con molti partecipanti con esperienza sia in ambito C# che SQL. I commenti e suggerimenti erano quasi universalmente positivi, ma era ovvio che qualcosa ancora mancava. In particolare è stato difficile per gli sviluppatori applicare la propria conoscenza di SQL, perché la sintassi che abbiamo ritenuto ideale non si adattava molto bene al loro campo di esperienza.
Espressioni di query
Il team di progettazione del linguaggio ha creato una sintassi più vicina a SQL, definita espressioni di query. Ad esempio, un'espressione di query per l'esempio potrebbe somigliare alla seguente:
var locals = from c in customers
where c.ZipCode == 91822
select new { FullName = c.FirstName + “ “ +
c.LastName, HomeAddress = c.Address };
Le espressioni di query vengono create sulla base delle funzionalità del linguaggio descritte in precedenza e vengono letteralmente tradotte nella sintassi sottostante vista in precedenza. La query precedente, ad esempio, viene direttamente tradotta in:
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + “ “ + c.LastName,
HomeAddress = c.Address });
Le espressioni di query supportano alcune diverse "clausole" come from, where, select, orderby, group by, let e join. Tali clausole vengono tradotte nelle chiamate operatore equivalenti, che vengono a propria volta implementate tramite i metodi di estensione. La stretta relazione delle clausole di query e i metodi di estensione che implementano gli operatori ne facilitano la combinazione, quando la sintassi di query non supporta una clausola per un operatore necessario. Ad esempio:
var locals = (from c in customers
where c.ZipCode == 91822
select new { FullName = c.FirstName + “ “ +
c.LastName, HomeAddress = c.Address})
.Count();
In questo caso la query restituisce il numero di clienti che vivono nell'area del codice postale 91822.
Si torna così al punto di inizio (che è sempre un ottimo risultato). La sintassi della prossima versione di C# si è evoluta negli ultimi anni tramite alcune nuove funzionalità del linguaggio, per giungere infine molto vicino alla sintassi originale proposta nell'inverno del 2004. L'aggiunta delle espressioni di query si fonda sugli elementi base costituiti dalle altre funzionalità del linguaggio nella prossima versione di C# e rende molti scenari di query più semplici da leggere e comprendere per gli sviluppatori con esperienza in ambito SQL.
Anson Horton è Program Manager in Microsoft da quasi sei anni. Lavora nel team di C# fin dalla fondazione e in precedenza ha fatto parte del team di C++. Ha partecipato alla progettazione del linguaggio e del compilatore C#, del sistema del progetto di C#, dell'IDE di C# (IntelliSense) e dell'analizzatore di espressioni e debugger di C#. Anson gestisce un blog all'indirizzo
blogs.msdn.com/ansonh che aggiorna meno che può