Immutabilité - Interfaces et classes d'implémentations - Correction
Table of Contents
Session 1 - Travaux dirigés - 4 mai 2015 - 1h15 - Correction
- Voir l'archive de code.
- paquet td1.mutabilite : correction de l'exercice 1
- paquet td1.log : correction de l'exercice 2
- Vidéos
- Utiliser des fabriques pour réduire la dépendance vis-à-vis des classes d'implémentation (18'20)
- Utiliser le débogueur pour comprendre l'exécution d'un programme (à venir)
- Comment terminer rapidement les TP (à venir)
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))); }
- En utilisant la fabrique fab, créer un entier naturel n7 de
valeur 7 et un entier naturel n1 de valeur 1.
- 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.
- Cas 1 : données immutables
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); } }