Aller au contenu

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 List contient d’autres objets.
  • La List fonctionne toujours de la même manière, quel que soit l’objet stocké à l’intérieur.
  • Cependant, la List exige 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 :
List<Apple> apples = new LinkedList<>();
  • La notation <Apple> assigne la classe Apple au générique de la List. À partir de là, seules des Apples (ou sous-classes) peuvent être ajoutées à l’objet List.
    • Autorisé : apples.add(new Apple())
    • Non autorisé (incohérence générique) : apples.add(new Pair())

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 Catapult ne peut être chargée qu’avec ce pour quoi elle a été conçue. (Imaginez une catapulte construite pour des Apples, et que quelqu’un tente d’y charger des Pairs !!)
  • 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’interface ActionListener :

    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

Exemples

  • Lambda simple pour afficher la valeur reçue dans la console :

    void main() {
        // Source: https://www.w3schools.com/java/java_lambda.asp
        ArrayList<Integer> numbers = new ArrayList<Integer>();
        numbers.add(5);
        numbers.add(9);
        numbers.add(8);
        numbers.add(1);
        numbers.forEach((n) -> System.out.println(n));
    }
    
  • 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 Consumer est 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 interface Consumer !
  • Exemple de stockage d’une lambda dans une interface personnalisée :

    • Interface :
    public interface StringLambda {
    
      String modify(String s);
    }
    
    • 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 fonction println comme 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::println pour désigner la fonction println comme 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.

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 664579 nombres premiers en dessous de 10 millions.
    • Il a fallu environ 1,4 secondes pour calculer le résultat.
  • Nous avons une collection (ArrayList list) qui stocke toutes les valeurs de 1 à 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,3 secondes – soit seulement 21% 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 :
    • wait trop long : l’application est non réactive.
    • wait trop 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’Exam noté 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