Dans cette séance, on va s’intéresser à des design patterns permettant :
Reprenons le code de l’objet Composite BandeEnnemis :
package fr.polytech.aco.jeu.ennemi;
import fr.polytech.aco.MyArray;
public class BandeEnnemis implements Ennemi {
private MyArray<Ennemi> ennemis;
public BandeEnnemis() {
this.ennemis = new MyArray<>(2);
}
public BandeEnnemis(Ennemi[] ennemis) {
this.ennemis = new MyArray<>(ennemis);
}
public void addEnnemi(Ennemi ennemi) {
this.ennemis.pushBack(ennemi);
}
public String attaquer() {
String res = "";
for (var ennemi : this.ennemis)
res += ennemi.attaquer() + " ; ";
res = res.substring(0, res.length() - 3);
return res;
}
}
Dans la méthode attaquer(), on est obligé de faire une boucle for pour parcourir tous les ennemis de la bande. On a vu précédemment que l’on avait deux possibilités pour faire cette boucle :
La boucle for traditionnelle : for (int i=0; i < ennemis.getSize(); i++)
La boucle range-for ou for-each : for (var ennemi : ennemis)
La seconde ne s’applique pas tout le temps mais elle a le mérite de cacher la structure interne du MyArray. Cette seconde boucle utilise des itérateurs.
Définition
L’objectif du design pattern Iterator est de fournir des classes (itérateurs) qui permettent de parcourir les éléments d’une collection sans révéler la représentation interne de cette dernière.
L’idée consiste à faire en sorte que la collection d’objets (MyArray pour notre jeu de rôle) implémente une interface Iterable dont l’objectif est d’imposer l’existence d’une méthode créant des itérateurs. Ici, la collection d’objets crée ainsi des instances de ConcreteIterator. Ceux-ci implémentent les méthodes communes à tous les itérateurs : une méthode hasNext() pour indiquer s’il reste encore des éléments à parcourir dans la collection et une méthode next() qui permet de passer à l’élément suivant dans la collection.
En ce qui concerne MyArray, un code possible est le suivant :
// contient les interfaces Iterable et Iterator
import java.util.Iterator;
public class MyArray<T> implements Iterable<T> {
private T[] array;
private int size = 0;
// on définit ici ce qu'est un itérateur
public class MyIterator<T> implements Iterator<T> {
private MyArray<T> myArray; // agrégation
private int index = 0; // l'itérateur pointe sur le 1er élément
public MyIterator(MyArray<T> array) {
this.myArray = array;
}
@Override
public boolean hasNext() {
return (this.index < this.myArray.size);
}
@Override
public T next() {
if (this.index < this.myArray.size)
return this.myArray.array[this.index++];
else
return null;
}
}
// les méthodes de la classe MyArray
public MyArray(int capacity) {
if (capacity <= 1) capacity = 2;
this.array = (T[]) new Object[capacity];
}
public Iterator<T> iterator() {
return new MyIterator<T>(this);
}
}
Ici, MyArray<T> implémente l’interface classique de Java Iterable<T>. Cela signifie que MyArray<T> s’engage à fournir une méthode iterator() qui renverra un itérateur pointant sur son premier élément. La définition de cet itérateur (classe MyIterator<T>) est réalisée à l’intérieur de la classe MyArray<T> (au tout début de la classe). Cela a son importance car cela permettra à MyIterator<T> d’accéder aux champs privés de MyArray<T>, ce qui simplifie le code de cet itérateur. Initialement, quand on crée l’itérateur, ce dernier pointe sur le premier élément de l’attribut array de MyArray<T>. Chaque fois qu’on a besoin d’un élément, on appelle la méthode next() qui nous le renvoie. La méthode hasNext(), elle, renvoie un booléen indiquant si la méthode next() est en capacité de nous renvoyer un élément. Dit autrement, l’équivalent de la boucle for (int i=0; i < ennemis.getSize(); i++) {…} est :
var iter = ennemis.iterator();
while (iter.hasNext()) {
var ennemi = iter.next();
.....
}
Avantages :
Les itérateurs permettent de respecter le principe Ouvert/Fermé : quand on remplace une collection (MyArray) par une autre (ArrayList), on n’a rien à modifier dans notre code. Les appels aux itérateurs pour parcourir ces collections sont les mêmes.
Le principe de responsabilité unique est également préservé puisque les parcours des collections sont entièrement réalisés par les itérateurs et c’est leur unique tâche.
Les itérateurs offrent des possibilités de parcours assez flexibles. Par exemple, on peut imaginer dans un arbre binaire de recherche des itérateurs pour réaliser des parcours en profondeur, d’autres en largeur, etc.
Inconvénients :
Je ne vois pas trop d’inconvénient à utiliser des itérateurs.
Dans la séance n°7, on avait prévu le diagramme de classes suivant pour modéliser le jeu de rôles :
La classe GUI a pour responsabilité d’afficher l’espace de jeu visible. À cet effet, elle peut rafraîchir souvent cet affichage pour être sûre de tenir compte des déplacement des ennemis (qui deviennent alors visibles). De même, lorsque le joueur combat un ennemi, ce dernier peut mourir et nécessiter un réaffichage. En rafraichissant suffisamment souvent la fenêtre, on peut faire en sorte que le jeu soit fluide. Une autre alternative pourrait être que, lorsque les ennemis se déplacent ou qu’ils combattent, ils en informent GUI, de manière à ce que les rafraichissements n’aient lieu que quand on en a besoin. Mettre en place un tel système de notification est précisément le rôle du design pattern Observer.
Définition
La problématique que le design pattern Observer essaie de résoudre est la mise en place d’un système de notification qui permet à une classe (le Publisher) de notifier d’autres classes (les Subscribers) d’une information qui les intéresse.
La solution apportée par le design pattern Observer peut être décrit grâce au diagramme UML suivant :
Concrètement, pour notre jeu de rôles, le Publisher est ÉtatJeu et le ConcreteSubscriber est la classe GUI.
En Java, cela donnerait un code similaire à :
public interface Subscriber {
public void update(String notif);
}
public class GUI implements Subscriber {
public GUI() {
System.out.println("construction de GUI");
}
@Override
public void update(String message) {
System.out.println("GUI reçoit le message " + message);
}
}
import fr.polytech.aco.MyArray;
public class EtatJeu {
private MyArray<Subscriber> subscribers;
public EtatJeu() {
this.subscribers = new MyArray<>(10);
}
public void subscribe(Subscriber subscriber) {
subscribers.pushBack(subscriber);
}
public void unsubscribe(Subscriber subscriber) {
subscribers.remove(subscriber);
}
public void notifySubscribers() {
for (var subscriber: subscribers)
subscriber.update("Notification");
}
public static void main(String[] args) {
var etatJeu = new EtatJeu();
var gui1 = new GUI();
var gui2 = new GUI();
etatJeu.subscribe(gui1);
etatJeu.subscribe(gui2);
etatJeu.notifySubscribers();
}
}
Avantages :
Le principe Ouvert/Fermé est respecté puisque le Publisher ne connaît ses abonnés qu’au travers de l’interface Subscriber. On peut également assurer cela côté Subscriber en faisant en sorte que Publisher implémente une interface InterPublisher qui est la seule connue par les Subscribers.
Le design pattern évite que l’on ait à mettre en place un timer pour que les Subscribers soient au courant des modifications du Publisher, ou que le Publisher ait explicitement à appeler des méthodes de plein de Subscribers pour leur envoyer des informations.
Le Publisher peut envoyer des notifications à un nombre arbitraire de Subscribers.
Inconvénients :
On ne maîtrise pas forcément l’ordre dans lequel les Subscribers sont notifiés, ce qui peut poser des problèmes dans certaines applications.
Un de vos amis, qui joue souvent aux jeux vidéos, a joué à votre jeu de rôle. Il le trouve super mais il dit que, dans les jeux vidéos, on peut souvent sauvegarder le jeu en cours de partie et c’est une fonctionnalité intéressante car elle permet de ne pas avoir à repartir « from scratch » quand notre personnage meurt. No problemo, il suffit de rajouter une méthode sauvegardeJeu() dans la class ÉtatJeu. Mais si on écrit tout le code de sauvegarde dans cette méthode, on va violer le principe Ouvert/Fermé parce que, si on rajoute de nouveaux types d'Ennemis, il faudra modifier cette méthode. Qu’à cela ne tienne, il suffit de rajouter une méthode sauvegardeEnnemi() dans chacune des classes d'Ennemi. Mais, là, on viole le principe de responsabilité unique : un Ennemi est là pour se promener dans le jeu et vous attaquer, pas pour interagir avec le système de fichiers. C’est d’autant plus vrai si, une fois les méthodes de sauvegarde sur fichier écrites, votre ami vous dit que « c’est has been d’écrire sur fichier, maintenant tout se passe sur le cloud ». Du coup, il faut rajouter de nouvelles méthodes de sauvegarde dans vos Ennemis. C’est bof. Heureusement, le design pattern Visitor est là pour vous sauver.
Définition
La problématique qu’essaie de résoudre le design pattern Visitor est la définition d’une nouvelle opération, d’un nouveau comportement, à des classes existantes, sans modifier ces classes.
La première idée qui vient à l’esprit est de créer une nouvelle classe SauvegardeJeu (c’est notre Visiteur) qui contiendra des méthodes de sauvegarde, une par type d'Ennemi. Ensuite, pour chaque Ennemi, SauvegardeJeu() appelle la méthode de sauvegarde spécifique à l'Ennemi. Cela donnerait par exemple :
public class EtatJeu {
private MyArray<Ennemi> ennemis;
public EtatJeu() {
this.ennemis = new MyArray<>(10);
}
public void addGobelin() {
this.ennemis.pushBack(new Gobelin());
}
public void addOrc() {
this.ennemis.pushBack(new Orc());
}
public MyArray<Ennemi> getEnnemis() {
return this.ennemis;
}
}
public class SauvegardeJeu {
public void sauvegardeJeu(EtatJeu etatJeu) {
for (var ennemi : etatJeu.getEnnemis()) {
this.sauvegardeEnnemi(ennemi);
}
}
public void sauvegardeEnnemi(Gobelin gobelin) {
System.out.println("sauvegarde d'un gobelin");
}
public void sauvegardeEnnemi(Orc orc) {
System.out.println("sauvegarde d'un orc");
}
}
Sur la ligne soulignée en jaune, par polymorphisme, on appelle la méthode sauvegardeEnnemi() correspondant à l’ennemi passé en argument et le tour est joué… Heu… Non, en fait, cela ne va pas fonctionner. En effet, sur la ligne en jaune, tout ce que connaît la classe sauvegardeJeu, c’est que ennemi est de type Ennemi. Et, à la compilation, c’est tout ce que l’on connaît. Le compilateur ne pourra donc pas choisir la bonne méthode sauvegardeEnnemi(). D’ailleurs, vous obtiendrez une erreur de compilation. Mais, souvenez-vous, on avait exactement la même problématique avec l’exercice 1 de la séance n°4. Ce que l’on va faire, c’est que l’on va demander à l'Ennemi d’appeler la méthode de sauvegarde qui va bien : lui sait s’il est un Orc ou un Gobelin et il pourra donc indiquer au compilateur quelle méthode de sauvegarde doit être appelée. Cette technique s’appelle le double dispatch (double répartition en français). Cela nous donne le code ci-dessous. D’abord la classe de sauvegarde (le visiteur) :
public interface Visiteur {
public void visiter(Orc ennemi);
public void visiter(Gobelin ennemi);
public void visiter(BandeEnnemis ennemi);
public void visiter(Boss ennemi);
public void visiter(Hulkise ennemi);
}
public class SauvegardeJeu implements Visiteur {
public void sauvegardeJeu(EtatJeu etatJeu) {
for (var ennemi : etatJeu.getEnnemis()) {
ennemi.accepter(this);
}
}
public void visiter(Gobelin gobelin) {
System.out.println("sauvegarde d'un gobelin");
}
public void visiter(Orc orc) {
System.out.println("sauvegarde d'un orc");
}
public void visiter(Boss boss) {
System.out.println("sauvegarde d'un boss");
}
public void visiter(Hulkise hulkise) {
System.out.println("sauvegarde d'un hulkisé");
}
public void visiter(BandeEnnemis bande) {
for (var ennemi : bande) {
ennemi.accepter(this);
}
}
}
Ensuite, on crée une interface Visitable que chaque Ennemi implémente :
public interface Visitable {
public void accepter(Visiteur visiteur);
}
public interface Ennemi extends Visitable {
public String attaquer();
}
public class Orc implements Ennemi {
public String attaquer() {
return "attaque d'un Orc";
}
public void accepter(Visiteur visiteur) {
visiteur.visiter(this);
}
}
Enfin, la classe ÉtatJeu qui déclenche la sauvegarde :
import fr.polytech.aco.MyArray;
public class EtatJeu {
private MyArray<Ennemi> ennemis;
public EtatJeu() {
this.ennemis = new MyArray<>(10);
}
public void addEnnemi(Ennemi ennemi) {
this.ennemis.pushBack(ennemi);
}
public MyArray<Ennemi> getEnnemis() {
return this.ennemis;
}
public static void main(String[] args) {
BandeEnnemis bande1 = new BandeEnnemis();
bande1.addEnnemi(new Boss(new Orc()));
bande1.addEnnemi(new Gobelin());
BandeEnnemis bande2 = new BandeEnnemis();
bande2.addEnnemi(new Gobelin());
bande2.addEnnemi(bande1);
var etatJeu = new EtatJeu();
etatJeu.addEnnemi(new Orc());
etatJeu.addEnnemi(bande1);
etatJeu.addEnnemi(bande2);
var sauvegarde = new SauvegardeJeu();
sauvegarde.sauvegardeJeu(etatJeu);
}
}
Il est à noter que, dans le code ci-dessus, pour simplifier, ÉtatJeu contient une méthode getEnnemis() qui permet à SauvegardeJeu de récupérer la liste des Ennemis à sauvegarder. Il vaudrait mieux fournir un itérateur permettant de parcourir la liste des Ennemis. Cela permettrait en effet de ne pas exposer la structure de données MyArray, de manière à préserver le principe Ouvert/Fermé (si on remplace cette structure par un ArrayList par exemple, aucune autre classe n’est impactée).
En termes de diagramme de classes UML, cela revient au diagramme ci-dessous. Le point de départ est la classe Client (la main() d'ÉtatJeu) qui demande au VisiteurConcret (la classe SauvegardeJeu) de réaliser une action (sauvegardeJeu) sur notre ElémentVisitable (ÉtatJeu). Celui-ci demande alors à l'ElémentVisitable d’accepter le Visiteur. L’acceptation consiste à appeler la méthode visiter() du visiteur qui prend en paramètre chaque type concret d”ElémentVisitable. La méthode visiter() réalise alors le travail qu’on demandait au VisiteurConcret de réaliser.
Avantages :
Le principe Ouvert/Fermé des éléments visitables est respecté : si l’on rajoute de nouvelles méthodes de sauvegarde, on n’aura pas à modifier ces éléments.
Le principe de Responsabilité Unique est respecté puisque le visiteur n’a qu’une tâche à réaliser et celle-ci n’a pas besoin d’être définie dans les éléments visitables.
Il est aisé de rajouter de multiples fonctionnalités à un élément visitable : une fois que celui-ci implémente l’interface visitable, on peut lui plugger n’importe quel visiteur.
Inconvénients :
Les visiteurs ne respectent pas le principe Ouvert/Fermé : si vous rajoutez de nouveaux types d’ennemis, ils doivent être mis à jour.
On peut être amené à exposer, au moins en partie, le contenu des classes visitables.
Quels patrons pensez-vous pouvoir utiliser dans l’application de votre petite entreprise de restauration ? Modifiez en conséquence son diagramme de classes.