Modéliser : du texte au diagramme de classes
Séance 2 · ~2 périodes
Vous savez écrire une classe et faire tourner des tests. Reste une question qui arrive avant le code : quelles classes écrire ? Comment passer d'un texte, un énoncé ou un cahier des charges, à une liste de classes, d'attributs et de méthodes ?
C'est le rôle du diagramme de classes. Il est écrit en UML, un langage de dessin normalisé qui sert à représenter un programme. Ce dessin, vous le referez au tableau avec un collègue, et vous le retrouverez dans la documentation de la plupart des projets. Quand la documentation de Laravel dira « un Model a plusieurs Relation », vous saurez la dessiner.
À la fin de ce chapitre, vous serez capables de :
- extraire d'un énoncé en français les classes, les attributs et les méthodes candidates ;
- dessiner une classe en notation UML : compartiments, visibilités, types ;
- choisir la bonne relation entre deux classes : association, dépendance, agrégation, composition ;
- critiquer un modèle : repérer la classe qui fait tout et les tableaux qui cachent des classes.
Le code qui sent mauvais
Cinq minutes, en binôme. Ce code sent mauvais (code smell : un détail d'écriture qui trahit un défaut de conception). Lisez-le et répondez à une seule question : combien de classes manquent ici ?
<?php
declare(strict_types=1);
class Game
{
public array $hero = ['name' => 'Arthur', 'hp' => 10];
public array $rooms = [['name' => 'Entrée', 'monster' => null]];
public array $items = [['name' => 'Épée', 'weight' => 2.0, 'room' => 0]];
public function play(): void
{
$this->hero['hp'] -= 3;
$this->rooms[0]['monster'] = ['name' => 'Gobelin', 'hp' => 5];
echo $this->hero['name'], ' : ', $this->hero['hp'], " PV\n";
echo count($this->items), " objet(s) au sol\n";
}
}
(new Game())->play();
Ce code fonctionne. Il affiche Arthur : 7 PV puis 1 objet(s) au sol. Il est pourtant très mauvais.
1. Lire un énoncé avec un crayon
La méthode est simple et mécanique : vous relisez l'énoncé trois fois, avec un crayon.
| Passage | Ce qu'on souligne | Ce que ça devient |
|---|---|---|
| 1 | Les noms communs importants, ceux qui reviennent | des classes candidates |
| 2 | Les données : adjectifs, nombres, qualificatifs | des attributs |
| 3 | Les verbes : ce que le système doit savoir faire | des méthodes |
Trois questions ensuite, pour réduire la liste :
- Ce nom a-t-il des données à lui ? Sinon, c'est un attribut. « Le héros a un nom » :
nameest une chaîne de caractères, pas une classeName. - Ce nom sert-il à autre chose qu'à l'affichage ? L'écran de jeu ne fait pas partie du modèle du jeu.
- Quelle classe détient les données de ce verbe ?
takeDamage()modifiehp: il va dansHero, pas dansGame.
Appliquons la méthode à une phrase du Donjon : « le héros explore un donjon composé de salles ; certaines salles abritent un monstre et contiennent des objets que le héros peut ramasser dans son sac ». Les noms donnent six candidats : héros, donjon, salle, monstre, objet, sac. Le sac a des données à lui, un poids maximum et une liste, et des actions, ajouter, retirer, peser. C'est donc une classe, que nous appelons Inventory. « Exploration » n'a aucune donnée propre : ce sera une méthode. Les verbes se placent ensuite. « Ramasser » a besoin du sac et de l'objet, donc il va dans Hero, qui possède le sac.
Ces six noms sont des pistes, pas encore un modèle. Le dessin les confirme ou les élimine, et vous n'avez toujours pas écrit une ligne de PHP.
2. La boîte de classe
Une classe se dessine dans un rectangle à trois compartiments : le nom, les attributs, les méthodes.
@startuml
class Hero {
+name: string
+strength: int
-hp: int
-maxHp: int
+takeDamage(amount: int): void
+heal(amount: int): void
+isAlive(): bool
}
@enduml
Les règles à connaître, il n'y en a pas d'autres :
| Symbole | Signification |
|---|---|
+ |
public : visible de partout |
- |
private : visible seulement dans la classe |
# |
protected : la classe et ses classes filles, au chapitre 5 |
nom: type |
un attribut et son type |
methode(param: type): retour |
une méthode, ses paramètres typés et son type de retour |
{static} |
membre statique, souligné dans un diagramme dessiné à la main |
{abstract} |
classe ou méthode abstraite, en italique dans un diagramme dessiné à la main |
Et trois variantes de boîte, que vous croiserez aux chapitres 5 et 6 :
@startuml
abstract class Monster {
+name: string
#hp: int
#maxHp: int
+{abstract} attack(): int
}
interface Fighter {
+attack(): int
+takeDamage(amount: int): void
+isAlive(): bool
}
enum Rarity {
Common
Rare
Legendary
}
class Dice {
-sides: int
+{static} d6(): Dice
+{static} d20(): Dice
+roll(): int
}
@enduml
Monster est abstraite : on ne peut pas en fabriquer une directement, il faut un Goblin ou un Dragon. Fighter est une interface : que des signatures, aucun code. Rarity est une énumération : une liste fermée de valeurs. Dice a deux fabriques statiques : on appelle Dice::d6() sans posséder de dé. Ces quatre boîtes sont des repères visuels ; leur fonctionnement vient aux chapitres 5 et 6.
Astuce
Un diagramme de classes n'est pas une recopie de votre code. On y met ce qui aide à comprendre : les données importantes, les méthodes publiques, les relations. Les méthodes de lecture, d'écriture et d'affichage, comme __toString(), peuvent rester en dehors du dessin.
3. Les quatre relations
La vraie valeur d'un diagramme est là : dans les traits entre les boîtes.
Association : « est en lien avec »
Un trait plein. Une association représente un lien durable entre les objets de deux classes. On indique les multiplicités aux deux bouts, c'est-à-dire combien d'objets participent au lien, et on peut nommer le rôle.
@startuml
class Hero
class Monster
class Weapon
Hero "0..1" -- "0..1" Monster : fights
Hero "0..1" --> "0..1" Weapon : wields
@enduml
Les multiplicités courantes : 1 (exactement un), 0..1 (zéro ou un, donc une propriété qui peut valoir null), * (zéro ou plusieurs), 1..* (au moins un). La flèche --> ajoute la navigabilité : on accède à l'arme depuis le héros. Dans le code du Donjon, Hero a une propriété ?Weapon et Weapon ne connaît pas son porteur.
Dépendance : « utilise »
Un trait pointillé fléché. Une classe a besoin d'une autre le temps d'un appel, sans la garder.
@startuml
class Hero
class Dice
Hero ..> Dice : uses
@enduml
Dans le Donjon, une méthode de Hero reçoit un Dice en paramètre pour lancer un jet, puis l'oublie : le dé n'est rangé dans aucune propriété. C'est la relation la plus faible du lot. Dessinez-la seulement si elle aide à comprendre la méthode.
Agrégation et composition
Les deux disent qu'un objet fait partie d'un autre. Elles ne disent pas la même chose sur les durées de vie.
Agrégation : losange vide, du côté du tout. Elle se lit « a un ». Le tout et la partie ont des vies indépendantes. La partie existe avant le tout, elle survit à sa destruction, et plusieurs touts peuvent la partager. Dans le Donjon, une Room a un Monster. Le gobelin existait avant qu'on ouvre la porte, et si la salle s'effondre il ira ailleurs.
Composition : losange plein, du côté du tout. Elle se lit « est fait de ». La partie est créée par le tout, elle meurt avec lui, et elle appartient à un seul tout à la fois. Dans le Donjon, un Hero est fait, entre autres, d'un Inventory. Le sac est créé dans le constructeur du héros. Personne d'autre ne le possède, et il disparaît avec lui.
@startuml
class Hero
class Inventory
class Item
class Room
class Monster
Hero "1" *-- "1" Inventory : inventory
Inventory "1" o-- "0..*" Item : items
Room "1" o-- "0..1" Monster
note right of Inventory : Losange plein : créé par le héros, détruit avec lui.
note right of Monster : Losange vide : le monstre existe sans la salle.
@enduml
Attention
Beaucoup de supports intervertissent les deux définitions et écrivent que l'agrégation est le lien fort. C'est l'inverse.
- Agrégation, losange vide, « a un » : la partie survit au tout et peut être partagée.
- Composition, losange plein, « est fait de » : la partie meurt avec le tout et n'appartient qu'à lui.
Le test qui tranche : si je supprime le tout, la partie a-t-elle encore un sens toute seule ? Oui, c'est une agrégation. Non, c'est une composition.
Récapitulatif à garder sous la main :
| Relation | Trait | Se lit | Durée de vie de la partie | Qui fabrique l'objet ? |
|---|---|---|---|---|
| Association | -- ou --> |
« est en lien avec » | indépendante | quelqu'un d'autre, le lien est posé après |
| Dépendance | ..> |
« utilise » | sans objet | l'appelant, qui passe l'objet en paramètre |
| Agrégation | o-- losange vide |
« a un » | survit au tout, partageable | quelqu'un d'autre, le tout le reçoit |
| Composition | *-- losange plein |
« est fait de » | meurt avec le tout, exclusif | le tout, dans son constructeur |
La dernière colonne donne le meilleur indice. Si le tout fabrique la partie dans son constructeur, c'est une composition. Si la partie arrive de l'extérieur, c'est une agrégation ou une association. Le chapitre 4 code exactement ces deux cas.
C'est un indice fiable la plupart du temps, pas une loi. Le modèle décide, le new ne fait que le trahir. Vous en verrez un contre-exemple au chapitre 7 : le Canvas reçoit ses formes par add(), sans jamais appeler new, et la relation reste une composition, Canvas *-- Shape, parce qu'une forme du dessin n'a aucune existence en dehors de son canvas. Quand l'indice et la phrase « est fait de » se contredisent, c'est la phrase qui gagne.
Ce qui vient plus tard
Deux relations arrivent dans les chapitres suivants :
- la généralisation, triangle vide,
<|--, qui dit « est un » :Weaponest unItem. Chapitre 5. - la réalisation, triangle vide en pointillé,
<|.., qui dit « respecte le contrat » :HeroréaliseFighter. Chapitre 6.
Au chapitre 5, vous verrez que beaucoup d'héritages auraient dû être des compositions.
4. Deux modèles du Donjon
Même énoncé, deux diagrammes. Prenez trois minutes pour lister ce qui cloche dans le premier avant de lire le second.
Un modèle à améliorer
@startuml
class Game {
-heroName: string
-heroHp: int
-rooms: array
-items: array
-monsters: array
-currentRoom: int
+play(): void
+move(direction: string): void
+fight(): void
+pickUp(itemName: string): void
+save(): void
+printScreen(): void
}
note bottom of Game : Une seule classe. Tout est dedans.
@enduml
Trois défauts, qu'on trouve presque toujours ensemble :
- La classe qui fait tout.
Gamesait tout et fait tout. Chaque nouvelle fonctionnalité s'ajoute dedans. On ne peut ni la tester par morceaux ni travailler dessus à deux. - Les attributs en tableaux.
rooms: array,items: array: le type ne dit rien de leur contenu. Rien n'empêche d'y mettre un entier, unnullou une chaîne. Un tableau associatif à clés fixes est presque toujours une classe qui n'a pas été écrite. - Les relations remplacées par des index.
currentRoom: intdésigne une case du tableaurooms. C'est une association écrite de la pire façon : si l'ordre du tableau change, tout casse.
Un meilleur modèle
@startuml
class Dungeon {
+room(index: int): Room
+rooms(): array
}
class Room {
+name: string
+drop(item: Item): void
+take(): Item
+describe(): string
}
class Monster {
+name: string
#hp: int
+attack(): int
}
class Hero {
+name: string
+strength: int
-hp: int
-maxHp: int
+takeDamage(amount: int): void
+heal(amount: int): void
+isAlive(): bool
}
class Inventory {
-maxWeight: float
+add(item: Item): bool
+remove(name: string): void
+totalWeight(): float
}
class Item {
-name: string
-weight: float
+name(): string
+weight(): float
}
Dungeon "1" *-- "1..*" Room : rooms
Room "1" o-- "0..1" Monster
Room "1" o-- "0..1" Item : loot
Hero "1" *-- "1" Inventory : inventory
Inventory "1" o-- "0..*" Item : items
Hero "0..1" -- "0..1" Monster : fights
note right of Dungeon : Un donjon sans salle n'est pas un donjon.
@enduml
Lisez-le en français : « un donjon **est fait d'**au moins une salle ; une salle a au plus un monstre et a au plus un objet au sol ; un héros **est fait d'**un inventaire ; un inventaire a zéro ou plusieurs objets ; un héros combat au plus un monstre à la fois. » Si la phrase se lit sans accroc, vérifiez ensuite que chaque relation et chaque multiplicité correspond bien à l'énoncé.
Item apparaît des deux côtés, en agrégation. C'est voulu : une épée posée au sol passe dans le sac sans changer d'identité. Le diagramme n'interdit pas qu'un objet soit au sol et dans le sac en même temps ; c'est une règle du jeu, et c'est le code qui la fera respecter. Un diagramme montre la structure, pas toutes les règles.
À retenir
- Les noms de l'énoncé donnent les classes candidates, les données les attributs, les verbes les méthodes. Un nom sans donnée ni comportement n'est pas une classe.
- Une boîte UML a trois compartiments,
+ - #pour la visibilité,nom: typepartout,{static}et{abstract}pour le reste. - Agrégation, losange vide, « a un » : la partie survit au tout et peut être partagée. Composition, losange plein, « est fait de » : la partie meurt avec le tout et n'appartient qu'à lui.
- La question qui tranche : qui fabrique l'objet ? Créé dans le constructeur, c'est une composition. Reçu de l'extérieur, c'est une agrégation ou une association.
- Une classe qui sait tout faire et un
arraysans contenu déclaré sont deux signaux qu'il manque des classes.
Exercice : la ludothèque
Modélisez le système suivant. Rendu attendu : un diagramme de classes en PlantUML, avec les attributs typés, les méthodes publiques utiles, les relations et toutes les multiplicités. Pas de code.
Une association de quartier gère une ludothèque. Elle possède un catalogue de jeux de société : chaque jeu a un titre, un éditeur, une durée moyenne en minutes, un nombre de joueurs minimum et un nombre maximum. La ludothèque possède plusieurs exemplaires physiques d'un même jeu ; chaque exemplaire a un code d'inventaire et un état, neuf, bon ou usé. Les membres de l'association ont un nom, un e-mail et une date d'inscription. Un membre peut emprunter jusqu'à trois exemplaires à la fois. Un prêt enregistre l'exemplaire emprunté, le membre, la date de sortie et la date de retour prévue ; il permet de savoir s'il est en retard. Un exemplaire ne peut être prêté qu'à un seul membre à la fois. Si l'association ferme, ses exemplaires n'ont plus de raison d'être ; les membres, eux, existaient avant elle.
Cinq questions à vous poser pendant le travail, dans l'ordre :
- Combien de classes ? Entre quatre et cinq, pas huit.
- Où sont les compositions ? Cherchez dans l'énoncé les parties qui disparaissent avec le tout.
Loanest-il une classe ou un simple trait ? Regardez s'il a des données à lui.- Comment traduisez-vous « jusqu'à trois exemplaires à la fois » ?
- Où mettez-vous la méthode qui dit si un prêt est en retard ?
Corrigé de l'exercice
Ne le lisez qu'après un vrai essai, dessin à l'appui.
@startuml
class Library {
-name: string
+addCopy(copy: Copy): void
+lend(copy: Copy, member: Member): Loan
}
class BoardGame {
-title: string
-publisher: string
-durationMinutes: int
-minPlayers: int
-maxPlayers: int
+fits(playerCount: int): bool
}
class Copy {
-inventoryCode: string
-condition: string
+isAvailable(): bool
}
class Member {
-name: string
-email: string
-registeredOn: DateTimeImmutable
+canBorrow(): bool
}
class Loan {
-startedOn: DateTimeImmutable
-dueOn: DateTimeImmutable
-returnedOn: DateTimeImmutable
+isOverdue(today: DateTimeImmutable): bool
+isOpen(): bool
}
Library "1" *-- "0..*" Copy : copies
Library "1" o-- "0..*" Member : members
Library "1" o-- "0..*" BoardGame : catalogue
Copy "0..*" --> "1" BoardGame : model
Member "1" -- "0..*" Loan : loans
Loan "0..*" --> "1" Copy : copy
note bottom of Loan : Au plus trois prêts ouverts par membre, règle vérifiée par canBorrow().
@enduml
Quatre points de vigilance :
Loanest une classe. Un membre et un exemplaire sont liés par un prêt, et ce prêt a ses propres données, trois dates, et son propre comportement,isOverdue(). Dès qu'un lien porte des données, il devient une classe.Library *-- Copyest une composition : l'énoncé dit que les exemplaires n'ont plus de raison d'être sans l'association.Library o-- Memberest une agrégation : les membres existaient avant.Copy --> BoardGameest une association navigable. Un jeu est un modèle, un exemplaire est un objet physique, et chaque exemplaire connaît son modèle. Confondre les deux est l'erreur classique. Avec un simplequantity: int, on ne sait plus quel exemplaire est chez qui.- « Trois exemplaires à la fois » n'est pas une multiplicité. La relation garde
0..*parce qu'un membre accumule des prêts au fil des années. La limite porte sur les prêts encore ouverts, et elle vit danscanBorrow(). Une règle de gestion se code dans une méthode, elle ne se dessine pas.
isOverdue() est sur Loan, parce que c'est Loan qui détient dueOn. Une méthode va toujours là où sont ses données.