Les diagrammes de package montrent l’organisation structurelle des classes en packages et modules. Ils montrent également leurs interdépendances. L’idée consiste à regrouper les classes du diagramme de classes qui forment une collection logique. Dans ce type de diagramme, les packages sont représentés comme des répertoires dans un gestionnaire de fichiers (ce qui a du sens puisqu’ils vont être stockés de cette manière une fois le programme écrit).
Revenons sur le diagramme de classes du jeu de rôles :
On voit que la partie gauche représente les interactions entre l’acteur et le système. La partie droite correspond à la gestion de l’état du jeu par le système. Il est donc logique de séparer ces deux parties en deux packages distincts. On peut imaginer qu’il existe de nombreux types d’ennemis. Dans ce cas, on peut considérer qu’il serait judicieux de placer tous les ennemis dans le même package. Cela nous donne la structuration du projet ci-dessous :
Ce diagramme représente les messages qui sont échangés chronologiquement entre les objets de l’application. En programmation objet, le seul moyen qu’ont des objets de communiquer les uns avec les autres est d’utiliser leurs méthodes. On voit donc que les messages du diagramme de séquences sont fortement liés aux descriptions précises des classes du diagramme de classes.
Un diagramme de séquences permet de spécifier les scénarios possibles d’utilisation de l’application. Parler de scénario fait penser au diagramme de cas d’usage et, effectivement, ces deux diagrammes sont, là encore, étroitement liés.
Définition
Dans un diagramme de séquence, on explicite ce qui se passe dans le système pour chaque action du diagramme de cas d’usage. Le déroulé de l’action est représenté via des messages entre les objets, qui ne sont autre que des appels de méthodes desdits objets.
En pratique, on est donc souvent amené à créer ces trois diagrammes (cas d’usage, classes, séquences) en même temps, en modifiant un diagramme quand un autre a besoin d’une telle modification. Le diagramme de séquence représente assez fidèlement le code que l’on aura à écrire dans l’application, donc il est important de ne pas le bâcler.
Note
Notez que l'on écrit ce diagramme avant le code : cela permet de s’apercevoir assez rapidement s’il y a des problèmes dans la conception de notre programme (en tous cas, beaucoup plus rapidement que si on avait écrit le code directement).
Reprenons notre exemple de jeu de rôle et concentrons-nous pour l’instant sur le choix du personnage. Voici la classe Joueur que nous avions proposé dans la séance 7 :
Si l’on utilise cette classe telle quelle, le choix du personnage reviendra essentiellement à choisir son nom. Mais, dans un jeu de rôles, on a des magiciens, des chevaliers, des voleurs, des bardes, etc., qui ont des caractéristiques différentes de force, d’intelligence, de charisme, etc. De plus, si notre jeu est graphique, il faudra utiliser des images différentes pour chacun de ces personnages. On voit donc que la spécification que l’on avait faite dans le diagramme de classes de la séance 7 n’est pas assez précise. Il faut donc adapter notre diagramme de classes :
Avec cette nouvelle partie du diagramme de classe, on a maintenant la possibilité de sélectionner un personnage : GUI doit demander à l’acteur s’il souhaite charger un personnage sauvegardé sur disque dur ou bien créer un nouveau personnage. Dans ce cas, GUI affichera les 4 possibilités : Magicien, Chevalier, Voleur, Barde. L’acteur choisit alors une de ces possibilités. On lui demande le nom du personnage et ÉtatJeu a toutes les informations pour construire le Joueur. Cela nous donne le diagramme de séquences ci-dessous :
Dans un diagramme de séquence, l’acteur et les différents objets du système correspondent aux différentes colonnes. Le temps s’écoule du haut vers le bas et les boîtes portant les noms des classes représentent les moments où les instances de ces classes sont créées. Par exemple, lorsqu’on démarre l’application, GUI et ÉtatJeu sont créés puisque leurs boîtes sont tout en haut du diagramme. En revanche, on crée les personnages après que l’acteur a spécifié celui avec lequel il souhaitait jouer. Donc les boîtes correspondant aux Magiciens, Chevaliers et Voleurs seront représentées plus bas que GUI et ÉtatJeu lorsque le diagramme sera complet. Une fois les instances créées, les lignes en pointillés représentent la durée de vie des objets (on parle de « ligne de vie »). Lorsque ces lignes s’arrêtent, cela signifie que les objets correspondants sont détruits.
Les barres verticales (ici couleur saumon) représentent les moments où les objets exécutent du code (des méthodes). Les flèches horizontales représentent les messages : celles en traits pleins sont les appels de méthodes, celles en pointillés les retours de ces dernières. Par exemple, loadPerso() est une méthode de la classe ÉtatJeu appelée par une instance de la classe GUI. Lorsqu’une instance d’une classe appelle une méthode de la même classe, on représente la flèche sous la fome d’un C retourné (cf. l’appel de la méthode demandeNomFichier()).
Lorsque l’on doit faire des if/else pour déterminer quelles méthodes appeler, on les place dans des boîtes « alt » (cf. les appels aux méthodes loadPerso() et créeNouveauPerso(), qui dépendent du choix de l’acteur (chargement à partir du disque dur ou pas)).
Lorsqu’un diagramme est trop gros, il devient illisible et il peut être utile de le scinder en plusieurs morceaux. Dans ce cas, on peut faire une « référence » à un autre diagramme : c’est le but des boîtes « ref ». Voici le diagramme de référence pour la création d’un nouveau personnage : ici, on appelle un constructeur du personnage sélectionné et, quand on appelle un constructeur, il est d’usage d’appeler sa méthode <<create>>. Le fragment UML pour charger le personnage à partir d’un disque dur est similaire, à ceci près qu’on appelle un constructeur avec d’autres paramètres.
Ce diagramme montre toutefois une faiblesse : on utilise des if/else afin que la classe ÉtatJeu puisse appeler un code spécifique à chaque classe de personnage. Avant d’avoir commencé à écrire le code de ÉtatJeu, on voit donc que l’on est en train de violer le principe Ouvert/Fermé et que l’on devrait concevoir notre fragment UML différemment. L’idée consiste alors à déléguer la recherche de la classe à créer, non pas à la classe ÉtatJeu mais à Java lui-même. Évidemment, on ne peut le faire à la compilation puisqu’on ne sait pas quel personnage le joueur va sélectionner. Donc c’est à l’exécution que Java doit déterminer lui-même la bonne instance de classe à créer. Voici un exemple pour illustrer ce que l’on peut faire en Java :
package fr.polytech.aco.jeu;
public abstract class Joueur {
protected String nom;
protected int x;
protected int y;
protected float angle;
protected int pointsDeVie;
public Joueur(String nom, int x, int y, float angle, int pointsDeVie) {
this.nom = nom;
this.x = x;
this.y = y;
this.angle = angle;
this.pointsDeVie = pointsDeVie;
}
@Override
public String toString() {
return this.getClass().toString() + " : " + this.nom;
}
}
public class Magicien extends Joueur {
public Magicien(String nom, int x, int y) {
super(nom, x, y, 0, 100);
}
public static String loadImages() { return "images Magicien"; }
}
public class Chevalier extends Joueur {
public Chevalier(String nom, int x, int y) {
super(nom, x, y, 0, 100);
}
public static String loadImages() { return "images Chevalier"; }
}
public class EtatJeu {
public static void main(String[] args) {
try {
var perso = Class.forName("fr.polytech.aco.jeu.Magicien")
.getConstructor(String.class, int.class, int.class)
.newInstance("toto", 10, 20);
System.out.println(perso);
System.out.println(classe.getMethod("loadImages").invoke(null));
}
catch (Exception e) {
e.printStackTrace();
}
}
}
Dans le main(), on appelle Class.forName() afin de charger une classe à partir de son nom complet (on inclut le nom du package dans la chaîne de caractères contenant le nom de la classe). Une fois la classe connue, on cherche le constructeur que l’on souhaite appeler (le constructeur pour un chargement du personnage à partir de fichier diffère de celui permettant de créer un nouveau personnage). Pour différencier les constructeurs, on passe en argument les types de leurs paramètres. Enfin, on appelle newInstance() en passant justement les paramètres attendus. Cela nous donne un fragment UML polymorphique :
À partir du moment où le personnage a été créé, ÉtatJeu n’a plus besoin de connaître les types précis des personnages (Magicien, Chevalier, Voleur) : il n’utilise plus que la classe abstraite Joueur. En plus du joueur, ÉtatJeu doit construire les Coffres et les Ennemis. Pour cela, on procède de la même manière. Une fois le Joueur, les Coffres et les Ennemis construits et initialisés, GUI peut terminer cette phase et passer à l’affichage du jeu.
À ce stade, on peut aussi s’interroger sur la manière dont GUI peut connaître les types possibles des personnages. Là encore, en Java, on a différents mécanismes pour déterminer les classes contenues dans un package, notamment la librairie « org.reflections ». Une autre possibilité, plus simple et plus flexible, consiste à avoir un fichier de configuration (par exemple en XML, YAML ou JSON) qui indique les noms des types possibles. Dans ce cas, GUI doit lire ce fichier dès sa création, ce qui nécessite de modifier le premier diagramme UML que nous avions vu.
Pour résumer, on voit que le diagramme UML nous a permis de détecter que notre première version du code n’était pas maintenable, sans avoir eu à écrire de coûteuses lignes de code. C’est donc un outil de réflexion important pour développer des applications complexes. En général, les diagrammes de séquence n’ont pas besoin d’être aussi détaillés que celui de la création du nouveau personnage mais, ici, on était obligé de s’attacher aux détails pour pallier le problème du Ouvert/Fermé.
Écrivez le diagramme de séquence de votre entreprise de restauration en ligne.