Voyons ensemble le Design Pattern "Factory"

Qui suis-je pour parler en ces lieux ?

Je suis le susnommé Kevin Nadin

 

Twitter : @kevinjhappy

 

Je travaille à Darkmira en tant que développeur PHP, c'est une boîte avec plein de dev cools ;)

Design Pattern, quésaco ?

Bonnes pratiques de conception de code

23 Design patterns en tout,

regroupés en 3 grandes parties :

  1. Création

  2. Architecture

  3. Comportement

Aujourd'hui on voit...

(roulement de tambour)

de la catégorie création

(bon, en même temps, c'est le titre de la conf... )

La Factory !!

Définition

Fonction qui crée un objet en fonction de paramètres

Exemples: 

  • Fonction qui va créer l'objet adapté pour la base de donnée
  • Fonction qui va créer un objet qui enregistre une donnée dans un format spécifique
  • Fonction qui va créer un objet avec des levées d'exception en fonction des paramètres

On va se focaliser sur un exemple concret :

intégrer une Factory avec deux personnages qui se combattent

Nous avons pour commencer un seul type de personnage :

  • Guerrier

 

Deux personnages se combattent, donc nous avons besoin que ces personnages soient capable de :

  • donner des dégâts

  • prendre des dégâts

  • mourir (il faut bien un vainqueur :D )

<?php

class Warrior
{
    private $lifePoint;
    private $hitPoint;

    public function __construct(int $lifePoint, int $hitPoint)
    {
        $this->lifePoint = $lifePoint;
        $this->hitPoint = $hitPoint;
    }
}

On crée une classe Warrior

Elle possède 2 attributs :

  • points de vie

  • points de dégât

<?php

class Warrior
{
    // ...
    
    public function isAlive(): bool
    {
        return 0 <= $this->lifePoint;
    }

    public function attack(): int
    {
        if ($this->isAlive()) {
            return $this->hitPoint;
        }

        return 0;
    }

    public function takeDamage(int $damage): void
    {
        $this->lifePoint -= $damage;
    }
}

A la classe Warrior, on va ajouter 3 fonctions pour combattre 

Et maintenant on procède au combat dans le script Battle

Je reçois des données 

<?php

// je recois les données en tableau, mais je ne sais pas dans quel ordre
$entryData = [
    [
        'type' => 'Warrior',
        'lifePoint' => 20,
        'hitPoint' => 5,
    ],
    [
        'type' => 'Warrior',
        'lifePoint' => 15,
        'hitPoint' => 8,
    ]
];

Je crée les deux guerriers

// je crée le premier personnage
$data = $entryData[0];
if ('Warrior' === $data['type']) {
    $firstCharacter = new Warrior($data['lifePoint'], $data['hitPoint']);
}

// je crée le second personnage
$data = $entryData[1];
if ('Warrior' === $data['type']) {
    $secondCharacter = new Warrior($data['lifePoint'], $data['hitPoint']);
}

Ils s'affrontent !

// Battle
while ($secondCharacter->isAlive() && $firstCharacter->isAlive()) {
    $secondCharacter->takeDamage($firstCharacter->attack());
    $firstCharacter->takeDamage($secondCharacter->attack());
}

if (!$secondCharacter->isAlive()) {
    echo 'Victory to the first character';
} elseif (!$firstCharacter->isAlive()) {
    echo 'Victory to the second character';
}

VS

Le souci... 

C'est d'utiliser "new"

Dans un gros projet, si la manière de créer un objet change, alors vous allez devoir modifier tous les "new Object" du projet.

Solution : on implémente une fonction dans Warrior qui va créer Warrior

class Warrior
{
    private $lifePoint;
    private $hitPoint;

    public function __construct(int $lifePoint, int $hitPoint)
    {
        //...
    }

 	//...

    public static function create(array $data): self
    {
        return new Warrior($data['lifePoint'] ?? 1, $data['hitPoint'] ?? 1);
    }
}

Et là, nous avons une méthode de Factory :)

La création de personnages dans Battle devient :

// je crée le premier personnage
$data = $entryData[0];
if ('Warrior' === $data['type']) {
    $firstCharacter = Warrior::create($data);
}

// je crée le second personnage
$data = $entryData[1];
if ('Warrior' === $data['type']) {
    $secondCharacter = Warrior::create($data);
}

Maintenant, on augmente la complexité

On ajoute un nouveau type de personnage :

  • Magicien

<?php

class Wizard
{
    private $lifePoint;
    private $hitPoint;
    private $magicPoint;

    public function __construct(int $lifePoint, int $hitPoint, int $magicPoint)
    {
        $this->lifePoint = $lifePoint;
        $this->hitPoint = $hitPoint;
        $this->magicPoint = $magicPoint;
    }
}

Je crée la classe Wizard

Elle possède 3 attributs :

  • points de vie

  • points de dégât

  • points de magie

<?php

class Wizard
{
    // ...
    
    public function isAlive(): bool
    {
        return 0 <= $this->lifePoint;
    }

    public function attackWithMagic(): int
    {
        if ($this->isAlive() && $this->magicPoint >= 2 ) {
            $this->magicPoint -= 2;
            return $this->hitPoint;
        }

        return 0;
    }

    public function takeDamage(int $damage): void
    {
        $this->lifePoint -= $damage;
    }
}

J'ajoute des fonctions de combat

<?php

class Wizard
{
    // ...
    
    public static function create(array $data): self
    {
    	// on peut aussi utiliser le mot clé self au lieu de Wizard
        return new self(
            $data['lifePoint'] ?? 1, 
            $data['hitPoint'] ?? 1, 
            $data['magicPoint'] ?? 1
        );
    }
}

Et une fonction de Factory

<?php

$entryData = [
    [
        'type' => 'Warrior',
        'lifePoint' => 20,
        'hitPoint' => 5,
    ],
    [
        'type' => 'Wizard',
        'lifePoint' => 15,
        'hitPoint' => 8,
        'manaPoint' => 10,
    ]
];

Et Battle change...

// je crée le premier personnage
$data = $entryData[0];
switch ($data['type']) {
    case 'Warrior':
        $firstCharacter = Warrior::create($data);
        break;
    case 'Wizard':
        $firstCharacter = Wizard::create($data);
        break;
}

// je crée le second personnage
$data = $entryData[1];
switch ($data['type']) {
    case 'Warrior':
        $secondCharacter = Warrior::create($data);
        break;
    case 'Wizard':
        $secondCharacter = Wizard::create($data);
        break;
}
// Battle
while ($secondCharacter->isAlive() && $firstCharacter->isAlive()) {
    if ($firstCharacter instanceof Wizard) {
        $secondCharacter->takeDamage($firstCharacter->attackWithMagic());
    } else {
        $secondCharacter->takeDamage($firstCharacter->attack());
    }
    
    if ($secondCharacter instanceof Wizard) {
        $firstCharacter->takeDamage($secondCharacter->attackWithMagic());
    } else {
        $firstCharacter->takeDamage($secondCharacter->attack());
    }
}

if (!$secondCharacter->isAlive()) {
    echo 'victory to the first character';
} elseif (!$firstCharacter->isAlive()) {
    echo 'victory to the second character';
}

VS

Le souci...

Pour être certain du type de l'objet, on doit vérifier les types à chaque fois

 

Une fois, deux fois, .... et on se retrouve à le faire partout dans le projet.

 

De plus, si on ajoute un autre type de personnage, on doit modifier tous les switch concernés.

... c'est le switch

La solution...

Le switch va être centralisé dans une classe dont le seul but est de sélectionner le bon type de personnage

... toujours la Factory,
mais sous une autre forme :
l'Abstract Factory
 

Classe FightingCharactersFactory avec une seule fonction create()

<?php

class FightingCharactersFactory
{
    public static function create(string $parameter, array $data)
    {
        switch ($parameter) {
            case 'Warrior':
                return new Warrior($data['lifePoint'], $data['hitPoint']);
            case 'Wizard':
                return new Wizard($data['lifePoint'], 
                	$data['hitPoint'], $data['manaPoint']);
        }
    }
}

Problème :
on ne connaît plus le type de l'objet retourné

Nous n'avons pas l'assurance que les fonctions :

  • isAlive()

  • takeDamage()

  • attack()

existent dans chaque cas

Solution :

Utilisation d'une Interface

L'interface va nous forcer à définir les fonctions qui seront demandées dans Battle

Je crée CanBattleInterface

<?php

interface CanBattleInterface
{
    public function isAlive(): bool;

    public function attack(): int;

    public function takeDamage(int $damage): void;
}

En utilisant cette interface, je serai forcé d'avoir ces fonctions disponibles dans ma classe, tout en respectant les paramètres et le type de retour

Je l'implémente à Warrior

class Warrior implements CanBattleInterface
{
    private $lifePoint;
    private $hitPoint;

    public function __construct(int $lifePoint, int $hitPoint)
    {
        $this->lifePoint = $lifePoint;
        $this->hitPoint = $hitPoint;
    }

    public function isAlive(): bool
    {
        //...
    }

    public function attack(): int
    {
        //...
    }

    public function takeDamage(int $damage): void
    {
        //...
    }
}

Je l'implémente à Wizard

class Wizard implements CanBattleInterface
{
    private $lifePoint;
    private $hitPoint;
    private $magicPoint;

    public function __construct(int $lifePoint, int $hitPoint, int $magicPoint)
    {
        $this->lifePoint = $lifePoint;
        $this->hitPoint = $hitPoint;
        $this->magicPoint = $magicPoint;
    }

    public function isAlive(): bool
    {
        //...
    }
	
    // attackWithMagic() devient simplement attack()
    public function attack(): int
    {
        //...
    }

    public function takeDamage(int $damage): void
    {
        //...
    }
}

Je peux mettre à jour ma Abstract Factory

<?php

class FightingCharactersFactory
{
    public static function create(string $parameter, array $data): CanBattleInterface
    {
        switch ($parameter) {
            case 'Warrior':
                return new Warrior($data['lifePoint'], $data['hitPoint']);
            case 'Wizard':
                return new Wizard($data['lifePoint'], 
                	$data['hitPoint'], $data['manaPoint']);
        }
    }
}

Ici, indiquer CanBattleInterface en type de retour est très important !
Abstract signifie justement que l'on renvoi l'abstraction d'un objet

Je passe la classe en final car il n'y a aucune raison d'autoriser l'héritage.
Et par sécurité, je lève une exception si j'atteins le default.

<?php

final class FightingCharactersFactory
{
    public static function create(string $parameter, array $data): CanBattleInterface
    {
        switch ($parameter) {
            case 'Warrior':
                return new Warrior($data['lifePoint'], $data['hitPoint']);
            case 'Wizard':
                return new Wizard($data['lifePoint'], 
                	$data['hitPoint'], $data['manaPoint']);
            default:
            	throw new \Exception();
        }
    }
}

Mieux : je crée une exception dédiée

<?php

class UnknownCharacterTypeException extends InvalidArgumentException
{
    private const MESSAGE = 'Invalid character type : %s';
    
    public function __construct(string $parameter, $code = 0, 
    			Throwable $previous = null)
    {
        parent::__construct(sprintf(self::MESSAGE, $parameter), $code, $previous);
    }
}

Je l'implémente au default

<?php

final class FightingCharactersFactory
{
    public static function create(string $parameter, array $data): CanBattleInterface
    {
        switch ($parameter) {
            case 'Warrior':
                return new Warrior($data['lifePoint'], $data['hitPoint']);
            case 'Wizard':
                return new Wizard($data['lifePoint'], 
                	$data['hitPoint'], $data['manaPoint']);
            default:
            	throw new UnknownCharacterTypeException($parameters);
        }
    }
}

Et le script Battle change :

<?php
// je recois les données en tableau, mais je ne sais pas dans quel ordre
$entryData = [
    [
        'type' => 'Warrior',
        'lifePoint' => 20,
        'hitPoint' => 5,
    ],
    [
        'type' => 'Wizard',
        'lifePoint' => 15,
        'hitPoint' => 8,
        'manaPoint' => 10,
    ]
];

$characters = [];
foreach ($entryData as $data) {
    $characters[] = FightingCharactersFactory::create($data['type'], $data);
}

// Battle
while ($characters[0]->isAlive() && $characters[1]->isAlive()) {
    $characters[1]->takeDamage($characters[0]->attack());
    $characters[0]->takeDamage($characters[1]->attack());
}

if (!$characters[1]->isAlive()) {
    echo 'victory to the first character';
} elseif (!$characters[0]->isAlive()) {
    echo 'victory to the second character';
}

Et dans mon script Battle, je sais que les fonctions de combat sont disponibles car Warrior et Wizard implémentent CanBattleInterface

<?php

$characters = [];
foreach ($entryData as $data) {
    $characters[] = FightingCharactersFactory::create($data['type'], $data);
}

// tous les éléments de $characters sont des objet de type CanBattleInterface
/** @var CanBattleInterface[] $characters */
while ($characters[0]->isAlive() && $characters[1]->isAlive()) {
    $characters[1]->takeDamage($characters[0]->attack());
    $characters[0]->takeDamage($characters[1]->attack());
}

Et si on rajoute un type de personnage ?

 

Par exemple, Archer

Rien de plus simple, on crée la classe Archer qui implémente CanBattleInterface

class Archer implements CanBattleInterface
{
    private $lifePoint;
    private $hitPoint;
    private $arrowNumber;
    
    public function __construct(int $lifePoint, int $hitPoint, int $arrowNumber)
    {
        $this->lifePoint = $lifePoint;
        $this->hitPoint = $hitPoint;
        $this->arrowNumber = $arrowNumber;
    }
    
    public function isAlive(): bool
    {
        //...
    }

    public function attack(): int
    {
        //...
    }

    public function takeDamage(int $damage): void
    {
        //...
    }
}

On l'ajoute dans la Factory

<?php

final class FightingCharactersFactory
{
    public static function create(string $parameter, array $data): CanBattleInterface
    {
        switch ($parameter) {
            case 'Warrior':
                return new Warrior($data['lifePoint'], $data['hitPoint']);
            case 'Wizard':
                return new Wizard($data['lifePoint'], 
                    $data['hitPoint'], $data['manaPoint']);
            case 'Archer':
                return new Archer($data['lifePoint'], 
                    $data['hitPoint'], $data['arrowNumber']);
            default:
                throw new UnknownCharacterTypeException($parameter);
        }
    }
}

Et le tour est joué !


Le script Battle n'a besoin d'aucune modification !

Et croyez moi, c'est très utile :)

Il ne vous reste plus qu'à vous laisser tenter !

Des questions ?