UP | HOME

Immutabilité - Interfaces et classes d'implémentations - Correction

Table of Contents

Session 1 - Travaux dirigés - 4 mai 2015 - 1h15 - Correction

Préalable : terminer les exemples du cours du 27 avril

Cf. l'archive de code.

Une interface et des classes d'implémentation pour les entiers naturels

Paquet cm1.demo1

  • Interface Nat
  • Classes d'implémentation NatParInt etr NatDecimal
  • Interface FabriqueNat
  • Classes d'implémentation FabriqueNatParInt et FabriqueNatDecimal
  • Test des classes d'implémentation

Une hiérarchie de structures algébriques

Paquet cm2.demo2

  • Hiérarchie des onze interfaces correspondant aux structures algébriques

Exercice 1 : sur l'immutabilité

Revenons aux entiers naturels. On a vu deux implémentations qui partagent une propriété commune, celle de définir des objets dits immutables : une fois créés, les entiers naturels conservent leur état initial, si bien que chaque calcul se traduit par la création d'un nouvel objet pour stocker le résultat.

// Implémentation de NatParInt
public Nat somme(Nat x){
  return new NatParInt(this.getInt() + x.getInt());
}
// Implémentation de NatDecimal
public Nat somme(Nat x){
  // ...
  int retenue = 0;
  String chiffres = "";
  for(int i=0; i < max; i++){
    int result = retenue + this.chiffre(i) + x.chiffre(i);
    //...
    chiffres = result + chiffres;
  }
  // ...
  return new NatDecimal(retenue == 1 ? "1" + chiffres : chiffres);
}

On peut imaginer des implémentations alternatives, qui modifient en place les entiers naturels sans créer de nouveaux objets à chaque calcul : l'état peut alors varier après la construction. On dit alors que les objets sont mutables.

Réalisation d'une implémentation mutable

  • Dans un paquet td1.mutabilite, créer une nouvelle classe NatParInt implémentant cm1.demo1.Nat.

    Rappel : l'interface Nat

    public interface Nat {
          Nat somme(Nat x);
          int getInt();
          int chiffre(int i);
          int taille();
    }
    
  • Récupérer le code de la classe NatParInt définie dans le paquet cm1.demo1. Voici le squelette de la classe dont seuls les éléments pertinents sont conservés.
    public class NatParInt implements Nat {
          // unique attribut
          private int i;
          // constructeur initialisant l'attribut
          public NatParInt(int i) {
                  ...
                  this.i = i;
          }
          @Override
          public Nat somme(Nat x){
                  return new NatParInt(this.getInt() + x.getInt());
          }
          @Override
          public int getInt() {
                  return this.i;
          }
          // autres méthodes
          ...
    }
    
  • Ajouter un accesseur privé en écriture void setInt(int i).

    Solution

    private void setInt(int i){
            this.i = i;
    }
    
  • Modifier la méthode somme de manière à modifier en place l'objet cible (celui référencé par this).

    Solution

    public Nat somme(Nat x){
            this.setInt(this.getInt() + x.getInt());
            return this;
    }
    
  • Réaliser une transformation analogue pour l'autre implémentation, en définissant une nouvelle classe td1.mutabilite.NatDecimal. Pour cela, récupérer le code de la classe NatDecimal définie dans le paquet cm1.demo1, y ajouter un accesseur en écriture et modifier la méthode somme. Voici le squelette de la classe NatDecimal dont seuls les éléments pertinents sont conservés.
    public class NatDecimal implements Nat {
          // unique attribut (suite de chiffres décimaux (entre 0 et 9))  
          private String chiffres;
    
          public NatDecimal(String chiffres) {
                  ...
                  this.chiffres = chiffres;
          }
          @Override
          public Nat somme(Nat x){
                  String c = "";
                  ...
                  return new NatDecimal(c);
          }
          // autres méthodes
    }
    

    Solution

    private void setChiffres(String chiffres){
            this.chiffres = chiffres;
    }
    public Nat somme(Nat x){
            String c = "";
            ...
            this.setChiffres(c);
            return this;
    }
    
  • Définir deux fabriques, une par classe d'implémentation. Chaque fabrique implémente l'interface cm1.demo1.FabriqueNat, rappelée ci-dessous.
    public interface FabriqueNat {
      Nat gen(int x);
      Nat gen(String repDecimale);
    }
    

    Note (et rappel du cours) : Il est possible d'abstraire la construction des objets, en utilisant des fabriques. Une fabrique est un objet particulier dont le rôle est de construire des objets : précisément, elle contient des méthodes dont l'implémentation se réduit à l'invocation d'un constructeur. Une bonne pratique de programmation est de n'utiliser les constructeurs que dans les fabriques et de construire les objets hors fabriques uniquement via les fabriques. Ainsi il suffit de changer la valeur d'une fabrique pour changer de constructeurs. On verra l'intérêt des fabriques dans le test qui va suivre. Pour une démonstration, voir la vidéo : Utiliser des fabriques pour réduire la dépendance vis-à-vis des classes d'implémentation (18'20).

    Solution

    // Code identique aux fabriques d'entiers immutables (au paquet près)
    public class FabriqueNatParInt implements FabriqueNat{
          @Override
          public Nat gen(int x) {
                  return new NatParInt(x);
          }
          @Override
          public Nat gen(String repDecimale) {
                  return new NatParInt(Integer.parseInt(repDecimale));
          }
    }
    public class FabriqueNatDecimal implements FabriqueNat {
          @Override
          public Nat gen(int x) {
                  return new NatDecimal(Integer.toString(x));
          }
          @Override
          public Nat gen(String repDecimale) {
                  return new NatDecimal(repDecimale);
          }
    }
    

Immutabilité contre mutabilité : comment choisir

  • En préalable, pour pouvoir comparer les entiers naturels, définir dans toutes les classes d'implémentation de Nat une méthode equals spécifiée ainsi : si l'argument n'est pas de type Nat, renvoyer false, sinon tester l'égalité des Nat en comparant les int associés. Réutiliser le même code dans toutes les classes.

    Solution

    public boolean equals(Object obj) {
            if(!(obj instanceof Nat))
                    return false;
            Nat x = (Nat)obj;
            return this.getInt() == x.getInt();
    } 
    
  • Créer une classe de test dans le paquet td1.mutabilite.
  • Réaliser le test suivant dans une fonction void comparerMutabilite(FabriqueNat fab).
    • En utilisant la fabrique fab, créer un entier naturel n7 de valeur 7 et un entier naturel n1 de valeur 1.

      Rappel : l'interface FabriqueNat

      public interface FabriqueNat {
        Nat gen(int x);
        Nat gen(String repDecimale);
      }
      
    • Comparer n7 à n7.somme(n1) en utilisant la méthode equals, et afficher le résultat de la comparaison.

    Solution

    private static void comparerMutabilite(FabriqueNat fab) {
            Nat n1 = fab.gen(1);
            Nat n7 = fab.gen(7);
            System.out.println("classe " + fab.getClass() + " : " + n7.equals(n7.somme(n1)));
    }
    
  • Appeler la fonction comparerMutabilite avec les quatre fabriques, celles permettant de créer des entiers immutables et celles permettant de créer des entiers mutables. Qu'observe-t-on lors de l'exécution ?

    Solution

    public static void main(String[] args) {
            FabriqueNat fabDecM = new FabriqueNatDecimal();
            FabriqueNat fabIntM = new FabriqueNatParInt();
            FabriqueNat fabDecI = new cm1.demo1.FabriqueNatDecimal();
            FabriqueNat fabIntI = new cm1.demo1.FabriqueNatParInt();
    
            comparerMutabilite(fabDecM);
            comparerMutabilite(fabIntM);
            comparerMutabilite(fabDecI);
            comparerMutabilite(fabIntI);
    }
    

    Exécution :

    classe class td1.mutabilite.FabriqueNatDecimal : true
    classe class td1.mutabilite.FabriqueNatParInt : true
    classe class cm1.demo1.FabriqueNatDecimal : false
    classe class cm1.demo1.FabriqueNatParInt : false
    

    On obtient une égalité pour les entiers mutables.

  • Analyser les résultats.

    La variable n7 contient initialement une référence k pointant vers un entier de valeur 7. Distinguons les deux cas.

    • Cas 1 : données immutables

      Le calcul de n7.somme(n1) produit un nouvel entier de valeur 8 et renvoie une référence l pointant vers cet entier. La comparaison de l'entier référencé par k et de l'entier produit référencé par l se ramène à comparer 7 à 8, ce qui donne faux.

    • Cas 2 : données mutables

      Le calcul de n7.somme(n1) modifie l'état de l'entier référencé par k, qui passe de 7 à 8, puis renvoie this, autrement dit la référence k. La comparaison de l'entier référencé par k et de l'entier référencé par le même k se ramène à comparer 8 à 8, ce qui donne vrai.

    Conclusion : il vaut mieux utiliser des données immutables à cause de leurs propriétés logiques. C'est ce que nous ferons dans ce cours. Cependant, elles peuvent être moins efficaces que des données mutables.

Une synthèse

Bien se rappeler la définition.

  • Donnée mutable = donnée dont l'état peut changer
  • Donnée immutable = donnée non mutable

La forme d'une interface, précisément le typage des opérations, peut permettre de déterminer la mutabilité des données associées.

void somme(Nat x)mutable
Nat somme(Nat x)immutable ou mutable

Dans le second cas, la documentation de l'interface doit préciser s'il s'agit de données mutables ou non.

D'un point de vue logique, seules les données immutables permettent de raisonner correctement. Comme on l'a vu, avec des données mutables, on obtient des incohérences, rédhibitoires.

Les données immutables possèdent ainsi une propriété importante, la transparence référentielle. Leur adresse en mémoire importe peu : elle ne sert qu'à se référer à la donnée dont seul l'état, permanent, est significatif. On peut donc imaginer pour un programme une situation où une donnée particulière est représentée par un unique objet en mémoire : elle sera donc manipulée par une unique référence, qui sera partagée par tous les utilisateurs de cette donnée. A l'opposé, on peut imaginer une situation où cette même donnée est représentée par de nombreux objets en mémoire : elle sera donc manipulée par de nombreuses références. Conséquence de la transparence référentielle, ces situations sont équivalentes. En revanche, avec des données mutables, ces situations ne le sont pas : le partage d'une référence est un point crucial, aux conséquences importantes. Pour raisonner avec des données mutables, il est donc nécessaire de prendre en compte non seulement les états des objets mais aussi leurs références (c'est-à-dire leurs adresses en mémoire). Pour un approfondissement de cette question, voir le document en complément du cours (p. 53, sur Campus).

Si les données mutables n'ont pas de bonnes propriétés logiques, elles se révèlent en revanche efficaces. A titre d'exemple, on pourra exécuter la fonction principale de la classe td1.complement.StringVSStringBuilder. Celle-ci utilise pour un même algorithme deux implémentations de chaînes de caractères, celle immutable, définie par la classe String, et celle mutable, définie par la classe StringBuilder. On observe un rapport de mille dans l'exécution : c'est que la complexité n'est pas la même (linéaire contre quadratique), à cause de la création de nouveaux objets pour chaque calcul dans le cas des String.

Recommandation finale : utiliser des données immutables quand on peut, des données mutables sinon.

En pratique, un programme s'organise souvent à partir de types qu'on peut classer en deux catégories :

  • des acteurs, peu nombreux, qui organisent le flot des données, et sont plutôt mutables,
  • des données, plus nombreuses, que s'échangent les acteurs et qui sont immutables.

Lorsque le fait d'utiliser des données immutables pénalise les performances du programme, les données immutables peuvent être localement converties en données mutables suivant le schéma ci-dessous.

public String fonctionPenalisante(String s){
  String res = ...;
  // nombreux calculs concernant s et res
  ... s ... res ... 
  return res;
}
// Passage de String à StringBuilder 
// tout en préservant l'interface   
public String fonctionPlusEfficace(String s){
  // Coercition de l'argument
  StringBuilder ms = new StringBuilder(s);
  StringBuilder mres = ... ;
  // nombreux calculs concernant ms et mres
  ... ms ... mres ...
  // Coercition du résultat
  return new String(mres);
}

Résultat : non seulement l'utilisateur de la fonction peut raisonner logiquement puisque l'interface utilise des données immutables, mais aussi la fonction s'exécute plus efficacement (d'une manière plus rapide et plus économe pour la mémoire).

Exercice 2 : un type pour les logs

Souvent, un système informatique conserve des logs de toutes les actions qu'il réalise. Nous allons définir un type de données pour les logs. On suppose qu'une interface Action est donnée.

  • Définir les accesseurs suivants dans l'interface Log.
    • Action getPremiereAction()
    • Log getActionsRestantes()
    • Action getAction(int i)
    • int taille()
  • On peut munir l'ensemble des logs d'une addition, réalisant la concaténation des logs. Faire hériter l'interface Log de la bonne structure algébrique.

    Rappel : voici quelques interfaces décrivant des structures algébriques. Elles sont génériques (paramétrées par un type) car elles expriment des propriétés de types.

    public interface SemiGroupeAdditif<T> {
          T somme(T x);
    }
    public interface MonoideAdditif<T> extends SemiGroupeAdditif<T> {
          T zero();
    }
    public interface GroupeAdditif<T> extends MonoideAdditif<T> {
          T oppose();
    } 
    

On trouvera l'ensemble de ces interfaces dans le paquet cm1.demo2.hierarchie.

Solution

public interface Log extends MonoideAdditif<Log>{
              Action getPremiereAction();
              Log getActionsRestantes();
              Action getAction(int i);
              int taille();
}

Cette définition peut se lire ainsi : soit le type de données Log tel que Log est un monoïde additif et possède quatre méthodes (des accesseurs).

  • Implémenter l'interface Log par une classe LogParListe utilisant une liste de type List<Action>. Définir trois constructeurs, l'un sans argument, le second prenant une action et un log, le troisième une liste d'actions. Indication pour le traitement des listes : on pourra utiliser la méthode add de l'interface générique List.

    Structure d'une classe d'implémentation :

    • attributs
    • constructeurs initialisant les attributs
    • accesseurs
    • services

    Solution

    import java.util.LinkedList;
    import java.util.List;
    public class LogParListe implements Log {
          private List<Action> liste;
    
          public LogParListe(List<Action> neoListe) {
                  this.liste = neoListe;
          }
    
          public LogParListe() {
                  this(new LinkedList<Action>());
          }
    
          public LogParListe(Action a, Log l) {
                  this.liste = new LinkedList<Action>();
                  this.liste.add(a);
                  for (int i = 0; i < l.taille(); i++) {
                          this.liste.add(l.getAction(i));
                  }
          }
    
          @Override
          public Log zero() {
                  return new LogParListe();
          }
    
          @Override
          public Log somme(Log x) {
                  List<Action> neoListe = new LinkedList<Action>();
                  for (int i = 0; i < this.taille(); i++) {
                          neoListe.add(this.getAction(i));
                  }
                  for (int i = 0; i < x.taille(); i++) {
                          neoListe.add(x.getAction(i));
                  }
                  return new LogParListe(neoListe);
          }
    
          @Override
          public Action getPremiereAction() {
                  return liste.get(0);
          }
    
          @Override
          public Log getActionsRestantes() {
                  List<Action> neoListe = new LinkedList<Action>();
                  for (int i = 1; i < this.taille(); i++) {
                          neoListe.add(this.getAction(i));
                  }
                  return new LogParListe(neoListe);
          }
    
          @Override
          public Action getAction(int i) {
                  return liste.get(i);
          }
    
          @Override
          public int taille() {
                  return liste.size();
          }
    
          @Override
          public String toString() {
                  String s = "";
                  for (int i = 0; i < this.taille(); i++) {
                          s = s + this.getAction(i);
                  }
                  return s;
          }
    } 
    
  • Proposer une seconde implémentation. On pourra utiliser deux classes, l'une pour représenter l'élément neutre de l'addition, l'autre pour représenter les autres logs.

    On retrouvera cette construction lors du premier TP.

    Solution

    public class LogVide implements Log {
    
          public static final Log ZERO = new LogVide();
    
          private LogVide(){}
    
          @Override
          public Log zero() {
                  return this;
          }
    
          @Override
          public Log somme(Log x) {
                  return x;
          }
    
          @Override
          public Action getPremiereAction() {
                  throw new UnsupportedOperationException("Log vide : pas de première action.");
          }
    
          @Override
          public Log getActionsRestantes() {
                  throw new UnsupportedOperationException("Log vide : pas d'actions restantes.");
          }
    
          @Override
          public Action getAction(int i) {
                  throw new UnsupportedOperationException("Log vide : pas d'actions.");
          }
    
          @Override
          public int taille() {
                  return 0;
          }
    
          @Override
          public String toString() {
                  return "";
          }
    }
    
    public class LogConstruit implements Log {
    
          private Action premiereAction;
          private Log actionsRestantes;
    
          public LogConstruit(Action premiereAction, Log actionsRestantes) {
                  this.premiereAction = premiereAction;
                  this.actionsRestantes = actionsRestantes;
          }
    
          @Override
          public Log zero() {
                  return LogVide.ZERO;
          }
    
          @Override
          public Log somme(Log x) {
                  return new LogConstruit(this.getPremiereAction(), this.getActionsRestantes().somme(x));
          }
    
          @Override
          public Action getPremiereAction() {             
                  return this.premiereAction;
          }
    
          @Override
          public Log getActionsRestantes() {
                  return this.actionsRestantes;
          }
    
          @Override
          public Action getAction(int i) {
                  if(i == 0)
                          return this.getPremiereAction();
                  return getActionsRestantes().getAction(i - 1);
          }
    
          @Override
          public int taille() {
                  return 1 + this.getActionsRestantes().taille();
          }
    
          @Override
          public String toString() {
                  return this.getPremiereAction().toString() + this.getActionsRestantes().toString();
          }
    }
    
  • Tester les implémentations.

    Solution

    public class ActionNommee implements Action {
          private String nom;
          public ActionNommee(String s) {
                  this.nom = s;
          }
          @Override
          public String toString() {
                  return this.nom;
          }
    }
    public class Test {
          private static Action action(String s) {
                  return new ActionNommee(s);
          }
          public static void main(String[] args) {
                  Log vide = new LogParListe(new LinkedList<Action>());
                  Log a = new LogParListe(action("A"), vide);
                  Log b = new LogParListe(action("B"), vide);
                  Log ab = a.somme(b);
                  Log ab2 = ab.somme(ab);
                  System.out.println(a);
                  System.out.println(b);
                  System.out.println(ab);
                  System.out.println(ab2);
    
                  vide = LogVide.ZERO;
                  a = new LogConstruit(action("A"), vide);
                  b = new LogConstruit(action("B"), vide);
                  ab = a.somme(b);
                  ab2 = ab.somme(ab);
                  System.out.println(a);
                  System.out.println(b);
                  System.out.println(ab);
                  System.out.println(ab2);
    
          }
    }
    

Last Updated 2015-05-11T17:56+0200. Comments or questions: Send a mail.