LINQ : Language INtegrated Query
Le fonctionnement
Un langage de requête : Language INtegrated Query
Au premier abord, LINQ n'est qu'un langage de requête de plus. La syntaxe LINQ est proche de la syntaxe SQL mais elle apporte deux concepts importants :
- l'intégration du modèle objet du framework .NET
- le principe de "list comprehension"
De plus, LINQ s'intègre à tous les états du code. Intégration dans le code source via la syntaxe. LINQ est aussi présent durant la phase de compilation, lors de la génération du code IL (Intermediate Language). Le code subit des traductions et des optimisations durant cette phase de compilation. Et enfin, à "runtime", un code LINQ subit encore quelques transformations au passage dans le JIT (Just In Time Compiler).
Une syntaxe simple
Commençons par la présentation du sucre syntaxique. Ce qu'on appelle sucre syntaxique est une façon élégante d'écrire un code.
var parisanCustomers = from c in customers where c.City == "Paris" && c.Country == "France" select new { c.CompanyName };
Dans le code ci-dessus, on remarque que la syntaxe est en effet très proche de celle de SQL. La différence étant l'ordre des mots clés. Mais voyons à présent, comment ce code est traduit. Il existe deux traductions :
- La traduction par expression :
var parisanCustomers = customers .Where(c => c.City == "Paris" && c.Country == "France") .Select(c => new { c.CompanyName });
- La traduction par "delegate" :
var parisanCustomers = customers .Where(delegate(Customers c) { return c.City == "Paris" && c.Country == "France" }) .Select(delegate(Customers c) { return new { c.CompanyName } ; });
Comme on peut le voir, chaque mot clé de la syntaxe LINQ correspond à une méthode de l'objet requêté par la clause from.
Des syntaxes étranges expliquée par nouveaux concepts qui font leurs apparitions avec LINQ. Dans les grandes lignes, ces concepts sont au nombre de sept :
- List comprehension
- L'inférence de type
- L'initialiseur d'objet
- Les types anonymes
- Les méthodes d'extensions
- Les délégates
- Les expressions lambda
List comprehension
Le principe de "list comprehension" n'est pas la traduction anglaise de "compréhension de liste". L'idée générale est cependant très simple. Il s'agit d'appliquer à chaque élément d'une liste un traitement, un filtre et/ou une transformation.
Tous les concepts expliqués ci-après montrent des exemples simples de "list comprehension" qui vous permettront de comprendre.
La magie de l'inférence de type
L'inférence de type est un mécanisme d'auto typage des variables. Ce mécanisme ne fontionne que sur les variables locales. Il fonctionne grâce à l'évaluation de l'arbre syntaxique abstrait par le compilateur. Le C# est un langage fortement typé. Le compilateur est capable de connaître à chaque instant le type de chaque membre de l'expression et donc de "calculer" le type final de l'expression. Voici deux exemples simples qui illustrent le fonctionnement.
var aString = "Paris";
Le compilateur se demande le type de l'expression "Paris". Dans notre cas, c'est très simple, c'est une chaîne de caractère. Il est donc facile de deviner que aString est également une chaîne.
Poussons notre réflection un peu plus loin :
var res = from x select new Object();
L'intérêt de créer de simples objets est proche de zéro. Cependant, nous voyons notre premier exemple de "list comprehension". Ce code crée pour chaque élément compris dans l'ensemble "x", un nouvel Object. Au niveau de l'inférence de type, on se retrouve donc avec une liste composée d'Object de la taille de l'ensemble "x". res est donc un IEnumerable<Object>.
Type anonyme et initialiseur d'objet
Les types anonymes (et types Ad-Hoc) permettent de créer rapidement une structure ne contenant que des données. La syntaxe est la suivante :
new {[name1 =] expr1, …,[namen =] exprn};
La propriété namex ne peut être typée pour forcer la correspondance des types. On utilise donc l'inférence de type. Les noms des propriétés sont optionels si l'initialiseur d'objet est capable de déduire un nom (nom d'une méthode ou d'une propriété d'un autre objet).
Nous avons donc avec les deux concepts précédents une syntaxe et des mécanismes permettant d'imiter la projection en SQL.
SELECT product_name, units_in_stock, unit_price FROM Customers as c //SQL from c in customers select new { c.productName, unitsInStock, unitPrice } //LINQ
Méthodes d'extension
Une méthode d'extension n'est rien d'autre qu'une méthode statique. L'avantage est de pouvoir venir "greffer" des méthodes sur des objets existants grâce à une syntaxe particulière. Cette méthode doit être placée dans une classe également statique.
public static IEnumerable<TSource> Do<TSource>(this IEnumerable<TSource>, Func<Tsource, bool> predicate);
Le mot clé this sur le premier paramètre permet de placer cet objet à gauche du point dans la syntaxe d'appel de méthode. Dans cette configuration, il doit être omis des paramètres d'appel.
((IEnumerable<Object>)aCollection).Do(); // OU AStaticExtensionClass.Do((IEnumerable<Object>)aCollection);
Pourquoi créer les méthodes d'extension ? Pour venir ajouter les méthodes nécessaires à l'utilisation de LINQ sur des classes existantes. Prenons l'exemple des tableaux, ils existent depuis la première version du framework .NET. Pour des contraintes de rétro compatibilité, ils peuvent difficilement évoluer. La solution pour faciliter les développements est d'utiliser les méthodes d'extension.
Expressions Lambda contre Delegate
Ces deux notions ne sont pas forcément familières aux développeurs même dans le monde Microsoft. Nous allons commencer par définir chacune des ces notions. Nous nous placerons dans le contexte de l'utilisation de LINQ en prennant le fonctionnenement de la méthode Where
Les delegates
Les delegates sont de simples pointeurs sur des fonctions. Ils permettent de changer facilement un algorithme appelé par une autre méthode. Par exemple, une comparaison de valeur, ou un code à executer dans un contexte de sécurité différente. Etudions notre méthode Where sur une collection. L'algorithme de la fonction est très simple :
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> enumerable, Func<Tsource, bool> predicate) { foreach(TSource obj in enumerable) { if(predicate(obj)) { yield obj; } } }
Where retourne le sous ensemble d'objets vérifiant la condition de l'algorithme de prédicate. Prédicate est bien sûr défini au moment de l'appel de la méthode. Cela permet de généraliser une partie des algorithmes. Voyons un exemple dans une classe :
public class AClass { public static void CallWhere() { Customer[] customers = new Customer[] { new Customer(), new Customer(), new Customer(), new Customer() }; IEnumerable<Customer> parisian = customers.Where(IsParisian); } // Signature définie par le delegate public static bool IsParisian(Customer c) { return c.City.Equals("Paris") && c.Country("France") ; } }
Comme notre algorithme est très simple, allégeons encore la syntaxe avec un delegate anonyme :
public class AClass { public static void CallWhere() { Customer[] customers = new Customer[] { new Customer(), new Customer(), new Customer(), new Customer() }; IEnumerable<Customer> parisian = customers // On respecte la signature mais inutile de créer une méthode pour ça .Where(delegate(Customers c) { return c.City.Equals("Paris") && c.Country("France"); }); } }
Les expressions lambda
Les expression lambda sont une représentation à "runtime" des AST. A quoi ça sert ? Les expressions lambda permettent de choisir, au moment de l'exécution, comment transformer notre expression. Par exemple, une requête LINQ sur une base de données sera facilement traduite en requête SQL. Une requête LINQ sur un objet issu d'ORM sera également traduite en requête SQL. Pourquoi ce choix ? Les performances :
- Optimisation de requête
- Système de cache
- Le plus important, éviter les aberrations :
var parisanCustomers = customers .Where(c => c.City == "Paris" && c.Country == "France") .Select(delegate(Customer c) { return new { c.CompanyName } ; });
Si customers est un objet permettant les requêtes en base, avec les delegates on aurait "SELECT * FROM customers" et une boucle locale. Pourquoi se passer des bénéfices d'une base de données ? Pour cela, on a une nouvelle signature pour la méthode Where. Cette methode prend en paramètre un objet Expression qui retourne un booléen. Cette expression est ensuite traduite à "runtime" en requête SQL.
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> enumerable , Expression<Func<TSource, bool>> expr);
La syntaxe d'une expression lambda est simple : ( [Type1] arg1, [Type2] arg2, …, [Typen] argn ) => expression. Les types des arguments peuvent être inférés. Voici un exemple d'utilisation dans une méthode LINQ puis dans une requête :
var parisanCustomers = customers .Where(c => c.City.Equals("Paris") && c.Country.Equals("France")) var parisanCustomers = from c in customers where c.City.Equals("Paris") && c.Country.Equals("France")
Comment choisir ?
Les signatures à fournir sont à la discretion du développeur qui implémente un "LINQ provider". Le choix doit toujours se faire en prévilègiant les performances. Cependant, il est préférable de toujours utiliser les expressions lambda car elles sont facilement convertibles en delegates. Le code sera plus uniforme et plus évolutif.