Dans cette séance, on va s’intéresser à des design patterns permettant de structurer des objets complexes :
Notre jeu est un vrai succès et de nombreux joueurs demandent un peu de diversité dans la niveau des ennemis. Pour l’instant, tous les Gobelins ont les mêmes caractéristiques. Ils aimeraient bien que certains d’entre eux soient des mini boss, d’autres des boss, etc. Il est facile de répondre aux attentes des joueurs en utilisant l’héritage :
L’inconvénient de cette méthode, c’est que l’on rajoute 3 nouvelles classes pour les Gobelins mais on doit faire la même chose avec les Orcs, les Elfs, les Dragons, les Nains, etc. Cela fait beaucoup de nouvelles classes à créer.
Pire encore, après avoir créé ces classes, les joueurs ont vu le film « L’incroyable Hulk » et demandent à ce que les ennemis puissent muter pendant une courte période de temps. Pendant cette période, leur force est décuplée et ils deviennent tout vert. On doit donc modifier notre hiérarchie de classe :
On sent bien que cette stratégie n’est pas la bonne : on doit en effet créer trop de sous-classes. Le problème, ici, c’est que l’héritage est une forme trop « statique » d’extension des classes. Le design pattern Decorator (Décorateur) permet de pallier cela.
Définition
La problématique générale que le design pattern Decorator essaie de résoudre est l’ajout dynamique de nouveaux comportements à des objets sans avoir à créer une hiérarchie de classe démente.
La solution apportée par le design pattern Decorator pour l’ensemble des Ennemis peut être décrit grâce au diagramme UML suivant :
L’idée consiste à créer une classe abstraite EnnemiDecorator qui, à la fois :
hérite de l’interface Ennemi,
contient par composition une instance d'Ennemi.
Ainsi, tout EnnemiDecorator est un Ennemi. Comme il contient une instance d'Ennemi, il peut déléguer à cette instance les appels aux méthodes incluses dans l’interface Ennemi. De plus, comme on hérite du décorateur, on peut redéfinir ces méthodes comme on le souhaite. Notez qu’en plus l'Ennemi inclus par composition peut lui-même être un EnnemiDecorator, ce qui permet de créer facilement, par exemple, des boss Gobelins hulkisés.
Pour être plus clair, voici un exemple en Java :
package fr.polytech.aco.jeu.ennemi;
public interface Ennemi {
public String attaquer();
}
package fr.polytech.aco.jeu.ennemi;
public class Gobelin implements Ennemi {
public String attaquer() {
return "attaque d'un Gobelin";
}
}
package fr.polytech.aco.jeu.ennemi;
public class Orc implements Ennemi {
public String attaquer() {
return "attaque d'un Orc";
}
}
Pour l’instant, on a créé les classes d'Ennemis classiques. Maintenant, passons au décorateur. Celui-ci contient par composition un Ennemi et on peut voir qu’il délègue l’exécution de la méthode attaquer() à cette instance d'Ennemi.
package fr.polytech.aco.jeu.ennemi;
public abstract class EnnemiDecorator implements Ennemi {
protected Ennemi ennemi;
public EnnemiDecorator(Ennemi ennemi) {
this.ennemi = ennemi;
}
public String attaquer() {
return this.ennemi.attaquer();
}
}
Les codes de Boss et Hulkise ci-dessous montrent l’idée clef des enfants du décorateur : ils redéfinissent la méthode attaquer() comme ils le souhaitent.
package fr.polytech.aco.jeu.ennemi;
public class Boss extends EnnemiDecorator {
public Boss(Ennemi ennemi) {
super(ennemi);
}
public String attaquer() {
return this.ennemi.attaquer() + " Boss";
}
}
package fr.polytech.aco.jeu.ennemi;
public class Hulkise extends EnnemiDecorator {
public Hulkise(Ennemi ennemi) {
super(ennemi);
}
public String attaquer() {
return this.ennemi.attaquer() + " hulkisé";
}
}
Enfin, le main() montre comment créer et utiliser des ennemis de différents types.
package fr.polytech.aco.jeu;
import fr.polytech.aco.jeu.ennemi.*;
public class EtatJeu {
public static void main(String[] args) {
// on crée un ennemi standard
Ennemi ennemi1 = new Gobelin();
// on crée un Boss Orc
Ennemi ennemi2 = new Boss(new Orc());
// on crée un Boss Gobelin qui est, en plus, hulkisé
Ennemi ennemi3 = new Hulkise(new Boss(new Gobelin()));
System.out.println(ennemi1.attaquer());
System.out.println(ennemi2.attaquer());
System.out.println(ennemi3.attaquer());
}
}
Le résultat du programme :
attaque d'un Gobelin
attaque d'un Orc Boss
attaque d'un Gobelin Boss hulkisé
Avantages :
On peut étendre facilement le comportement des classes sans nécessiter une hiérarchie de classes démente.
Les comportements modifiés peuvent être assez complexes puisqu’on peut enchaîner les compositions (comme le boss hulkisé).
Étant donné que les compositions sont réalisées à runtime (à l’exécution) et non à la compilation (comme l’héritage), on peut modifier dynamiquement le comportement des classes en cours d’exécution du programme. Il suffit alors de changer les classes incluses par composition.
Les décorateurs respectent le principe Ouvert/Fermé.
Inconvénients :
L’ordre des compositions peut être important (par exemple new Hulkise(new Boss(new Orc)) ne donnera pas les memes affichages que new Boss(new Hulkise(new Orc))). Dans ce cas, il faut faire attention au code que l’on génère.
Notre jeu de rôle prévoit que l’on rencontre et combatte individuellement des Ennemis. Et si on le corsait un peu en prévoyant que certains Ennemis sont en bandes. On pourrait rajouter une nouvelle classe BandeEnnemis qui contient une liste d”Ennemis et faire en sorte que ÉtatJeu contienne un nouvel attribut bandes qui contient les bandes d'Ennemis. Mais cela va complexifier ÉtatJeu, ce qui n’est peut-être pas nécessaire car, dans une bande, les Ennemis se déplacent tous de la même manière et, quand le joueur combat la bande, il combat tous les Ennemis qu’elle contient. Donc, finalement, une BandeEnnemis se comporte comme un Ennemi solitaire. Peut-être pourrait-on unifier les BandeEnnemis et les Ennemis. C’est l’objectif du design pattern Composite.
Définition
L’objectif du design pattern Composite est de créer des objets complexes contenant d’autres objets (en fait une arborescence d’objets) et d’offrir une interface uniforme pour l’ensemble de ces objets.
La solution apportée est la même que celle du Décorateur. Essentiellement, c’est la sémantique qui diffère : dans un décorateur, on souhaite rajouter des fonctionnalités dynamiquement en cours d’exécution du programme; dans un objet composite, on souhaite maintenir ensemble un groupe d’objets dont le comportement est similaire. Pour notre problème de bandes d'Ennemis, voilà le diagramme UML correspondant, où BandeEnnemis est l’objet composite :
En termes Java, on aurait un code similaire à celui ci-dessous, l’objet Composite étant la classe BandeEnnemis:
package fr.polytech.aco.jeu.ennemi;
public interface Ennemi {
public String attaquer();
}
package fr.polytech.aco.jeu.ennemi;
public class Gobelin implements Ennemi {
public String attaquer() {
return "attaque d'un Gobelin";
}
}
package fr.polytech.aco.jeu.ennemi;
public class Orc implements Ennemi {
public String attaquer() {
return "attaque d'un Orc";
}
}
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;
}
}
import fr.polytech.aco.jeu.ennemi.*;
public class EtatJeu {
public static void main(String[] args) {
// une bande avec un Orc et un Gobelin
BandeEnnemis bande1 = new BandeEnnemis();
bande1.addEnnemi(new Orc());
bande1.addEnnemi(new Gobelin());
System.out.println(bande1.attaquer());
// une bande contenant la bande précédente + un Gobelin
BandeEnnemis bande2 = new BandeEnnemis();
bande2.addEnnemi(new Gobelin());
bande2.addEnnemi(bande1);
System.out.println(bande2.attaquer());
}
}
Le résultat du programme :
attaque d'un Orc ; attaque d'un Gobelin
attaque d'un Gobelin ; attaque d'un Orc ; attaque d'un Gobelin
Avantages :
Manipuler des objets complexes (arborescents) est relativement simple.
L’objet composite cache sa complexité, c’est-à-dire que c’est lui et non les classes qui l’utilisent qui doivent effectuer des appels récursifs pour réaliser les opérations sur l’ensemble des éléments inclus dans l’objet composite.
L’objet composite respecte le principe Ouvert/Fermé.
Inconvénients :
Ce n’est pas toujours évident d’avoir une interface commune à tous les objets contenus dans l’objet composite.
Quels patrons pensez-vous pouvoir utiliser dans l’application de votre petite entreprise de restauration ? Modifiez en conséquence son diagramme de classes.