Aller au contenu

Atelier : dessiner avec des objets

Séance 4 · ~5 périodes

Slides du chapitre (PDF)PDF

Les chapitres 6 et 7 ont donné l'héritage, les classes abstraites et les interfaces sur le Donjon. Cet atelier les fait servir à autre chose : une petite bibliothèque de formes géométriques, puis un moteur de rendu qui transforme ces objets en image SVG. Les tests sont fournis. Ils décrivent exactement ce que votre code doit faire.

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

  1. construire une hiérarchie de classes à partir d'un contrat de tests.
  2. choisir entre hériter d'une classe et en posséder une, et justifier ce choix.
  3. implémenter une interface pour produire deux formats de sortie à partir des mêmes objets.
  4. produire un fichier SVG à partir d'objets PHP et l'ouvrir dans un navigateur.

Le dépôt : https://github.com/opmvpc/formes-26. Forkez-le maintenant. Son code vit dans le namespace Shapes.

1. Le code qui sent mauvais

Cinq minutes, en binôme. Cet extrait vient d'un ancien projet de départ. Ce code sent mauvais : un détail d'écriture y trahit un défaut de conception. Lequel ?

class SvgRenderer implements Renderer
{
    public function render(): string { /* fabrique le texte SVG */ }
    public function save(string $path): void { /* écrit le .svg */ }
}

class JpgRenderer extends SvgRenderer implements Renderer
{
    public function save(string $path): void { /* écrit le .jpg */ }
}

2. Pourquoi ces objets-là

La question du chapitre 6 se pose devant tout extends : la phrase « un X est un Y » est-elle vraie en dehors du code ? Éviter de recopier trois méthodes ne suffit pas à justifier un héritage. Le dessin vectoriel se prête bien à cette question, et chaque notion des trois dernières séances y sert :

  • une valeur qui ne change plus : un Point, deux coordonnées, readonly.
  • un héritage vrai : un cercle est une forme, un rectangle est une forme.
  • une classe abstraite utile : toutes les formes ont une aire, chacune la calcule autrement.
  • une agrégation : un Canvas contient des formes, il n'en est pas une.
  • une interface : Renderer, deux façons de dessiner les mêmes objets.

Et le résultat se regarde : à la fin, vous ouvrez votre dessin dans un navigateur.

3. Le SVG en cinq balises

Un SVG est un fichier texte. Vous n'allez pas manipuler des pixels. Vous allez écrire des balises, comme en HTML.

<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" viewBox="0 0 500 500">
  <rect x="0" y="0" width="500" height="500" fill="#FFFFFF" />
  <line x1="0" y1="0" x2="500" y2="500" stroke="#0000FF" stroke-width="1" />
  <circle cx="250" cy="250" r="80" fill="#00FF00" />
  <rect x="50" y="50" width="250" height="400" fill="#FF0000" />
  <polygon points="250,0 300,200 500,200" fill="#FFFF00" />
</svg>

Ces cinq balises suffisent pour tout l'atelier.

Attention

L'origine (0, 0) est en haut à gauche et l'axe Y descend. Une figure placée avec les coordonnées d'un repère mathématique apparaît donc retournée.

Du texte SVG au dessin : un fond gris et un disque rouge, avec le centre et le rayon mesurés depuis le coin supérieur gauche

4. Le dépôt et les commandes

git clone https://github.com/<votre-compte>/formes-26.git
cd formes-26
composer install
composer test

Presque tout est rouge au départ : seules deux vérifications sur Shape passent déjà. Les fichiers de src/ sont des squelettes : les classes existent, les méthodes ont leur signature, et chaque corps à écrire lève LogicException('À implémenter'). Trois constructeurs, ceux de Point, SvgRenderer et JpgRenderer, sont à écrire à partir des commentaires. Chaque fichier commence déjà par declare(strict_types=1). Les tests de tests/ sont complets et ne se modifient pas. Ils sont le cahier des charges.

Une étape à la fois :

composer test:etape-1
composer test:etape-2
composer test:etape-3
composer test:etape-4
composer test:bonus

Envoyez votre travail sur GitHub à la fin de chaque étape. L'onglet Actions de votre fork lance une vérification par étape et affiche une coche par étape, verte ou rouge, plus une pour le bonus.

Astuce

Lisez la ligne Failed asserting that… avant de modifier votre code. Elle donne la valeur attendue et la valeur obtenue.

5. Étape 1 : Point et Line

Fourni : src/Point.php et src/Line.php, avec les signatures des méthodes et des indices en commentaire. À écrire : les deux constructeurs et les corps de méthode.

@startuml
class Point {
  +x: float
  +y: float
  +translate(dx: float, dy: float): Point
  +distanceTo(other: Point): float
  +equals(other: Point): bool
  +__toString(): string
}
class Line {
  +start: Point
  +end: Point
  +color: string
  +length(): float
}
Line "0..* -lines" -- "1 +start" Point : commence à >
Line "0..* -lines" -- "1 +end" Point : finit à >
@enduml

Un point est une valeur, pas un objet qui évolue. Un héros change d'état, il perd des points de vie. Un point, non. (3, 4) reste (3, 4). Ce point est immuable : il ne change plus après sa construction. readonly fait respecter la règle par PHP, et readonly devant class rend toutes les propriétés readonly d'un coup, sans le répéter devant chacune. L'antislash de \Stringable dit que l'interface vient de PHP, pas du dossier Shapes, comme au chapitre 7.

final readonly class Point implements \Stringable
{
    public function __construct(
        public float $x,
        public float $y,
    ) {}

    public function translate(float $dx, float $dy): self
    {
        return new self($this->x + $dx, $this->y + $dy);
    }

    public function __toString(): string
    {
        return '(' . $this->x . ', ' . $this->y . ')';
    }

    // … distanceTo(), equals()
}

translate() ne déplace rien. Elle renvoie un nouveau point, et l'original garde ses coordonnées.

$depart = new Point(1, 1);
$arrivee = $depart->translate(4, -1);

echo $depart, PHP_EOL;
echo $arrivee, PHP_EOL;
(1, 1)
(5, 0)

Toute écriture dans un point déjà construit est refusée :

$depart->x = 10;
Fatal error: Uncaught Error: Cannot modify readonly property Shapes\Point::$x

Ce que les tests attendent

Classe Comportement vérifié
Point __construct(float $x, float $y) : new Point(3, 4) donne 3.0 et 4.0, entiers, décimaux et négatifs acceptés. Une écriture lève Error. translate(4, -1) sur (1, 1) rend (5, 0) sans toucher l'original. distanceTo() vaut 5.0 entre (0, 0) et (3, 4), dans les deux sens, et 0.0 vers soi-même. equals() compare les coordonnées. (string) new Point(10, -3) vaut (10, -3).
Line __construct(Point $start, Point $end, string $color = '#000000'). $line->start et $line->end rendent les objets reçus, en public readonly. $line->color vaut #000000 par défaut, et #ff0000 devient #FF0000. length() vaut 5.0 entre (1, 1) et (4, 5), et 0.0 si les deux points sont confondus.

Indice : ne recopiez pas la formule de la distance dans Line. Point::distanceTo() la connaît déjà.

Vérification : composer test:etape-1, 13 tests verts.

Question : deux Point(2, 7) distincts sont égaux pour equals(), mais === répond false. Pourquoi ? Le chapitre 5 a la réponse.

6. Étape 2 : Shape, Circle, Rectangle

Fourni : src/Shape.php avec sa constante et sa méthode abstraite, src/Circle.php et src/Rectangle.php avec leurs signatures. À écrire : les trois constructeurs, avec leurs validations, et les calculs d'aire. Pas de méthode qui renvoie seulement une propriété : radius, width, height et color sont des propriétés public readonly, comme au chapitre 3.

@startuml
abstract class Shape <<abstract>> {
  +{static} DEFAULT_COLOR: string
  +color: string
  +{abstract} area(): float
}
class Line {
  +start: Point
  +end: Point
  +length(): float
  +area(): float
}
class Circle {
  +center: Point
  +radius: float
  +diameter(): float
  +area(): float
}
class Rectangle {
  +origin: Point
  +width: float
  +height: float
  +perimeter(): float
  +area(): float
}
Shape <|-- Line
Shape <|-- Circle
Shape <|-- Rectangle
note right of Shape : Abstraite, jamais instanciée.
@enduml

Un cercle et un rectangle ont deux choses en commun : une couleur, et une aire. Les deux vont dans la classe parente Shape. La couleur a du code, puisque Shape la valide et la met en majuscules. L'aire n'en a pas, puisque rien ne se calcule sans savoir de quelle forme il s'agit.

abstract class Shape
{
    public const string DEFAULT_COLOR = '#000000';

    /** La couleur, au format #RRGGBB, toujours en majuscules. Lisible partout, écrite ici une fois. */
    public readonly string $color;

    public function __construct(string $color = self::DEFAULT_COLOR)
    {
        if (preg_match('/^#[0-9A-Fa-f]{6}$/', $color) !== 1) {
            throw new \InvalidArgumentException("Couleur invalide : {$color}");
        }

        $this->color = strtoupper($color);
    }

    abstract public function area(): float;
}

Trois écritures nouvelles. public const string DEFAULT_COLOR est une constante de classe : une valeur fixée une fois pour toutes, lue avec self::DEFAULT_COLOR dans la classe et Shape::DEFAULT_COLOR ailleurs. preg_match() vérifie qu'un texte suit un motif : celui-ci veut un # puis six chiffres hexadécimaux. Et $color n'est pas promu, c'est voulu : une propriété readonly ne s'écrit qu'une fois. Le constructeur vérifie le texte reçu, puis écrit la version en majuscules dans $this->color. Une promotion aurait écrit la valeur brute avant toute vérification.

abstract class interdit d'instancier Shape :

new Shape('#FF0000');
Fatal error: Uncaught Error: Cannot instantiate abstract class Shapes\Shape

abstract public function area(): float; n'a pas de corps. C'est une obligation faite aux classes filles, vérifiée par PHP au chargement du fichier, comme au chapitre 6.

Revenez ensuite sur Line. Elle aussi est une forme. Faites-la hériter de Shape, supprimez sa couleur en double et appelez parent::__construct($color) :

final class Line extends Shape
{
    public function __construct(
        public readonly Point $start,
        public readonly Point $end,
        string $color = self::DEFAULT_COLOR,
    ) {
        parent::__construct($color);
    }

    public function area(): float
    {
        return 0.0;
    }

    // … length()
}

Relancez les tests de l'étape 1 : ils doivent rester verts pendant que vous déplacez ce code.

Attention

area() d'une ligne renvoie 0.0. Un segment n'a pas de surface. Le contrat n'est donc pas parfait, et vous l'acceptez pour n'avoir qu'une seule classe parente.

Ce que les tests attendent

Classe Comportement vérifié
Shape La classe est abstraite, area(): float aussi. Line, Circle et Rectangle en héritent. Couleur par défaut #000000, public readonly. Les valeurs rouge, #FFF, 000000, #GGGGGG et la chaîne vide lèvent InvalidArgumentException.
Circle __construct(Point $center, float $radius, string $color = '#000000'). $circle->center et $circle->radius sont public readonly. diameter() vaut le double du rayon, area() vaut M_PI, la constante de PHP qui contient π, pour un rayon de 1. Un rayon nul ou négatif lève InvalidArgumentException.
Rectangle __construct(Point $origin, float $width, float $height, string $color = '#000000'). area() vaut 20.0 et perimeter() vaut 18.0 pour un rectangle de 4 sur 5. Une dimension nulle ou négative lève InvalidArgumentException.

Indice : une classe fille qui définit son propre constructeur doit appeler parent::__construct() elle-même. PHP ne le fait pas à sa place.

Vérification : composer test:etape-2, 26 tests verts, et composer test:etape-1 toujours vert.

Question : la couleur est validée et stockée dans Shape, une fois pour toutes, alors que area() y reste abstraite. Sur quel critère une méthode reste-t-elle abstraite plutôt que d'être écrite dans le parent ?

7. Étape 3 : Canvas et Polygon

Fourni : src/Canvas.php avec la propriété $shapes déjà déclarée, src/Polygon.php avec la formule du lacet décrite en commentaire. À écrire : les deux constructeurs avec leurs validations, add(), isEmpty(), totalArea(), pointCount() et Polygon::area().

@startuml
class Canvas {
  +width: float
  +height: float
  +background: string
  -shapes: Shape [0..*]
  +add(shape: Shape): void
  +isEmpty(): bool
  +totalArea(): float
}
abstract class Shape <<abstract>> {
  +color: string
  +{abstract} area(): float
}
Canvas "0..* -canvases" o-- "0..* -shapes" Shape : contient >
@enduml

Le losange vide {uml:agregation} entre Canvas et Shape se lit comme au chapitre 5 : le canvas tient la liste des formes, mais les formes sont construites à l'extérieur et lui sont données par add(). Une même forme peut aller dans deux canvas, et elle survit à la disparition de l'un. Entre Canvas et Shape, il n'y a pas de triangle : un canvas n'est pas une forme, il en contient. class Canvas extends Shape s'écrit sans erreur, relisez la phrase à voix haute, elle ne tient pas.

@startuml
abstract class Shape <<abstract>> {
  +color: string
  +{abstract} area(): float
}
class Polygon {
  +points: Point [3..*]
  +pointCount(): int
  +area(): float
}
class Point {
  +x: float
  +y: float
}
Shape <|-- Polygon
Polygon "0..* -polygons" o-- "3..* +points" Point : relie >
@enduml

Même losange vide entre Polygon et Point. Les sommets sont construits à l'extérieur, puis donnés au polygone. Comme un Point est immuable, le partager ne présente aucun risque.

add() ne contient aucun if pour refuser un Point. Le type du paramètre s'en charge :

public function add(Shape $shape): void
{
    $this->shapes[] = $shape;
}
$canvas->add(new Point(0, 0));
Fatal error: Uncaught TypeError: Shapes\Canvas::add(): Argument #1 ($shape) must be of type Shapes\Shape, Shapes\Point given

totalArea() est la méthode qui justifie la hiérarchie de l'étape 2 :

public function totalArea(): float
{
    $total = 0.0;

    foreach ($this->shapes as $shape) {
        $total += $shape->area();
    }

    return $total;
}

Le canvas ne demande jamais à une forme ce qu'elle est. À chaque tour de boucle, PHP appelle la méthode area() de l'objet parcouru. C'est le polymorphisme du chapitre 6, et il évite ici d'écrire un cas par forme.

Le même appel area() sur un rectangle, un cercle et une ligne : trois calculs différents, un seul total

Ce que les tests attendent

Classe Comportement vérifié
Canvas __construct(float $width, float $height, string $background = '#FFFFFF'). Le fond ressort en majuscules. Un canvas neuf a $canvas->shapes vide et isEmpty() à true. $canvas->shapes se lit partout et ne se remplace pas : $canvas->shapes = [] lève Error. add() conserve l'ordre. add(new Point(0, 0)) lève TypeError. totalArea() vaut 40.0 pour deux rectangles de 20 et une ligne. Une taille nulle ou négative lève InvalidArgumentException.
Polygon __construct(array $points, string $color = '#000000'), et Polygon hérite de Shape. $polygon->points garde l'ordre, pointCount() les compte. area() vaut 6.0 pour le triangle (0,0) (4,0) (0,3) et 100.0 pour un carré de côté 10. Moins de trois sommets, ou un élément qui n'est pas un Point, lève InvalidArgumentException.

Indice : le type array de PHP ne dit pas ce qu'il contient. C'est à vous de parcourir le tableau reçu par Polygon et de vérifier chaque élément avec instanceof Point.

Vérification : composer test:etape-3, 18 tests verts.

Question : une même forme peut-elle être ajoutée à deux canvas différents ? Que devient-elle si l'un des deux disparaît ? Comparez votre réponse avec la multiplicité 0..* côté Canvas.

8. Étape 4 : Renderer et SvgRenderer

Fourni : l'interface src/Renderers/Renderer.php, complète et à ne pas modifier, et le squelette src/Renderers/SvgRenderer.php. À écrire : le constructeur, render(), save(), renderShape() et number().

interface Renderer
{
    public function render(): string;

    public function save(string $path): void;
}

Deux méthodes, aucune ligne de code. Comme Fighter au chapitre 7, l'interface dit ce qu'un moteur de rendu doit savoir faire, pas comment il s'y prend.

@startuml
class Canvas {
  +totalArea(): float
}
interface Renderer {
  +render(): string
  +save(path: string): void
}
class SvgRenderer {
  -canvas: Canvas
  +render(): string
  +save(path: string): void
  -renderShape(shape: Shape): string
  -number(value: float): string
}
Renderer <|.. SvgRenderer
SvgRenderer "0..* -renderers" -- "1 -canvas" Canvas : dessine >
@enduml

Le pointillé {uml:realisation} dit que SvgRenderer réalise le contrat. Le moteur de rendu reçoit le canvas dans son constructeur. Il ne le fabrique pas. Il l'utilise pour produire du texte.

renderShape() traduit une forme en balise. Elle utilise match (true) : PHP compare true à chaque condition, dans l'ordre, et s'arrête à la première vraie. C'est une suite de if/elseif qui renvoie une valeur, dans la même écriture que le match du chapitre 7.

private function renderShape(Shape $shape): string
{
    return match (true) {
        $shape instanceof Line => sprintf(
            '<line x1="%s" y1="%s" x2="%s" y2="%s" stroke="%s" stroke-width="1" />',
            $this->number($shape->start->x),
            $this->number($shape->start->y),
            $this->number($shape->end->x),
            $this->number($shape->end->y),
            $shape->color,
        ),
        // … Circle, Rectangle, Polygon …
        default => throw new \InvalidArgumentException(
            'Forme inconnue du renderer : '.$shape::class,
        ),
    };
}

Le default lève une exception, parce qu'un moteur de rendu qui rencontre une forme inconnue doit le signaler au lieu de dessiner du vide. $shape::class donne le nom complet de la classe de l'objet, comme get_class() au chapitre 6.

Le chapitre 6 déconseillait cette cascade de instanceof. Elle revient ici, et c'est un choix de conception : le format de sortie n'appartient pas aux formes. Connaître chaque type pour écrire sa balise est le métier du moteur de rendu. La question de fin de section y revient.

Ce que les tests attendent

Le document commence par <?xml version="1.0" encoding="UTF-8"?>, contient xmlns="http://www.w3.org/2000/svg", la largeur, la hauteur et le viewBox, puis se termine par </svg>. Le fond est peint en premier. Les formes suivent dans l'ordre d'ajout. save() écrit dans le fichier exactement ce que render() renvoie.

Élément Balise attendue
fond du canvas <rect x="0" y="0" width="500" height="500" fill="#00FFFF" />
Line <line x1="1" y1="1" x2="1" y2="500" stroke="#0000FF" stroke-width="1" />
Circle <circle cx="250" cy="250" r="12.5" fill="#000000" />
Rectangle <rect x="50" y="50" width="250" height="400" fill="#FF0000" />
Polygon <polygon points="1,1 1,500 500,500" fill="#FF0000" />

Astuce

Les tests attendent 500, pas 500.0, et 12.5 tel quel. Une conversion simple, (string) $value, donne déjà ces deux résultats. number() sert aux calculs qui traînent des décimales, comme 1 / 3 : la solution arrondit à deux décimales avec number_format(), puis retire les zéros et le point inutiles avec rtrim().

Vérification : composer test:etape-4, 10 tests verts. Lancez ensuite l'exemple fourni.

php examples/star.php
Étoile écrite dans examples/star.svg (2 formes, aire totale : 77527 px²).

Ouvrez examples/star.svg dans votre navigateur.

Question : le match de renderShape() grandit d'un cas à chaque nouvelle forme. Une autre conception donnerait une méthode toSvg() à chaque forme. Que coûterait-elle le jour où le projet ajoute un troisième format de sortie ?

9. Bonus : JpgRenderer

Le bonus reprend le problème posé en ouverture. Vous écrivez la deuxième classe qui remplit le contrat Renderer, cette fois avec l'extension GD et le paquet meyfa/php-svg déjà installé.

@startuml
interface Renderer {
  +render(): string
  +save(path: string): void
}
class SvgRenderer {
  +render(): string
}
class JpgRenderer {
  -canvas: Canvas
  -svg: SvgRenderer
  -quality: int
  +render(): string
  +save(path: string): void
}
Renderer <|.. JpgRenderer
JpgRenderer "1 -owner" *-- "1 -svg" SvgRenderer : délègue à >
@enduml

JpgRenderer a un SvgRenderer au lieu d'en hériter, le losange plein {uml:composition} : il fabrique son moteur SVG dans son constructeur et le garde pour lui. Il lui demande le texte SVG, le convertit en image, puis renvoie les octets du JPEG. Deux classes sœurs, pas une classe et sa fille.

Hériter ou posséder : à gauche l'héritage refusé, à droite JpgRenderer qui a un SvgRenderer et lui demande le texte

php -m

Cherchez gd dans la liste. Sans cette extension, les tests du groupe bonus sont ignorés et non rouges. Les indices détaillés sont dans le squelette et dans le README du dépôt.

10. Le rendu

  1. Les quatre étapes vertes, en local et dans l'onglet Actions de votre fork.
  2. php examples/star.php écrit l'étoile.
  3. Modifiez ensuite examples/star.php et dessinez ce que vous voulez. Une maison, un logo, une fractale. Une seule contrainte : au moins trois types de formes différents.
  4. Postez votre SVG sur le canal du cours, avec le lien vers votre fork.

À retenir

  • readonly interdit toute écriture après la construction. Une méthode qui « déplace » un tel objet renvoie une nouvelle instance.
  • Une classe abstraite met en commun ce qui est partagé et impose le reste par des méthodes abstraites.
  • Le polymorphisme, c'est un même appel qui exécute un code différent selon l'objet reçu. Dans totalArea(), il évite d'écrire un cas par forme.
  • Un héritage se justifie par la phrase « un X est un Y », pas par le code économisé. Si elle sonne faux, X a un Y.
  • Une interface est un contrat sans code. Deux classes sans parenté commune peuvent le remplir.

Exercices

Sur papier

Les quatre questions de fin d'étape se traitent sur papier, avant de coder l'étape suivante : l'égalité de deux points (étape 1), le critère qui laisse area() abstraite (étape 2), la forme partagée entre deux canvas (étape 3), le coût d'un troisième format de sortie (étape 4).

Atelier

  1. Obligatoire. Le rendu de la section 10, en entier.
  2. Au choix. Ajoutez src/Ellipse.php, définie par un centre et deux rayons, et le cas correspondant dans renderShape(). Vérifiez avec un dessin dans examples/star.php et l'ouverture du SVG. Comptez les fichiers que vous avez dû ouvrir : ce nombre répond à la question de l'étape 4.
  3. Bonus. JpgRenderer vert.
  4. Bonus difficile. Ajoutez Canvas::withBackground(string $color): Canvas, qui renvoie un nouveau canvas avec les mêmes formes et un autre fond, sans modifier l'original. Quelle idée de l'étape 1 réutilisez-vous ?