Programmation Java/Réflexion

La réflexion (reflection en anglais) permet l'introspection des classes, c'est-à-dire de charger une classe, d'en créer une instance et d'accéder aux membres statiques ou non (appel de méthodes, lire et écrire les attributs) sans connaître la classe par avance.

Java possède une API permettant la réflexion. Elle sert notamment à gérer des extensions (plug-in en anglais) pour une application.

L'API de réflexion modifier

Le package java.lang possède trois classes utilisées pour l'utilisation dynamique des classes :

java.lang.Class
Cette classe permet d'accéder aux caractéristiques d'une classe, à ses membres (méthodes et attributs), à la classe mère.
java.lang.ClassLoader
Cette classe permet de gérer le chargement de classe. Il existe des sous-classes dont notamment java.net.URLClassLoader permettant de charger des classes en les cherchant dans une liste d'URLs (donc de fichiers JAR et répertoires également, en convertissant le chemin du fichier ou répertoire en URL).
java.lang.Package
Cette classe permet d'accéder aux informations d'un package (informations de version, annotations, ...).

Les autres classes utiles sont définies dans le package java.lang.reflect et permettent d'accéder aux détails d'une classe. Les principales classes sont les suivantes :

java.lang.reflect.Constructor
Référence à un constructeur d'une classe.
java.lang.reflect.Method
Référence à une méthode d'une classe.
java.lang.reflect.Field
Référence à un champ d'une classe.
java.lang.reflect.Modifier
Attributs et méthodes statiques pour décoder les modificateurs des membres (public, private, protected, static, abstract, final, native, ...).

Les classes représentant des membres d'une classe (Constructor, Method, Field) implémentent toutes l'interface java.lang.reflect.Member comportant les méthodes suivantes :

Class getDeclaringClass()
Retourne la classe définissant ce membre.
String getName()
Retourne le nom.
int getModifiers()
Retourne les modificateurs (public, protected, private, static, final, ...).
boolean isSynthetic()
Teste si ce membre a été généré par le compilateur.

Charger une classe dynamiquement modifier

La classe java.lang.Class possède deux méthodes statiques pour obtenir une classe (après chargement si nécessaire) :

static Class forName(String name)
Cette méthode équivaut à appeler la seconde méthode avec pour paramètres : (name, true, this.getClass().getClassLoader()).
static Class forName(String name, boolean initialize, ClassLoader loader)
Charge la classe dont le nom complet (incluant les packages) est spécifié, en utilisant l'instance du chargeur de classe fourni. Le paramètre initialize vaut true pour initialiser la classe (appeler le bloc d'initialisation statique), ou false pour ne pas l'initialiser.

Il est également possible d'obtenir une java.lang.Class de manière statique :

  • à partir d'un objet en appelant la méthode getClass(),
  • à partir d'une référence à la classe en utilisant le champ class.

Exemple :

package org.wikibooks.fr;

public class Exemple
{
    String nom;
    public String getNom()
    { return nom; }
}
...

Class c = Class.forName("org.wikibooks.fr.Exemple"); // sans référence statique à la classe
// ou
Class c = org.wikibooks.fr.Exemple.class; // référence statique à la classe
// ou
Class c = new Exemple().getClass(); // référence statique à la classe

Liste des membres d'une classe modifier

Les méthodes suivantes permettent de lister les membres d'une classe :

getConstructors()
Cette méthode retourne un tableau de java.lang.reflect.Constructor contenant tous les constructeurs définis par la classe.
getMethods()
Cette méthode retourne un tableau de java.lang.reflect.Method contenant toutes les méthodes définies par la classe.
getFields()
Cette méthode retourne un tableau de java.lang.reflect.Field contenant tous les attributs définis dans la classe.

Les méthodes ci-dessus retournent les membres publics de la classe, comprenant également ceux hérités des classes mères. Il existe une variante "Declared" de ces méthodes retournant tous les membres (publics, protégés, privés) déclarés par la classe uniquement (les membres hérités sont exclus).

Au lieu de lister tous les membres, puis en rechercher un en particulier, il est possible d'utiliser les méthodes spécifiques de recherche d'un membre précis d'une classe (publics et hérités, ou bien "Declared" pour tous ceux déclarés par la classe seule) :

getConstructor(Class... parameterTypes)
getDeclaredConstructor(Class... parameterTypes)
Cette méthode retourne le constructeur déclaré avec les paramètres dont les types sont spécifiés.
getMethod(String name, Class... parameterTypes)
getDeclaredMethod(String name, Class... parameterTypes)
Cette méthode retourne la méthode portant de nom spécifié et déclarée avec les paramètres dont les types sont spécifiés.
getField(String name)
getDeclaredField(String name)
Cette méthode retourne l'attribut portant de nom spécifié.

Les membres retournés par toutes ces méthodes peuvent être d'instance ou statiques.

La méthode getAnnotations() retourne un tableau de java.lang.Annotation contenant toutes les annotations associées à la classe.

Instancier une classe et appel à un constructeur modifier

La méthode newInstance() de la classe java.lang.Class permet de créer une nouvelle instance de la classe, en appelant le constructeur sans paramètre de la classe (qui doit donc en posséder un) :

Class c = Class.forName("org.wikibooks.fr.Exemple");

Object o = c.newInstance();  // équivaut à   new org.wikibooks.fr.Exemple();

Une classe comme celle ci-dessous peut ne pas avoir de constructeur sans paramètres :

package org.wikibooks.fr;

public class Livre
{
    String titre;
    int nb_pages;

    public Livre(String titre, int nb_pages)
    {
        this.titre = titre;
        this.nb_pages = nb_pages;
    }
}

Dans ce cas, il faut d'abord obtenir le constructeur, puis l'appeler :

Class c = Class.forName("org.wikibooks.fr.Livre"); // Accès à la classe Livre
Constructor constr = c.getConstructor(String.class, int.class); // Obtenir le constructeur (String, int)
Object o = constr.newInstance("Programmation Java", 120); // -> new Livre("Programmation Java", 120);

Pour les versions de Java antérieures à 5.0 où l'auto-boxing n'existe pas, et où il faut explicitement utiliser des tableaux :

Class c = Class.forName("org.wikibooks.fr.Livre"); // Accès à la classe Livre
Constructor constr = c.getConstructor(new Class[]{ String.class, Integer.TYPE }); // Obtenir le constructeur (String, int)
Object o = constr.newInstance(new Object{ "Programmation Java", Integer.valueOf(120) }); // -> new Livre("Programmation Java", 120);

Appel à une méthode modifier

L'appel à une méthode de la classe est basé sur le même principe que l'appel à un constructeur vu juste avant. Cependant, pour obtenir la référence à une méthode, il faut spécifier le nom. Lors de l'invocation de la méthode, il faut spécifier l'instance (l'objet) auquel s'applique la méthode (null pour une méthode statique).

Exemple :

package org.wikibooks.fr;

public class Livre
{
    String titre;
    int nb_pages;
  
    public Livre(String _titre, int _nb_pages) {  
        this.titre = _titre;
        this.nb_pages = _nb_pages;
    }

    public int getNombreDeFeuilles(int pages_par_feuille)
    {
        return (nb_pages+pages_par_feuille-1)/pages_par_feuille;
    }
}
...
Class c = Class.forName("org.wikibooks.fr.Livre"); // Accès à la classe Livre

Constructor constr = c.getConstructor(String.class, int.class); // Obtenir le constructeur (String, int)
Object o = constr.newInstance("Programmation Java", 120); // -> new Livre("Programmation Java", 120);

Method method = c.getMethod("getNombreDeFeuilles", int.class); // Obtenir la méthode getNombreDeFeuilles(int)
int nb_feuilles = (int)method.invoke(o, 2); // -> o.getNombreDeFeuilles(2);

Accès à un attribut public modifier

L'accès à un attribut public se fait en appelant les méthodes sur l'instance de java.lang.reflect.Field obtenu auprès de la classe.

Exemple :

package org.wikibooks.fr;

public class Livre
{
    public String titre;
    public int nb_pages;

    public Livre(String _titre, int _nb_pages) {  
        this.titre = _titre;
        this.nb_pages = _nb_pages;
    }
}
...
Class c = Class.forName("org.wikibooks.fr.Livre"); // Accès à la classe Livre

Constructor constr = c.getConstructor(String.class, int.class); // Obtenir le constructeur (String, int)
Object o = constr.newInstance("Programmation Java", 120); // -> new Livre("Programmation Java", 120);

Field f_titre = c.getField("titre"); // Obtenir l'attribut titre
String titre_du_livre = (String)f_titre.get(o); // -> o.titre
f_titre.set(o, "Java"); // -> o.titre = "Java";

Accès à un attribut privé modifier

Il est également possible d'avoir accès aux champs privés grâce à la méthode setAccessible de la classe Field. Cela est cependant fortement déconseillé puisque modifier les valeurs d'un champs privée revient à violer le principe d'encapsulation.

Exemple :

package org.wikibooks.fr;

public class Livre
{
    private String titre;
    private int nb_pages;

    public Livre(String _titre, int _nb_pages) {  
        this.titre = _titre;
        this.nb_pages = _nb_pages;
    }
}
...
Class c = Class.forName("org.wikibooks.fr.Livre"); // Accès à la classe Livre

Constructor constr = c.getConstructor(String.class, int.class); // Obtenir le constructeur (String, int)
Object o = constr.newInstance("Programmation Java", 120); // -> new Livre("Programmation Java", 120);

Field f_titre = c.getField("titre"); // Erreur: titre est privé

Field fields[] = c.getDeclaredFields();
fields[0].setAccessible(true); // titre désormais équivalent à un attribut publique
Field f_titre = fields[0]; // Obtenir l'attribut titre

String titre_du_livre = (String)f_titre.get(o); // -> o.titre
f_titre.set(o, "Java"); // -> o.titre = "Java";

Exemple concret : un gestionnaire d'extensions modifier

L'extension d'une application Java peut se faire en utilisant la réflexion pour charger dynamiquement une classe. Le but de cet exemple est de permettre d'ajouter des fonctionnalités dynamiquement à une application, sans avoir à effectuer de changement autre que l'ajout d'une nouvelle archive JAR dans un répertoire.

Par défaut, une application est composée de plusieurs classes dont le chargement est effectué par la JVM. Cependant, il faut que les chemins des répertoires de fichiers *.class ou des archives *.jar soit renseignés d'avance avant le lancement de l'application dans le classpath.

Chargeur de classes modifier

Supposons que l'application doive être capable de charger des extensions fournies sous forme de fichiers *.jar situés dans un sous-répertoire nommé « ext ». Quand l'application est lancée, le classpath est déjà configuré pour les classes de l'application, mais ne contient pas le répertoire ext. Mais comme les classes ne sont pas directement dans le répertoire, la JVM a besoin du chemin de chaque fichier *.jar dans le classpath. L'application doit donc lister les fichiers *.jar du répertoire ext pour construire un classpath passé au constructeur d'un nouveau chargeur de classes.

public static ClassLoader creerChargeurArchives(File dir) throws IOException
{
	if (!dir.isDirectory()) throw new FileNotFoundException("Répertoire non trouvé");
	File[] fichiers = dir.listFiles();
	ArrayList<URL> urls_fichiers = new ArrayList<>();
	// Ajouter les fichiers *.jar
	for(File f : fichiers)
	{
		if (f.isFile() && f.getName().toLowerCase().endsWith(".jar"))
			urls_fichiers.add(f.toURI().toURL());
	}
	// Créer le chargeur de classes pour les URLS de fichiers *.jar
	return new URLClassLoader(urls_fichiers.toArray(new URL[urls_fichiers.size()]));
}

Il est possible de créer un chargeur de classes par fichier *.jar. Cependant, chaque chargeur de classe a un chargeur de classe parent, permettant aux classes chargées d'utiliser des classes gérées par le chargeur parent. En l’occurrence il s'agit de celui des classes de l'application elle-même. Cela signifie que les classes d'extensions peuvent utiliser les classes de l'application (classes de base, interfaces d'API, ...). En utilisant un chargeur par fichier *.jar, chaque extension ne peut donc pas utiliser les classes d'une autre extension. En utilisant un seul chargeur pour tous les fichiers *.jar, les extensions peuvent utiliser des classes d'autres extensions. Cette dernière solution permet de créer des extensions utiles à d'autres extensions.

Toutefois l'inconvénient est que toutes les extensions sont chargées en mémoire même si elles ne sont pas utilisées. De plus, le nom complet des classes doit être unique.

Chargement des classes d'extension modifier

Le chargement des classes d'extension doit utiliser le chargeur de classes des fichiers *.jar trouvés dans le répertoire ext. Chaque extension doit utiliser un nom de paquetage unique afin que deux classes n'aient pas le même nom absolu.

Il faut aussi connaître la liste de noms des classes à charger. Cette liste peut être fixe en calculant un nom de paquetage unique à partir du nom du fichier archive JAR (donc une seule classe chargée), ou elle peut être lue à partir d'un fichier inclut dans le fichier JAR (par exemple, un fichier nommé extension.ini à la racine du fichier JAR).

public static void chargerClasses(ClassLoader cl, File f_jar) throws IOException
{
	JarFile jf = new JarFile(f_jar);
	try
	{
		JarEntry je = jf.getJarEntry("extension.ini");
		if (je != null)
		{
			InputStream in = jf.getInputStream(je);
			try
			{
				// Lecture du fichier texte
				BufferedReader br = new BufferedReader(new InputStreamReader(in));
				String line;
				while ((line = br.readLine()) != null)
				{
					// Enlever l'éventuel commentaire de fin de ligne
					// commençant par le caractère #
					int i = line.indexOf('#');
					if (i>=0) line = line.substring(0,i);
					line = line.trim(); // Sans les espaces autour
					// Ne pas traiter les lignes vides
					if (line.length()==0) continue;

					// Traitement de la ligne
					// Il peut s'agir simplement de la classe à instancier immédiatement :
					//   Class<? extends Object> c = cl.loadClass(line);
					//   c.newInstance();
					// Ou d'une classe instanciée lorsqu'elle est utilisée dans un script ou
					// une commande de l'application en utilisant un nom associé à la classe :
					i = line.indexOf('=');     //  <nom> = <classe>
					if (i>=0)
					{
						String nom = line.substring(0,i).trim();
						String nom_classe = line.substring(i+1).trim();
						// Associer le nom à la classe instanciée plus tard
						Class<? extends Object> c = cl.loadClass(nom_classe);
						map_extensions.put(nom, c);
					}
				}
			}
			finally{ in.close(); }
		}
	}
	finally{ jf.close(); }
}

public static void chargerExtensions(File dir) throws IOException
{
	// Créer le chargeur de classes
	ClassLoader cl = creerChargeurArchives(dir);

	File[] fichiers = dir.listFiles();

	// Charger les classes de chaque fichier *.jar
	for(File f : fichiers)
	{
		if (f.isFile() && f.getName().toLowerCase().endsWith(".jar"))
			chargerClasses(cl, f);
	}
}