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 tous 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
{
// Fields
[CompilerGenerated]
private
string
<
Nom>
k__BackingField;
[CompilerGenerated]
private
string
<
Prenom>
k__BackingField;
// Properties
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>
{
// Fields
[DebuggerBrowsable(
0
)]
private
readonly
<
Nom>
j__TPar <
Nom>
i__Field;
[DebuggerBrowsable(
0
)]
private
readonly
<
Prenom>
j__TPar <
Prenom>
i__Field;
// Methods
[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
(
);
// Properties
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)
{
// Ajouter une logique de validation
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)
{
// Faire ici un traitement
}
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
(
/* Ajouter ici les paramètres */
);
}
}
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.-à-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)
{
// La liste n'est pas recopiée pour la lisibilité
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▲
À partir de maintenant, je vais considérer que toi, lecteur, as 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 :
// Visual Studio et le compilateur savent que prodVar est un Produit
var
prodVar =
new
Produit
(
);
// Visual Studio et le compilateur ne savent pas que prodObject est un 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 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 pseudocode l'instruction de la ligne suivante en se servant des choses que nous avons apprises précédemment. Nous pouvons obtenir quelque chose 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 pseudocode) 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 pseudocode) 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. À 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 pseudocode 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.
VII. 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 rendu 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.