Les génériques

Manipulation des génériques : T, contraintes, default,...

05/07/2011 2075 lectures 2 commentaires 4.5/5 (4 votes)
Les génériques sont une des plus importantes nouveautés en C# 2.0. Ils permettent de résoudre un problème assez ennuyant auquel n’importe quel programmeur a déjà été confronté : la généricité. Imaginez par exemple le code suivant :


Ici, il n’y a rien de bien compliqué. En effet, nous avons juste 2 classes différentes et dans la fonction « Main » de notre application console, nous créons deux listes contenant chacune des objets d’une des deux classes.

Déjà ici, nous sommes confrontés aux génériques. En effet, la classe « List<T> » est une classe générique. C’est une classe représente une liste pouvant contenir n’importe quel type d’objet. Autrement dit, vous pouvez avoir une liste « List<Animal> » ou « List<Etudiant> ». « List » est donc une classe générique représentant une liste d’objet pouvant être de n’importe quel type que ce soit.

C’est à cela que se résume en fait les génériques. Ils permettent de créer des méthodes/classes/interfaces permettant de gérer n’importe quel type de données. L’avantage principal des génériques par rapport à la classe « object » est que si vous voulez gérer des « values types », il n’y aura pas besoin de faire de « boxing/unboxing » en utilisant les génériques, ce qui n’est pas le cas avec la classe « object ». La classe « List » est un bon exemple. En effet, elle va permettre de contenir n’importe quel type de données, que ce soit des « values types » ou des « reference types ».

Nous allons voir comment créer une méthode générique qui sera utilisée dans le programme montré précédemment. Pour ce qui est de la création de classe ou interface générique, nous ne montrerons pas d’exemple car il suffira d’utiliser la même technique que pour les méthodes. Après l’exemple, nous verrons quelques fonctions supplémentaires des génériques.

Le but de la méthode que nous allons développer ici sera en fait la même que la méthode « Where » de « LINQ ». Comme vous le savez sans doute, la fonction « Where » de « LINQ » vous permet de retrouver des objets dans une collection selon un critère bien précis. Nous allons donc imaginer que nous ne possédons pas « LINQ » et que nous voulons une méthode faisant la même chose, nous allons donc la développer. Pour être sûr de bien comprendre le reste de ce cours, nous vous conseillons de lire le cours sur les lambda expression, Func et Action ainsi que sur les méthodes d'extension.

Tout d’abord, pour être sûr de ne pas utiliser le « Where » de « LINQ », commencez par supprimer la clause :



La méthode « Where » est en fait une méthode d’extension de « IEnumerable<T> ». C’est de cette interface que toutes les listes génériques de C# héritent. Notre méthode « Where » sera donc une méthode d’extension de cette interface. Pour ce faire, créez une classe statique et déclarez votre méthode :



Au premier coup d’œil, cette fonction peut sembler incompréhensible, vous allez vite voir qu’en fait, c’est très simple. Commençons tout d’abord par le type de retour. L’interface « IEnumerable<T> » est une interface générique. C’est en fait le « <T> » qui indique que cette interface est générique. En mettant un type entre chevrons, nous indiquons que nous allons utiliser la généricité. Il faut savoir que « T » est juste une question de notation. En effet, vous pourriez taper « MyType », cela serait également correct, mais généralement, nous utilisons « T » ou « T1, T2, T3,... » quand il y a plusieurs types et « TResult » pour les type de retour génériques.

Pour en revenir à la fonction « Where », l’important est que celle-ci doit pouvoir être appelée sur n’importe quelle collection d’objet et qu’elle doit pouvoir gérer n’importe quel type d’objet. Autrement dit, elle doit être capable de retrouver des objets dans une collection, que celle-ci contienne des nombres, des objets de type « Etudiant » ou « Animal ».

Quand nous allons appeler cette méthode sur une collection d’objet de type « Animal », c’est une autre collection d’objet de type « Animal » qui sera renvoyée. Effectivement, si nous voulons récupérer tous les animaux à 4 pattes dans une liste d’animaux, au final, c’est une liste d’animaux que nous récupérerons (ceux qui ont 4 pattes). Donc, le type de retour de la méthode sera « IEnumerable<T> ».

Vient ensuite le nom de la méthode « Where ». Il faut savoir qu’une fonction va pouvoir gérer plusieurs types génériques, il fallait donc un endroit permettant d’indiquer à la méthode tous les types génériques qu’elle va devoir gérer (c'est également ainsi que nous spécifierons explicitement l'inférence, nous verrons cela plus tard). Cet endroit se trouve en fait derrière le nom de la méthode. Il suffit d’indiquer entre chevrons le nom de tous les types génériques que pourra exploiter votre méthode. Pour l’instant, si vous ne comprenez pas, tapez « <T> » et ne vous inquiétez pas trop, vous comprendrez mieux lorsque l’on créera la fonction « Select » de « LINQ ».

Comme nous voulons ajouter notre méthode à n’importe quelle collection générique de C#, le premier argument de la méthode sera précédé de « this » et sera du type « IEnumerable<T> ». Le deuxième argument, lui, sera un « delegate » prenant un objet de type « T » comme seul paramètre et qui renverra une valeur booléenne.

En fait, notre fonction va devoir parcourir tous les objets de la collection, tester une condition sur chacun de ces objets et les inclure dans le résultat renvoyé si la condition est respectée. Cette condition sera contenue dans le « delegate Func ». Cette condition va donc devoir tester un objet du type des objets contenus dans la collection. Donc, si nous avons une collection de type « IEnumerable<T> », les objets contenus dans celle-ci seront du type « T ». C’est donc pourquoi le paramètre du « delegate » est du type « T ». Voici maintenant le code de cette méthode :


Nous parcourons donc tous les objets de la collection grâce à une boucle « foreach ». Vous pouvez remarquer que chaque objet est placé dans un objet nommé « obj » de la classe « T ». Nous le répétons, cela vient simplement du fait que la collection est de type « IEnumerable<T> » et contient donc des objets de type « T ». Nous testons ensuite le prédicat sur l’objet parcouru et s’il est respecté, nous ajoutons l’objet à la collection renvoyée.

Au final, dans votre méthode « Main », cette fonction pourra être utilisée comme ceci :


Vous pouvez donc récupérer des objets dans une collection selon un critère sans même vous inquiéter du type des objets contenus dans la collection. Ici, une chose importante à remarquer est l’inférence des types. En effet, lorsque vous faites un « Where » sur la liste « etudiants », il est intéressant de voir que l’IntelliSense de Visual Studio sait tout de suite que vous allez effectuer des opérations sur des objets de la classe « Etudiant » :

Image


Cela est possible car Visual Studio voit que la liste « etudiants » est une liste générique contenant des objets de la classe « Etudiant », il peut donc dire que le « Func » qui attend un objet de type « T » sera en fait un « Func » attendant un objet de type « Etudiant ». Le type « T » est donc destiné à être remplacé par le type des objets contenus dans la collection, « T » n’est donc qu’un alias.

Pour être sûr de bien comprendre les types génériques des méthodes, nous allons créer également la méthode « Select » de « LINQ ». Cette méthode fonctionne de la même façon que « Where » sauf qu’elle ne renvoie pas forcément un objet du même type que les objets contenus dans la collection. En effet, imaginons notre collection « etudiants ». Nous pourrions vouloir récupérer juste le nom et prénom des étudiants majeurs sans pour autant avoir besoin de leur âge. Dans ce cas, nous traitons des objets de la classe « Etudiant », mais nous récupérons une liste d’objet de type « string ». Mais d’un autre côté, nous pourrions vouloir récupérer uniquement les âges des élèves majeurs. Dans ce cas, nous récupérions une liste d’entiers sur base d’une liste d’étudiant. La fonction « Select » prend donc en entrée une liste générique et renvoie une liste générique dont les objets peuvent être d'un type différent que les objets contenus dans la liste source.

La définition d’une telle méthode est la suivante :


Ceux qui connaissent déjà la fonction « Select » de « LINQ » diront que ce n’est pas exactement la même fonction. En effet, le « Select » de « LINQ » ne permet pas de faire un filtre, il ne fait qu’une transformation de la collection d’entrée, ici, nous faisons en fait une fonction qui est la fusion d’un « Where » et d’un « Select », mais cela n’a pas d’importance.

La première chose qui change est le type de retour de la méthode. En effet, comme expliqué précédemment, la fonction « Select » permet de renvoyer des objets n’étant pas du même type que les objets contenus dans la collection source. Comme il est impossible de prédire le type des objets renvoyés, nous utilisons un type générique. Généralement, un type générique de retour sera nommé « TResult », nous utilisons donc cette nomenclature et disons que nous renvoyons une collection d’objet du type « TResult ».

Nous indiquons ensuite le nom de la méthode (« Select ») suivi de la liste de tous les types génériques que cette méthode pourra gérer. Ici, nous avons tout d’abord le type « T » qui sera le type des objets contenus dans la collection source. Mais nous avons également le type « TResult » qui sera le type des objets contenus dans la collection renvoyée.

En ce qui concerne les paramètres de la méthode, nous avons tout d’abord la collection de « T » précédée de « this ». Ensuite, nous avons le deuxième paramètre qui est le prédicat de sélection des objets (voir la fonction « Where » déclarée précédemment dans ce cours). Enfin, nous avons un « delegate » qui permettra d’indiquer la transformation appliquée sur les objets de la collection source pour devenir des objets du type « TResult ». Voyez que ce « delegate » prend un objet du type « T » en entrée et retourne un objet du type « TResult ». Voici maintenant le corps de cette méthode :


Remarquez que Visual Studio ne trouve rien à redire sur cette méthode. Le fait que nous retournons une collection d’objet résultant du « delegate resultSelector » ne pose aucun souci car nous indiquons que ce « delegate » renvoie un objet du type « TResult ». Et étant donné que nous utilisons « yield » et que le type de retour de la méthode est « IEnumerable<TResult> », cela ne pose de problème.

Il nous est alors possible d’utiliser cette méthode de cette manière :


Le premier paramètre de la méthode est toujours le prédicat de sélection des objets, mais vous remarquerez que le deuxième paramètre indique le type d’objet à renvoyer. A la première ligne, nous récupérons en fait chaque objet sous forme de la concaténation de leur nom et leur prénom. Dans la seconde ligne, nous récupérons juste le nom de chaque animal du type « Oiseau ».

Vous comprenez sans doute mieux l’intérêt des types génériques maintenant. La méthode « Select » est excellente pour ça. Cette méthode doit pouvoir traiter une liste d’objet de type « T » pour renvoyer une liste d’objet de type « TResult ».

Inférence


Jusqu’à présent, tous les exemples que nous avons abordés jouissaient du principe de l’inférence. Reprenons la fonction précédente :


Que nous appelions de la manière suivante :


Ici, il est inutile de spécifier à la fonction le type d’objets contenus dans la collection. Ainsi, lorsque nous ouvrons la parenthèse pour définir le « delegate », Visual Studio sait automatiquement que « T » est en fait « Etudiant » et vous propose donc l’IntelliSense adapté.

Cependant, cela ne sera pas toujours le cas. Pour expliquer cela, nous allons prendre un exemple de code « SharePoint ». En SharePoint, aucune collection n’est générique. En effet, par exemple, une collection de champ est du type « SPFieldCollection ». Cette classe ne dérive pas de « IEnumerable<T> », mais bien de « SPBaseCollection », qui est la classe de base de toutes les collections en SharePoint. Cette classe ne dérivant pas de « IEnumerable<T> », il est impossible d’appeler la fonction « LINQ Where » dessus (sans faire de Cast). Imaginons donc que nous avons cette fonction :


Cette fonction permettra donc de parcourir un objet de type « SPBaseCollection » et de renvoyer les éléments correspondant au prédicat. Nous ne nous pencherons pas sur l’implémentation de la fonction car ce n’est pas le but ici, mais pensez maintenant à l’appel de cette fonction. Si vous avez une collection du type « SPFieldCollection », celle-ci n’est pas générique mais vous pourrez appeler la fonction « Where » que nous venons de développer car elle dérive de « SPBaseCollection ». Cependant, un appel de ce type ne fonctionnera pas :


Et l’erreur que Visual Studio vous donne est la suivante :

Image


Il vous dit donc que le type générique ne peut être « inféré » par son utilisation. En effet, Visual Studio n’est pas capable de se dire que les objets contenus dans une collection de type « SPFieldCollection » sont du type « SPField », l’inférence du type est donc impossible. Mais pas de panique, il y a une solution à ce problème. L’appel à la fonction doit se faire de cette manière :


Comme Visual Studio ne peut inférer le type de lui-même, nous le faisons pour lui en spécifiant explicitement le type des objets contenus dans la collection. Pour cela, nous devons spécifier ce type entre chevrons après le nom de la fonction. Maintenant, Visual Studio sait que les objets contenus dans la collection « collection » sont de type « SPField », il peut donc proposer l’IntelliSense relative sur l’objet « f ».

Contraintes


Lorsque nous utilisons les génériques, nous pouvons imposer des contraintes aux types que nous utilisons. Par exemple, nous pouvons indiquer qu’une méthode traite un type générique « T », mais uniquement si ce type est un type de référence. A contrario, nous pouvons également dire que cette méthode ne peut gérer que les types de valeur. Nous définissons cela grâce aux contraintes qui sont introduites par le mot-clé « where ».

Contrainte de référence


Une des contraintes les plus fréquentes est la contrainte de référence. Celle-ci permet d’indiquer que le contenu des objets de type générique doit absolument être de type de référence, donc, différent d’une structure ou d’un type de valeur. Pour ce faire, il suffit de taper ceci :


Grâce à cette contrainte, vous ne pourrez jamais faire ceci :


Sinon, vous obtiendrez le message d’erreur suivant :

Image


Vous indiquant que le paramètre générique de cette fonction doit être du type référence.

Contrainte de valeur


L’exemple inverse du précédent est bien entendu celui-ci permettant de faire accepter à la méthode « GenericMethods » des objets de type valeur et non plus de référence. Pour ce faire, rien de plus simple :


En remplaçant « class » par « struct », vous assurez que cette fonction pourra être appelée de cette manière :


Mais plus de cette manière :


Et ce, tout simplement parce que « int » est un « value type » et « string » un « reference type ».

Contrainte de constructeur


Vous pouvez également inclure une contrainte au niveau du constructeur du type générique. Cependant, cette contrainte est relativement limitée car elle se limite au constructeur sans paramètre. Notez que cette contrainte doit être la dernière de la liste des contraintes. En effet, dans le cas où vous avez plusieurs contraintes, il faut les séparer par des virgules. Soyez donc attentif que si vous avez plusieurs contraintes, celle concernant le constructeur doit être placée en dernière position. Pour inclure cette contrainte, vous devez procéder de la sorte :


Ainsi, vous pourrez appeler cette méthode de cette manière :


Car le type « int » possède un constructeur sans argument. Cependant, vous ne pourrez faire :


Car le type « string » ne contient pas de constructeur sans argument.

Contrainte de conversion


Le dernier type de contrainte permet d’indiquer que le type générique doit pouvoir être « casté » vers un autre type (non générique). Imaginons par exemple que notre méthode doit gérer des « Stream ». Bien entendu, « T » pourrait être du type « FileStream », « MemoryStream »,... Il est tout à fait possible d’indiquer que la méthode n’est valide que si le type générique peut être converti en « Stream » (ou tout autre classe). Pour ce faire :


Ici, nous spécifions donc simplement que le type « T » doit pouvoir être « casté » en « Stream ». Ainsi, dans le code suivant :


Seules les deux premières lignes seront valides car « MemoryStream » et « FileStream » peuvent être convertis en objet de type « Stream ». Quant à la troisième ligne, elle va échouer car « StreamWriter » ne dérive pas de « Stream » mais de « TextWriter ».

Contraintes multiples


Il peut arriver que votre méthode gère plus d’un type et que vous deviez mettre une ou plusieurs contraintes sur un, voir les deux types. Pour assembler plusieurs contraintes, ce n’est pas compliqué. Il suffit de définir toutes les contraintes d’un type, séparées par des virgules, suivi d’un « where » déclarant toutes les contraintes du second type, toujours séparées par des virgules et ainsi de suite. Par exemple :


Ici, nous spécifions simplement que « T1 » doit être un type de référence possédant un constructeur sans argument et que « T2 » doit être un type de valeur. Essayez donc de vous demander quelles lignes fonctionneront dans les appels suivants :


Le résultat est « 1 et 5 ». En effet, pour la première ligne, « MemoryStream » est une bien une classe et possède un constructeur sans paramètre. Quant à « int », c’est bien un type de valeur. Pour la deuxième ligne, l’erreur vient du fait que le type « string » ne possède pas de constructeur sans paramètre. Dans le troisième ligne, les deux paramètres sont incorrects. « int » n’est pas un type de référence et « string » n’est pas un type de valeur. Dans la quatrième ligne, c’est juste le premier « int » qui pose problème car il n’est pas un type de référence. La cinquième ligne ne pose pas de problème et enfin, la sixième ligne provoque une erreur car « string » n’est pas un type de valeur.

default


La dernière fonctionnalité intéressante des génériques que nous allons voir est le mot-clé « default ». En effet, il permet simplement de renvoyer la valeur par défaut d’un type générique. C’est très utile car, étant donné que vous ne pouvez pas savoir d’avance le type que vous allez gérer, vous ne pouvez pas savoir si sa valeur par défaut est « null » pour un type de référence, « 0 » pour un numérique ou autre. Pour obtenir cette valeur, il suffit simplement d’utiliser le mot-clé « default ». Par exemple :


Cette fonction renvoie simplement la valeur par défaut du type passé en paramètre. Ainsi, si nous utilisons cette méthode de cette manière :


Nous obtiendrons « null », « 0 » et « null ».

En conclusion, nous dirons que les génériques sont une nouveauté extraordinaire et qu’ils permettent de résoudre pas mal de problèmes récurrents. Si vous n’avez pas tout compris de ce cours, nous vous conseillons fortement de le relire. N’hésitez pas non plus à poster des commentaires si certains passages ont besoin de plus amples explications.

Voter :

2 commentaires

  • François a dit:

    25/05/2012

    Efficace et didactique... Merci

  • ArteFakt a dit:

    23/09/2011

    Très bon article, assez bien vulgarisé ;)

    Bonne continuation

Ajouter un commentaire