• 6 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 30/11/2016

Utilisez vos annotations dans votre code

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Dans ce chapitre nous allons apprendre à nous servir de nos annotations dans notre code.
Pour ce faire, nous allons voir une nouvelle notion : l'implémentation dynamique. Comme vous pourrez le voir, ceci va changer votre façon de concevoir vos architectures objets et vos objets, mais pour la bonne cause.

Nous verrons aussi et surtout comment utiliser cette implémentation dynamique dans vos codes pour rendre vos annotations plus… utilisables et fonctionnelles.

Ce chapitre va vous permettre d'élucider beaucoup de mystères... il va falloir vous accrocher car ça se complique un peu, mais tenez le coup, je suis sûr que ça va vous plaire ! :diable:

L'implémentation dynamique : proxy et InvocationHandler

Avant de vous plonger dans les méandres de l'introspection pour utiliser les annotations, je tiens à éclaircir cette notion d'implémentation dynamique.
Alors, qu'est-ce que ça peut bien pouvoir être. Avez-vous une idée ?

J'imagine que nous allons pouvoir utiliser des méthodes de façon dynamique...

Résumé à l'extrême, c'est exactement ça et, concrètement, voilà quelques exemples d'utilisation d'implémentation dynamique dans le monde Java :

  • Les conteneurs EJB qui permettent de rajouter des méthodes et des interfaces à des objets Java sans que le développeur n'ait besoin de le faire ;

  • Le "lazy loading" (chargement paresseux) dans Hibernate ;

  • Les conteneurs AOP comme Spring qui permettent de rajouter des fonctionnalités à des objets Java ;

  • etc.

Pour réussir ce tour de force, toutes ces technologies ont recours à l'implémentation dynamique et pour utiliser ce potentiel du langage nous allons devoir utiliser ce qu'on appelle vulgairement un proxy.

Un quoi ?

Un proxy. En fait, il s'agit d'un bête objet qui aura pour seule et unique raison de vivre de faire le médiateur entre votre objet de référence et celui qui l'utilise... Pour faire une analogie, prenons l'exemple d'un jeune homme fluet qui souhaite faire du racket dans la cours d'école... Ce jeune rebelle peut utiliser un proxy pour faire le travail à sa place : un garçon beaucoup plus costaud. Il pourra même superviser de loin les opérations en lui donnant des instructions via une oreillette, façon James Bond. :-°

Voilà en quoi va consister la création de proxy Java : faire des objets qui auront les mêmes fonctions que d'autres mais que nous allons pouvoir manipuler et modeler à notre convenance et où nous pourrons ajouter dynamiquement des fonctionnalités.

Et comment ça fonctionne, concrètement ?

J'allais y venir...
En fait, les proxys que nous allons utiliser sont des implémentations d'interfaces que nous allons créer au préalable. Ce sera grâce à ces interfaces que nous allons pouvoir intercepter les méthodes que nous souhaitons... Le but étant de rajouter des traitements ou des actions sur certaines méthodes d'un objet...

Pour vous donner un aperçu de ce qu'il est possible de faire, je vous propose de modifier le fonctionnement d'une méthode de la classe String.

Je croyais que la classe String étais déclarée final et que, par conséquent, nous ne pouvions pas faire d'héritage sur cette classe ?

Tout à fait, mais c'est tout de même ce que nous allons faire en utilisant ce dont je vous ai parlé plus tôt...

Pour ce faire, nous allons avoir besoin d'une interface qui définit les méthodes que nous pourrons utiliser et d'une classe qui implémente cette interface. Voici les codes sources que j'ai utilisés :

StringInterface.java
public interface StringInterface {
   public String toString();
   public int hashCode();
   public String substring(int start);
}
StringInterfaceImplementation.java
public class StringInterfaceImplementation implements StringInterface {

   private String monString;

   public StringInterfaceImplementation(String str){
      monString = str;
   }
   
   @Override
   public String substring(int start) {
      //nous allons retirer les espaces en début et en fin de chaîne
      //avant de faire notre substring...
      return monString.trim().substring(start);
   }

   @Override
   public String toString() {
      return monString;
   }

   @Override
   public int hashCode() {
      return monString.hashCode();
   }

   //Méthodes qui vont nous servir à illustrer le principe du proxy
   public String getString(){
      return monString;
   }
   public void setString(String str){
      monString = str;
   }

}

Ce que je vais faire maintenant, c'est créer un objet proxy du type StringInterfaceImplementation où nous pourrons exécuter du code lorsque nous allons invoquer les méthodes définies dans l'interface StringInterface.
Nous allons procéder en trois étapes :

  1. Créer un objet qui va se charger d'espionner toutes les invocations de méthodes présentes dans notre interface. Ceci se fait grâce à l'interface InvocationHandler.

  2. Écrire le code qui va créer nos proxy en utilisant les objets qui surveillent les méthodes appelées;

  3. Tester le tout !

Voici un diagramme de classe de ce que nous allons mettre en place :

Mise en place d'un proxy

Nous avons déjà le code de notre interface et de son implémentation (qui va nous servir de proxy pendant cet exemple), nous allons donc coder l'objet qui va surveiller nos proxies. Comme vous l'aurez sans doute compris, c'est dans cet objet que les fonctionnements seront modifiés ou ajoutés. De ce fait, je vous propose donc deux implémentations afin de bien voir ce qu'il se passe. Voici le code source de ces deux objets :

ProxyInvocationHandlerReverse
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

class ProxyInvocationHandlerReverse implements InvocationHandler {

      //On y stocke l'objet d'origine, le proxy donc
      private StringInterfaceImplementation monString;
       
      public ProxyInvocationHandlerReverse(StringInterfaceImplementation str){
         monString = str;
      }
       
      //Méthode à redéfinir pour y coder le fonctionnement souhaité
      public Object invoke(Object proxy, Method method, Object[] args) 
                     throws Throwable {
            //On récupère le nom de la méthode
            String methodName = method.getName();
           
           //Vous verrez que ceci sera affiché
           //à l'invocation des méthodes présentent dans l'interface
           System.out.println("----------------------------------------------------");
           System.out.println("Invocation de la méthode " + methodName);
          
           if(methodName.equals("substring")){
              System.out.println("avant substring ! ");
              //Dans cet objet, nous allons retourner notre chaîne de caractère 
              //avant l'appel de la méthode
              
              char[] initial = monString.getString().toCharArray();
              String reverse = new String();
              
              for(int i = (initial.length-1); i > 0; i--)
                 reverse += initial[i];
              
              monString.setString(new String(reverse));
           }
          
           //invocation de la méthode concernée
           return method.invoke(monString, args);
      }
   }
ProxyInvocationHandlerUpperCase
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

class ProxyInvocationHandlerUpperCase implements InvocationHandler {

      //On y stocke l'objet d'origine, le proxy donc
      private StringInterfaceImplementation monString;
       
      public ProxyInvocationHandlerUpperCase(StringInterfaceImplementation str){
         monString = str;
      }
       
      //Méthode à redéfinir pour y coder le fonctionnement souhaité
      public Object invoke(Object proxy, Method method, Object[] args) 
                     throws Throwable {
           
           //On récupère le nom de la méthode appelée
           String methodName = method.getName();
           
           //Vous verrez que ceci sera afficher 
           //à l'invocation des méthodes présentent dans l'interface
           System.out.println("----------------------------------------------------"quot;);
           System.out.println("Invocation de la méthode " + methodName);
          
          
           //invocation de la méthode concernée
           Object o = method.invoke(monString, args);
           
           //Après que la méthode en question soit invoquée
           if(methodName.equals("toString")){
              System.out.println("Après toString ! ");
              //On passe notre chaîne en majuscule après l'invocation de toString
              //et cette modification n'est effective qu'après l'exécution de la méthode
              monString.setString(monString.getString().toUpperCase());
           }
                       
           //Cette instruction retourne le résultat de l'invocation de la méthode
           //donc, dans notre cas, soit le hashcode soit la chaîne de caractères
           return o;
      }
   }

Les commentaires devraient vous aider à comprendre le fonctionnement de ces classes… En fait, elles redéfinissent chacune la même méthode : substring(int start) mais d'une façon différente.
Pour être tout à fait clair, les proxys que nous allons créer ne vont pas appeler eux-mêmes leurs méthodes mais ils vont déléguer cette tâche à ces objets qui, eux, vont utiliser leurs méthodes invoke(). Voici un petit schéma qui vous montre ce qu'il va se passer :

Proxy et InvocationHandler

Maintenant, la dernière étape : la création des proxys avec interceptions des invocations de méthode.
Pour cette dernière étape nous allons utiliser un objet déjà présent dans Java, l'objet Proxy et sa méthode statique : newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h).
Cette méthode va donc se charger de créer notre proxy en fonction des informations que nous allons lui transmettre.

  • Le premier argument correspond au chargeur de classe utilisé par la JVM, vu que nous n'avons pas de chargeur de classe personnalisé, nous allons prendre celui de noter classe.

  • Le second argument est un tableau d'objet Class correspondant aux différentes interfaces que notre proxy devra implémenter.

  • Le troisième et dernier argument est une implémentation de l'interface InvocationHandler que notre proxy utilisera.

Vu que nous avons deux implémentations servant à gérer les invocations, j'ai donc créé deux fabriques de proxy, que voici :

ReverseStringProxyFactory
import java.lang.reflect.Proxy;

public class ReverseStringProxyFactory {
   public static StringInterface newInstance(String str) {
      return (StringInterface) Proxy.newProxyInstance(
             ProxyInvocationHandlerReverse.class.getClassLoader(),
                  new Class[]{StringInterface.class},
                  new ProxyInvocationHandlerReverse(new StringInterfaceImplementation(str))
              );
   }   
}
UpperCaseStringProxyFactory
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class UpperCaseStringProxyFactory {
   public static StringInterface newInstance(String str) {
       return (StringInterface) Proxy.newProxyInstance(
             ProxyInvocationHandlerUpperCase.class.getClassLoader(),
                  new Class[]{StringInterface.class},
                  new ProxyInvocationHandlerUpperCase(new StringInterfaceImplementation(str))
              );
   }   
}

Et voilà, nous avons tout ce dont nous avons besoin pour tester la création de proxys et l'ajout dynamique de comportements.
Bref rappel, nous avons donc :

  • une interface qui défini des fonctionnalités;

  • une implémentation qui implémente cette interface et modifie le comportement d'une méthode de l'objet String;

  • deux gestionnaires d'invocations qui vont encore modifier ce comportement;

  • deux objet qui se chargent de fabriquer des proxys.

Voilà maintenant un code de test et vous pourrez voir, avec ravissement, que les méthodes substring() sont bien modifiées à la volée par noter gestionnaire d'invocation :

public class DebugProxy {

   public static void main(String[] args) {
      
      //utilisation d'un String normal
      String str1 = new String("    Ma phrase 1     ");
      System.out.println(str1);
      System.out.println(str1.hashCode());
      System.out.println("Sous chaîne : *" + str1.substring(5) + "*");      
      System.out.println(str1.getClass());
      
      //Création d'un proxy de type UpperCase et utilisation des méthodes
      System.out.println("###############################################");
      StringInterface str2 = UpperCaseStringProxyFactory.newInstance(str1);
      System.out.println(str2);
      System.out.println(str2.hashCode());
      System.out.println("Sous chaîne : *" + str2.substring(5) + "*");
      //La sous chaîne est bien en majuscule ! ^^
      System.out.println(str2.getClass());
      //cette méthode ne faisant pas parti de l'interface, le gestionnaire n'est pas appelé
      
      //Création d'un proxy de type reverse et utilisation des méthodes
      System.out.println("###############################################");
      StringInterface str3 = ReverseStringProxyFactory.newInstance(str1);
      System.out.println(str3);
      System.out.println(str3.hashCode());
      System.out.println("Sous chaîne : *" + str3.substring(5) + "*");
      //La sous chaîne est bien à l'envers ! ;)
      System.out.println(str3.getClass());
      //cette méthode ne faisant pas parti de l'interface, le gestionnaire n'est pas appelé
   }
}

Et le résultat

Ma phrase 1     
1370497130
Sous chaîne : *a phrase 1     *
class java.lang.String
###############################################
----------------------------------------------------
Invocation de la méthode toString
Après toString ! 
    Ma phrase 1     
----------------------------------------------------
Invocation de la méthode hashCode
-1523227574
----------------------------------------------------
Invocation de la méthode substring
Sous chaîne : *RASE 1*
class $Proxy0
###############################################
----------------------------------------------------
Invocation de la méthode toString
    Ma phrase 1     
----------------------------------------------------
Invocation de la méthode hashCode
1370497130
----------------------------------------------------
Invocation de la méthode substring
avant substring ! 
Sous chaîne : *rhp aM*
class $Proxy0

Voilà, vous connaissez maintenant le principe de base de la création de proxy en utilisant les outils présents dans le langage Java.
Je vous propose maintenant de mettre cet outil en corrélation avec le sujet qui nous intéresse : les annotations.

Des annotations fonctionnelles

Le moment est venu d'utiliser des annotations dans nos codes sources pour ajouter du dynamisme à nos programmes.
Ce que je vous propose c'est de reprendre notre proxy vu ci-dessus et d'y ajouter une annotation utilisable, ça vous va ?

Que pouvons-nous rajouter ?.. Que diriez-vous de crypter le retour de la méthode toString() et de la méthode substring(int start) ?

La première étape revient à nous demander ce que nous voulons faire : crypter une chaîne de caractère selon un algorithme de cryptage existant.

Cet objet accepte beaucoup de types de cryptages dont, parmi eux, les types MD5 et SHA-1.
Nous allons donc définir des types de cryptage en fonction de ce qu'accepte ce fameux objet, via une énumération, que voici :

package com.sdz.annotation;

public enum TypeCryptage {
   MD5("MD5"), SHA1("SHA1");
   
   private String type;
   
   TypeCryptage(String typ){
      type = typ;
   }
   
   public String toString(){
      return type;
   }
}

Nous allons maintenant définir notre annotation pour que celle-ci soit utilisable dans notre code.

package com.sdz.annotation;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface Crypted {
   public TypeCryptage type() default TypeCryptage.MD5;
}

Nous allons maintenant annoter nos deux méthodes de notre implémentation d'interface, comme ceci :

import com.sdz.annotation.Crypted;
import com.sdz.annotation.TypeCryptage;


public class StringInterfaceImplementation implements StringInterface {

   private String monString;

   public StringInterfaceImplementation(String str){
      monString = str;
   }
   
   @Crypted(type=TypeCryptage.SHA1) 
   @Override
   public String substring(int start) {
      //nous allons retirer les espaces en début et en fin de chaîne
      //avant de faire notre substring...
      return monString.trim().substring(start);
   }

   @Crypted 
   @Override
   public String toString() {
      return monString;
   }

   @Override
   public int hashCode() {
      return monString.hashCode();
   }
   
   //Méthodes qui vont nous servir à illustrer le principe du proxy
   public String getString(){
      return monString;
   }
   public void setString(String str){
      monString = str;
   }
}

Et maintenant la phase finale, l'utilisation de cette annotation dans notre handler. Attention, ça pique les yeux :

import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.security.MessageDigest;

import com.sdz.annotation.Crypted;

class ProxyInvocationHandlerUpperCase implements InvocationHandler {

      //On y stocke l'objet String d'origine
      private StringInterfaceImplementation monString;
       
      public ProxyInvocationHandlerUpperCase(StringInterfaceImplementation str){
         monString = str;
      }
       
      //Méthode à redéfinir pour y coder le fonctionnement souhaité
      public Object invoke(Object proxy, Method method, Object[] args) 
                     throws Throwable {
           
           //On récupère le nom de la méthode appelée
           String methodName = method.getName();
           
           //Vous verrez que ceci sera affiché 
           //à l'invocation des méthodes présentes dans l'interface
           System.out.println("----------------------------------------------------");
           System.out.println("Invocation de la méthode " + methodName);
           
           String monStringInitial = monString.getString(); 
           
           Method methodeToCapture = null;
           
           //S'il y a des arguments, c'est qu'il s'agit de la méthode substring...
           if((args != null)){
              //Notre méthode substring(int start) attend un objet de type int
              //Par conséquent, nous devons passer int.class dans notre recherche de méthode
              //Si nous avions utilisé args[0].class, le type passé aurait été Integer.class
              //et aurait provoqué une exception lors de la recherche de méthode 
              //car cette signature n'existe pas dans l'objet String
              methodeToCapture = monString.getClass().getMethod(method.getName(), int.class);
           }
           else{
              
              //Aucun paramètre ne doit être passé à la méthode toString()
              //Par conséquent, nous devons passer null dans notre recherche de méthode
              methodeToCapture = monString.getClass().getMethod(method.getName(), null);
              
           }
           
           //Nous vérifions que la méthode appelée est annotée
           Crypted crypt = methodeToCapture.getAnnotation(Crypted.class);
           //Si tel est le cas, on traite l'annotation
           if(crypt != null){
              
              //On récupère la chaîne à crypter
              String str = monString.getString();
              //On créer une instance de cet objet qui va se charger de crypter notre chaîne
              //Le paramètre passé correspond au type de cryptage
              MessageDigest md = MessageDigest.getInstance(crypt.type().toString());
              //Cet objet travail avec un tableau de byte, nous lui donnons donc
              //le tableau de byte correpondant à la chaîne à crypter
              md.update(str.getBytes());
              //On crypte le tout
              byte[] strByte =  md.digest();
              
              //Le cryptage n'étant pas lisible pour un œil humain
              //nous devons le transformer en caractère hexadécimal
              StringBuffer hexString = new StringBuffer();
              for (int i=0;i<strByte.length;i++) {
                 //Cette instruction permet de transformer chaque byte en caractère hexadécimal
                 hexString.append(Integer.toHexString(0xFF & strByte[i]));
              }
              //Il ne nous reste plus qu'à mettre à jour notre chaîne
              monString.setString(hexString.toString());
           }
       
           
           //invocation de la méthode concernée
           Object o = method.invoke(monString, args);
           
           //Après que la méthode en question soit invoquée
           if(methodName.equals("toString")){
              System.out.println("Après toString ! ");
              //On passe notre chaîne en majuscule après l'invocation de toString
              //et cette modification n'est effective qu'après l'exécution de la méthode
              monString.setString(monString.getString().toUpperCase());
           }
           
           //On remet tout de même la chaîne initiale dans notre objet
           monString.setString(monStringInitial);
           //Cette instruction retourne le résultat de l'invocation de la méthode
           //donc, dans notre cas, soit le hashcode soit la chaîne de caractères
           return o;
      }
   }

Il ne nous reste plus qu'à tester ce code :

import com.sdz.annotation.Crypted;


public class DebugCrypted {

   /**
    * @param args
    */
   public static void main(String[] args) {
      
      String str1 = new String("    Ma phrase 1     ");
      StringInterface str2 = UpperCaseStringProxyFactory.newInstance(str1);
      System.out.println(str2);
      System.out.println(str2.hashCode());
      System.out.println("Sous chaîne : *" + str2.substring(5) + "*");
      
   }
}

Et voici ce que ça nous donne :

----------------------------------------------------
Invocation de la méthode toString
Après toString ! 
d85315a0adbccfa8da0109b8110f0
----------------------------------------------------
Invocation de la méthode hashCode
1370497130
----------------------------------------------------
Invocation de la méthode substring
Sous chaîne : *39acc79f2cb59f7591bcba354f0e7dd72*

Alors ! C'est pas mal hein ?
Vous pouvez ainsi rajouter des fonctionnalités sans trop modifier votre code : ceci peut être très pratique mais à utiliser avec parcimonie…

Nous sommes passés par un proxy pour gérer les annotations d'un objet, mais rien ne vous empêche d'utiliser l'introspection dans un autre contexte : annoter les méthodes, les variables ou autres choses d'une classe et gérer ces annotations dans un objet qui sera invoqué pour effectuer cette tâche.

Pardon ?

Oui... Je me doute que ce soit difficile à comprendre. Voici un code qui devrait vous éclairer :

package com.sdz.test;

import java.awt.Color;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.JPanel;

public class Action2 implements ActionListener {
   private JPanel pan;

   public Action2(JPanel Ppan) {
      pan = Ppan;
   }

   public void actionPerformed(ActionEvent e) {
      pan.setBackground(Color.BLUE);
   }
}
package com.sdz.test;

import java.awt.Color;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.JPanel;

public class Action implements ActionListener {
   private JPanel pan;

   public Action(JPanel Ppan) {
      pan = Ppan;
   }

   public void actionPerformed(ActionEvent e) {
      pan.setBackground(Color.RED);
   }
}
package com.sdz.test;

import java.awt.event.ActionListener;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;

public class Fenetre extends JFrame {

   @Listener(classe = Action.class)
   private JButton bouton1 = new JButton("Bouton 1");
   @Listener(classe = Action2.class)
   private JButton bouton2 = new JButton("Bouton 2");

   private JPanel panneau = new JPanel();

   public Fenetre() {
      this.setSize(200, 200);
      panneau.add(bouton1);
      panneau.add(bouton2);

      this.getContentPane().add(panneau);
      processAnnotation();
      this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      this.setLocationRelativeTo(null);
      this.setVisible(true);
   }

   private void processAnnotation() {
      Class thisClass = this.getClass();
      Field[] listVariables = thisClass.getDeclaredFields();

      for (Field field : listVariables) {

         Listener listener = field.getAnnotation(Listener.class);
         if (listener != null) {
            // Ceci autorise la JVM à modifier le champ, même privé
            field.setAccessible(true);

            try {
               // Nous allons créer notre instance d'écouteur dynamiquement
               Class<? extends ActionListener> obj = listener.classe();

               // et son constructeur
               Constructor<? extends ActionListener> construct = obj
                     .getConstructor(new Class[] { JPanel.class });
               // Nous avons maintenant notre objet implémentant ActionListener
               Object db = construct.newInstance(panneau);
               // Cette méthode permet de récupérer explicitement la variable
               // sur laquelle nous travaillons
               Object monBouton = field.get(this);

               // On récupère la méthode souhaitée, addActionListener qui attend
               // un paramètre du type que je ne vous présente plus : ActionListener
               Method adder = monBouton.getClass().getMethod(  "addActionListener",
                                                               ActionListener.class);

               // Nous invoquons cette méthode sur notre bouton
               // En lui passant le paramètre attendu

               adder.invoke(monBouton, db);
               // Nous affichons un petit débuggage, pour la forme. ^^
               System.out.println("Création dynamique d'un écouteur de type "
                                    + db.getClass().getName());
               System.out.println("Sur la variable " + field.getName() + "\n");

            } catch (IllegalAccessException | IllegalArgumentException
                  | InvocationTargetException e) {
               e.printStackTrace();
            } catch (NoSuchMethodException | SecurityException e) {
               e.printStackTrace();
            } catch (InstantiationException e) {
               e.printStackTrace();
            }
         }
      }
   }

   @Retention(RetentionPolicy.RUNTIME)
   private @interface Listener {
      Class<? extends ActionListener> classe();
   }

   public static void main(String args[]) {
      new Fenetre();
   }
}

Et le résultat de ce code nous donne bien une fenêtre affichant deux boutons qui changent la couleur de fond de notre composant, comme prévu :

Image utilisateur
Image utilisateur

Et voilà, vous venez de voir comment créer et utiliser des annotations dans votre code de façon dynamique. :magicien:
Je vous l'accorde, ceci est assez complexe et la gestion des erreurs risque de vous torturer quelques temps mais au moins vous savez que ça existe et vous savez le faire.

Ce chapitre aura été, je pense, le plus compliqué jusqu'à maintenant, donc ne vous inquiétez pas si vous n'avez pas tout compris du premier coup et retravaillez dessus si besoin ! Et si vous vous en êtes sortis indemnes, je vous invite à passer au TP que je vous ai concocté... :pirate:

Exemple de certificat de réussite
Exemple de certificat de réussite