Concepts avancés de programmation orientée objet
Dans ce dernier cours sur les concepts orientés objet, nous allons examiner de plus près des notions avancées, notamment les génériques, les lambdas & flux, l’inversion de contrôle et la réflexion.
Résumé du cours
Les génériques permettent de gérer de façon cohérente des types inconnus. Les lambdas et flux offrent une alternative orientée programmation fonctionnelle aux boucles, l’inversion de contrôle réduit les dépendances codées en dur, et la réflexion permet de modifier les propriétés du code à l’exécution.
Génériques
Il arrive souvent que l’on souhaite utiliser (ou concevoir) une classe qui fonctionne avec une autre classe – mais lors du codage, on ne sait pas encore quelle est cette autre classe, et peu importe, tant qu’elle est utilisée de façon cohérente.
Un premier exemple est celui des List :
- Une
Listcontient d’autres objets. - La
Listfonctionne toujours de la même manière, quel que soit l’objet stocké à l’intérieur. - Cependant, la
Listexige que tous les objets stockés soient du même type (ou d’un sous-type).
| List | Statut |
|---|---|
List de Apples |
OK |
List de Pairs |
OK |
List de Apples et Pairs |
Pas OK |
Mais comment une List peut-elle imposer des vérifications de cohérence si elle peut fonctionner aussi bien avec des
objets Apple qu’avec des Pair ?
Génériques
Les génériques sont des espaces réservés pour des classes. N’importe quelle classe peut prendre leur place, mais dans ce cas elle doit être utilisée de manière cohérente dans toutes les occurrences génériques.
Collections
- Vous avez très probablement déjà utilisé des génériques lors de l’initialisation d’une collection.
- Par exemple, lors de l’instanciation d’une liste, vous utilisez la notation :
- La notation
<Apple>assigne la classeAppleau générique de laList. À partir de là, seules desApples (ou sous-classes) peuvent être ajoutées à l’objetList.- Autorisé :
apples.add(new Apple()) - Non autorisé (incohérence générique) :
apples.add(new Pair())
- Autorisé :
Et le <> ?
Dans les anciennes versions de Java, il fallait faire correspondre le générique de la variable avec celui de l’objet, c’est-à-dire List<Apple> apples = new LinkedList<Apple>(). Cependant, comme la cohérence est implicite, les versions plus récentes de Java ont introduit l’opérateur diamant <>, pour plus de concision.
Maps
- Les maps sont un exemple de collections avec plusieurs génériques.
- Une map est essentiellement comme un annuaire, une liste allant de clés (uniques) à des valeurs :
- Exemple :
| Professeur | Bureau |
|---|---|
| Assi | 4620 |
| Kavanagh | 4330 |
| Mili | 4340 |
| Schiedermeier | 4440 |
| Stiévenart | 4735 |
Quel rôle jouent les génériques pour une map, et quels types devraient être associés pour cet exemple ?
La map requiert deux génériques : un pour garantir la cohérence du type utilisé pour les noms de professeurs, et un autre pour assurer la cohérence du type utilisé pour leurs bureaux. Dans cet exemple, cela pourrait être new HashMap<String, Integer>().
Définir des génériques
- Jusqu’ici nous n’avons vu que comment utiliser des classes existantes avec des contraintes génériques.
- Voyons maintenant comment écrire une nouvelle classe personnalisée appliquant une contrainte générique.
Catapulte générique
Imaginons que nous ayons une classe Catapult qui peut être chargée avec des objets, puis les propulse.
Quelques contraintes s’appliquent :
- Une fois initialisée, la
Catapultne peut être chargée qu’avec ce pour quoi elle a été conçue. (Imaginez une catapulte construite pour desApples, et que quelqu’un tente d’y charger desPairs !!) - Une fois chargée, la catapulte peut être déclenchée. L’objet est alors annulé (envoyé sur la lune, c’est-à-dire
remplacé par une référence
null).
Implémentation :
// <T> represents a generic. Whatever class T will be, it has to be consistent
// across all later occurrences in this code.
public class Catapult<T> {
private T charge;
public void load(T charge) {
this.charge = charge;
}
public T unload() {
T chargeBuffer = charge;
charge = null;
return chargeBuffer;
}
public void fire() {
System.out.println("Sending charge (" + charge.toString() + ") to the moon");
charge = null;
}
}
Extension générique : catapulte à fruits
Un exemple plus avancé est la FruitCatapult. Elle fonctionne de manière similaire à la catapulte classique, mais peut
accueillir deux charges – à condition qu’elles soient toutes deux du même type de Fruit.
Illustration :
| Liste | Statut |
|---|---|
Deux Apples |
OK |
Deux Pairs |
OK |
Un Apple et un Pair |
Pas OK |
Heureusement, les Apples et les Pairs suivent une hiérarchie stricte de Fruit :
classDiagram
class Fruit {
<<Interface>>
}
class Apple {
<<Class>>
}
class Pair {
<<Class>>
}
Fruit <|.. Apple: implements
Fruit <|.. Pair: implements
Cela semble artificiel, pourquoi ne pas simplement utiliser Fruit au lieu de <T extends Fruit> ?
Dans ce cas, la catapulte serait bien restreinte aux Fruits, mais elle pourrait être chargée avec différents fruits en même temps.
Code :
// Catapult works with anything implementing Fruit
// Note: For generics "extends" covers also interfaces !
public class FruitCatapult<T extends Fruit> {
private T charge1;
private T charge2;
public void loadFirst(T charge1) {
this.charge1 = charge1;
}
public void loadSecond(T charge2) {
this.charge2 = charge2;
}
public T unloadFirst() {
T chargeBuffer = charge1;
charge1 = null;
return chargeBuffer;
}
public T unloadSecond() {
T chargeBuffer = charge2;
charge2 = null;
return chargeBuffer;
}
public void fire() {
System.out.println("Sending both charges to the moon");
charge1 = null;
charge2 = null;
}
}
Les génériques ne connaissent que extends, pas implements
La déclaration de classe est un peu contre-intuitive : Fruit est une interface, alors pourquoi écrire T extends Fruit ? Pour les génériques, la syntaxe est légèrement différente : qu’il s’agisse d’une interface ou d’une super-classe, dans une définition générique on utilise toujours extends.
Hiérarchies génériques
Nous pouvons également supposer que notre catapulte peut être spécialisée pour n'importe quelle sous-classe de Fruit,
tout en exigeant que la sous-classe soit utilisée de manière cohérente dans toutes les méthodes :
Ceci est réalisé avec un générique : <T extends Fruit>
classDiagram
class Fruit
class Apple
class Pear
class Banana
Fruit <|-- Apple
Fruit <|-- Pear
Fruit <|-- Banana
T peut maintenant être soit un Apple, un Pear ou un Banana, mais quel que soit son choix, il doit être utilisé
de manière cohérente !
Pour notre FruitCatapult, cela signifie qu'une fois qu'un certain type de fruit est placé dans la catapulte, toutes
les méthodes de la catapulte sont également liées à ce type de fruit :
/**
* This last example shows how to use a specific extension consistently.
* If we used just "Fruit" instead of a generic, we could not enforce consistency across methods.
* Using T extends Fruit ensures:
* * It is a Fruit
* * It is the same fruit extension across all methods.
*/
public class FruitCatapult<T extends Fruit> {
private T charge1;
private T charge2;
public void loadFirst(T charge1) {
this.charge1 = charge1;
}
public void loadSecond(T charge2) {
this.charge2 = charge2;
}
public T unloadFirst() {
T chargeBuffer = charge1;
charge1 = null;
return chargeBuffer;
}
public T unloadSecond() {
T chargeBuffer = charge2;
charge2 = null;
return chargeBuffer;
}
public void fire() {
System.out.println("Sending both charges to the moon");
charge1 = null;
charge2 = null;
}
}
Bornes supérieures génériques
Parfois, nous voulons ajouter une contrainte tout en conservant de la flexibilité. C'est là qu'intervient la
notation ? :
<? extends Fruit> signifie : Je ne sais pas exactement quel type c'est, mais c'est une sous-classe de Fruit.
- Par conséquent, lorsqu'on accède à l'élément
?, nous savons qu'il peut être assigné àFruit(c'est une sous-classe) - Mais nous ne pouvons pas y insérer un type spécifique, car nous ne savons pas quelle sous-classe exacte il s'agit !
Exemple :
// Here we create a list, that is specific for a Fruit subclass (Apples, Pairs, Bananas)
// But we do NOT specificy which exact subclass it is:
List<? extends Fruit> fruits = new ArrayList<Apple>();
// Consequently, when retrieving elements, we can store them in the (superclass) Fruit variable
Fruit f = fruits.get(0); // OK, we know it’s a Fruit
// But we can NOT add specific subclass elements, because we don't know which exact subclass is used.
fruits.add(new Apple()); // not possible
Lambdas
- Jusqu’à présent nous avons fait une séparation stricte entre les fonctionnalités (méthodes) et les données (valeurs, objets).
- Chaque fois que nous voulions accéder à une certaine fonctionnalité, nous passions par une classe.
- Dans cette section, nous allons étudier les lambdas, un mécanisme de Java permettant de traiter les fonctions comme des données.
Que sont les lambdas
Les lambdas sont des fonctions anonymes définies de manière compacte, c’est-à-dire des fonctions qui peuvent être exécutées directement ou bien stockées et passées comme variable.
Motivation
-
Il existe des situations où l’on souhaite traiter les fonctions comme des données, c’est-à-dire faire circuler des fonctions.
-
Un exemple est celui d’un bouton avec listener :
- Lors de la "programmation" du listener, vous assignez un événement à une fonction.
- La définition du listener exige une fonction en argument d’entrée.
-
Auparavant, vous résolviez cela avec des classes internes anonymes :
public static void main(String[] args) { // Creating a java swing button. JButton button = new JButton(); // Programming button behaviour by using an anonymous inner class button.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { System.out.println("The button was pressed."); } }); } -
La classe anonyme est le corps de
new ActionListener(){...}, elle crée implicitement une sous-classe anonyme de l’interfaceActionListener:classDiagram class ActionListener { <<Interface>> +actionPerformed() } class AnonymousClass { <<Class>> +actionPerformed() } ActionListener <|.. AnonymousClass: "implements"
Jusqu’ici tout va bien, mais...
L’inconvénient des classes internes anonymes est leur verbosité. Beaucoup de code pour quelque chose de plutôt simple : encapsuler une fonction à exécuter.
Syntaxe
- Comme toute fonction Java, une lambda fait correspondre de zéro à plusieurs valeurs d’entrée à une valeur de sortie.
- La syntaxe générale est :
() -> résultat- S’il y a des paramètres, ils sont notés entre parenthèses :
(param1, param2, ...) -> résultat - Si le résultat n’est pas une seule instruction (par exemple un appel de fonction existante), la fonction
résultante :
- Doit être encadrée par
{...} - Doit contenir une instruction
return
- Doit être encadrée par
- S’il y a des paramètres, ils sont notés entre parenthèses :
Exemples
-
Lambda simple pour afficher la valeur reçue dans la console :
-
Stocker une lambda dans une variable, utiliser une fonction comme donnée :
void main() { // Defining lambda and storing it in variable Consumer<Integer> printToConsole = (n) -> System.out.println(n); // Using the lambda on a collection ArrayList<Integer> numbers = new ArrayList<Integer>(); numbers.add(5); numbers.add(9); numbers.add(8); numbers.add(1); numbers.forEach(printToConsole); }
Mises en garde
L’exemple précédent semble un peu artificiel. Pourquoi avons-nous
utilisé l’interface Consumer pour
stocker notre lambda ?
- Les lambdas peuvent être stockées dans n’importe quelle interface, à condition que ce soit une interface
fonctionnelle, c’est-à-dire une interface qui possède une seule méthode publique et non statique.
- L’interface
Consumerest un choix pragmatique, car elle est déjà fournie par Java. - Nous pouvons tout à fait définir notre propre interface dans le but de stocker notre lambda.
- Cependant, l’interface que nous utilisons doit correspondre à l’usage. Ainsi, pour une utilisation dans
une instruction
forEach, la lambda doit être stockée dans une interfaceConsumer!
- L’interface
-
Exemple de stockage d’une lambda dans une interface personnalisée :
- Interface :
-
Utilisation de la lambda :
void main() { // Define lambdas and store them in custom interface StringLambda repeat = (s) -> s + " " + s; StringLambda reverse = (s) -> new StringBuilder(s).reverse().toString(); // Here we pass our lambdas as parameters printModified("Hello", repeat); printModified("Hello", reverse); } void printModified(String str, StringLambda stringLambda) { System.out.println(stringLambda.modify(str)); }
Référence de méthode
- Dans certains cas, on souhaite simplement passer une fonction existante comme lambda.
- Nous avons déjà vu une façon de le faire, par exemple :
(s) -> System.out.println(s)permet d’exposer la fonctionprintlncomme lambda. - Cependant, cet exemple est un peu artificiel, techniquement nous encapsulons juste une fonction existante.
- Une approche plus élégante consiste à exposer directement la fonction existante en utilisant l’opérateur de référence
de méthode. On peut simplement utiliser
System.out::printlnpour désigner la fonctionprintlncomme lambda.String::foo
Exemple de code :
void methodReferenceOperator() {
ArrayList<Integer> numbers = new ArrayList<>();
numbers.add(5);
numbers.add(9);
numbers.add(8);
numbers.add(1);
numbers.forEach(System.out::println);
}
Flux (Streams)
- Java est (principalement) un langage impératif, c’est-à-dire que le code s’exécute ligne par ligne.
- Néanmoins, il est possible de résoudre certains problèmes avec un concept dédié à la programmation fonctionnelle :
les
Streams.
Que dit l’API ?
Illustrations
Voyons ce que signifie concrètement cette définition formelle !
- En général, on traite les données élément par élément, par exemple dans une collection.
- Par exemple, si l’on veut compter combien de nombres premiers se trouvent dans un intervalle donné :
void main() {
List<Integer> list = new ArrayList<>();
for (int i = 1; i <= 10000000; i++) {
list.add(i);
}
// Checking iteratively for primes, count
int result = 0;
for (Integer i : list) {
if (isPrime(i)) {
result++;
}
}
System.out.println(result);
}
Récapitulons :
- Le code a fonctionné :
- Il a correctement déterminé qu’il y a
664579nombres premiers en dessous de10 millions. - Il a fallu environ
1,4secondes pour calculer le résultat.
- Il a correctement déterminé qu’il y a
- Nous avons une collection (
ArrayList list) qui stocke toutes les valeurs de1à15.- La collection stocke les données.
- La collection n’opère pas sur les données (nous avons besoin d’une boucle supplémentaire pour le faire).
Voyons maintenant comment une version utilisant un flux fonctionnerait :
- Avec un stream, nous pourrions directement effectuer des opérations d’agrégation sur les éléments, notamment :
- Mapping (transformation des éléments)
- Reducing (filtrage / regroupement des éléments)
Map-Reduce
Ces deux ingrédients suffisent pour un traitement de données de type Map&Reduce – un paradigme puissant permettant d’obtenir d’énormes gains d’efficacité grâce au traitement parallèle.
- Voici une version avec stream qui fait la même chose que la boucle initiale :
- Les données sont d’abord converties en flux.
- Les éléments sont transformés en valeurs entières (
map) - Les éléments sont filtrés (
reduce) - Les éléments sont comptés (
reduce)
void main() {
List<Integer> list = new ArrayList<>();
for (int i = 1; i <= 15; i++) {
list.add(i);
}
System.out.println(
// convert to stream
list.stream()
// map
.mapToInt(Integer::intValue)
// reduce
.filter(i -> isPrime(i))
// reduce
.count());
}
La version avec stream de notre code est un peu plus rapide, elle calcule le même résultat en seulement 1,2 secondes.
Streams et lambdas
Les lambdas sont un outil essentiel pour le map & reduce :
- Mapping : Nous pouvons utiliser une lambda pour définir efficacement comment une valeur est transformée.
- Reducing : Nous pouvons utiliser une lambda pour définir efficacement quelles conditions s’appliquent pour le filtrage.
Streams et multithreading
C’est le moment de se demander pourquoi le gain de performance n’a pas été d’un ordre de grandeur supérieur.
- Nous avons correctement implémenté notre algorithme en variante map-reduce en utilisant un stream et des lambdas.
- Nous n’avons observé qu’une augmentation de performance de
14%: pas mal, mais pas révolutionnaire. - La raison est que nous n’exploitons pas encore tout le potentiel des streams.
Revenons à la définition initiale du stream :
- Elle indique : les Streams supportent "[...]des opérations d’agrégation séquentielles et parallèles".
- Jusqu’ici, nous utilisons uniquement des opérations séquentielles ! Nous avons un stream, et il est traité élément par élément !
Heureusement, il existe une solution simple : nous pouvons utiliser la méthode parallelStream() :
void main() {
List<Integer> list = new ArrayList<>();
for (int i = 1; i <= 15; i++) {
list.add(i);
}
System.out.println(
// convert to PARALLEL stream
list.parallelStream()
// map
.mapToInt(Integer::intValue)
//reduce
.filter(i -> isPrime(i))
//reduce
.count());
}
Avec parallelStream(), nous indiquons à la JVM de répartir la vérification des nombres premiers des éléments du stream
sur tous les cœurs CPU disponibles.
- Selon votre machine, cela entraîne des gains de performance importants.
- La machine 8 cœurs utilisée par le prof mesure
0,3secondes – soit seulement21%du temps initial !
Une solution parallèle n’est pas toujours plus performante
La distribution du traitement des éléments individuels du stream sur plusieurs cœurs nécessite toujours une planification en arrière-plan par la JVM, ce qui engendre un certain overhead. Pour les petits streams, une solution parallèle n’est pas forcément plus rapide, car le coût de planification peut dépasser les gains de performance des cœurs CPU parallèles. Dans notre exemple, l’approche parallèle n’est plus avantageuse qu’au-delà d’environ 1 million d’éléments.
Inversion de contrôle
L’inversion de contrôle est un terme générique désignant plusieurs contextes de programmation où le développeur cède volontairement le contrôle à une entité externe.
- Elle s’accompagne souvent d’un contexte déclaratif, c’est-à-dire que le développeur ne déclare que ce qui est demandé, et non comment y parvenir. Il cède le contrôle sur le mécanisme spécifique pour atteindre l’objectif global.
- Également appelée le "principe d’Hollywood" : "Ne nous appelez pas, nous vous appellerons !"
Le principe d’Hollywood
Le nom provient d’une situation stéréotypée dans les castings de films. De nombreux acteurs postulent pour un rôle et, impatients de savoir s’ils ont été retenus, appellent à plusieurs reprises le studio pour obtenir des nouvelles. Évidemment, c’est une perte de ressources, car le studio peut simplement appeler les acteurs une fois la décision prise. Les candidats doivent accepter que la situation échappe à leur contrôle et ne pas tenter d’interférer dans le processus.
Patron Observer
Le scénario du casting peut être traduit directement en concept de programmation. Il s’agit de gérer un événement externe, par exemple un clic sur un bouton UI ou un changement d’état sur un serveur distant.
Garder le contrôle
Le polling est un exemple frappant de maintien du contrôle (là où il ne faudrait pas).
- Qu’il s’agisse d’un événement sur un clic de bouton ou d’un changement de statut sur un serveur, l’implémentation la plus naïve consiste à vérifier en boucle infinie :
flowchart TD
Start([Start])
CheckStatus[Check resource status]
IsReady{Event happened?}
Wait[Wait for a while]
Process[Do something]
End([End])
Start --> CheckStatus
CheckStatus --> IsReady
IsReady -- Yes --> Process
IsReady -- No --> Wait --> CheckStatus
Process --> End
Problèmes :
- La plupart du temps, il n’y a pas de nouvelles.
- Le timing est critique :
waittrop long : l’application est non réactive.waittrop court : l’application consomme trop de CPU.
Céder le contrôle
La réponse classique de l’"inversion de contrôle" à cette situation est d’abandonner la boucle de polling au profit d’un patron Observer, c’est-à-dire d’enregistrer un gestionnaire pour l’événement d’intérêt.
import java.beans.EventHandler;
public static void main() {
// Create a handler that embodies a specific behaviour.
EventHandler handler = new EventHandler() {
@Override
public void onEvent() {
System.out.println("The button was clicked!");
}
};
// Tell resource to notify the handler, when event happened.
Resource resource = ...
resource.registerEventHandler(handler);
}
Injection de dépendances
Un autre exemple d’"inversion de contrôle" est l’"injection de dépendances".
Nous en apprendrons davantage sur l’injection de dépendances dans un prochain cours.
Réflexion
La réflexion est la pratique consistant à modifier les propriétés d’une classe à l’exécution.
- Habituellement, toutes les propriétés d’une classe (constructeur, champs, méthodes, etc.) sont définies dans le code source et transformées en bytecode par le compilateur. On considère généralement les classes compilées comme figées, reflétant parfaitement ce qui a été traduit depuis le code source.
- La réflexion consiste à modifier les classes après compilation, c’est-à-dire que nous pouvons changer des champs ou modifier l’accès aux méthodes à l’exécution.
- Java possède une capacité intégrée de réflexion, c’est-à-dire qu’aucune bibliothèque supplémentaire n’est nécessaire pour l’appliquer.
Exemples
Dans ce qui suit, nous allons examiner deux exemples montrant comment la réflexion peut être utilisée pour modifier les propriétés d’une classe à l’exécution.
Modification de paramètres
- Supposons une classe simple représentant l’
Examnoté d’un étudiant. - Nous pouvons imaginer comme exigence que, une fois l’objet créé, la note ne puisse pas être modifiée.
- Une façon d’implémenter une telle classe est d’utiliser un champ privé, accessible uniquement en lecture (getter) :
/**
* Represents a graded exam. We assume a grade can never be changed.
*/
public class Exam {
// private field for grade. Is only exposed via getter.
private final char grade;
/**
* Exam constructor. This is the only way of setting the grade.
* @param grade
*/
Exam(char grade) {
this.grade = grade;
}
/**
* Getter for grade.
* @return the grade as character from A-F.
*/
public char getGrade() {
return grade;
}
}
Assez solide, pourrait-on dire. Cependant, comme la réflexion permet de modifier la classe à l’exécution,
nous pouvons rendre n’importe quel champ "accessible" (équivalent à public)
et modifier la valeur malgré tout :
public static void main(String[] args) throws Exception {
// Create an immutable exam object and print the initial status.
Exam exam = new Exam('C');
System.out.println("Original grade: " + exam.getGrade());
// Access the private field 'grade' (change to public)
Field gradeField = Exam.class.getDeclaredField("grade");
gradeField.setAccessible(true); // bypass private access
// Modify the field
gradeField.setChar(exam, 'A');
System.out.println("Modified grade: " + exam.getGrade());
}
Final ajoute une protection supplémentaire
Les versions récentes de Java ont ajouté des mécanismes de sécurité supplémentaires pour empêcher l’accès aux champs internes d’une classe si nécessaire. Lorsqu’un field est final, des mécanismes de sécurité empêchent l’accès naïf via la réflexion.
Objenesis
- Un second exemple (un peu "diabolique") est une petite bibliothèque appelée Objenesis.
- Objenesis a une seule fonction : créer des objets de classes.
Pourquoi quelqu’un en aurait-il besoin ?
Certaines bibliothèques / frameworks imposent leur propre façon d’obtenir des classes spécifiques. Avec Objenesis, vous pouvez contourner leur "inversion de contrôle" et reprendre le contrôle sur le moment et la manière de créer n’importe quel objet que vous souhaitez.
- Supposons que nous ayons quelque chose qui ne peut pas être instancié :
/**
* We assume this class is provided as is, we have no write access (e.g. it comes from a maven repo,
* as JAR).
*/
class UnconstrucableBlob {
/**
* Private constructor. This is nasty, because how do we ever create an object of this blob?
*/
private UnconstrucableBlob() {
}
/**
* Calling the helloWorld method requires an instance. But how to create one if the constructor is
* private ?
*/
public void helloWorld() {
System.out.println("You've done the impossible - your blob is alive!");
}
}
Cela signifie que nous ne pouvons pas simplement appeler le constructeur (il est privé) :
public static void main(String[] args) {
// This will not work (constructor is private)
UnconstrucableBlob blob = new UnconstrucableBlob();
}
Cependant, nous pouvons utiliser Objenesis :
main() {
Objenesis objenesis = new ObjenesisStd();
UnconstrucableBlob blob =
objenesis.getInstantiatorOf(UnconstrucableBlob.class).newInstance();
blob.helloWorld();
}
Comment Objenesis crée-t-il une nouvelle instance de blob ?
Avec la réflexion. La bibliothèque crée un nouveau constructeur, ou modifie le constructeur existant pour le rendre public.
Annotations
- La réflexion est souvent utilisée en combinaison avec les annotations.
- Les annotations Java sont de petits indicateurs qui peuvent être ajoutés à :
- Des définitions de classe
- Des définitions de variables
- Des définitions de méthodes
- Des définitions de champs
- La plupart des annotations sont ignorées par le compilateur, c’est-à-dire qu’elles n’ont pas de comportement associé direct.
- Par exemple, nous pouvons créer notre propre annotation "@Toto" et l’ajouter à une classe, sans rien casser.
- Le compilateur ignorera simplement l’annotation.
Annotations personnalisées
Définir une nouvelle annotation est simple :
import java.lang.annotation.*;
// We need this annotation to survive the compile process. Retention ensures so.
@Retention(RetentionPolicy.RUNTIME)
// We need our annotation to be placeable exclusively in front of class fields
@Target(ElementType.FIELD)
public @interface GradeInfo {
// The fields within this annotation definition define what payload can be added.
String value() default "N/A"; // Optional parameter
boolean required() default false;
}
Nous pouvons désormais utiliser notre annotation personnalisée au niveau du code, pour annoter les champs d’une classe :
public class Exam {
// We want to set a default value to every midterm exam grade, in case not initialized, using reflection.
// To do so, we add an annotation to the grade field.
@GradeInfo(value = "midterm", required = true)
private char grade;
public Exam(char grade) {
this.grade = grade;
}
}
Enfin, la vérification des annotations à l’exécution se fait grâce à la réflexion :
public static void main(String[] args) throws NoSuchFieldException {
// Use reflection to look up annotation information at runtime:
Field gradeField = Exam.class.getDeclaredField("grade");
// Note: No objects are required to inspect class attributes
if (gradeField.isAnnotationPresent(GradeInfo.class)) {
GradeInfo annotation = gradeField.getAnnotation(GradeInfo.class);
System.out.println("GradeInfo value: " + annotation.value());
System.out.println("Is required: " + annotation.required());
}
}
Annotations et réflexion
Les annotations sont souvent utilisées en combinaison avec la réflexion, afin de réduire le code répétitif.
Toutes les annotations n’indiquent pas pour autant l’usage de la réflexion, pour donner deux exemples :
| Framework / Bibliothèque | Utilise des annotations | Modifie le code à |
|---|---|---|
| Lombok | Oui | Compilation (APT) |
| Spring Core | Oui | Exécution (avec réflexion) |
La gestion des conteneurs de Beans est une forme avancée d’injection de dépendances. Avec Spring Core, presque toutes les classes (Beans) sont initialisées, gérées et détruites par un framework externe, par exemple Spring Core. Cela concerne notamment leur configuration, qui est définie via un fichier de configuration explicite ou des conventions de syntaxe. L’intérêt principal est de garder le code propre de tout ce qui est nécessaire uniquement pour l’instanciation de classes (le code doit contenir uniquement la logique réelle, et non le code répétitif nécessaire à la préparation des classes).
Conclusion
La réflexion est un outil puissant, mais comme toujours, un grand pouvoir implique de grandes responsabilités.
- La réflexion est notoirement difficile à déboguer, car le code que vous inspectez (code source) n’est pas le code que vous exécutez (modifié à l’exécution par la réflexion).
- Il est facile d’écrire du code que personne ne comprend plus, utilisez la réflexion avec précaution.
Réfléchissez-y à deux fois
Chaque fois que vous êtes tenté d’utiliser la réflexion, réfléchissez bien à savoir si elle est réellement nécessaire, et si les bénéfices surpassent les inconvénients potentiels pour la lisibilité et la compréhensibilité du code.
Bibliographie
Inspiration et lectures complémentaires pour les esprits curieux :
Voici le lien pour accéder à l’unité de labo : Lab 05