Aller au contenu principal

2) Organiser son code

Organiser son code avec des classes

Architecture du projet avec classes

Objectifs de la séance

  • Comprendre le problème que les classes viennent résoudre.
  • Créer une classe avec des propriétés et des méthodes.
  • Contrôler l'accès aux données avec les mots-clés private et public.
  • Utiliser les accesseurs (get / set) pour lire et modifier des propriétés de manière sécurisée.
  • Créer une classe fille qui hérite d'une classe parente avec extends.
  • Déclarer une classe abstraite qui ne peut pas être instanciée directement.

Notions théoriques

Le problème des variables en vrac

À la fin de la séance 1, le code de index.php ressemblait à ceci :

$nom1 = "Lara";
$vie1 = 100;
$dps1 = 10;

$nom2 = "Mario";
$vie2 = 80;
$dps2 = 15;

Trois variables par personnage. Si l'on ajoute un troisième attribut demain (par exemple une armure), il faut ajouter $armure1, $armure2, $armure3... Pour passer un personnage à une fonction, on est obligé de transmettre toutes ses variables une par une :

simulerCombat($nom1, $vie1, $dps1, $nom2, $vie2, $dps2);

Six paramètres pour deux personnages. Avec une armure, ce serait huit. Ce code est difficile à lire et encore plus difficile à maintenir.

Les classes permettent de regrouper les données et les actions d'une entité dans un seul endroit.

Qu'est-ce qu'une classe ?

Une classe est un modèle. Elle décrit la structure d'un objet : quelles données il contient (ses propriétés) et quelles actions il peut effectuer (ses méthodes).

Un objet est une instance de la classe : c'est un exemplaire concret créé à partir du modèle.

L'analogie classique : une classe est comme un plan d'architecte, et un objet est la maison construite à partir de ce plan. On peut construire plusieurs maisons (objets) à partir du même plan (classe), chacune avec ses propres caractéristiques (couleur, surface...).

// Définir la classe (le modèle)
class Personnage {
// propriétés et méthodes...
}

// Créer des objets (des instances)
$p1 = new Personnage();
$p2 = new Personnage();

Propriétés et méthodes

Les propriétés sont les variables internes à la classe. Elles décrivent l'état de l'objet.

Les méthodes sont les fonctions internes à la classe. Elles décrivent le comportement de l'objet.

À l'intérieur d'une méthode, on accède aux propriétés et aux autres méthodes de la même classe via $this (qui signifie "cet objet-ci", celui sur lequel on est en train de travailler) :

class Personnage {
public $nom;
public $vie;

public function sePresenter() {
print "<p>Je suis " . $this->nom . " et j'ai " . $this->vie . " points de vie.</p>";
}
}

$p = new Personnage();
$p->nom = "Lara";
$p->vie = 100;
$p->sePresenter(); // affiche : Je suis Lara et j'ai 100 points de vie.

Le constructeur

La méthode spéciale __construct est appelée automatiquement au moment où l'on crée un objet avec new. Elle sert à initialiser les propriétés dès la création, plutôt que de les remplir une par une ensuite.

class Personnage {
public $nom;
public $vie;

public function __construct($nom, $vie) {
$this->nom = $nom;
$this->vie = $vie;
}
}

$p = new Personnage("Lara", 100);
// $p->nom vaut "Lara", $p->vie vaut 100

Contrôler l'accès : private et public

Le mot-clé public signifie que la propriété ou la méthode est accessible depuis n'importe où (depuis l'extérieur de la classe, depuis une autre classe...).

Le mot-clé private signifie que la propriété ou la méthode n'est accessible que depuis l'intérieur de la classe elle-même.

Pourquoi utiliser private ? Pour se protéger des erreurs. Si $vie est publique, n'importe quel code peut écrire $p->vie = -999; sans aucune vérification. Si elle est privée, on est obligé de passer par une méthode setVie() qui peut vérifier que la valeur est cohérente avant de l'enregistrer.

class Personnage {
private $vie;

public function setVie($vie) {
if (!is_numeric($vie)) {
print "La vie doit être un nombre";
return;
}
$this->vie = $vie;
}

public function getVie() {
return $this->vie;
}
}

Ces méthodes de lecture et de modification s'appellent des accesseurs :

  • getVie() est un "getter" (lire la propriété) ;
  • setVie($vie) est un "setter" (modifier la propriété, avec validation).

La méthode __toString

PHP permet de définir ce qui doit être affiché quand on utilise print directement sur un objet. C'est le rôle de la méthode spéciale __toString : elle doit renvoyer une chaîne de caractères.

public function __toString() {
return "Le personnage " . $this->nom . " a " . $this->vie . " points de vie";
}

$p = new Personnage("Lara", 100, 10);
print $p; // affiche : Le personnage Lara a 100 points de vie

L'héritage

L'héritage permet de créer une classe plus spécialisée à partir d'une classe existante. La classe fille récupère toutes les propriétés et méthodes de la classe parente, et peut en ajouter de nouvelles ou en redéfinir certaines.

On utilise le mot-clé extends :

class Guerrier extends Personnage {
public function __construct($nom, $vie, $dps, $mana) {
parent::__construct($nom, $vie, $dps, $mana); // transmet $mana au parent
}

public function attaquer($cible) {
// chaque sous-classe implémente sa propre logique d'attaque
}
}

parent::__construct(...) appelle le constructeur de la classe parente. C'est obligatoire quand on redéfinit le constructeur dans une classe fille.

Une méthode de la classe fille peut aussi redéfinir (remplacer) une méthode du parent. C'est ce qu'on appelle la surcharge (override). La méthode de la classe fille prend le dessus sur celle du parent.

Les classes abstraites

Le mot-clé abstract devant une classe signifie qu'on ne peut pas créer d'objet directement à partir d'elle. On doit obligatoirement passer par une classe fille.

C'est utile quand la classe parente est trop générique pour exister seule. Dans notre jeu, il n'y a pas de "personnage générique" : il y a des guerriers, des mages, etc. La classe Personnage n'existe que comme modèle commun ; c'est pourquoi elle est abstraite.

abstract class Personnage {
// ...
}

$p = new Personnage(...); // Erreur : impossible d'instancier une classe abstraite
$g = new Guerrier(...); // Correct : Guerrier est une classe fille concrète

Test de mémorisation/compréhension


Quel est le principal inconvénient de l'utilisation de « variables en vrac » ?


Dans l'analogie de l'architecture, que représente exactement la « classe » ?


Que désigne précisément le terme « instance » ?


À quoi sert spécifiquement le mot-clé `$this` à l'intérieur d'une méthode ?


Que se passe-t-il si vous n'écrivez pas de méthode `__construct` dans votre classe ?


Quel est l'avantage concret de rendre une propriété `private` plutôt que `public` ?


Comment s'appellent les méthodes telles que `getNom()` et `getVie()` ?


Comment s'appellent les méthodes telles que `setNom()` et `setVie()` ?


Parmi ces affirmations, laquelle distingue correctement un « getter » d'un « setter » ?


Quel est le rôle exact de la méthode spéciale `__toString` ?


Quel mot-clé PHP est utilisé pour indiquer qu'une classe hérite d'une autre classe ?


Dans le cadre de l'héritage, que permet de faire l'instruction `parent::__construct(...)` ?


Que signifie le terme de « surcharge » (override) dans le contexte de l'héritage ?


Quelle est la conséquence directe de l'ajout du mot-clé `abstract` devant une classe ?



TP pour réfléchir et résoudre des problèmes

Ce TP reprend le projet jeu-de-combat créé lors de la séance 1.

On repart du code final de cette séance, qui contient 2 fichiers :

  • index.php
  • fonctions.php.

À la fin de cette séance, le projet contiendra 2 fichiers PHP supplémentaires :

  • Personnage.php,
  • Guerrier.php.

Et le fichier index.php sera réécrit pour utiliser les classes à la place des fonctions et des variables en vrac.

astuce

Le fichier fonctions.php sera conservé mais vidé de son contenu utile, au fur et à mesure que les fonctions seront remplacées par des méthodes de classes.

Choix de nommage : $dps et $mana

La classe Personnage portera deux propriétés communes à tous les types de personnages :

  • $dps (damage per second) : la puissance d'attaque. Pour un guerrier, c'est la force de frappe ; pour un mage, c'est la puissance de son sort.
  • $mana : la ressource défensive. Pour un guerrier, c'est l'épaisseur de son bouclier ; pour un mage, c'est sa réserve d'énergie magique.

Ces noms viennent du vocabulaire des jeux de rôle numériques, où dps désigne les dégâts infligés par seconde et mana l'énergie magique. Ils sont courts, expressifs, et compris immédiatement par tout développeur qui a déjà touché à un jeu vidéo. Une alternative plus générique comme $puissance et $protection serait correcte sur le plan sémantique, mais moins parlante dans ce contexte ludique.

Étape 1 - Créer la classe Personnage avec des propriétés publiques

On commence par la version la plus simple possible d'une classe, sans protection ni constructeur, pour comprendre la syntaxe de base.

Créer un fichier Personnage.php dans le dossier du projet.

Y déclarer une classe Personnage avec trois propriétés publiques : $nom, $vie et $dps. Ajouter une méthode publique sePresenter() qui affiche une ligne de texte HTML contenant le nom et les points de vie du personnage.

Dans index.php, inclure Personnage.php avec require_once, créer deux objets Personnage, affecter leurs propriétés, puis appeler sePresenter() sur chacun.

L'affichage attendu :

Jeu de combat PHP
Le personnage Lara a 100 points de vie.
Le personnage Mario a 80 points de vie.

Bonne pratique - Nommage des fichiers

Une classe = un fichier. Le nom du fichier doit correspondre exactement au nom de la classe, avec la même casse (Personnage.php pour la classe Personnage). Cette convention est universelle dans l'écosystème PHP. Elle rend la navigation dans le projet immédiate.

Étape 2 - Ajouter un constructeur

Affecter les propriétés une par une après new Personnage() est fastidieux. Le constructeur permet de tout initialiser dès la création de l'objet.

Ajouter un constructeur __construct($nom, $vie, $dps) à la classe Personnage. Le constructeur doit simplement affecter chaque paramètre à la propriété correspondante via $this.

Modifier index.php pour créer les deux personnages en passant les valeurs directement à new Personnage(...), sans affecter les propriétés une par une.


Bonne pratique - Responsabilité du constructeur

Le constructeur doit se limiter à l'initialisation des propriétés. Il ne doit pas effectuer de calculs complexes ni appeler de fonctions extérieures susceptibles d'échouer. Un constructeur qui lève une exception est difficile à gérer pour le code appelant.

Étape 3 - Passer les propriétés en private et ajouter des accesseurs

Les propriétés publiques sont un problème : n'importe quel code peut écrire $p1->vie = -500; sans qu'aucune vérification ne soit effectuée.

Passer les trois propriétés de Personnage en private.

Ajouter les six méthodes accesseurs suivantes :

  • getNom() : renvoie $this->nom.
  • setNom($nom) : vérifie que $nom est bien une chaîne de caractères avec is_string(), affiche un message d'erreur si ce n'est pas le cas, sinon affecte la valeur.
  • getVie() : renvoie $this->vie.
  • setVie($vie) : vérifie que $vie est bien un nombre avec is_numeric(), affiche un message d'erreur sinon, puis affecte la valeur.
  • getDps() : renvoie $this->dps.
  • setDps($dps) : affecte simplement la valeur (pas de validation particulière pour le moment).

Modifier le constructeur pour qu'il appelle setNom, setVie et setDps au lieu d'affecter directement les propriétés. Ainsi, la validation est automatiquement effectuée à la création.

Modifier la méthode sePresenter() pour qu'elle utilise les getters (getNom(), getVie()) plutôt que d'accéder directement à $this->nom et $this->vie.

Dans index.php, remplacer tout accès direct aux propriétés par des appels aux accesseurs.


Bonne pratique - Encapsulation

Les propriétés sont toujours déclarées en private. On n'accède jamais directement à une propriété depuis l'extérieur de la classe. On passe systématiquement par les accesseurs. Cette discipline garantit que les données d'un objet sont toujours dans un état cohérent, quelle que soit la manière dont on les manipule.

Étape 4 - Ajouter la méthode __toString et la méthode attaquer

Deux méthodes manquent encore à la classe Personnage.

Ajouter la méthode __toString(). Elle doit renvoyer (avec return, pas avec print) la chaîne suivante : "Le personnage [nom] a [vie] points de vie".

Grâce à cette méthode, on pourra écrire print $p1; directement, sans appeler sePresenter(). PHP appellera automatiquement __toString() quand on utilise print sur un objet.

Ajouter ensuite la méthode attaquer($cible). Elle prend en paramètre un autre objet Personnage (la cible). Elle doit réduire les points de vie de la cible du montant des DPS de l'attaquant, en utilisant les accesseurs : $cible->setVie($cible->getVie() - $this->getDps()).

Dans index.php, remplacer sePresenter() par print $p1; et print $p2; (avec un <br/> entre les deux). Faire attaquer $p1 sur $p2, puis afficher les deux personnages après l'attaque pour vérifier que les points de vie ont bien changé.


Bonne pratique - Rôle de __toString

__toString doit renvoyer une chaîne de caractères avec return, et jamais l'afficher avec print. Si la méthode affiche directement, elle ne peut plus être utilisée dans des contextes où l'on veut récupérer la représentation textuelle sans l'afficher (pour la mettre dans une variable, dans un fichier de log, etc.).

Étape 5 - Rendre la classe abstraite

La classe Personnage est trop générique pour exister seule. Dans ce jeu, on ne crée jamais un "personnage" au sens abstrait : on crée un guerrier, un mage, un archer... Autrement dit, Personnage ne sert que de modèle commun ; ce n'est pas un type qu'on instancie directement.

Effectuer les trois modifications suivantes sur la classe Personnage :

1. Ajouter le mot-clé abstract devant la déclaration de la classe.

2. Ajouter la propriété $mana : un entier représentant la ressource défensive du personnage (bouclier pour un guerrier, mana pour un mage). Ajouter le paramètre $mana au constructeur (après $dps), et les accesseurs setMana($mana) et getMana().

3. Remplacer la méthode concrète attaquer($cible) par une déclaration abstraite :

abstract public function attaquer($cible);

Chaque type de personnage attaque différemment : la classe parente déclare que "tout personnage peut attaquer", mais laisse le COMMENT aux classes filles. C'est une méthode abstraite.

Dans index.php, essayer de créer un new Personnage(...) et observer le message d'erreur. Comprendre l'erreur, puis supprimer cette ligne.

À ce stade, index.php ne peut plus créer de personnages : Personnage est abstraite et exige que ses classes filles implémentent attaquer(). La prochaine étape crée Guerrier.


Bonne pratique - Déclaration de classes abstraites

Il est conseillé de déclarer une classe abstraite dès qu'elle ne doit pas être instanciée directement. C'est une contrainte volontaire qui documente l'intention du développeur et empêche les erreurs d'utilisation. Si quelqu'un essaie de créer un Personnage par erreur, PHP l'arrêtera immédiatement avec un message clair.

Étape 6 - Créer la classe Guerrier par héritage

Créer un nouveau fichier Guerrier.php.

La classe Guerrier hérite de Personnage (avec extends). La propriété $mana est déjà dans Personnage : le guerrier l'utilise comme bouclier sans avoir besoin de la redéfinir.

Écrire le constructeur __construct($nom, $vie, $dps, $mana). Il doit uniquement transmettre tous les paramètres au constructeur parent : parent::__construct($nom, $vie, $dps, $mana).

La classe Personnage déclare attaquer() comme abstraite : toute classe fille concrète doit l'implémenter. Pour l'instant, écrire une version minimale (les dégâts n'utilisent pas encore $mana) — la logique complète viendra à l'étape suivante.

Dans index.php, remplacer les new Personnage(...) par des new Guerrier(...). Ajouter require_once "Guerrier.php"; en haut du fichier. Vérifier que l'affichage fonctionne toujours : le guerrier hérite de __toString et peut donc être affiché avec print.


Bonne pratique - Responsabilité du constructeur

Dans le constructeur de la classe fille, appeler parent::__construct(...) en tout premier. Cela garantit que les propriétés de la classe parente sont initialisées (et validées) avant que la classe fille n'ajoute les siennes. Oublier cet appel est une source fréquente de bugs où les propriétés héritées restent non initialisées.

Étape 7 - Améliorer la méthode attaquer pour tenir compte du mana

Pour le moment, la méthode attaquer du guerrier réduit simplement la vie de la cible de la valeur de $dps, sans tenir compte du mana de la cible.

On veut que les dégâts soient réduits par le mana de la cible. Si la cible a une mana de 5 et que l'attaquant inflige 10 DPS, les dégâts effectifs sont de 5. Si le mana est supérieur aux DPS, les dégâts tombent à 0.

Modifier la méthode attaquer($cible) dans la classe Guerrier. Elle doit :

  1. Récupérer les DPS de l'attaquant avec $this->getDps().
  2. Si le mana de la cible est supérieur à 0, soustraire sa valeur aux DPS : $cible->getMana() fonctionne sur n'importe quel Personnage — pas besoin de instanceof !
  3. S'assurer que les DPS ne descendent pas en dessous de 0.
  4. Appliquer les dégâts effectifs à la cible avec $cible->setVie(...).

Tester dans index.php avec deux guerriers dont l'un a un mana plus élevé que les DPS de l'autre, pour vérifier que les dégâts tombent bien à 0 dans ce cas.


Bonne pratique - Cas limites

Il est conseillé de toujours penser aux cas limites lors de l'écriture d'une méthode. Ici, le cas où le mana dépasse les DPS (résultat négatif) doit être traité explicitement. Un personnage ne doit jamais gagner de la vie parce qu'il s'est fait attaquer. Tester systématiquement ces cas extrêmes est une habitude qui évite beaucoup de bugs.

Étape 8 - Simuler un combat complet avec les objets

La séance 1 utilisait une fonction simulerCombat qui prenait six paramètres. On va maintenant réécrire cette logique en utilisant les objets.

Dans index.php, écrire une boucle while qui simule un combat complet entre deux guerriers. À chaque tour, le premier attaque le deuxième, puis le deuxième attaque le premier (si ce dernier est encore en vie). La boucle s'arrête quand l'un des deux n'a plus de points de vie.

On ne crée pas de nouvelle fonction ni de nouvelle méthode : la logique de la boucle est écrite directement dans index.php. L'objectif est de constater à quel point le code est plus lisible avec des objets qu'avec des variables en vrac.

Afficher un titre "Tour N" à chaque itération, puis l'état des deux personnages après les attaques.


Le code objet est plus lisible que le code procédural

Comparer le code de cette étape avec la fonction simulerCombat de la séance 1. Les 6 paramètres ($nom1, $vie1, $dps1, $nom2, $vie2, $dps2) ont disparu. Le code est plus court, plus lisible, et il n'y a plus besoin de retourner un tableau pour récupérer les données du vainqueur : l'objet les porte avec lui.

Étape 9 - Expérimenter les erreurs pour comprendre les protections

Cette étape est volontairement différente des précédentes : il s'agit de provoquer des erreurs pour comprendre ce que les mécanismes mis en place protègent réellement.

Réaliser les quatre expériences suivantes dans index.php. Pour chacune, noter le message d'erreur affiché par PHP, comprendre pourquoi il apparaît, puis supprimer la ligne fautive avant de passer à la suivante.

Expérience A - Tenter d'instancier une classe abstraite :

$p = new Personnage("Lara", 100, 10);

Expérience B - Tenter d'accéder directement à une propriété privée :

$p1 = new Guerrier("Lara", 100, 10, 5);
print $p1->nom; // propriété privée

Expérience C - Passer une valeur invalide à un setter :

$p1 = new Guerrier("Lara", 100, 10, 5);
$p1->setVie("beaucoup");
print $p1; // que vaut la vie de Lara ?

Expérience D - Passer un nombre à setNom :

$p1 = new Guerrier("Lara", 100, 10, 5);
$p1->setNom(42);
print $p1;

Bonne pratique - Comprendre les erreurs

Provoquer volontairement des erreurs est une bonne manière de comprendre les mécanismes de protection du code. Un développeur qui ne sait pas ce que provoque une erreur ne peut pas anticiper les comportements inattendus de son programme.

Étape 10 - Finaliser le code et vérifier la cohérence avec l'état attendu

Il est temps de vérifier que le code produit correspond exactement à l'état attendu décrit en début de TP (voir la section "Point de départ du TP").

Comparer ligne par ligne Personnage.php, Guerrier.php et index.php avec le code cible.

Quelques points à vérifier :

  • Personnage est abstract, a la propriété $mana avec ses accesseurs, et déclare attaquer() comme abstraite (pas d'implémentation dans le parent).
  • Guerrier implémente attaquer() en utilisant $cible->getMana() — accessible sur n'importe quel Personnage, sans besoin de instanceof.
  • Le index.php cible utilise les personnages "Lara" et "Miss Marple". Adapter le fichier en conséquence.

Une fois ces vérifications faites, s'assurer que le code fonctionne en lançant le serveur et en chargeant http://localhost:8000 dans le navigateur.


Solution complète

Solution complète du TP

Exercices complémentaires

Exercice A - Ajouter un affichage du mana dans __toString

Modifier la méthode __toString de la classe Guerrier pour qu'elle renvoie une chaîne plus complète, incluant la valeur du mana. Par exemple :

Le guerrier Lara a 100 points de vie et 5 de mana

Observer qu'on surcharge ici __toString dans la classe fille, tout comme on a surchargé attaquer. La méthode de la classe fille prend le dessus sur celle du parent.

Exercice B - Simuler un tournoi à trois guerriers

Créer trois guerriers avec des caractéristiques différentes. Organiser deux combats :

  1. Le premier guerrier contre le deuxième.
  2. Le vainqueur contre le troisième.

La difficulté : après le premier combat, le vainqueur a perdu des points de vie. Il entre dans le second combat affaibli. C'est un avantage de l'approche par objets : l'objet "porte" son état avec lui, sans qu'on ait besoin de le stocker et de le transmettre manuellement.

Exercice C - Comprendre la visibilité private entre instances

Voici un code à analyser. Avant de l'exécuter, essayer de prédire ce qu'il fait et si PHP affiche une erreur :

<?php
require_once "Personnage.php";
require_once "Guerrier.php";

$p1 = new Guerrier("Lara", 100, 10, 5);
$p2 = new Guerrier("Miss Marple", 100, 15, 7);

// Accès à la propriété privée $vie de $p2 depuis une méthode de Personnage
// (c'est ce que fait attaquer() dans le code d'origine du plan du cours)
// $p2->vie = $p2->vie - 10; // ligne commentée : ferait une erreur depuis l'extérieur

// Mais depuis l'intérieur de la classe, PHP autorise l'accès aux propriétés privées
// d'une autre instance de la même classe.

Pour vérifier, ajouter temporairement dans Personnage.php une méthode :

public function copierVie($autrePersonnage) {
// Accès direct à la propriété privée d'une autre instance de la même classe
$this->vie = $autrePersonnage->vie;
}

Appeler $p1->copierVie($p2) dans index.php et vérifier que cela fonctionne sans erreur. Puis essayer d'écrire $p2->vie = 50; directement dans index.php et observer la différence.

Une solution complète

Ce qu'il faut retenir

NotionRésumé
ClasseModèle décrivant les propriétés et les méthodes d'un type d'objet.
ObjetInstance d'une classe, créée avec new.
$thisRéférence à l'objet courant, dans une méthode de la classe.
__constructMéthode appelée automatiquement à la création de l'objet.
privatePropriété ou méthode accessible uniquement depuis l'intérieur de la classe.
publicPropriété ou méthode accessible depuis n'importe où.
AccesseursMéthodes get et set pour lire et modifier des propriétés privées avec validation.
__toStringMéthode appelée automatiquement quand on utilise print sur un objet.
abstractClasse qui ne peut pas être instanciée directement.
extendsMot-clé pour faire hériter une classe d'une autre.
parent::__constructAppel du constructeur de la classe parente depuis la classe fille.
SurchargeRedéfinition d'une méthode héritée dans la classe fille.

Aperçu de la prochaine séance

Le jeu ne comporte pour le moment qu'un seul type de personnage : le guerrier. La séance 3 ajoutera un second type, le mage, qui a une mécanique de jeu différente : il possède une réserve de mana et attaque en lançant des sorts offensifs.

On ajoutera aussi les coordonnées $x et $y à la classe Personnage, ce qui permettra de positionner les personnages sur un plateau de jeu et de les déplacer. Ce plateau sera affiché sous forme de grille HTML avec un tableau <table>.