Voici par exemple le code qui m’a été présenté dans l’optique de justifier le test des méthodes protégées :
interface Personn { public function getBirthdate(); } class Francais implements Person { private $birthdate = null; public function __construct(JulienDate $date) { $this->birthdate = $date; } public function getBirthdate() { return $this->birthdate; } } class DisneyLandParisPricingSystem implements TicketPricingSystem { public function getPrice(Person $person) { $now = new julianDate(); if ($now - $person->getBirthdate() <= 2) { return 0; } elseif ($now - $person->getBirthdate() <= 4) { return 10; } else { return 30; } } }
Il n’a rien d’exceptionnel et il est certainement semblable à celui que vous ou moi avons pu écrire ou écrivons quotidiennement.
Pourtant, il ne s’agit pas de programmation orientée objet.
En effet, ce style de programmation a pour but de masquer aux yeux du monde le fonctionnement interne d’un objet.
Un objet est donc une boite noire capable d’effectuer des actions en fonction de son état interne et des informations qui lui sont communiquées.
La boite noire est donc la classe, les actions qu’elle peut effectuer sont définies par les méthodes de cette classe, tandis que son état interne est représenté par les propriétés de la classe.
Or, dans le code ci-dessous, une propriété de la classe, donc une information relative à l’état interne de la boite noire est utilisée dans une méthode externe à la classe.
En effet, la date de naissance de la personne est utilisée pour calculer son âge afin de décider de la valeur d’une variable.
La boite noire qu’est censée être l’objet ne l’est donc plus réellement, puisqu’elle expose des informations sur sa structure interne.
Donc, même si ce code atteint son objectif, à savoir calculer une valeur en fonction de l’âge de la personne, il ne s’agit pas de programmation orientée objet, mais bien de programmation procédurale, puisqu’il y a rupture de l’encapsulation.
Il arrive cependant que certains ne soient pas convaincus par cet argument, et il est donc nécessaire de pousser la démonstration un cran plus loin.
Quel est le résultat fourni par le code précédent si la valeur de la date de naissance est modifiée entre le premier et le second appel à Person::getBirthdate()
?
Car même si le code ne semble pas le permettre, c’est tout de même possible, puisqu’il suffit pour cela que l’âge soit par exemple stocké dans un fichier, une base de données ou tout autre contenant modifiable indépendamment de ce code.
Si nous supposons par exemple que le premier appel permet de calculer un âge de 10 ans, puis que le second permet de calculer un âge de 1 an, car quelqu’un a entre temps édité le fichier ou la base de données correspondante pour par exemple corriger une faute de frappe, la méthode va retourner la valeur 10 au lieu de 30.
On va alors me répondre que pour éviter cela, il suffit de n’appeler qu’une seule fois la méthode Person::getBirthdate()
et de stocker sa valeur de retour dans une variable.
C’est tout à fait exact, mais encore faut-il y penser, et y penser systématiquement à chaque fois qu’on utilise cette méthode pour prendre une décision…
De plus, le nom de la méthode laisse à penser qu’elle renvoie la date de naissance, mais rien n’impose le type dans lequel elle sera exprimée.
En fonction des différentes implémentations possibles de l’interface Person, la date de naissance pourra donc être exprimée dans un calendrier qui n’est pas celui utilisé par DisneyLandParisPricingSystem
.
Et dans ce cas, le résultat de la méthode DisneyLandParisPricingSystem ::getPrice()
pourra être erroné :
class Maya implements Person { private $birthdate = null; public function __construct(MayaDate $date) { $this->birthdate = $date; } public function getBirthdate() { return $this->birthdate; } }
De plus, rien n’impose à Person ::getBirthdate()
de renvoyer un objet, et elle pourrait donc très bien renvoyer un timestamp UNIX, par exemple, ou le nombre de jour écoulé depuis l’an 0.
Certes, il est possible de déléguer ce calcul à des méthodes qui s'appeleraient par exemple Person::isBaby()
, Person::isChild()
ou Person::isAdult()
, mais outre le fait qu'il va être difficile de trouver un nom pour une méthode permettant de détecter les personnes ayant entre 37 et 42 ans, seul la bonne volonté de leur développeur fera qu'elles renverront effectivement un booléen.
De plus, au fur et à mesure de l'évolution de vos règles de tarification, il faudra ajouter de plus en plus de méthodes de ce type à l'interface Person
, donc les implémenter systèmatiquement pour chaque classe qui l'implémentera.
Donc en admettant que votre système de tarification utilise 20 critères, il faudra implémenter 20 méthodes…
Or, il se trouve que si la classe Person n’expose pas son état interne via la méthode getBirthdate()
, tous ces problèmes ne se posent plus :
class Maya implements Person { private $birthdate = null; public function setBirthdate(MayaDate $date) // Oui, Maya, pourquoi pas :) { $this->birthdate = $date; return $this; } public functionc giveInformationsTo(DisneyLandParisPricingSystem $pricingSystem) { $pricingSystem->setBirthdate($this->birthdate->toJulianDate()); } } class Français implements Person { private $birthdate = null; public function setBirthdate(JulianDate $date) { $this->birthdate = $date; return $this; } public functionc giveInformationsTo(DisneyLandParisPricingSystem $pricingSystem) { $pricingSystem->setBirthdate($this->birthdate); } } class DisneyLandParisPricingSystem implements TicketPricingSystem { private $birthdate = null; public function setBirthdate(JulianDate $date) { $this->birthdate = $date; return $this; } public function getPrice(Personn $person) { $person->giveInformationTo($this); $age = $this->computeAge(); if ($age <= 2) { return 0; } elseif ($age <= 4) { return 10; } else { return 30; } } private function computeAge() { return new julianDate() - $this->birthdate; } }
Avec cette solution qui suit les paradigmes de la programmation orientée objet, les problèmes évoqués précédemment n’existent plus.
En effet, c’est la classe implémentant Person
qui se charge de renseigner la classe chargée de calculer le prix du billet et le développeur n’a ainsi plus à se soucier d’appeler une seule fois une méthode pour être certain d’avoir un résultat cohérent.
De plus, le système de tarification exprime ses besoins à l’aide du typage des arguments de ses méthodes, puisque DisneyLandParisPricingSystem::setBirthdate()
précise qu’elle n’accepte que les dates au format julien.
Le problème posé par le calendrier utilisé pour exprimer l’âge disparait donc automatiquement et de plus, les classes implémentant l’interface Person
sont à même de faire les éventuelles conversions nécessaires au diaglogue avec une instance de DisneyLandParisPricingSystem
.
Think declaratively instead of procedurally!
Enfin, cerise sur le gâteau, si les règles de tarification évoluent, pour par exemple prendre en compte le fait que la personne est handicapée, il suffit de faire évoluer DisneyLandParisPricingSystem
pour que les instances de Person
soit à même de lui fournir l'information ou non en fonction de leur état :
class Handicape implements Person { private $birthdate = null; public function setBirthdate(JulianDate $date) { $this->birthdate = $date; return $this; } public functionc giveInformationsTo(DisneyLandParisPricingSystem $pricingSystem) { $pricingSystem ->setBirthdate($this->birthdate) ->setHandicape(true) ; } } class DisneyLandParisPricingSystem implements TicketPricingSystem { private $birthdate = null; private $handicape = false; public function setBirthdate(JulianDate $date) { $this->birthdate = $date; return $this; } public function setHandicape($boolean) { $this->handicape = ($boolean == true); } public function getPrice(Personn $person) { $person->giveInformationTo($this); $age = $this->computeAge(); if ($age <= 2) { return 0; } elseif ($age <= 4) { return 10; } else { return ($this->handicape ? 15 : 30); } } private function computeAge() { return new julianDate() - $this->birthdate; } }
Lorsque notre code demande une information à une instance de classe et prend une décision en fonction, nous ne faisons donc pas de la programmation orientée objet.
Afin de profiter des avantages de cette dernière, il est bien plus efficace de respecter ses paradigmes en n’exposant pas les propriétés de nos objets aux yeux de l’extérieur et en demandant à nos objets de faire des actions.
En anglais, ce concept est connu sous le nom de Tell, Don't as.
Procedural code gets information then makes decisions. Object-oriented code tells objects to do things.
PS : Cet article est également une bonne ressource, en plus d'être un excellent exercice pour améliorer ses compétences en programmation orientée objet.
8 réactions
1 De Adirelle - 12/04/2014, 10:14
C'est intéressant sur le fond, mais dans la forme, une chose est biaisée par le fait qu'on utilise du PHP, dont les valeurs de retour des méthodes ne sont pas statiquement typées. La discussion aurait-elle été exactement la même si, dans un langage comme Java par exemple, l'interface Person avait imposé à getBirthDate de renvoyer une JulianDate ?
L'autre problème de ce qui est proprosé est que la classe Francais devient dépendante de DisneyLandParisPricingSystem (dans le sens où DisneyLandParisPricingSystem apparaît dans le source de Person), alors qu'en général on essaie d'éviter que les DAO soient liés aux services. Pour chaque nouveau système utilisant Francais (ou Person), il faudrait lui rajouter des méthodes ? Si oui, l'interface Person va devenir un nœud de dépendances que l'on ne pourra pas réutiliser.
Bref, même si comprends l'idée, je ne suis pas vraiment convaincu.
2 De LxdrBrl - 12/04/2014, 10:55
Je ne rentrerai pas dans le débat de la manière de résoudre ce problème, juste dans la justification de tes explications qui sont d'abord basées sur une limitation du langage utilisé (PHP et l'impossibilité de faire un contrat sur le type de retour) et une bonne pratique de l'orienté objet... qui ne concerne pas du tout le cas présent à mon avis.
"That is, you should endeavor to tell objects what you want them to do; do not ask them questions about their state, make a decision, and then tell them what to do.
The problem is that, as the caller, you should not be making decisions based on the state of the called object *****that result in you then changing the state of the object****.
The logic you are implementing is probably the called object’s responsibility, not yours. For you to make decisions outside the object violates its encapsulation."
Dans ce cas, il n'y a pas d'action sur Person, on récupère une information de Person pour modifier l'état de notre propre objet (bien que là ce soit plutôt l'interface TicketPricingSystem qui n'est pas très OOP). Dans tous les cas, l'algorithme est bien de la responsabilité de TicketPricingSystem et non de Person.
3 De mageekguy - 12/04/2014, 13:34
@Adirelle : Le fait que tu puisses typer la valeur de retour dans un autre langage ne change absolument rien, ne serait-ce que parce que cela n'empêche pas que deux appels consécutifs à la méthode pourront renvoyer deux valeurs différentes.
C'est exactement le même problème que lorsqu'on regarde si un fichier existe et qu'il est ouvert en écriture avant de l'ouvrir pour l'utiliser pour ensuite écrire dedans :
Entre le moment ou l'on effectue les vérifications et le moment ou l'on écrit effectivement dedans, l'état du fichier représenter par son instance a pu évoluer jusqu'à le rendre inutilisable.
Il a pu être fermé par une méthode d'un autre objet appelé entre les vérifications et la commande d'écriture, il a pu être effacé du système de fichier, etc.
Alors autant demander que les données soit écrit directement :
Quand à la seconde partie de ton commentaire, oui,
Person
va devoir définir l'ensemble des méthodes lui permettant d'interagir avec son environnement.Ainsi, si elle doit interagir avec une serrure, elle va devoir savoir utiliser les clefs en sa possession (donc qui font partie de ses propriétés sous la forme d'une liste de clef) pour essayer de l'ouvrir ou de la fermer, la serrure devant elle uniquement exposer les méthodes permettant de l'ouvrir ou de la fermer avec une clef (et non les méthodes permettant à une instance de
Person
de savoir si elle est ouverte ou fermée afin que cette dernière puisse agir en conséquence).4 De Dimitri - 12/04/2014, 14:28
1) Pourquoi ce n'est pas getPrice() qui fait $person->giveInformationTo($this) ? En sortant cet appel de getPrice() tu obliges le client de DisneyLandParisPricingSystem à savoir qu'il faut faire cet appel lui-même. Comment peut-il le savoir en regardant simplement les méthodes publiques de la classe et leurs signatures ?
2) À quoi sert $person dans getPrice() ?
3) Le type de calendrier de getBirthdate() aurait pû être indiqué dans un tag @return de getBirthdate(). C'est dommage que PHP n'ait pas prévu de type hinting sur le retour, mais pour moi ton argument sur l'intérêt du type hinting en argument de setBirthdate() ne tient que pour PHP et pas pour la POO en général.
« De plus, le nom de la méthode laisse à penser qu’elle renvoie la date de naissance, mais rien n’impose le type dans lequel elle sera exprimée. »
Il est difficile de raisonner sur une interface qui permet de retourner tout et n'importe quoi. Si on fait de la vraie POO, le type de retour fait partie de la signature.
Si cette interface n'impose rien sur le type de retour, j'ai envie de dire qu'elle ne sert à rien car le résultat ne sera pas exploitable. Comment veux-tu respecter le principe de substitution de Liskov avec ça ? Exemple :
Qu'est-ce que je peux faire de $date ? Certaines personnes me renverrons un int, d'autres un DateTime, d'autre un MayaDate, d'autre JulianDate, qui sont sans rapports entre eux ?
4) Tes implémentations de Person dépendent d'une implémentation particulière de TicketPricingSystem.
Maya -> DisneyLandParisPricingSystem
Francais -> DisneyLandParisPricingSystem
Handicape -> DisneyLandParisPricingSystem
Si j'ai AsterixPricingSystem, il faudra implémenter un second giveInformationTo() par implémentation de Person, spécifique à AsterixPricingSystem.
PS : désolé pour la mise en page horrible, je ne sais pas comment mettre en forme les morceaux de code.
5 De mageekguy - 12/04/2014, 14:33
@Dimitri : Bien vu pour ta première remarque, j'ai oublié l'appel… (un comble vu que c'est le plus important). J'ai corrigé.
Si tu définis dans une interface
PricingSystem
les méthodes génériques (setBirthadate()
,setHandicape()
, etc) qui permettent à n'importe quoi de communiquer à un PricingSystem les informations dont il a besoin pour calculer son prix, tu n'as plus ce problème.Ta troisième remarque ne fait que confirmer mon point de vue dans le cadre de PHP, mais de toute façon, le fait de ne pas pouvoir typer le type de retour n'est pas fondamental dans le cadre de cette discussion.
Si tu interroges l'état d'un objet X à un instant t dans l'optique de te servir de cette indication pour prendre une décision, tu n'as absolument aucune garantie qu'au moment t+n ou tu exécuteras l'action déterminé par l'état que tu as récupéré, cet état n'a pas évolué et que ta décision est donc encore pertinente.
Mon exemple n'est en fait pas très parlant, car la première implémentation de
getPrice()
n'a rien d'objet vu qu'elle ne contient aucun appel à$this
.getPrice()
pourrait donc être méthode statique ou bien une simple fonction.Je devrais remplacer les
return 0
,return 10
etreturn 30
respectivement par$this->price = 0
,$this->price = 10
et$this->price = 30
, et enfin un return$this->price
terminal.6 De kao98 - 14/04/2014, 16:28
Je suis perplexe par ce billet. Très perplexe.
Certes, l'exemple initial est complètement à revoir.
Mais mageekguy, je suis très sceptique, et je dois bien l'avouer un peu déçus, par ta réponse.
Le plus grand des principes de la programmation objet, le S de SOLID, soit le pilier de la POO est ici complètement foulé au pied. Single Responsibility Principle. Une classe ne doit être responsable que d'une et une seule chose.
Ici, la classe Person : ça n'a aucun sens de lui attribue la responsabilité de transférer des données à quiconque. Ce n'est pas son rôle, qui est de simplement représenter une personne au sein du programme.
Une de ses principales responsabilité peut-être, par exemple, de calculer l'âge de la personne justement, en fonction des données qui lui son propre, telle sa date de naissance. Car ce n'est pas de la responsabilité de la classe Pricing un tel calcul !
Quant à la possibilité d'une altération de la birthdate au cours du temps, il sera de la responsabilité de Person d'y prêter attention, et à personne d'autre. Selon le degré de concurrence souhaité, il sera alors peut-être nécessaire d'implémenter un système de lock, ou pas, car peut-être cela est de la responsabilité de la DAL. Mais je m'égare ...
Maintenant, la classe pricing. Il n'est aucunement souhaitable qu'elle ait la responsabilité du calcul de l'âge d'un membre de la classe Person comme je l'ai déjà évoqué. Et tout comme tu l'illustre si bien mageekguy, on peut imaginer de nombreux critère du calcul du prix. Et c'est de sa responsabilité, cette fois, de prendre en compte ces critères. C'est pourquoi on pourrait ton approche pour cette classe est plutôt correcte à mon avis. Mais on peut casser ce lien fort qui existe malgré tout entre la classe Pricing et la classe Person (imaginons que je veuille calculer le prix d'un chien, comment je fais en l'état !?).
Donc, potentiellement, j'écrirais plutôt quelque chose comme ça :
interface PersonInterface {
}
//implémentation d'une classe, avec des birthdates, & Co, ...
interface PricingInterface {
}
//Puis c'est dans le code appelant, par exemple, que le "liens" sera fait.
$person = new Person();
$person->setBirthDate(new JulianDate('1990-01-31'));
$pricingSystem = new Pricing();
$pricingSystem->setAge($person->getAge());
$price = $pricingSystem->getPrice();
Alors bien sûr, vous allez dire "ouais, mais si les règles de calcul changent, faudra les changer partout". Je répond que s'il faut les changer à plusieurs endroit, c'est qu'il y a encore du code à refactoriser, car ça ne devrait pas être le cas.
Pis là, c'était pour faire "simple", mais un service complet et complexe de tarification peut être imaginer. Avec cet exemple, je veux juste essayer d'illustrer qu'il faut faire attention à la responsabilité de chaque classe, et ne pas en sortir.
A partir du moment ou l'on écrit la classe "Pricing" en pensant à la classe "Person", c'est que ça ne va pas. Et à partir du moment où l'on écrit la classe "Person" en imaginant un moyen de l'interfacer avec la classe "Pricing" (ou d'autres), c'est que ça ne va pas non plus.
On écrit la classe "Person" en ne pensant à rien d'autre qu'à elle même. Pareil avec la classe "Pricing". Et c'est le déroulement du programme qui les lieras l'une à l'autre d'une façon ou d'une autre.
Dans le cadre d'une application PHP web "classique" "MVC", il s'agira sans doute d'un contrôleur.
7 De Paul - 15/04/2014, 10:25
Personnellement si je lisais la solution avec "giveInformationsTo" j'irais engueuler mon développeur.
1°) L'object Français/Maya n'a pas a avoir connaissance du comment il va etre utiliser, car sinon ca implique que je ne peux pas réutiliser ces objets dans un autre projet.
1-bis) Ca veux dire que si on remonte dans la hiérachie des classes, PricingSystem doit avoir connaissance du lieu où il va être utilisé (park, parking, avion, cinéma), qui eux même devront avoir connaissance du pays où ils sont etc...
Mais on peut aussi pousser dans le sens inverse, pourquoi l'objet date n'a pas conscience de l'objet Personne ? Et si la représentation interne de date était modifier dans tous les cas mon objet $date n'aurait pas la même valeur.
Donc je devrait modifier mon objet Date et rajouter
Date.giveInformationsTo(Personne p)
D'ailleurs vous gérer bien ce cas avec le calcul unique de la date actuelle(now)
2°) ""Si tu interroges l'état d'un objet X à un instant t dans l'optique de te servir de cette indication pour prendre une décision, tu n'as absolument aucune garantie qu'au moment t+n ou tu exécuteras l'action déterminé par l'état que tu as récupéré, cet état n'a pas évolué et que ta décision est donc encore pertinente.""
La POO n'implique pas le parallèle et inversement.
Un programme qui marche en parallèle à de forte chance de marché sur un système qui ne l'est pas, mais l'inverse est faux. Un programme est fait pour un type d'environnement et le changer d'environnement ca serait idiot.
"Or, dans le code ci-dessous, une propriété de la classe, donc une information relative à l’état interne de la boite noire est utilisée dans une méthode externe à la classe."
Ca s'appel pas "accesseurs/mutateurs" et qui font partie du paradigme objet ?
---
Tout ca pour dire que de mon point de vue, le code du début est tout à fait correcte et reste bien la POO.
C'est aux développeurs des 2 classes de savoir si l'environement sera partagé ou non (thread/base de données/fichier/autre...), si oui c'est au développeur de Personne de gérer ce cas(cache...) ou de permettre au développeur de PricingSystem de le faire (sémaphore...)
---
Ps: Désolé pour la mise en forme je n'ai pas trouvé comment faire.
8 De bob l'éponge - 14/06/2014, 10:29
Salut, juste une petit coquille dans le titre :
À propos "de de" la programmation orientée objet