Aller au contenu

Modéliser : du texte au diagramme de classes

Séance 2 · ~2 périodes

Slides du chapitre (PDF)PDF

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 énoncé en français à une liste de classes ?

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 qu'un Post a plusieurs Comment, vous saurez le dessiner.

À la fin de ce chapitre, vous serez capables de :

  1. extraire d'un énoncé en français les classes, les attributs et les méthodes candidates ;
  2. dessiner une classe en notation UML : compartiments, visibilités, types ;
  3. relier deux classes avec la bonne relation, ses deux rôles et ses multiplicités ;
  4. 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 : un détail d'écriture y 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();
Arthur : 7 PV
1 objet(s) au sol

Ce code tourne.

1. Lire un énoncé avec un crayon

La méthode est mécanique : vous relisez l'énoncé trois fois, avec un crayon.

Passage Ce que vous soulignez 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

L'UML dit attribut là où PHP dit propriété. Même chose, deux mots.

Les trois passages de lecture d'un énoncé : noms, données, verbes

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 » : name est une chaîne de caractères, pas une classe Name.
  • 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() modifie hp : il va dans Hero, pas dans Game.

Appliquez 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 précisé plus loin dans l'énoncé complet, et une liste d'objets. Il a aussi des actions : ajouter, retirer, peser. Ce sac devient donc une classe, 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.

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

Anatomie d'une boîte de classe UML

Les symboles :

Symbole Signification
+ public : visible de partout
- private : visible seulement dans la classe. Convention du cours : une propriété public private(set) se note aussi -, ce qui compte pour le modèle est que seule la classe l'écrit
# protected : la classe et ses classes filles, au chapitre 6
~ visible dans le même paquet. PHP n'a pas cette visibilité, vous la croiserez dans d'autres cours
nom: type un attribut et son type
nom: type [0..1] un attribut facultatif, qui peut valoir null
methode(param: type): retour une méthode, ses paramètres typés et son type de retour
souligné membre statique. PlantUML le note {static}

Une classe abstraite est une classe dont on ne fabrique jamais d'objet directement. Deux notations existent : le nom en italique, ou le stéréotype «abstract» au-dessus du nom. Le cours recommande le stéréotype : il se lit sans hésiter, au tableau comme à l'écran.

@startuml
abstract class Monster <<abstract>> {
  +name: string
  #hp: int
  +{abstract} attack(): int
}
@enduml

Deux autres boîtes existent, l'interface et l'énumération. Elles arrivent au chapitre 7 avec leur code.

Astuce

Un diagramme de classes n'est pas une recopie de votre code. Vous y mettez 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 relations

Association : « est en lien avec »

Un trait plein entre deux boîtes. Une association représente un lien durable entre les objets de deux classes.

@startuml
class Hero
class Weapon
Hero "0..1 -wielder" -- "0..1 -weapon" Weapon : wields >
@enduml

Une association, ses deux rôles, ses multiplicités et son nom

Une association porte toujours quatre choses :

  1. Un nom, un verbe, avec un petit triangle qui donne le sens de lecture : « un héros wields une arme ».
  2. Deux rôles, un à chaque bout. Le rôle dit comment la classe d'en face appelle celle-ci : côté Weapon, le rôle weapon est le nom que Hero donne à son arme. Dans le code, c'est la propriété $weapon de Hero.
  3. Une visibilité par rôle, -, + ou #, la même que pour un attribut. Un rôle privé devient une propriété privée.
  4. Deux multiplicités, une à chaque bout : combien d'objets participent au lien de ce côté.

Un rôle par côté, sans exception, agrégation et composition comprises. Dans le code, vous n'êtes pas obligé de programmer les deux sens du lien. Le chapitre 5 en code un seul : la salle connaît l'objet posé au sol, l'objet ne connaît pas la salle. Le modèle, lui, nomme les deux.

Les multiplicités :

Multiplicité Se lit En PHP
1 exactement un une propriété obligatoire
0..1 zéro ou un une propriété qui peut valoir null
* ou 0..* zéro ou plusieurs un tableau, éventuellement vide
1..* au moins un un tableau jamais vide

Trois combinaisons reviennent tout le temps. Vous les retrouverez telles quelles dans Laravel, où chaque relation entre deux modèles porte un nom de méthode.

Un à un. Chaque héros porte un sac, chaque sac a un porteur.

Un à un : chaque héros porte un sac, chaque sac a un porteur

Un à plusieurs. Un donjon contient plusieurs salles, une salle n'a qu'un donjon. C'est la relation la plus fréquente : un Post a plusieurs Comment, un Comment appartient à un seul Post.

Un à plusieurs : un donjon a plusieurs salles, une salle n'a qu'un donjon

Plusieurs à plusieurs. Un héros accomplit des quêtes, une quête est accomplie par plusieurs héros. En base de données, ce lien demande une table intermédiaire, que Laravel appelle table pivot.

Plusieurs à plusieurs : un héros accomplit des quêtes, une quête est accomplie par des héros

Multiplicités Exemple du Donjon Dans Laravel
1 — 1 Hero porte Inventory hasOne d'un côté, belongsTo de l'autre
1 — 0..* Dungeon contient Room hasMany du côté 1, belongsTo du côté plusieurs
0..* — 0..* Hero accomplit Quest belongsToMany des deux côtés, avec une table pivot

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 : «use»
@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 : « fait partie de »

Les deux disent qu'un objet fait partie d'un autre. Le losange se place du côté du tout. Elles ne disent pas la même chose sur les durées de vie.

@startuml
class Room
class Monster
class Hero
class Inventory
Room "0..1 -room" o-- "0..1 -monster" Monster : abrite >
Hero "1 -owner" *-- "1 -inventory" Inventory : porte >
@enduml

Agrégation ou composition : le cycle de vie décide

Agrégation {uml:agregation} : losange vide, « a un ». Le tout et la partie ont des vies indépendantes. 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. La norme UML ne garantit rien de plus qu'une annotation « tout et partie ». Convention du cours : la partie survit au tout et peut changer de tout.

Composition {uml:composition} : losange plein, « est fait de ». La partie est créée par le tout et meurt avec lui. Deux contraintes sont fixées par UML : la multiplicité côté tout vaut toujours 1, et une partie n'entre que dans une seule composition à 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.

En base de données, la même question se pose au moment de supprimer une ligne. La réponse est déjà dans le losange :

Losange Quand le tout est supprimé Clé étrangère
vide, agrégation la partie reste, son lien est vidé ON DELETE SET NULL
plein, composition la partie est supprimée avec lui ON DELETE CASCADE

Attention

Beaucoup de supports intervertissent les deux définitions et écrivent que l'agrégation est le lien fort. C'est l'inverse. 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.

Un indice fiable la plupart du temps : qui fabrique l'objet ? 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. Cet indice se trompe parfois. Quand il contredit la phrase « est fait de », suivez la phrase. Le chapitre 5 code exactement ces deux cas.

Ce qui vient plus tard

Deux relations arrivent dans les chapitres suivants :

  • la généralisation {uml:generalisation}, trait plein et triangle vide collé à la classe mère, qui dit « est un » : Weapon est un Item. Chapitre 6.
  • la réalisation {uml:realisation}, trait pointillé et triangle vide collé à l'interface, qui dit « respecte le contrat » : Hero réalise Fighter. Chapitre 7.

Au chapitre 6, vous verrez que beaucoup d'héritages auraient dû être une simple propriété.

Les traits du diagramme de classes

Les quatre relations de ce chapitre, avec ce qu'il faut taper en PlantUML :

Relation Trait Se lit Durée de vie de la partie En PlantUML
Association {uml:association} « est en lien avec » indépendante A -- B
Dépendance {uml:dependance} « utilise » le temps d'un appel A ..> B
Agrégation, losange vide {uml:agregation} « a un » survit au tout, peut changer de tout A o-- B, losange contre A
Composition, losange plein {uml:composition} « est fait de » meurt avec le tout, exclusif A *-- B, losange contre A

En PlantUML, le tout s'écrit à gauche et le losange se dessine contre lui. Les multiplicités et les rôles se mettent entre guillemets, de chaque côté : Hero "1 -owner" *-- "1 -inventory" Inventory.

4. Deux modèles du Donjon

Même énoncé, deux diagrammes. Prenez trois minutes pour lister ce qui cloche dans le premier avant d'ouvrir la carte.

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

Un meilleur modèle

@startuml
class Dungeon {
  +name: string
  +entrance(): Room
}
class Room {
  +name: string
  +drop(item: Item): void
  +take(): Item [0..1]
  +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
  +attack(target: Monster): void
}
class Inventory {
  -maxWeight: float
  +add(item: Item): bool
  +remove(name: string): void
  +totalWeight(): float
}
class Item {
  +name: string
  +weight: float
}
Dungeon "1 -dungeon" *-- "1..* -rooms" Room : contient >
Room "0..1 -room" o-- "0..1 -monster" Monster : abrite >
Room "0..1 -floor" o-- "0..1 -loot" Item : expose >
Hero "1 -owner" *-- "1 -inventory" Inventory : porte >
Inventory "0..1 -bag" o-- "0..* -items" Item : contient >
Hero "0..1 -visitor" -- "0..1 -location" Room : explore >
@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 explore au plus une salle à la fois. »

Le combat n'est pas un trait : un héros attaque un monstre le temps d'un appel, attack(target: Monster), puis l'oublie. C'est une dépendance, et elle n'a pas besoin d'être dessinée.

currentRoom: int a disparu. Il est remplacé par l'association explore entre Hero et Room : le héros connaît sa salle, pas un numéro de case. 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.

5. Dessiner

Commencez toujours sur papier, au crayon. Une feuille, une gomme, des boîtes vides, puis les traits. Vous effacez un losange en deux secondes, et vous voyez tout le modèle d'un coup. C'est le meilleur outil pour une première version, et c'est ce que vous ferez au tableau avec votre binôme.

Pour mettre le dessin au propre, trois outils :

Outil Ce que c'est Quand
Modelio un logiciel libre avec une interface graphique, qui produit des diagrammes conformes à UML 2 recommandé pour vos rendus : rôles, multiplicités et losanges sont posés à la souris et toujours corrects
StarUML un autre logiciel graphique, plus léger si Modelio ne s'installe pas chez vous
PlantUML un diagramme écrit en texte, comme les blocs de ce chapitre, rendu par un site ou une extension de votre éditeur quand vous voulez versionner le diagramme avec le code

Mermaid est interdit dans ce cours : il ne respecte pas le standard UML 2. Ses associations n'ont ni rôles ni sens de lecture corrects, et ses losanges sont approximatifs. Un diagramme Mermaid sera refusé.

En PlantUML, le squelette minimal tient en trois lignes :

@startuml
Hero "1 -owner" *-- "1 -inventory" Inventory : porte >
@enduml

Le rendu attendu pour l'exercice ci-dessous est au choix : une photo nette de votre dessin au propre, un export Modelio ou StarUML, ou un fichier PlantUML.

À 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: type partout, souligné pour statique, «abstract» pour une classe abstraite.
  • Toute relation porte deux rôles avec leur visibilité et deux multiplicités. Une association porte en plus un nom et son sens de lecture.
  • Agrégation, losange vide, « a un » : la partie survit au tout, en base ON DELETE SET NULL. Composition, losange plein, « est fait de » : la partie meurt avec le tout, multiplicité 1 côté tout, en base ON DELETE CASCADE.
  • Une classe qui sait tout faire et un array sans 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 avec les attributs typés, les méthodes publiques utiles, les relations avec leurs deux rôles et toutes les multiplicités. Pas de code. Papier d'abord, mise au propre ensuite. Comptez 40 minutes.

Un club 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 du club 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 la ludothèque 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 :

  1. Combien de classes ? Comptez-les avant de dessiner.
  2. Où sont les compositions ? Cherchez dans l'énoncé les parties qui disparaissent avec le tout.
  3. Le prêt est-il une classe ou un simple trait ? Regardez s'il a des données à lui.
  4. Comment traduisez-vous « jusqu'à trois exemplaires à la fois » ?
  5. Où mettez-vous la méthode qui dit si un prêt est en retard ?
@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: Condition
  +isAvailable(): bool
}
enum Condition {
  New
  Good
  Worn
}
class Member {
  -name: string
  -email: string
  -registeredOn: DateTimeImmutable
  +canBorrow(): bool
}
class Loan {
  -startedOn: DateTimeImmutable
  -dueOn: DateTimeImmutable
  -returnedOn: DateTimeImmutable [0..1]
  +isOverdue(today: DateTimeImmutable): bool
  +isOpen(): bool
}
Library "1 -library" *-- "0..* -copies" Copy : possède >
Library "0..1 -library" o-- "0..* -members" Member : réunit >
Library "1 -library" o-- "0..* -catalogue" BoardGame : propose >
Copy "0..* -copies" -- "1 -game" BoardGame : est un exemplaire de >
Member "1 -borrower" -- "0..* -loans" Loan : contracte >
Loan "0..* -loans" -- "1 -copy" Copy : porte sur >
note bottom of Loan : Au plus trois prêts ouverts par membre, règle vérifiée par canBorrow().
@enduml

Cinq points de vigilance :

  1. Loan est une classe. Un prêt a trois dates à lui et un comportement, isOverdue(). Dès qu'un lien porte des données, il devient une classe.
  2. Library *-- Copy est une composition : l'énoncé dit que les exemplaires n'ont plus de raison d'être sans la ludothèque. Multiplicité 1 côté tout, et Copy n'entre dans aucune autre composition. Library o-- Member est une agrégation : les membres existaient avant.
  3. Copy -- BoardGame est une association. 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 simple quantity: int, on ne sait plus quel exemplaire est chez qui.
  4. « 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 dans canBorrow(). Une règle de gestion se code dans une méthode, elle ne se dessine pas comme multiplicité.
  5. returnedOn vaut null tant que l'exemplaire n'est pas rendu. C'est ce que lit isOpen(). L'état de l'exemplaire est une liste fermée, neuf, bon ou usé : c'est une énumération, Condition. Vous la coderez au chapitre 7.

isOverdue() est sur Loan, parce que c'est Loan qui détient dueOn. Une méthode va toujours là où sont ses données.