.NET : Introduction à la réflexion (C#)

La réflexion permet de découvrir et d'utiliser dynamiquement des types, comme l'instanciation tardive d'objets ou l'invocation de méthodes à la volée.

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Avertissement

Cet article est une introduction et non pas une présentation complète des possibilités de la réflexion.

II. Introduction

A. Qu'est-ce que la réflexion ?

La réflexion est l'art de découvrir des types et d'invoquer leurs membres à l'exécution. La réflexion permet d'inspecter dynamiquement le contenu d'assemblages, d'en lire ses types, de créer des instances de ces types durant l'exécution du programme et d'appeler leurs méthodes ou champs dynamiquement. La réflexion est également appelée introspection suivant les auteurs, mais, avec l'arrivée de .NET, le terme réflexion s'est généralisé. On utilise également le terme de liaison tardive (late binding) pour décrire une instanciation à la volée au moment de l'exécution. Il existe d'autres mécanismes s'approchant de la réflexion utilisés avant .NET, comme RTTI (Run-Time Type Identification) en C/C++ ou, dans une certaine mesure, l'interface IDispatch pour les composants COM.

Certaines personnes, dont moi, pensent que la traduction du terme anglais reflection en réflexion est inappropriée. En effet, le terme introspection est nettement plus adapté et correspond mieux à la réalité du mécanisme. Cela dit, la majorité des livres et documents en français, ainsi que le MSDN, utilisent le terme de réflexion. C'est pour cette raison que j'utiliserai également ce terme dans la suite de cet article.

CLR (Common Langage Runtime) : environnement d'exécution des programmes .NET.

B. Comment ca marche ?

Tous les assemblages managés (programmés pour le CLR .NET) comportent des métadonnées qui décrivent le contenu de l'assemblage. En lisant ces métadonnées, un programme peut inspecter les types contenus dans l'assemblage, déterminer ses membres et les invoquer.

C. Quelles sont ses applications ?

Les applications de la réflexion sont nombreuses, et beaucoup d'entres nous s'en servent tous les jours depuis longtemps sans s'en rendre compte. L'exemple le plus flagrant reste les environnements de développement évolués et leurs multiples fenêtres et outils : un explorateur de classes utilise la réflexion. Les membres des classes et les classes elles-mêmes, sont découvertes et ajoutées au fur et à mesure des actions du développeur.

Un autre exemple : la complétion de code. N'est-ce pas magnifique que, lorsque l'on tape un point après un identificateur d'objet en C# dans un environnement de développement comme Visual Studio, la liste de ses membres s'affiche? Dans ce cas aussi, le mécanisme repose sur la réflexion pour découvrir à la volée tous les membres de l'objet concerné.

III. La réflexion avec C#

Un espace de noms entier est réservé aux types et méthodes qui permettent la réflexion dans le framework .NET : il s'agit de System.Reflection. Seul le type System.Type est utilisé par le mécanisme de réflexion en étant hors de l'espace de noms System.Reflection.

La réflexion fonctionne avec tous les langages managés (C#, VB.NET, Managed C++ ...)

A. Les types à utiliser

Type Description
System.Reflection.Assembly Définit un assemblage managé, comme un exécutable ou une DLL .NET. C'est à partir de ce type que seront appelées les principales méthodes de la réflexion. C'est directement depuis un objet de type Assembly que pourront être découverts et invoqués les membres.
System.Reflection.MemberInfo Représente les informations d'un membre d'un type quelconque. Le membre peut être un champ ou une méthode, un objet de type MemberInfo définit sa signature.

B. Les méthodes à utiliser

Méthode Description
Assembly.LoadFrom Charge dynamiquement un assemblage dont le nom de fichier est passé en paramètre. Il s'agit d'un chargement physique, au contraire de la méthode Assembly.Load qui effectue un chargement logique avec le nom fort de l'assemblage.
Assembly.GetTypes Renvoie un tableau de System.Type contenant tous les types définis dans l'assemblage.
Assembly.GetMembers Renvoie un tableau de System.Reflection.MemberInfo contenant les signatures de tous les membres de chaque type de l'assemblage.
Activator.CreateInstance Crée dynamiquement une instance du type spécifié. Principe de la liaison tardive (late binding).
Type.InvokeMember Tente d'invoquer le membre spécifié sur le type. Il est possible de travailler avec des membres statiques ou sur une instance du type créée précédemment.

C. Chargement d'un assemblage

Il y a deux manières de charger dynamiquement un assemblage : logique ou physique. Le chargement logique s'appuie sur une des caractéristiques des assemblages .NET, les noms forts. En travaillant avec le Global Assembly Cache (GAC) de .NET, le CLR est capable de charger un assemblage en connaissant son nom, sa version ou encore sa culture. Voici un exemple :

Un nom fort .NET est un nom d'assemblage composé. Il contient plusieurs attributs qui permettent de différencier les assemblages les uns des autres, non pas par leur nom de fichier, mais par des propriétés bien plus logiques, comme par exemple leur langage (culture) ou leur version. Il est ainsi possible d'éviter l'un des nombreux désavantage des DLLs natives qui étaient les multiples versions possibles d'une même DLL qui entraient en conflit. Cette notion du nom fort abolit une partie du tristement célèbre DLL Hell.

Chargement logique d'un assemblage
Sélectionnez

// On suppose que la directive using System.Reflection est présente plus haut

// Chargement en spécifiant la culture
Assembly monAssembly = Assembly.Load("assemblyName, Culture=fr");

// Chargement en spécifiant la version
Assembly monAssembly = Assembly.Load("assemblyName, version=1.2.3.4");

Voyons maintenant la manière de charger physiquement un assemblage. Celui-ci nécessite de connaître le nom du fichier de l'assemblage que l'on veut charger et sa localisation sur le système, ce qui le rend un peu plus dépendant que le chargement logique, mais plus facile à appréhender.

Chargement physique d'un assemblage
Sélectionnez

// On suppose que la directive using System.Reflection est présente plus haut

Assembly monAssembly = Assembly.LoadFrom("C:\assemblyName.dll");

Dans les deux cas, monAssembly pourra être utilisé de manière similaire dans la suite du programme.

L'assemblage est maintenant chargé. Nous pouvons désormais inspecter ce qu'il contient. Avant cela, je vais faire un rapide rappel concernant les termes utilisés ci-dessous.

  • Un type caractérise le contenu et le sens d'un objet. En C, int et char sont des types de données. En .NET, on différencie les types de valeurs et les types de références. Les types de valeurs sont alloués sur une pile (stack) alors que les types de références sont alloués sur le tas managé (managed heap). Les premiers sont accessibles plus rapidement et plus ou moins directement. Int32, Char sont des types de valeurs, alors que ArrayList ou HttpChannel sont des types références. On peut facilement connaître le type de chaque objet .NET en utilisant la méthode GetType.
  • Un membre est une méthode, propriété ou champ appartenant à une classe. En .NET, toutes les classes héritent de la classe de base Object, ce qui fait qu'elles ont toutes au moins 4 membres : ToString, Equals, GetHashCode et GetType. Chaque classe contient encore un membre spécial, le constructeur .ctor.
  • Invoquer un membre signifie appeler une méthode membre, lire ou écrire un champ.

D. Découverte de types

En règle générale, un assemblage contient un ou plusieurs types définis (une ou plusieurs classes par exemple). Ces types appartiennent à l'espace de noms défini dans l'assemblage, et il serait utile de lister tous les types que l'assemblage contient pour, par la suite, instancier l'un d'eux. L'exemple de code suivant permet d'inspecter chaque type d'un assemblage :

Découverte de types
Sélectionnez

// On suppose que la directive using System.Reflection est présente plus haut

Assembly monAssembly = Assembly.LoadFrom("C:\assemblyName.dll");

foreach(Type type in monAssembly.GetTypes())
{
	Console.WriteLine("Type présent dans l'assemblage : " + type.Name);
} 

La méthode GetTypes permet de lire les types de l'assemblage, et renvoie une tableau d'instances de System.Type de tous les types contenus dans l'assemblage. Si la DLL .NET assemblyName.dll contient deux classes MyClassOne et MyClassTwo, GetTypes retournera ces deux types et la sortie du programme sera la suivante :

 
Sélectionnez

Type présent dans l'assemblage : MyClasseOne
Type présent dans l'assemblage : MyClasseTwo

A la place de la propriété Name qui rend le nom simple du type, on peut utiliser la méthode ToString qui renvoit elle le nom complet du type avec les espaces de noms parents (ex. : MyNameSpace.MyClassOne).

E. Découverte de membres

Chaque type comporte des membres. Ces membres peuvent être des méthodes, des champs ou des propriétés par exemple. La réflexion permet de découvrir chaque membre d'un type donné. Soit la séquence de code suivante :

Découverte de membres
Sélectionnez

// On suppose que la directive using System.Reflection est présente plus haut

Assembly monAssembly = Assembly.LoadFrom("C:\assemblyName.dll");

foreach(Type type in monAssembly.GetTypes())
{
	foreach(MemberInfo member in type.GetMembers())
	{
		Console.WriteLine("Type : " + type.Name + " \tMembre :" + member.Name);
	}
} 

Le code ci-dessus parcourt tous les membres de chaque type présent dans l'assemblage assemblyName.dll. On affiche dans la console tous les membres découverts.

Dans le cas de la réalisation d'un simple explorateur de classes, on pourrait être amener à colorer les membres selon leur nature : méthode, champ, constructeur... Pour déterminer la nature des membres, on utilise le champ MemberType de la classe MemberInfo, qui est une énumération. Voici les principales natures que vous rencontrerez :

Principales natures des membres
  • MemberType.Method : Ce membre est une méthode.
  • MemberType.Field : Ce membre est un champ.
  • MemberType.Property : Ce membre est une propriété.
  • MemberType.Constructor : Ce membre est un constructeur du type.

Donc, dans le cas d'un explorateur de classes avec une vue en arbre (TreeView) ou l'on veut colorer chaque membre en fonction de sa nature, le code serait le suivant :

Détermination de la nature des membres
Sélectionnez

// nodeTemp est un noeud de l'arbre créé plus haut (racine)

foreach(MemberInfo member in type.GetMembers())
{
	TreeNode nodeMember = nodeTemp.Nodes.Add(member.Name);			

	switch(member.MemberType)
	{
		case MemberTypes.Method :
			// On colorie les méthodes en bleu ...
			nodeMember.ForeColor = System.Drawing.Color.Blue;
			break;

		case MemberTypes.Field :
			// On colorie les champs en rouge ...
			nodeMember.ForeColor = System.Drawing.Color.Red;
			break;

		(...)
	}
}

Un champ intéressant de la classe MemberInfo se nomme DeclaringType. Il contient le nom du type ou le membre est déclaré. En d'autres termes, cela permet de savoir si le membre est un membre natif du type actuel, ou s'il est hérité d'une quelconque superclasse. Par exemple, la valeur de DeclaringType sur le membre GetType de n'importe quel objet .NET sera toujours System.Object, car c'est dans cette classe que GetType est déclarée.

F. Instanciation dynamique de types

Dans la plupart des cas, la découverte dynamique de types est suivie d'une instanciation de l'un de ces types. On parle alors de late binding, ou liasion tardive pour décrire la création d'un objet à l'exécution. L'exemple ci-dessous permet d'instancier des types et de les utiliser comme des objets rééls également lors du développement, car leur interface sera commune et connue.

Considérons le problème suivant : nous voulons créer plusieurs implémentations de classes qui réalisent une même interface, et choisir à l'exécution quelle implémentation utiliser. Par exemple, on considère une interface IAutomobile, et deux classes AudiRS4 et BmwM3. On décide de faire deux assemblages AudiRS4 et BmwM3 qui contiennent l'implémentation des méthodes décrites dans l'interface IAutomobile, elle-même dans IAutomobile.dll.

Interface IAutomobile
Sélectionnez

using System;

namespace GenericAutomobile
{
	public interface IAutomobile
	{
		void Accelerer(double dIncrement);
		void Tourner(double dAngle);
	}
}

Le programme principal est chargé de découvrir et d'instancier un objet de type AudiRS4 ou BmwM3. Pour ce faire, il lui faut impérativement une référence sur l'assemblage IAutomobile.dll. Ensuite, il suffira de renseigner l'application sur le nom du type que l'on veut créer, de parcourir les assemblages et de voir si le type demandé est bel et bien présent.

Instanciation d'un type
Sélectionnez

// Objet réalisant notre interface
IAutomobile Automobile;

// Nom de l'objet à créer
string m_sClassName = "AudiRS4";

// Chargement de l'assemblage
Assembly assembly = Assembly.LoadFrom(m_sClassName + ".dll");

// Introspection des types
foreach(Type type in assembly.GetTypes())
{
	if(m_sClassName == type.Name)
	{
		// Un type correspond, création d'un objet
		Automobile = (GenericAutomobile.IAutomobile) Activator.CreateInstance(type);
	}
}

Notre objet Automobile est maintenant créé. Dans l'exemple et pour que cela soit utilisable dynamiquement, il faudrait que le nom du type que l'on souhaite utiliser provienne d'un champ texte ou d'ailleurs, mais qu'il ne soit pas codé en dur.

G. Invocation de membres

Le code suivant permet d'appeler dynamiquement une méthode. Si la méthode n'existe pas, on affiche un message d'erreur dans la console. Si la méthode est présente, on affiche son résultat.

Invocation d'une méthode
Sélectionnez

// Nom de la méthode à invoquer
m_sQuery = "ToString";

try
{
	object result = (object) Automobile.GetType().InvokeMember(m_sQuery, 
		BindingFlags.Default | 
		BindingFlags.Public | BindingFlags.NonPublic | 
		BindingFlags.Instance | BindingFlags.InvokeMethod, null, Automobile, null);

	Console.WriteLine("Résultat : " + result.ToString());
}
catch(System.MissingMethodException mme)
{
	// Dans le cas ou la méthode n'est pas un membre de ce type ...
	Console.WriteLine("Méthode '" + m_sQuery + "' introuvable.");
}

On utilise la méthode InvokeMember pour invoquer un membre d'un type. Celle-ci prend plusieurs paramètres dont le nom de la méthode, les informations de liaison (binding flags) et éventuellement une instance d'objet sur lequel peut être effectué l'invocation.

Les informations de liaison permettent de différencier les invocations suivant la nature du membre : méthode, champ (lecture/écriture), propriété... Il existe de nombreuses combinaisons qui permettent de restreindre ou de garantir l'accès à un membre. Par exemple, BindingFlags.Public spécifie que tous les membres publics doivent être inclus dans la recherche. BindingsFlags.Instance permet de rechercher dans les membres d'instance.

Plus de détails sur ces informations de laison sont disponibles sur cette page du MSDN.

Page du MSDN sur la méthode InvokeMember

IV. Applications concrètes de la réflexion

A. Reflector

Reflector est un puissant explorateur d'assemblages. Il permet de parcourir les modules, les types et leurs membres, et permet même de décompiler le code IL (intermediate language) en code C# ou VB.NET.

Reflector

B. Complétion de code

La complétion de code est un mécanisme très utilisé par les environnements de développement comme Visual Studio ou celui de Borland Delphi. C'est grâce à la réflexion que cela est rendu possible en .NET. L'image ci-dessous montre le type System.Double et tous ses membres découverts par Visual Studio .NET :

Complétion de code

V. Masquer la réflexion

Dans certains cas, la réflexion peut devenir un trou de sécurité important. Comme le prouve le logiciel Reflector, il est possible de décompiler n'importe quel assemblage managé. Il existe quelques moyens pour contrer la réflexion.

Le premier consiste à utiliser des obfuscateurs de code (ou brouilleurs). Ceux-ci placent des lignes leurres dans le code IL (intermediate language, équivalent du byte-code Java) et le réordonne de sorte que les décompilateurs ne puissent plus s'y retrouver. La suite de XenoCode (voir liens en fin d'article) permet ce genre de protection.

Un autre moyen, plus simple mais moins propre, est de passer par des librairies non-managés (par ex. : écrites en C++ pur) pour les parties de code que l'on veut protéger et d'importer par la suite ces fonctions dans du code managé en utilisant le mécanisme P/Invoke (Platform Invoke). Vous trouverez plus de détails sur ce mécanisme dans l'article de Morpheus à ce sujet.

VI. Ressources

VI. Remerciements

Je remercie en particulier Bestiol pour la correction, ainsi que Laurent Dardenne pour ses précieux conseils et remarques et Piotrek pour la correction du code VB.NET.

Liste de mes articles :
.NET : Les threads en C#

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2005 Olivier Brin. 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'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.