facebook

Blog

Resta aggiornato

Continuiamo ad analizzare come LINQ abbia rivoluzionato il modo con cui accediamo ai dati in .NET
LINQ in depth: aspetti avanzati
mercoledì 25 Novembre 2020

Nel precedente articolo, abbiamo dato uno sguardo all’evoluzione del linguaggio C# e delle novità in esso introdotte a supporto di LINQ, iniziando a scoprire le principali caratteristiche di quest’ultimo.

Proseguiamo ora, osservando alcune peculiarità delle due sintassi che LINQ ci mette a disposizione e che, come abbiamo detto, sono praticamente equivalenti.

// Method syntax
var filteredCustomersAge = customers.Where(c => c.Age > 19 && c.Age < 36);
 
// Query syntax
var filteredCustomersAge =
    from customer in customers
    where customer.Age > 19 && customer.Age < 36
    select customer;

Nella precedente query inerente la Query Syntax, l’operatore di query where in fase di compilazione viene convertito con la chiamata al metodo di estensione .Where() utilizzato nell’esempio di codice scritto con la Method Syntax.

Alcune query tuttavia devono essere effettuate con la Method Syntax.

Ad esempio, per esprimere una query che recupera il numero di elementi che corrispondono ad una condizione specificata:

var filteredCustomersAgeCount = customers
    .Where(c => c.Age >19 && c.Age <36)
    .Count();                                //Count: 2

O per una query che recupera l’elemento con il valore massimo in una sequenza di origine:

var customerWithOlderAge = customers.Select(c => c.Age).Max();   //Age: 55

Tuttavia, è sempre possibile applicare la sintassi del metodo dopo aver utilizzato la sintassi delle query come nel prossimo esempio, dove useremo la Query Syntax per recuperare le età dei clienti > 19 e < di 36 anni e, utilizzando la Method Syntax, prenderemo poi l’età più alta:

int maxAgeInRange = 
   (from customer in customers
    where customer.Age > 19 && customer.Age < 36
    select customer.Age).Max();

Vediamo invece qualche esempio dove è più comodo utilizzare la Query Syntax.

Grazie all’utilizzo della keyword let, possiamo immagazzinare un risultato e utilizzarlo all’interno della query. Supponiamo di avere un metodo GetYearsOfFidelity() che ci restituisce gli anni di affiliazione di un cliente:

var querySyntaxGoldenCustomers =
    from customer in customers
    let yearsOfFidelity = GetYearsOfFidelity(customer)
    where yearsOfFidelity > 5
    orderby yearsOfFidelity
    select customer.CustomerName;
 
var methodSyntaxGoldenCustomers = customers
    .Select(customer => new   //anonymous type
    {
        YearsOfFidelity = GetYearsOfFidelity(customer),
        Name = customer.CustomerName
    })
    .Where(x => x.YearsOfFidelity > 5)
    .OrderBy(x => x.YearsOfFidelity)
    .Select(x => x.Name);

Come vediamo, con la sintassi della query il risultato è più chiaro. La sintassi del metodo richiede di creare un anonymous type e utilizzarlo per il resto della query.

Se avessimo più origini dati, la sintassi della query sarebbe probabilmente la scelta migliore, perché possiamo utilizzare la parola chiave from più volte, rendendo il codice più esplicativo:

var rows = Enumerable.Range(1, 3); //1,2,3
var columns = new string[] { "A", "B", "C" };
 
var querySyntax = from row in rows
                  from col in columns
                  select $"cell [{row}, {col}]";
 
var methodSyntax = rows.SelectMany(row => columns, (r, c) => $"cell [{r}, {c}]");

Prendiamo ora in esame del codice che integra la funzionalità degli Object Initializer con LINQ. Gli Object Initializer vengono usati in genere nelle espressioni di query, quando proiettano i dati di origine in un nuovo tipo di dati. Utilizziamo sempre la nostra classe Customer e supponiamo di avere un’origine dati denominata IncomingOrders e che per ogni ordine con OrderSize maggiore di 100 si debba creare un nuovo oggetto Customer basato sull’ordine:

var largeOrderCustomers = from o in IncomingOrders
                          where o.OrderSize > 100
                          select new Customer { CustomerName = o.CName, CustomerID = o.CId };

L’origine dati può avere diverse proprietà rispetto alla classe Customer, ad esempio OrderSize, ma utilizzando l’Object Initializer i dati restituiti dalla query vengono modellati nel tipo di dati desiderato con una singola operazione. Di conseguenza, è ora disponibile un oggetto IEnumerable che contiene i nuovi oggetti Customer desiderati.

In questo e nel precedente articolo su LINQ, abbiamo parlato delle feature a supporto di questo potente framework e visto piccoli esempi del suo utilizzo. Ma come mai abbiamo parlato di delegate anonimi?

Analizziamo una delle query viste in precedenza:

var filteredCustomersAge = customers.Where(c => c.Age > 19 && c.Age < 36);

Il metodo Where prende come parametro una condizione, in questo caso espressa da una lambda expression, che è una funzione che prende in ingresso un oggetto Customer e restituisce un booleano:

Func<Customer, bool> func = c => c.Age > 19 && c.Age < 36;
 
var filteredCustomersAge = customers.Where(func);

Ma cos’è in effetti quella funzione?

Quella funzione è un delegate! Una variabile che fa riferimento a codice eseguibile non elaborato. Gli extension methods LINQ di IEnumerable<T> accettano come parametro un delegate, sia che si tratti di delegate anonimi come nel caso precedente con la lambda expression, o di metodi definiti esplicitamente:

bool func(Customer customer)
   {
       return customer.Age > 19 && customer.Age < 36;
   }

In teoria, è possibile analizzare l’IL per capire che cosa il metodo che stiamo utilizzando sta tentando di fare e applicare la logica di quel metodo a qualsiasi origine dati sottostante. Ma questo non sarebbe un lavoro semplice.

Fortunatamente .NET ci fornisce l’interfaccia IQueryable<T> che ci procura gli stessi metodi di estensione dell’interfaccia dalla quale deriva, ovvero IEnumerable<T>. Tali metodi però, accettano come parametro gli expression tree al posto dei delegate.

Diamo giusto uno sguardo a cos’è e a come si traduce il codice trovato in un’espressione in un expression tree.

Per comodità, prendiamo in esame una semplice lambda expression simile alla precedente:

Func<int, int, int> function = (a,b) => a + b;

LINQ ci fornisce una semplice sintassi, il primo passo è aggiungere un’istruzione using per introdurre lo spazio dei nomi Linq.Expressions:

using System.Linq.Expressions;

Ora possiamo creare un expression tree:

Expression<Func<int, int, int>> expression = (a, b) => a + b;

La lambda expression viene convertita in un expression tree di tipo Expression<T>

che non è un codice eseguibile: tutti gli elementi della nostra espressione vengono rappresentati come nodi di una struttura di dati.

Possiamo visualizzare la struttura in debug con Visual Studio:

L’expression tree è composto da quattro proprietà principali:

  • Body: contiene il corpo dell’espressione e le sue informazioni.
  • Parameters: contiene informazioni sui parametri dell’espressione lambda.
  • NodeType : contiene informazioni sui diversi tipi possibili di nodi di espressione, come quelli che restituiscono costanti, quelli che restituiscono parametri, quelli che decidono se un valore è inferiore a un altro (<), quelli che decidono se uno è maggiore di un altro (>), quelli che aggiungono valori insieme (+), ecc.
  • Type: ottiene il tipo statico dell’espressione. In questo caso, l’espressione è di tipo
    Func < int , int , int >.

Abbiamo quindi la possibilità di utilizzare ed analizzare queste proprietà.

Ora che abbiamo le idee più chiare, analizziamo il codice di questa query LINQ, nello specifico LINQ to SQL:

var query = from c in db.Customers
                    where c.City == "Nantes"
                    select new { c.City, c.CompanyName };

La variabile query restituita da questa espressione LINQ è di tipo IQueryable, che contiene nella sua definizione una proprietà di tipo Expression. La proprietà di tipo Expression èprogettata per contenere l’expression tree associato a un’istanza di IQueryable ed è una struttura di dati equivalente al codice eseguibile trovato in un’espressione di query:

public interface IQueryable : IEnumerable
{
    Type ElementType { get; }
    Expression Expression { get; }
    IQueryProvider Provider { get; }
}

Ma qual è il motivo principale per cui, in alcuni casi, per esempio con LINQ to SQL, abbiamo bisogno dell’interfaccia IQueryable e degli expression tree?

Una query LINQ to SQL non viene effettivamente eseguita all’interno del programma C#!

Il codice di una espressione di query viene tradotto in una query SQL che può essere inviata a un altro processo come stringa. In questo caso, un database di SQL Server. Ovviamente è molto più semplice tradurre una struttura di dati come un expression tree, piuttosto che tradurre l’IL o codice eseguibile non elaborato in istruzioni SQL.

La query precedente viene quindi prima tradotta in una istruzione SQL come quella seguente e poi eseguita su un server:

SELECT [t0].[City], [t0].[CompanyName]
FROM [dbo].[Customers] AS [t0]
WHERE [t0].[City] = @p0

Un algoritmo molto sofisticato di LINQ to SQL analizza le diverse parti di un expression tree e ne ricava una stringa contenente un’istruzione SQL che restituirà i dati richiesti.

Diamo uno sguardo anche alla definizione dell’interfaccia IEnumerable<T>:

public interface IEnumerable<T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

Come possiamo vedere, non contiene un campo di tipo Expression.

Ecco perché l’interfaccia IEnumerable<T> è più adatta in contesti dove si può convertire l’espressione della query direttamente nel codice .NET che può essere eseguito e dove non è necessario tradurlo in una stringa o eseguire qualsiasi altra operazione complessa su di essa. Viene quindi utilizzata per eseguire query su dati provenienti da raccolte in-memory come List e Array, ed è più adatta per le query LINQ to Object e LINQ to XML.

Non supporta il lazy loading e non è quindi adatta a scenari dove abbiamo bisogno di paginazione.

Inoltre durante una query di dati da un database, IEnumerable esegue una query di selezione sul lato server, carica i dati in memoria sul lato client e quindi filtra i dati.

L’interfaccia IQueryable<T> invece, come abbiamo visto viene utilizzata dove è necessario tradurre un’espressione di query in una stringa che verrà passata a un altro processo.

È quindi migliore per eseguire query sui dati da raccolte out-memory come database remoti e servizi, e con LINQ to SQL.

Infine, supporta il lazy loading per gli scenari dove c’è bisogno di paginazione e supporta query personalizzate utilizzando i metodi CreateQuery ed Execute.

In questo articolo ci siamo soffermati ad analizzare alcuni aspetti del funzionamento di LINQ e delle funzionalità a suo supporto. Ci vorrebbe davvero molto tempo per spiegare quello che c’è dietro e tutto quello che un framework così complesso ci permette di fare.

Terminerei, però, elencando alcuni dei vantaggi che possiamo ottenere utilizzandolo:

  • Ci permette di scrivere meno codice, in modo più leggibile e manutenibile e di ridurre i tempi di sviluppo.
  • Non dobbiamo imparare un nuovo linguaggio di query per ogni tipo di origine dati o formato dati.
  • Ci fornisce il supporto IntelliSense per la scrittura delle query sulle raccolte generiche e maggior sicurezza grazie al controllo del tipo degli oggetti in fase di compilazione.
  • Ci permette di eseguire query in modo più semplice anche da più sorgenti di dati in una singola query e grazie alla sua funzionalità gerarchica, ci consente di comporre query unendo più tabelle in meno tempo.
  • Semplifica il debug grazie alla sua integrazione col linguaggio C#.
  • Ci offre un modo facile per convertire un tipo di dato in un altro, come la trasformazione di dati SQL in dati XML.
  • Può essere esteso, il che significa che è possibile utilizzare LINQ per eseguire query su nuovi tipi di origine dati.

Al prossimo articolo! Stay Tuned!

Scritto da

Francesco Vastarella