Imaginons un systéme de billeterie obéissant aux rêgles suivantes :
- Un client peut acheter de 1 à n ticket;
- Un ticket est valable pour une et une seule personne;
- Le prix du ticket doit être 2 si la personne à moins de 4 ans, 10 si la personne à moins de 12 ans et 30 pour toute personne agée de plus de 12 ans.
Il nous est demandé de développer le code correspondant à cet ensemble de régles en obéissant aux paradigme de la programmation orientée objet.
Une implémentation possible pourrait être la suivante :
<?php
namespace billeterie;
class date
{
/* ... */
}
class birthdate extends date
{
/* ... */
public function getAge()
{
/* ... */
}
}
class personn
{
private $birthdate = null;
public function __construct(birthdate $birthdate)
{
$this->birthdate = $birthdate;
}
public function getAge()
{
return $this->birthdate->getAge();
}
}
class order
{
private $personns = null;
public function addPerson(personn $personn)
{
$this->personns[] = $personn;
return $this;
}
public function getPersonns()
{
return $this->personns;
}
}
class tarification
{
public function getPersonPrice(person $personn)
{
switch (true)
{
case $this->isConsideredABaby($personn):
return 2;
case $this->isConsideredAChild($personn):
return 10;
default:
return 30;
}
}
protected function isConsideredABaby(personn $personn)
{
return $person->getAge() < 4;
}
protected function isConsideredAChild(personn $personn)
{
return $person->getAge() < 12;
}
}
class quotation
{
private $tarification = null;
public function __construct(tarification $tarification = null)
{
$this->tarification = $tarification ?: new tarification();
}
public function getPrice(order $order)
{
$price = 0;
foreach ($order->getPersonns() as $personn)
{
$price += $this->tarification->getPersonnPrice(personn);
}
return $price;
}
}
echo (new quotation())->getPrice((new order())->addPersonn(new personn(…))->addPersonn(new personn(…))->addPersonn(new personn(…)));
Cette solution définie une classe nommée quotation
dont la responsabilité est de calculer le prix de la commande représentée par la classe order
en fonction d'une tarification.
Cette tarification est modélisée à l'aide de la classe éponyme qui fait appel à des méthodes protégées pour définir le prix, le développeur ayant anticipé une éventuelle évolution des règles de calcul.
Cependant, une autre implémentation pourrait être :
<?php
namespace billeterie;
class ticket
{
private $price = 0;
public function setPrice($price)
{
$this->price = $price;
return $this;
}
}
class date
{
/* ... */
}
class birthdate extends date
{
/* ... */
public function getAge()
{
/* ... */
}
}
class personn
{
public function __construct(birthdate $birthdate)
{
$this->birthdate = $birthdate;
}
public function getAge()
{
return $this->birthdate->getAge();
}
public function createTicket(ticket $ticket = null)
{
if ($ticket === null)
{
$ticket = new ticket();
}
$age = $this->getAge();
switch (true)
{
case $age < 4:
return $ticket->setPrice(2);
case $age < 12:
return $ticket->setPrice(10);
default:
return $ticket->setPrice(30);
}
}
public function getTicketPrice(ticket $ticket = null)
{
return $this->createTicket($ticket)->getPrice();
}
}
class order
{
private $personns = array();
public function addPerson(personn $personn)
{
$this->personns[] = $personn;
return $this;
}
public function getPrice()
{
$price = 0;
foreach ($this->personns as $personn)
{
$price += $personn->getTicketPrice();
}
return $price;
}
}
echo (new order())
->addPersonn(new personn(…))
->addPersonn(new personn(…))
->addPersonn(new personn(…))
->getPrice();
La philosophie de cette solution est complétement différente de la première conceptuellement parlant.
En effet, le tarificateur n'existe plus car il n'y en a pas besoin, le calcul du prix étant délégué à la fois à la classe order
et à la classe personn
.
Alors, quelle est la meilleure solution ?
Pour répondre à cette question, imaginons une évolution des règles de calculs.
Ainsi, le prix d'un billet est maintenant de 45 si la personne a entre 35 et 65 ans et le billet est gratuit le jour de l'anniversaire de la personne.
En outre, si la commande est réalisée avant le 01/07, alors une réduction de 50% doit être appliquée.
Dans le cas de la première solution, le code doit être remanié de la manière suivante :
class date
{
/* ... */
}
class birthdate extends date
{
/* ... */
public function getAge()
{
/* ... */
}
}
class personn
{
private $birthdate = null;
public function __construct(birthdate $birthdate)
{
$this->birthdate = $birthdate;
}
public function getAge()
{
return $this->birthdate->getAge();
}
}
class order
{
private $personns = null;
public function addPerson(personn $personn)
{
$this->personns[] = $personn;
return $this;
}
public function getPersonns()
{
return $this->personns;
}
}
class tarification
{
public function getPersonPrice(personn $personn)
{
$price = 0;
switch (true)
{
case $this->isConsideredABaby($personn):
$price = 2;
case $this->isConsideredAChild($personn):
$price = 10;
case $this->hasAgeBetween35And65($personn):
$price = 45;
default:
$price = 30;
}
return $this->isBirthdate($personn) ? 0 : $price;
}
public function getOrderPrice(order $order, $price)
{
$now = new date();
if ($now->getMonth() < 7)
{
$price /= 2;
}
return $price;
}
protected function isConsideredABaby(person $personn)
{
return $person->getAge() < 4;
}
protected function isConsideredAChild(person $personn)
{
return $person->getAge() < 12;
}
protected function hasAgeBetween35And65(personn $personn)
{
return $person->getAge() >= 35 && $personn->getAge() <= 65;
}
protected function isBirthdate(personn $personn)
{
$now = new date();
return $person->getBirthdate()->getDay() == $now->getDay() && $person->getBirthdate()->getMonth() == $now->getMonth();
}
}
class quotation
{
private $tarification = null;
public function __construct(tarification $tarification = null)
{
$this->tarification = $tarification ?: new tarification();
}
public function getPrice(order $order)
{
$price = 0;
foreach ($order->getPersonns() as $personn)
{
$price += $this->tarification->getPersonnPrice(personn);
}
return $this->tarification->getOrderPrice($order, $price);
}
}
Dans le cas de la première solution, il est donc nécessaire d'intervenir :
- Au niveau de la classe
quotation
pour pouvoir gérer la nouvelle règle relative à la date de la commande; - Au niveau de la classe
tarification
pour pouvoir gérer les nouvelles règles relative à la date de la commande et à l'âge des personnes;
L'évolution nécessaire au niveau de la seconde solution serait :
<?php
namespace billeterie;
class ticket
{
private $price = 0;
public function setPrice($price)
{
$this->price = $price;
return $this;
}
}
class date
{
/* ... */
}
class birthdate extends date
{
/* ... */
public function getAge()
{
/* ... */
}
}
class personn
{
public function __construct(birthdate $birthdate)
{
$this->birthdate = $birthdate;
}
public function getAge()
{
return $this->birthdate->getAge();
}
public function createTicket(ticket $ticket = null)
{
$ticketPrice = 0;
if ($ticket === null)
{
$ticket = new ticket();
}
$age = $this->getAge();
switch (true)
{
case $age < 4:
$ticketPrice = 2;
break;
case $age < 12:
$ticketPrice = 10;
break;
case $age >= 35 && $age <= 65:
$ticketPrice = 45;
break;
default:
$ticketPrice = 30;
}
return $ticket->setPrice($ticketPrice);
}
public function getTicketPrice(ticket $ticket = null)
{
return $this->createTicket($ticket)->getPrice();
}
}
class order
{
private $personns = array();
public function addPerson(personn $personn)
{
$this->personns[] = $personn;
return $this;
}
public function getPrice()
{
$price = 0;
foreach ($this->personns as $personn)
{
$price += $personn->getTicketPrice();
}
$now = new date();
return ($now->getMonth() < 7 ? $price / 2 : $price);
}
}
Dans le cas de la seconde solution, il est donc nécessaire d'intervenir :
- Au niveau de la classe
order
pour pouvoir gérer la nouvelle règle relative à la date de la commande; - Au niveau de la classe
personn
pour pouvoir gérer la nouvelle règle à l'âge des personnes;
Les modification à effectuer dans le cas de la seconde solution sont donc à la fois moins complexes et plus logiques puisque seul les classes concernées par les nouvelles règles sont modifiées.
De plus, la classe tarificateur
de la première solution devient une classe monolithique et fourre-tout, puisqu'elle a pour responsabilité de calculer à la fois le prix en fonction des personnes qui auront des billets, mais aussi en fonction de la date de la commande.
Par ailleurs, la première solution implique d'exposer fortement dans la classe tarification
les propriétés de la classe personn
.
Le couplage entre ces deux classes est donc très fort et il y a de plus une rupture de l'encapsulation.
De plus, si le nombre de propriétés de la classe order
ou personn
devant être pris en compte par tarificateur
augmente, le code de cette classe va devenir de plus en plus complexe.
J'ajoute que si le tarificateur est amené à utiliser des instances de la classe personn
et des instances d'une ou plusieurs classes dérivant de personn
, donc avec potentiellement des propriétés supplémentaires, il va être très difficile de conserver un code générique au niveau de la classe tarificateur
.
Enfin, vu que les propriétés de order
ou personn
sont très largement exposées dans la classe tarificateur
, toute modification de l'API (ce qui n'arrive jamais, les BC break étant des légendes urbaines) de ces deux classes devra y être répercutée.
La seconde solution respecte quand à elle l'encapsulation car aucune propriété des différentes classes n'est exposé au cours du calcul et le couplage est très faible.
En terme de programmation orientée objet, la secode solution est donc de meilleure qualité, car la première est en effet une utilisation procédurale de la programmation orientée objet.
Comme le dit Alan Sharp :
Procedural code gets information then makes decisions. Object-oriented code tells objects to do things.
C'est la même combinatoire si ce n'est que tu passes en paramètre order au tarificateur.
Donc tu admets que la signature change, pas dans l'exemple 1 (à son bénéfice) qui se manipule toujours de la même façon, qui donne toujours le bon tarif dans toutes les situations, qui est interchangeable à souhait car il peut faire l'objet d'une interface, donc de changement d'implémentations à souhait.
Ensuite, que l'on écrive ou non une interface, on devrait "toujours" penser en interface, les interfaces définissant les responsabilités des objets.