LINQ to Objects : l'envers du décor
Par
Johann Blais (Mon espace personnel)
Cet article a pour objectif de lever le voile sur LINQ to Objects, sur son fonctionnement interne et sur les nouveautés de C# 3.0 nécessaires à son utilisation. Cet article est le premier d'une série dont le thème sera l'implémentation des différentes composantes de LINQ (LINQ to Objects, LINQ to SQL, etc.).
I. Prérequis
II. Introduction
III. LINQ
III-A. Pensées abstraites
III-B. Implications et conséquences dans le "monde réel"
IV. Les nouveautés du langage C# 3.0
IV-A. Les propriétés auto-implémentées
IV-B. Les initialiseurs d'objets et de collections
IV-C. Les types anonymes
IV-D. Les méthodes d'extensions
IV-E. Les lambda expressions
V. IEnumerable<T> et LINQ to Objects
V-A. Définition de la requête LINQ analysée
V-B. Une première requête simple
V-C. Une requête plus complexe
V-D. Pour aller plus loin
VI. Pour récapituler
VI. Conclusion
VIII. Remerciements
I. Prérequis
Cet article n'est pas destiné à des débutants en .NET 3.5. Je pars du principe que le lecteur est un
développeur .NET 3.5 (pas forcément confirmé), mais simplement familier avec l'écriture de lambda
expressions et quelques nouveautés du langage C# 3.0. L'élément principal est que le lecteur
doit être intéressé par la découverte des rouages de LINQ et du travail réalisé par le compilateur
C# 3.0.
II. Introduction
La sortie du framework .NET 3.5 a apporté de nombreuses améliorations et nouveautés. La principale nouveauté
concerne une technologie nommée LINQ. L'objectif de cet article est de vous présenter une des variantes
de LINQ dédiée à la gestion des collections d'objets : LINQ to Objects. La première partie vous permettra
de découvrir les motivations et les concepts nouveaux qui se cachent derrière LINQ de manière générale.
Nous passerons ensuite en revue les différentes nouveautés de C# qui ont rendu possible
LINQ. Nous nous concentrerons enfin sur LINQ to Objects et spécifiquement sur l'interface IEnumerable<T>
avant de conclure.
III. LINQ
III-A. Pensées abstraites
LINQ est le fruit de plusieurs années de recherche dans les laboratoires de développement de Microsoft.
La motivation principale derrière LINQ est de fournir une passerelle entre le monde des données
et le monde des objets. Le monde des données est l'ensemble des représentations physiques des données
que nous connaissons. Il s'agit par exemple de fichiers XML, de bases de données, etc. Le monde
des objets est notre monde à nous, développeurs (programmation orientée objet). C'est le monde des
architectures N-Tiers, MVC, et aussi le monde des entités business, des entités et des classes en
général. Ce monde orienté objet est plus focalisé sur la manipulation des entités. La notion de
sauvegarde/récupération depuis une base (ou un XML) n'est qu'un moyen d'assurer la persistance de
nos entités.
Je désigne par "monde" un ensemble cohérent d'outils et de langages regroupés dans un but commun, par
exemple :
-
Monde des données : base de données + SQL
-
Monde des objets : modèle objet + environnement de développement + langage orienté objet (C#)
La question qui nous brûle les lèvres est "Pourquoi vouloir rapprocher ces deux mondes ?". C'est en effet
une question intéressante car nous sommes en présence de deux mondes qui ont finalement peu de choses
en commun. Le monde des données (dont le représentant principal est la base de données) ne conçoit
pas (et donc ne manipule pas) les données de la même façon que le monde des objets. Par exemple,
pour le concept de client, tous les clients sont stockés dans une table, un conteneur global où
l'individualité de la donnée "Client" est noyée dans le concept général d'une unité de stockage
de clients. En revanche, dans le monde des objets que nous manipulons quotidiennement, l'élément
de base se trouverait être le client, et dans ce cas la notion de groupe de clients (fondamentale
précédemment) n'est plus qu'une extension de la notion de client.
Le fait d'avoir ces deux mondes séparés mais tout deux nécessaires à l'élaboration de solutions nous
oblige à inventer des mécanismes pour les lier de la manière la plus simple possible; même si en
apparence opposés, ils sont les éléments indispensables de la création et de la pérennité d'une
solution. Ce caractère indissociable est la base de la réflexion qui nous pousse (et a incité Microsoft)
à essayer de construire des outils permettant de faciliter l'intégration de ces deux mondes au sein
d'une même solution. Cette réflexion n'est d'ailleurs pas née avec LINQ, elle existait bien avant
et avait trouvé dans les frameworks de mapping objet/relationnel une sorte de compromis. Le problème
dans la plupart des frameworks de mapping est leur incapacité à tirer partie des avantages des deux
mondes en orientant leur approche vers l'utilisation des concepts partagés.
La véritable avancée dans le cas de LINQ est d'apporter dans le monde des objets la puissance d'un langage
spécifiquement taillé pour le monde des données. Car c'est ici qu'est le réel progrès, pouvoir depuis
le monde des objets utiliser un langage et une syntaxe tellement proche de son alter-ego (SQL, XPath,
etc.) que la frontière auparavant massive entre les deux est finalement réduite à presque rien.
III-B. Implications et conséquences dans le "monde réel"
Maintenant que nous avons présenté dans les grandes lignes les concepts derrière LINQ avec la notion
de "mondes", passons aux manifestations pratiques de l'effacement partiel de la frontière entre
cesdits mondes.
La plus grande avancée de ce point de vue est la quasi-disparition du code SQL pur stocké dans des chaînes
de caractères. C'est important à plusieurs niveaux. Le premier est la suppression d'une source importante
de plantages liés à des erreurs de syntaxe SQL dans le code. Cet exemple est tout aussi valable
pour les données provenant de fichiers XML avec la syntaxe XPath associée.
Deuxièmement, au niveau du débogage, le fait d'avoir un langage interprété par un élément externe nous
oblige à confier une partie de la gestion des erreurs et de la cohérence du programme à cette
entité externe (la base de données, le moteur XPath, etc.).
Citons enfin une dernière raison (il est inutile de faire une liste exhaustive). Chaque source de données
nécessite de devoir apprendre une syntaxe spécifique liée à la manière dont les données sont représentées
dans la source. Nous pouvons citer le langage SQL, XPath, le langage de requêtes pour LDAP, etc.
Cet apprentissage arrive avec son lot d'erreurs qui peuvent entraver la bonne marche d'un projet.
Nous avons donc un peu (beaucoup j'espère) levé le voile sur les concepts et les motivations qui ont
donné naissance à LINQ. Passons maintenant à une partie plus technique à propos des modifications
introduites dans les langages pour rendre LINQ possible.
IV. Les nouveautés du langage C# 3.0
IV-A. Les propriétés auto-implémentées
Les propriétés auto-implémentées permettent de créer des propriétés pour une classe sans devoir gérer
l'attribut associé à la main. En effet, ce dernier est automatiquement géré par le compilateur C#.
Cela permet d'écrire ce genre de choses :
public class Client
{
public String Nom { get; set; }
public String Prenom { get; set; }
}
|
Nous gagnons ainsi de nombreuses lignes de codes répétitives pour nous concentrer sur le reste de l'application.
En utilisant Reflector pour analyser le code de l'assembly, voici ce que le compilateur a généré
pour nous :
public class Client
{
[CompilerGenerated]
private string <Nom>k__BackingField;
[CompilerGenerated]
private string <Prenom>k__BackingField;
public string Nom
{
[CompilerGenerated]
get
{
return this.<Nom>k__BackingField;
}
[CompilerGenerated]
set
{
this.<Nom>k__BackingField = value;
}
}
public string Prenom
{
[CompilerGenerated]
get
{
return this.<Prenom>k__BackingField;
}
[CompilerGenerated]
set
{
this.<Prenom>k__BackingField = value;
}
}
}
|
Nous voyons ici clairement les champs ajoutés à notre classe par le compilateur. C'est le plus simple
et pourtant un des plus importants services que nous rend le compilateur C# 3.0.
Concernant les symboles < et >, ils n'ont pas la signification générique que nous connaissons.
Le compilateur choisit, pour les types générés, des identifiants interdits
en C# pour éviter d'avoir des conflits avec des types existants. Gardez à l'esprit que le code généré
par le compilateur est directement en MSIL et que ce n'est que Reflector qui nous
présente ces identifiants dans un contexte C#.
IV-B. Les initialiseurs d'objets et de collections
Les initialiseurs d'objets (et de collections par extension) sont une facilité offerte (de nouveau par
le compilateur C# 3.0) pour nous permettre de créer des objets de manière plus pratique que l'utilisation
(et la déclaration) de multiples constructeurs pour une classe donnée.
Reprenons notre classe Client définie précédemment. Nous souhaitons lui ajouter la possibilité d'affecter
le nom et le prénom du client lors de la construction de l'objet. Avant C# 3.0, la seule solution
était de créer un constructeur spécifique prenant en paramètre les deux valeurs nécessaires. Le
problème principal est que cela nous oblige à créer autant de constructeurs qu'il y a d'utilisations
possibles. De plus, nous nous basons sur l'utilisation que nous souhaitons faire de l'objet pour
définir la manière dont la classe est écrite. Plus précisément, nous créons des constructeurs en
fonction des besoins du reste de l'application. Cette dernière contrainte va à l'encontre du principe
de découplage et d'abstraction.
Le compilateur C# 3.0 introduit la syntaxe suivante :
static void Main(string[] args)
{
Client monClient = new Client { Nom = "Blais", Prenom = "Johann" };
}
|
Cette syntaxe nécessite la présence d'un constructeur par défaut dans la classe et nous permet d'initialiser
directement les propriétés souhaitées lors de la construction de l'objet. Voici par exemple le code
généré automatiquement par le compilateur :
private static void Main(string[] args)
{
Client <>g__initLocal0 = new Client();
<>g__initLocal0.Nom = "Blais";
<>g__initLocal0.Prenom = "Johann";
Client monClient = <>g__initLocal0;
}
|
Le compilateur crée une instance vide de Client en utilisant le constructeur par défaut, puis initialise
ses propriétés avec les valeurs souhaitées et enfin, affecte cette instance à notre variable monClient.
Cette étape d'objet temporaire permet de s'assurer de l'atomicité de la construction de la variable
monClient. Les propriétés non affectées sont automatiquement initialisées avec la valeur par défaut
de leur type (comme dans un constructeur classique).
Les initialiseurs de collections fonctionnent exactement de la même manière et permettent de définir
directement la collection des éléments lors de la construction de celle-ci.
IV-C. Les types anonymes
var maVariable = new { Nom = "Blais", Prenom = "Johann" };
|
Nous utilisons le mot-clé var (nouveauté du C# 3.0) pour ne pas avoir à spécifier le type d'une
variable, laissant le compilateur "deviner" le type lors de la compilation. Cette notion de divination
s'appelle l'inférence de type. La ligne de code présentée crée donc un nouvel objet dont le type
n'est pas précisé, mais qui contient une propriété Nom et Prenom. Malgré le fait que cet objet contienne
les mêmes propriétés que la classe Client, cet objet n'est pas de type Client. En fait, ce type
anonyme n'est anonyme que pour le développeur. Le compilateur C# va créer un type avec un nom connu
de lui-seul pour ce type anonyme. Voici par exemple le code généré par le compilateur pour la ligne
de code précédente :
var maVariable;
maVariable = new <>f__AnonymousType0<string, string>("Blais", "Johann");
|
Nous voyons ici que le compilateur a généré une classe générique nommée <>f__AnonymousType0. Voici
la déclaration de cette classe :
[DebuggerDisplay(@"\{ Nom = {Nom}, Prenom = {Prenom} }", Type="<Anonymous Type>"), CompilerGenerated]
internal sealed class <>f__AnonymousType0<<Nom>j__TPar, <Prenom>j__TPar>
{
[DebuggerBrowsable(0)]
private readonly <Nom>j__TPar <Nom>i__Field;
[DebuggerBrowsable(0)]
private readonly <Prenom>j__TPar <Prenom>i__Field;
[DebuggerHidden]
public <>f__AnonymousType0(<Nom>j__TPar Nom, <Prenom>j__TPar Prenom);
[DebuggerHidden]
public override bool Equals(object value);
[DebuggerHidden]
public override int GetHashCode();
[DebuggerHidden]
public override string ToString();
public <Nom>j__TPar Nom { get; }
public <Prenom>j__TPar Prenom { get; }
}
|
Pour des raisons de lisibilité, le code des méthodes n'est pas montré ici. Nous pouvons cependant voir
que notre classe possède bien deux propriétés telles que nous les avions nommées, et que, cerise
sur le gâteau, le compilateur nous a même généré un attribut DebuggerDisplay qui affiche les valeurs
de ces deux propriétés ! Pour des explications sur cet attribut, vous pouvez vous référer à cet
article.
 |
Pour voir ce code à partir de Reflector, il faut désactiver les optimisations sous peine de voir exactement
la même ligne de code qu'au début. Pour cela, il suffit d'aller dans les options de Reflector et
de sélectionner dans la liste "Optimization:" la valeur None.
|
IV-D. Les méthodes d'extensions
Les méthodes d'extensions de C# 3.0 permettent d'ajouter des méthodes à un type défini sans pour autant
modifier la définition même dudit type. Prenons l'exemple de notre classe Client, et décidons pour
une raison non précisée que nous souhaitons lui ajouter une méthode Valider() sans pour autant modifier
la déclaration même de l'objet Client. Voici donc comment déclarer une telle méthode :
static class ClientExtensionsDemo
{
public static bool Valider(this Client client)
{
return false;
}
}
|
La première contrainte est que la méthode d'extension doit être placée dans une classe statique non générique.
Le nom de la classe n'a pas d'importance pour le développeur (si ce n'est la cohérence), car il
n'est pas utilisé dans le code (hormis par le compilateur). Une méthode d'extension est une méthode
statique dont le premier paramètre définit le type des objets sur lequel cette méthode va s'appliquer.
Cette contrainte est renforcée par l'utilisation du mot-clé this dans une signification différente
de celle à laquelle nous sommes habitués. L'ensemble de ces contraintes rend éligible notre méthode
en tant que méthode d'extension. Certains d'entre vous auront déjà compris comment le compilateur
nous permet d'écrire maintenant ce genre de chose :
Client monClient = new Client { Nom = "Blais", Prenom = "Johann" };
monClient.Valider();
|
Nous appelons la méthode Valider() sur la variable monClient exactement de la même manière que s'il s'agissait
d'une méthode propre de Client. Voici le travail réalisé par le compilateur concernant cette syntaxe
:
private: static void __gc* Main(String __gc* args __gc [])
{
Client __gc* monClient;
Client __gc* <>g__initLocal0;
<>g__initLocal0 = __gc new Client();
<>g__initLocal0->Nom = S"Blais";
<>g__initLocal0->Prenom = S"Johann";
monClient = <>g__initLocal0;
ClientExtensionsDemo::Valider(monClient);
return;
}
|
La section de code précédente est en C++, car pour une raison inconnue, Reflector refuse de désactiver
l'optimisation pour mon exemple en C# et continue d'afficher monClient.Valider(). Je considère ceci
comme un bug de Reflector. Le code nous permet tout de même de voir que l'appel a été transformé
en un appel de la méthode statique avec passage de monClient comme paramètre de cette méthode.
Ces méthodes d'extensions permettent aussi de définir des comportements pour des entités abstraites (comme
les interfaces par exemple) sans devoir les définir dans chaque classe qui implémente cette interface.
Voici par exemple une méthode qui pourrait être appelée sur tout objet qui implémente IList :
public static void Action(this IList list)
{
}
|
Nous venons donc de vérifier que les méthodes d'extensions ne sont qu'une facilité d'écriture offerte
par le compilateur et que ce n'est en aucun cas le framework .NET, ni même le CLR qui est capable
de gérer cette nouvelle écriture.
IV-E. Les lambda expressions
Les lambda expressions sont une grande nouveauté en C# 3.0, mais sont bien connues des développeurs du
domaine de la programmation fonctionnelle. L'idée derrière l'introduction des lambda expressions est
d'éloigner le langage C# de l'écriture pure et simple d'instructions au profit de l'expression d'un
but, d'un objectif. Pour être plus clair, il s'agit en fait de permettre au développeur d'exprimer
une intention (un objectif) plutôt que de spécifier la manière d'atteindre ce but, et donc de laisser
le compilateur interpréter l'intention pour la transformer en instructions telles que nous avons
l'habitude de les écrire. Pour rendre tout cela un peu plus parlant, prenons un exemple simple et
faisons le évoluer depuis C# 1.0 jusqu'à C# 3.0.
Notre objectif dans cet exemple est de filtrer une liste de produits pour ne sélectionner que ceux dont
le nom commence par P. Pour faciliter l'écriture de l'exemple et améliorer la lisibilité, nous nous
contenterons de faire évoluer la méthode de filtrage uniquement et utiliserons les possibilités
de C# 3.0 pour toutes les parties non critiques de l'exemple. Voici le contexte avant filtrage :
class Program
{
static void Main(string[] args)
{
#region Liste
List<Produit> mesProduits = new List<Produit> {
new Produit { Nom = "Pinceau", Categorie = "Bricolage" },
new Produit { Nom = "Seau", Categorie = "Jardinage" },
new Produit { Nom = "Pelle", Categorie = "Jardinage" },
new Produit { Nom = "Ciseaux", Categorie = "Décoration" },
new Produit { Nom = "Tournevis", Categorie = "Bricolage" },
new Produit { Nom = "Feutre", Categorie = "Décoration" },
new Produit { Nom = "Peinture", Categorie = "Décoration" }
};
#endregion
List<Produit> listeFiltree = mesProduits.FindAll();
}
}
class Produit
{
public String Nom { get; set; }
public String Categorie { get; set; }
}
|
Nous avons créé une liste de Produit(s) que nous allons maintenant filtrer en utilisant la méthode FindAll
de la classe List<T>.
Première méthode façon C# 1.0, il nous faut passer en paramètre de la méthode FindAll un prédicat de
recherche. Nous créons donc un prédicat, c-a-d une méthode statique acceptant un Produit en paramètre
et renvoyant un booléen symbolisant la conformité par rapport au prédicat. Voici maintenant
le code associé :
class Program
{
static void Main(string[] args)
{
List<Produit> listeFiltree = mesProduits.FindAll(FiltrerProduit);
}
static bool FiltrerProduit(Produit p)
{
return p.Nom.StartsWith("P");
}
}
|
Ce code a un inconvénient majeur. Nous sommes obligés d'introduire une nouvelle méthode statique uniquement
pour filtrer les données de la liste, et sommes en présence d'une syntaxe extrèmement lourde et
non intuitive. Heureusement C# 2.0 arrive à la rescousse.
En C# 2.0, nous avons à notre disposition le concept de délégué anonyme (aussi appelé méthode anonyme).
Améliorons l'exemple précédent grâce à cette nouvelle notion :
List<Produit> listeFiltree = mesProduits.FindAll(delegate(Produit p) { return p.Nom.StartsWith("P"); });
|
Nous avons pu nous débarrasser de la méthode déclarée à l'extérieur de notre Main. C'est un progrès indéniable
qui est cependant un peu gâché par la lourdeur (encore une fois) de la syntaxe associée à l'écriture
de ce délégué anonyme (sans tenir compte non plus de l'aspect déroutant de la construction
pour un développeur débutant).
C'est à ce moment-là que la notion de lambda expression vient à notre secours en C# 3.0 et va nous permettre
de définir notre intention ("filtrer la liste") sans devoir nous préoccuper de toute la syntaxe
(plomberie) associée. Voici comment écrire une telle expression :
List<Produit> listeFiltree = mesProduits.FindAll(p => p.Nom.StartsWith("P"));
|
La ligne précédente est d'une grande clarté et nous amène à nous demander l'utilité de toute la lourdeur
précédente. Cette expression est d'une évidence telle qu'elle s'impose de manière naturelle. Voici
comment lire cette expression : "Pour tous les produits, donne moi ceux dont le nom commence par
P". Ici, plus de delegate, de static, de bool, ce que nous écrivons est ce que nous voulons faire.
Et tout cela grâce encore à ce que nous nommons l'inférence et aux lambda expressions. Nous laissons
ici le soin au compilateur de faire ce qu'il veut avec notre expression, pour autant que notre besoin
soit satisfait, peu importe la manière tant que l'objectif de filtrage est atteint.
Cet exemple n'est qu'une infime levée du voile sur la puissance que vous pouvez tirer de ces expressions.
Nous avons passé en revue différentes nouveautés de C# 3.0. Il est maintenant temps de voir comment les
pièces se mettent en place dans le grand terrain de jeu que constitue LINQ to Objects.
V. IEnumerable<T> et LINQ to Objects
 |
A partir de maintenant, je vais considérer que toi, lecteur, a déjà utilisé LINQ to Objects et vais me
concentrer sur le code généré par le compilateur à partir d'une requête LINQ simple qui servira de
fil rouge pour les explications.
|
V-A. Définition de la requête LINQ analysée
Avant de rentrer dans le vif du sujet, nous allons définir l'exemple qui va nous servir de base pour
la suite de la plongée dans les tréfonds de LINQ to Objects. Nous utiliserons notre classe Produit
définie précédemment avec une liste de Produit(s) qui nous servira de base pour les requêtes LINQ.
class Program
{
static List<Produit> mesProduits = new List<Produit> {
new Produit { Nom = "Pinceau", Categorie = "Bricolage" },
new Produit { Nom = "Seau", Categorie = "Jardinage" },
new Produit { Nom = "Pelle", Categorie = "Jardinage" },
new Produit { Nom = "Ciseaux", Categorie = "Decoration" },
new Produit { Nom = "Tournevis", Categorie = "Bricolage" },
new Produit { Nom = "Feutre", Categorie = "Decoration" },
new Produit { Nom = "Peinture", Categorie = "Decoration" }
};
static void Main(string[] args)
{
}
}
class Produit
{
public String Nom { get; set; }
public String Categorie { get; set; }
}
|
Le code précédent définit une liste de Produit(s) sur laquelle nous allons tester nos requêtes LINQ et
voir comment ces dernières sont interprétées par le compilateur.
V-B. Une première requête simple
Commençons par une requête simple qui va nous permettre de rentrer sans avoir (trop) peur dans le code
généré. Nous allons simplement récupérer les noms des produits contenus dans la liste.
static void Main(string[] args)
{
var liste = from prod in mesProduits
select prod.Nom;
}
|
Notre variable liste va donc contenir la liste des noms des différents produits. Voici maintenant
le code que le compilateur a généré pour nous :
private static void Main(string[] args)
{
IEnumerable<string> liste;
liste = Enumerable.Select<Produit, string>(mesProduits,
(CS$<>9__CachedAnonymousMethodDelegate1 != null) ?
CS$<>9__CachedAnonymousMethodDelegate1 :
(CS$<>9__CachedAnonymousMethodDelegate1 = new Func<Produit, string>(Program.<Main>b__0)));
return;
}
|
 |
En fait, il ne s'agit pas exactement du code fourni par Reflector, j'ai modifié à la main le code renvoyé
pour qu'il fasse apparaître la méthode d'extension Select. Il s'agit du même bug que lors
de la précédente explication des méthodes d'extensions.
|
La première instruction est la déclaration d'une variable de type IEnumerable<string> nommée liste.
Il s'agit de la même variable que nous avions déclarée avec le mot-clé var précédemment.
C'est une preuve supplémentaire (pour ceux qui ne me croyaient pas) que le compilateur va déduire
le type de la variable liste automatiquement à partir du code situé après le signe =.
C'est ce que nous appelons (pour rappel) l'inférence de type. Pour le compilateur liste a
un type bien précis, même si pour nous, le mot-clé var masque cette notion. Il ne faut cependant
pas confondre une référence de type object avec une référence déclarée avec var. En
utilisant var, nous demandons au compilateur de trouver lui-même le type de la variable.
En utilisant object, nous demandons au compilateur de traiter la variable comme une référence
générique (non typée explicitement). Une preuve supplémentaire est que l'Intellisense de Visual
Studio est capable de reconnaître le type d'une variable déclarée avec var, alors qu'il n'était
pas en mesure de reconnaître le type initial d'une variable référencée avec un type object.
Exemple :
var prodVar = new Produit();
object prodObject = new Produit();
|
Voila pour la clarification du mot-clé var. Expliquons maintenant la deuxième instruction beaucoup
plus impressionnante à première vue. Déchiffrons l'instruction dans l'ordre de lecture.
Le premier élément rencontré est Enumerable.Select<Produit, string>(...), il s'agit de la
transformation de l'instruction select par le compilateur. Select<TSource, TResult>
est une méthode d'extension définie dans la classe Enumerable introduite avec .NET 3.5. Cette
méthode est applicable sur tous les objets de type IEnumerable<TSource> et voici sa
déclaration exacte :
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, TResult> selector
)
|
Cette méthode prend donc en paramètre un IEnumerable<TSource> (la cible de la méthode d'extension)
et une variable de type Func<TSource, TResult>. Le premier paramètre passé dans l'instruction
Select est la collection de Produit(s) sur laquelle est exécutée la requête. Dans notre cas,
il s'agit de mesProduits. Le deuxième paramètre de type Func est un délégué générique
(ajouté dans le framework 3.5). Il représente une méthode qui prend en paramètre un objet de type
TSource et renvoie un objet de type TResult. Cela nous permet de passer en paramètre
de Select n'importe quelle méthode respectant le prototype que nous venons de citer. Ce Func passé
en paramètre est nommé selector dans la déclaration de Select, et il porte bien son nom. Il s'agit
en fait de la méthode qui, pour chaque objet Produit, va renvoyer son nom. Ce Func est donc finalement
la transformation par le compilateur de la partie prod.Nom dans le code suivant :
static void Main(string[] args)
{
var liste = from prod in mesProduits
select prod.Nom;
}
|
Nous venons de voir que les méthodes d'extensions s'appliquent sur un objet de type IEnumerable<T>.
Cela nous laisse présager de très grandes possibilités pour LINQ to Objects, car à partir du moment
où nous avons un IEnumerable sous la main, nous pouvons utiliser toute la puissance de LINQ
pour démarrer nos traitements.
Il reste maintenant à expliquer la construction un peu barbare à base de CS$<>9__CachedAnonymousMethodDelegate1.
Cette construction ternaire est l'initialisation du paramètre Func décrit juste avant. L'idée est
de stocker dans un délégué l'adresse de la méthode que l'on passe en deuxième paramètre de Select.
Cela évite de recréer le délégué à chaque fois. Il ne reste plus qu'à retrouver quelle méthode est
effectivement pointée par ce délégué. Pour cela, nous suivons l'instruction ternaire jusqu'à la
fin pour trouver ceci :
(CS$<>9__CachedAnonymousMethodDelegate1 = new Func<Produit, string>(Program.<Main>b__0))
|
Cette instruction est l'initialisation du délégué avec l'adresse de la méthode nommée Program.<Main>b__0.
Reflector nous donne (une fois de plus) un coup de main et nous dit :
[CompilerGenerated]
private static string <Main>b__0(Produit prod)
{
return prod.Nom;
}
|
Voici donc le sélecteur passé en paramètre de Select, et qui nous permet de "transformer" un Produit
en une chaîne de caractères (ici le nom du Produit). L'attribut [CompilerGenerated] nous rappelle
une fois de plus que ce code est généré par le compilateur à partir de notre select prod.Nom.
Nous avons donc maintenant une toute petite idée du travail réalisé par le compilateur pour nous simplifier
la vie. Nous savons maintenant aussi que les mots-clés var, from et select
ne sont que des raccourcis d'écriture fournis par le compilateur et que ceux-ci sont transformés
en appels de méthodes pour pouvoir être interprétés par le CLR (à l'exécution).
 |
Les esprits attentifs auront même remarqué une très jolie subtilité du compilateur. Le mot-clé select
est transformé en un appel de méthode d'extension mesClients.Select(...), appel transformé
ensuite en appel de la méthode statique Enumerable<Produit>.Select(mesClients, ...). Qui a
dit qu'en .NET nous ne faisions pas de belles choses ?
|
Nous venons à peine de gratter la surface du vernis syntaxique qu'est LINQ to Objects. Pour ceux qui
ont envie de voir avec moi quelles sont les merveilles de conception qui se cachent encore dans
LINQ to Objects, cette prochaine partie devrait vous intéresser.
V-C. Une requête plus complexe
L'exemple précédent présentait une requête LINQ très simple, peu représentative d'une requête que nous
sommes susceptibles d'écrire dans le cadre d'un projet réel. Tâchons maintenant de trouver un exemple
plus complexe permettant de présenter d'autres concepts de LINQ to Objects. Voici par exemple un
code un peu plus utile :
class Program
{
static List<Produit> mesProduits = new List<Produit> {
new Produit { Numero = 1, Nom = "Pinceau", Categorie = "Bricolage" },
new Produit { Numero = 2, Nom = "Seau", Categorie = "Jardinage" },
new Produit { Numero = 3, Nom = "Pelle", Categorie = "Jardinage" },
new Produit { Numero = 4, Nom = "Ciseaux", Categorie = "Decoration" },
new Produit { Numero = 5, Nom = "Tournevis", Categorie = "Bricolage" },
new Produit { Numero = 6, Nom = "Feutre", Categorie = "Decoration" },
new Produit { Numero = 7, Nom = "Peinture", Categorie = "Decoration" }
};
static void Main(string[] args)
{
var liste = from prod in mesProduits
where prod.Categorie == "Bricolage"
select new { Nom = prod.Nom, CodeProduit = prod.Categorie[0] + "-" + prod.Numero.ToString("00000") };
}
}
class Produit
{
public String Nom { get; set; }
public String Categorie { get; set; }
public Int32 Numero { get; set; }
}
|
Cette requête n'est pas vraiment complexe mais elle introduit la notion de filtrage sur les données.
C'est un concept souvent rencontré dans le cas d'une fonctionnalité de recherche de produits. Nous
en profitons au passage pour étendre un peu les données des produits afin de faire des traitements
plus intéressants. La requête recherche donc les produits de la catégorie bricolage et renvoie
le nom du produit ainsi que le code produit. Le code produit est une donnée constituée de l'initiale
de la catégorie et du numéro de produit (sur 5 chiffres) séparés par un tiret. Passons sans plus
attendre à l'analyse du code généré.
private static void Main(string[] args)
{
IEnumerable<<>f__AnonymousType0<string, string>> liste;
liste = Enumerable.Select<Produit, <>f__AnonymousType0<string, string>>(
Enumerable<Produit>.Where(
mesProduits,
((CS$<>9__CachedAnonymousMethodDelegate2 != null)
? CS$<>9__CachedAnonymousMethodDelegate2
: (CS$<>9__CachedAnonymousMethodDelegate2 = new Func<Produit, bool>(Program.<Main>b__0)))),
((CS$<>9__CachedAnonymousMethodDelegate3 != null)
? CS$<>9__CachedAnonymousMethodDelegate3
: (CS$<>9__CachedAnonymousMethodDelegate3 = new Func<Produit, <>f__AnonymousType0<string, string>>(Program.<Main>b__1)));
return;
}
|
Le code a été reformaté et ré-organisé de manière à faire apparaître les appels de méthodes d'extensions.
Comme pour l'exemple précédent, essayons de déchiffrer ce code étape par étape.
Prenons d'abord la déclaration de la variable liste. Sous des aspects un peu repoussants, vous
pouvez reconnaître la construction nommée <>f__AnonymousType0. En effet, si vous vous
référez à la section concernant les types anonymes, vous remarquerez qu'il s'agit de la marque de
fabrique d'un type généré par le compilateur à partir des données d'un type anonyme. Je ne donnerai
pas la déclaration complète de ce type, en recopiant l'exemple, et en utilisant Reflector, vous
pourrez la voir vous-même. Il vous suffit simplement de garder à l'esprit qu'il s'agit de la signature
d'un type anonyme (il est d'ailleurs très similaire à celui de la section III-C). Pour en
revenir à la variable liste, nous pouvons en déduire que cet IEnumerable est destiné
à contenir une liste des objets anonymes définis par le code suivant :
new { Nom = prod.Nom, CodeProduit = prod.Categorie[0] + "-" + prod.Numero.ToString("00000") }
|
Essayons maintenant de réécrire en pseudo-code l'instruction de la ligne suivante en se servant des choses
que nous avons apprises précédemment. Nous pouvons obtenir quelquechose qui ressemble à ceci :
liste = Enumerable.Select<Produit, typeAnonyme>(Enumerable<Produit>.Where(mesProduits,
delegate1),
delegate2);
|
En remplaçant les opérateurs ternaires associés aux délégués, nous obtenons un code bien plus lisible.
Nous conservons donc un appel à la méthode d'extension Enumerable.Select qui prend en paramètre
un IEnumerable<Produit> et un délégué (le sélecteur). Il s'agit donc de la même construction
Select que dans l'exemple précédent. Le premier paramètre passé au Select est la valeur de retour
de la méthode d'extension Enumerable.Where. Cette dernière prend en paramètre un IEnumerable<Produit>
et un délégué chargé de filtrer les données (du premier paramètre). La première chose qui nous vient
à l'esprit est que les appels sont chaînés dans le sens contraire de celui dans lequel nous les
avons écrits au départ. Le compilateur nous permet donc d'écrire les instructions dans un ordre
qui nous est naturel, et se charge de faire la transformation en une suite d'instructions conformes
à notre intention.
 |
La structure des appels chaînés, les uns prenant comme paramètre d'entrée la sortie des appels précédents,
est un concept bien connu. Il s'agit du design pattern Pipeline. Ce modèle permet de décrire
des systèmes qui se comportent comme des chaînes d'instructions. Il est alors possible d'insérer
de nouveaux maillons dans la chaîne sans modifier la structure du programme. Dans le cas de LINQ
to Objects, un maillon est une méthode qui attend un IEnumerable<T> comme premier paramètre,
et qui renvoie un IEnumerable<T> pour pouvoir être chaîné avec les autres maillons.
|
Il ne nous reste plus qu'à examiner les deux délégués que nous avons évoqués. Le premier (nommé delegate1
dans le pseudo-code) est utilisé pour déterminer si un élément de type Produit correspond à notre
condition de sélection. Ce délégué est la transcription de ce code :
prod.Categorie == "Bricolage"
|
Voici le code généré par le compilateur pour ce délégué (initialisé avec l'adresse de la méthode <Main>b__0)
:
[CompilerGenerated]
private static bool <Main>b__0(Produit prod)
{
return (prod.Categorie == "Bricolage");
}
|
Le second délégué (nommé delegate2 dans le pseudo-code) est une référence vers la méthode chargée de
transformer un objet de type Produit en un objet de type "anonyme" ou plus exactement de type <>f__AnonymousType0<string,
string>. Ce délégué est initialisé avec l'adresse de la méthode <Main>b__1 dont
voici le code :
[CompilerGenerated]
private static <>f__AnonymousType0<string, string> <Main>b__1(Produit prod)
{
return new <>f__AnonymousType0<string, string>(prod.Nom, ((char) prod.Categorie[0]) + "-" + prod.Numero.ToString("00000"));
}
|
Cette méthode renvoie bien un objet correspondant à notre type anonyme.
Nous venons donc d'analyser les différentes transformations réalisées par le compilateur sur notre requête.
A partir de maintenant, vous avez la connaissance nécessaire pour analyser à peu près toutes les
requêtes LINQ to Objects.
V-D. Pour aller plus loin
Pour vous prouver qu'une requête LINQ plus complexe n'est pas difficile à analyser, il me suffit de prendre
un exemple un peu plus complexe et de vous montrer le delta qui existe par rapport à la requête
précédente. Pour cela, nous allons utiliser cette requête :
var liste = from prod in mesProduits
where prod.Categorie == "Bricolage"
where prod.Nom.StartsWith("P")
select new { Nom = prod.Nom, CodeProduit = prod.Categorie[0] + "-" + prod.Numero.ToString("00000") };
|
Cette requête ajoute simplement une contrainte de sélection (une clause where) sur la requête précédente.
 |
J'aurais tout aussi bien pu ajouter la contrainte de sélection dans l'instruction where précédente
en utilisant l'opérateur &&.
|
Voici le pseudo-code associé à cette nouvelle instruction LINQ :
liste = Enumerable.Select<Produit, typeAnonyme>(Enumerable.Where(Enumerable<Produit>.Where(mesProduits,
delegate1),
delegate2),
delegate3);
|
Vous avez de suite saisi le principe, un nouvel appel a été chaîné dans le pipeline (cf V-C). Il
correspond à la nouvelle instruction where. C'est exactement le même principe pour toutes
les autres instructions possibles avec LINQ to Objects : à chaque fois (ou presque), il s'agit
simplement d'ajouter dans le pipeline la méthode d'extension correspondant à l'opérateur utilisé,
et au besoin, de créer la méthode correspondant au Func<> attendu par la méthode d'extension.
Tout ce travail est heureusement réalisé par le compilateur.
VI. Pour récapituler
Nous venons, dans ce premier article, de lever le voile sur le fonctionnement de LINQ to Objects. Nous
savons maintenant que toutes les méthodes d'extensions qui entrent en jeux sont définies dans la
classe Enumerable et que ces méthodes s'appliquent sur l'interface IEnumerable générique.
Ceci nous permet d'utiliser LINQ sur la collection la plus basique possible, IEnumerable.
VI. Conclusion
Cet article démarre la série consacrée à LINQ en général. Nous venons de donner un premier coup de projecteur
sur le travail réalisé par le compilateur C# 3.0, et nous nous sommes de suite rendus compte qu'il
y a un monde entre les possibilités offertes par ce dernier par rapport au compilateur C# 2.0. Nous
aborderons dans les articles suivants les autres moutures de LINQ avec par exemple LINQ to SQL, LINQ
to XML, etc.
VIII. Remerciements
Je tiens à remercier toute l'équipe de Developpez.net pour ses conseils avisés et son soutien et
tout particulièrement
Laurent Dardenne pour
ses nombreuses relectures.


Copyright © 2008 Johann Blais. Aucune reproduction, même partielle, ne peut être faite
de ce site et de l'ensemble de son contenu : textes, documents, images, etc
sans l'autorisation expresse de l'auteur.
Sinon vous encourez selon la loi jusqu'à 3 ans de prison et jusqu'à 300 000 E
de dommages et intérêts. Droits de diffusion permanents accordés à developpez LLC.
Cette page est déposée à la
SACD.