Les Principes de programmation SOLID

Introduction

Ce tutoriel met l’accent sur un aspect du développement orienté objet: une pratique connue sous l’acronyme S.O.L.I.D. Bien qu’ils existent des dizaines de concepts, d’acronymes et de comportements dans le monde de la programmation orientée objet, la compréhension de SOLID et sa mise en oeuvre, vous aideront à être un meilleur programmeur et vous outillera pour travailler dans le monde moderne du développement orienté objet indépendamment de tout framework.

Que signifie S.O.L.I.D?

SOLID est un acronyme qui comprend 5 grands principes fondamentaux de base en développement orienté objet:

  • Single Responsibility Principle – SRP, qui stipule que les classes et les objets doivent avoir une seule tâche/rôle.
  • Open Closed Principle – OCP, qui stipule que les objets doivent être ouverts à l’extension du comportement et fermés à la modification du comportement.
  • Liskov Substitution Principle – LSP, qui stipule que les objets de même type doivent être interchangeables.
  •  Interface Segregation Principle – ISP, qui stipule que les petites et compactes interfaces sont préférables aux objets volumineux (God objects).
  • Dependency Inversion Principle – DIP, qui stipule que les applications doivent dépendre des abstractions, et non pas des instances concrètes d’un objet.

Beaucoup de ces principes communiquent et interagissent les uns avec les autres. Qu’est-ce que cela signifie pour le développeur? Qu’en comprenant un de ces principes, les autres deviendront ainsi plus facile à appréhender. Cela signifie que l’emploi de quelques uns des principes rendra ainsi plus facile la mise en œuvre de tous les autres principes plus tard.

Single Responsability Principe (SRP)

Considérons la version 1 de la  classe User suivante:

class User {
public function create(array $date) {
try {
// ...save user to database
}
catch (DatabaseException $e) {
$this->logError($e->getMessage());
}
}

public function logError($message) {
// ...write error to file
}
}

Dans cette version 1 de la classe User, le principe de la Responsabilité Unique (Single Responsability Principle – SRP)  n’est pas respectée car la classe User a deux rôles:

  • Elle crée le user, méthode create(),
  • Elle écrit les messages d’erreurs dans un fichier, méthode logError().

La classe User n’est donc pas SOLID.

Pour y remédier, il convient de procéder à quelques modifications. 

Dans la version 2 de la classe User, nous allons transférer la responsabilité de gérer les messages d’erreurs à une autre classe, la classe Logger.

Ce qui donne ceci pour la classe User modifiée:

class User {   
protected $logger;  

public function __construct(Logger $logger) {
$this->logger = $logger;
}

public function create(array $data) {
try {
// ...save user to database
}
catch (DatabaseException $e) {
$this->logger->writeToFile($e->getMessage());
}
}
}

Nous avons ajouter un constructeur à la classe User avec la fonction magique __construct(Logger $logger) qui prend un paramètre de type Logger qui est la nouvelle classe Logger. Une variable protégée de la classe User récupère la valeur du paramètre ($logger). Il s’agit là de l’application du principe DIP (Dependency Inversion Principle) que nous verrons plus tard.

C’est la nouvelle classe Logger qui prend en charge la gestion des messages d’erreurs:

class Logger {
public function writeToFile($message) {
// ...write to the file
}
}

La fonction publique writeToFile($message) pourra être appelée par d’autres classes qui dépendent de la classe Logger comme la classe User par l’application du principe d’inversion des dépendances.

Pour tester ces deux classes, créons un fichier index.php comme suit:

$logger = new Logger();
$user = new User($logger); $user->create(array());

Open close principle (OCP)

Pour aborder le principe Ouvert Fermé (Open Close principle – OCP), considérons le fichier index.php suivant:

$paypalIpn = new PayPalIpn(); 
$paymentManager = new paymentManager($paypalIpn);
$paymentManager->process();

Nous avons 2 variables $paypalIpn et $paymentManager qui sont reçoivent pour le premier, une instance de la classe PayPalIpn et pour le second une instance de la classe paymentManager

Soit la version 1 de la classe PaypalIpn:

class PaypalIpn {
public function processPayment() {
// ... process the payment with Paypal
}
}

La classe PaypalIpn est composée d’une seule fonction processPayment().

Soit la version 1 de la classe paymentManager:

class PaymentManager {
protected $paypal;

public function __construct(PaypalIpn $paypal) {
$this->PayPal = $paypal;
}

public function process() {
$this->paypal->processPayment();
// ... and other payment stuff
}
}

La classe PaymentManager a un constructeur magique qui prend un paramètre qui dépend de la classe PaypalIpn et uniquement de ce type d’objets.

La classe PaymentManager de par le typage imposé à l’argument passé en paramètre à son constructeur, interdit par conséquent,  l’ouverture à d’autres moyens de paiement comme la carte de crédit ou autre. Le rôle de la classe PaymentManager est d’assurer le payement par le biais des moyens de payement existants. Toutefois, son constructeur limite cette classe au seul moyen de paiement PayPal. Donc cet ensemble de classes PayPalIpn et PaymentManager ne sont pas SOLID. Et ne respectent pas le principe OCP qui veut qu’une classe soit ouverte à l’extension (ou surcharge) de son comportement sans en modifier la nature (Polymorphisme). Le comportement de la classe PaymentManager est son rôle à savoir assurer le paiement par un moyen de paiement connu, mais pas uniquement limiter à PayPal. Il faut que d’autres moyens de paiement soient éligibles. Et cela ne peut se faire qu’avec l’utilisation des interfaces.

Nous allons donc créer une interface comme classe intermédiaire comme suit:

interface PaymentMethodInterface {
public function processPayment();
}

Rappelons ce qu’est une interface:

Une interface est un peu comme une classe abstraite dans laquelle aucune méthode ne serait implémentée (juste des signatures de méthodes) : les méthodes y sont seulement déclarées. Cela permet de définir un ensemble de services visibles depuis l’extérieur (l’API : Application Programming Interface), sans se préoccuper de la façon dont ces services seront réellement implémentés. Une classe qui implémente une interface doit obligatoirement implémenter chacune des méthodes déclarées dans l’interface, à moins qu’elle ne soit elle-même déclarée abstraite !

Notons qu’une classe abstraite est une classe dont toutes les méthodes n’ont pas été implémentées. Elle n’est donc pas instanciable, mais sert avant tout à factoriser du code. Une classe qui hérite d’une classe abstraite doit obligatoirement implémenter les méthodes manquantes (qui ont été elles-mêmes déclarées « abstraites » dans la classe parente). En revanche, elle n’est pas obligée de ré-implémenter les méthodes déjà implémentées dans la classe parente (d’où une maintenance du code plus facile).

La classe PaypalIpn va être modifiée comme suite:

class PaypalIpn implements PaymentMethodInterface {   
public function processPayment() {
// ... process the payment with Paypal
}
}

La classe PaypalIpn implémente maintenant la nouvelle interface PaymentMethodInterface et donc peut surcharger la méthode processPayment() pour l’adapter au paiement par PayPal.

De même, nous pouvons créer une nouvelle classe CreditCard pour prendre en compte le paiement par la carte de crédit, comme suit:

class CreditCard implements PaymentMethodInterface {
public function processPayment() {
// ... process the payment with credit card
}
}

Cette nouvelle classe CreditCard implémente aussi la nouvelle interface PaymentMethodInterface et donc peut surcharger la méthode processPayment() pour l’adapter au paiement par Carte de Crédit.

La classe PaymentManager peut maintenant être modifiée pour être conforme au principe OCP de SOLID comme suit:

class PaymentManager {
protected $paymentMethod;
public function __construct(PaymentMethodInterface $paymentMethod) {
$this->paymentMethod = $paymentMethod;
}

public function process() {
$this->paymentMethod->processPayment();
// ... and other payment stuff
}
}

Le constructeur magique __construct() de la classe PaymentManager prend désormais en paramètre une interface PaymentMethodInterface et non plus une classe concrète comme PaypalIpn. Ce qui permet à toute classe implémentant l’interface PaymentMethodInterface d’être éligible comme paramètre au constructeur magique de la classe PaymentManager, comme désormais le sont les classes PaypalIpn et CreditCard, qui implémentent toutes deux l’interface PaymentMethodInterface.

Conclusion

Maintenant l’ensemble des classes PaypalIpn, Creditcard, PaymentManager et PaymentMethodInterface respectent le principe OCP de SOLID.

Le grand changement a eu lieu dans la classe PaymentManager qui cette fois, le constructeur prend en parametre une interface (PaymentMethodInterface). Et sa méthode process() appelle la méthode processPayment() associée a l’interface correspondante ( c a d: PaypalIpn ou CreditCard).

Les classes paypalIpn et CrediCard sont des classes implémentant l’interface principale PaymentMethodInterface qui n’a qu’une méthode processPayment() qui est surchargée (ou spécialisée) en fonction du contrat d’interface à respecter (c a d: PaypalIpn ou CreditCard).

 Sans nous limiter à un seul cas pour un même comportement, nous avons permis d’autres possibilités (d’autres manières de faire pour le même comportement) pour le même objectif. C’est le principe polymorphique Ouvert/Fermé (Open/Close).

Liskov substitution principle (LSP)

Considérons les classes suivantes:

class Car {
public function drive() {
// let's go on an adventure!
}
}

La classe Driver.php:

class Driver {
protected $car;
public function __construct(Car $car) {
$this->car = $car;
}
public function go() {
$this->car->drive();
}
}

La classe Astra.php:

class Astra extends Car {    
// ...
}

La classe Beetle.php:

class Beetle extends Car {   
// ...
}

Considérons le code suivant:

$car = new Car(); 
$driver = new Driver($car);
$driver->go();

En remplaçant Car() par Astra() ou Beetle() notre code fonctionne toujours et sans erreur.

La Classe Driver qui prend en paramètre un objet de type Car (injection de dependance et typage fort) a une méthode go(), qui fait elle même appelle à la méthode drive() de la classe Car via un attribut qui est un objet de type Car et qui hérite donc des méthodes de la classe Car (injection des dépendances).

Les classes Astra et Beetle étendent (extends) la classe Car. Elles sont donc de type Car dans ce sens qu’elles héritent de la classe Car, héritage simple (le seul valable en PHP). Les classes Astra et Beetle héritent donc aussi des méthodes de la classe Car et peuvent aussi définir leurs propres méthodes.

Au niveau de notre code ci-dessus, en remplaçant $car = new Car(); par  $car = new Astra(); ou $car = new Beetle(); cela ne change rien au  resultat final. C’est le principe de Substitution de Liskov, qui stipule que les objets de même type doivent être interchangeables et c’est bien le cas ici.

Les classes Car, Astra et Beetle sont interchangeables car de même type (Car) et respectent le principe de Substitution de Liskov (SLP) et sont donc SOLID.

Interface segregation principle (ISP)

Ce principe peut se resumer ainsi:

Aucun client ne doit être obligé de dépendre de méthodes qu’il n’utilise pas.

Considérons les classes suivantes:

La classe Parrot:

class Parrot implements BirdInterface {
public function fly() {
// ... fly away, Peter!
}
}

La classe Pigeon:

class Pigeon implements BirdInterface {
public function fly() {
// ... fly away, Peter!
}
}

Et la classe interface BirdInterface:

interface BirdInterface {
public function fly();
}

Considérons le code source suivant:

$bird = new Parrot(); 
$bird->fly();

La variable $bird est un objet de type Parrot, lui même implémentant l’interface BirdInterface. $bird  hérite donc de la méthode fly() de la classe BirdInterface. Parrot est un oiseau et vole. En remplaçant new Parrot() par new Pigeon(), le résultat est le même. Pigeon est un oiseau et vole.

Considérons maintenant la nouvelle classe Peguin comme suit:

class Penguin implements BirdInterface {
// ...
}

La classe Peguin  implémente l’interface BirdInterface. En remplaçant new Parrot() par new Penguin() dans le code ci-dessus, nous avons le même résultat. Penguin est un oiseau et vole. Ce qui est une incohérence fonctionnelle. La classe Penguin ne devrait donc pas implémenter l’interface BirdInterface car la méthode fly() ne concerne pas les pingouins.

La bonne classe Penguin est la suivante:

class Penguin {    
// ...
}

C’est ce que stipule le principe de Ségrégation des interfaces (Interface Segregation PrincipleISP), qu’aucun client (ici Penguin) ne doit être obligé de dépendre des méthode qu’il n’utilise pas. Ce n’est donc pas  SOLID que la classe Penguin implémente l’interface BirdInterface.

Dependency inversion principle (DIP)

L’Inversion des Dépendances, le Contrôle de l’Inversion et l’Injection des Dépendances sont liées.

Inversion des Dépendances

Ce principe énonce que:

  • Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre des abstractions.
  • Les abstractions ne doivent pas dépendre des détails. Les détails devraient dépendre des abstractions.

Le principe de l’inversion de dépendance (DIP) permet de découpler votre code en s’assurant que les modules de haut et de bas niveau dependent des abstractions plutôt que des implémentations concrètes. 

Essayons de comprendre ce principe ….

  • Lorsque ce principe n’est pas appliqué:   L’interface de haut niveau depend de l’interface de bas niveau.
    La classe de haut niveau doit donc prévoir toutes les interfaces. Lorsqu’un nouveau module de bas niveau est crée, la classe de haut niveau doit être changée, ce qui complique la maintenance et viole le principe Open/Close. Donc, tout changement dans une classe de haut niveau doit penser à toutes les interfaces. C’est pour cette raison que les interfaces de haut niveau doivent être des abstractions et non des implémentations concrètes.
  • Lorsque ce principe est appliqué: L’interface de haut niveau ne depend pas de l’interface de bas niveau. Une classe de haut niveau definit une interface et les classes de bas niveau implementent l’interface.  Ainsi, il n’est pas nécessaire de modifier les classes de haut niveau pour étendre l’application.

Inversion du Contrôle

L’Inversion du Contrôle oriente sur certaines manières d’appliquer l’Injection des Dépendances (qui, ne nous dit pas comment résoudre le problème de l’Injection des Dépendances).

L’idée est donc d’inverser le contrôle:

« Ne pas nous appeler, nous vous appellerons. »

Si vous souhaitez créer un module de haut niveau indépendant à partir d’un module de bas niveau, nous devons inverser le contrôle afin que le module de bas niveau ne contrôle pas l’interface et la création de l’objet.

Nous avons déjà vu l’inversion du contrôle des interfaces dans notre dernier exemple en parlant de la conception de l’interface pour appliquer DIP (Le Principe d’Injection des Dépendances). Nous allons maintenant nous concentrer sur l’inversion du contrôle pour la création de l’objet.

Inverser le contrôle pour la création de l’objet (« Ne pas nous appeler, nous vous appellerons »). La Création d’objets en dehors de la classe dans laquelle ils sont utilisés afin que la classe de haut niveau ne dépende pas directement de la classe de bas niveau.  « La frequence du mot-cle ‘new‘ dans votre code est une estimation approximative du degré de couplage dans votre structure d’objets. » (Code pourri !!!).

Inverser le contrôle pour la création de l’objet ( « Ne pas nous appeler, nous vous appelons »), comme nous le savons en POO, un objet interface peut faire référence à l’une des classes, en implementant la même interface. C’est la base de l’Injection de dependance d’interface.
Dépendance déclarée dans la classe dépendante sous la forme d’une interface. Ainsi, toute classe qui implémente une interface de dépendance peut être remplacée par une classe dépendante. L’Injection de dependances est un modèle de conception de logiciel et donne une piste pour implementer l’inversion du controle (IoC) pour la création d’objet.

3 façons de mettre en œuvre l'Injection de Dépendances

1- Injection de constructeur  

L’injection de dépendances est effectuée en fournissant la dépendance à travers le constructeur de la classe lors de l’instanciation de la classe.

2- Injection de propriété (également appelée Injection de Setter)

Utilisée lorsqu’une classe possède des dépendances facultatives ou les implementations doivent être échangées. Les différentes implémentations du logger pourraient être utilisées de cette manière.

3- Injection de méthode  

Injectez la dependance en une seule méthode, pour une utilisation par cette méthode. Peut être utile lorsque la classe entière n’a pas besoin de l’ensemble de la dépendance mais, juste d’une méthode.

En gros pour résumer si une classe a besoin d’une autre classe, il faudra injecter cette classe au moment de la construction de celle-ci ou au moment de l’appel de la méthode concernée.