- Les principes SOLID ont été popularisés par l'ingénieur Robert Martin (surnommé Uncle Bob), consultant chez Xerox
-
SOLID est un acronyme désignant 5 principes importants en programmation objet pour améliorer l'architecture des applications :
- Single Responsibility Principle : chaque entité du code (classe, méthode) ne doit pas cumuler plusieurs responsabilités
- Open-Closed principle : une entité (classe, méthode...) ne doit pas être redéfinissable à l'extérieure de son module ; en revanche elle doit pouvoir être extensible
- Liskov substitution principle : le système de typage accepte que l'on puisse substituer l'usage d'une classe par une autre si le contrat initial reste rempli
- Interface segregation principle : il faut éviter d'écrire des interfaces trop volumineuses pour limiter le couplage
- Dependency inversion principle : il faut promouvoir les dépendances sur des interfaces, pas sur des implantations concrètes
Single Responsibility Principle
- Robert Martin déclare : A class should have only one reason to change
- Pour cela une classe doit avoir une seule responsabilité
Imaginons que nous gérons un conteneur de proverbes :
public record Aphorism(String content, String author); public class AphorismContainer { private List<Aphorism> aphorisms; @Override public String toString() { return "AphorismContainer[size=" + aphorisms.size + ""]"; } public void exportToCSVFile(String filepath) throws IOException { ... } public void exportToJSONFile(String filepath) throws IOException { ... } public void exportToYAMLFile(String filepath) throws IOException { ... } public void exportToXMLFile(String filepath) throws IOException { ... } }
Notre classe gère une liste d'aphorismes et en même temps l'export de cette liste dans des fichiers avec différents formats (CSV, JSON, YAML, XML...).
Il serait préférable que la classe ne gère que la liste (une seule responsabilité) ; nous allons utiliser une autre interface pour l'export.
@FunctionalInterface public interface AphorismContainerExporter { public void exportToFile(String filepath) throws IOException; }
Nous pouvons ensuite implanter chaque exportateur, par exemple pour l'export au format JSON :
public class AphorismContainerJSONExporter implements AphorismContainerExporter { @Override public void exportToFile(String filepath) throws IOException { ... } }
Ces exportateurs peuvent utiliser le pattern singleton si ils ne sont pas paramétrés.
Open-Closed principle
-
Le principe Open-Closed est évoqué par Bertrand Meyer (créateur du langage orienté objet Eiffel) dès 1988
- Pour Bertrand Meyer, la force des langages orientés objet est de permettre l'héritage d'une classe par une autre classe
- Ce principe a évolué pour plutôt promouvoir la création d'interfaces stables (donc non modifiables) ce qui équivaut à des classes purement abstraites que l'on peut ensuite utiliser pour des implantations qui peuvent évoluer
- Dans l'idéal un module (paquetage) ne doit exposer publiquement que des interfaces ; ces interfaces doivent rester stables
- La stabilité des interfaces est la garantie de ne pas avoir à modifier à l'avenir du code externe dépendant de ces interfaces
- Les implantations doivent rester masquées (de visibilité package en Java)
- Le masquage des implantations permet de les modifier, d'en créer de nouvelles, sans risque de casser le code existant
Imaginons que nous ayons implanté un appareil photo argentique en 1995 avec le tout nouveau langage Java :
public class Camera { private CameraFilm film = null; private void insertFilm(CameraFilm film) { this.film = film; } private void takePicture(int expositionDuration) { if (film.isAtEnd()) throws IllegalStateException("All the film has been exposed, please insert a new film"); film.expose(expositionDuration); film.forward(); } }
Plus de 20 ans plus tard, les appareils photo argentiques ne sont presque plus utilisés, nous souhaitons implanter un appareil photo numérique. Créer une classe DigitalCamera héritant de Camera sera difficile car nous n'avons plus de film à manipuler (on utilise plutôt une carte SD). On propose donc de réaliser un refactoring en utilisant des interfaces :
public interface Camera { void takePicture(int expositionDuration); } public interface AnalogCamera extends Camera { void insertFilm(CameraFilm film); } public interface DigitalCamera extends Camera { void insertMemoryCart(MemoryCard card); }
On peut ensuite implanter des Canera avec de possibles fonctionnalités supplémentaires héritant de ces interfaces. Les interfaces définies restent figées : le code qui dépend de Camera, AnalogCamera ou DigitalCamera n'aura pas à être modifié même si les classes implantant ces interfaces évoluent.
Imaginons maintenant que nous souhaitons ajouter une fonctionnalité de retardateur sur les appareils photos. Quelles solutions s'offrent à nous :
- Nous pourrions changer la signature de la méthode takePicture de Camera en void takePicture(int expositionDuration, int triggerDelay). Cela nécessitera de changer toutes les implantations héritant de l'interface Camera ce qui est possible si nous contrôlons toutes ces implantations dans notre module (bien sûr il ne faut pas que des implantations existent autre part). Toutefois ce changement imposerait de changer tous les usages de la méthode takePicture ce qui gênerait tous les utilisateurs de notre bibliothèque photographique (qui devait remplacer takePicture(...) par takePicture(..., 0) pour une prise instantanée).
- Une autre solution serait d'exploiter le polymorphisme et rajouter une nouvelle méthode takePicture à deux arguments et de laisser celle à un argument dans l'interface. Cela ne gêne pas les utilisateurs de notre bibliothèque, en revance nous devons ajouter une nouvelle méthode dans nos implantations et prendre garde que ces implantations soient cohérentes (typiquement takePicture(int) appelerait takePicture(int, int) avec le deuxième paramètre à zéro. Nous pourrions aussi modifier takePicture(int) avec une implantation par défaut appelant takePicture(..., 0).
- Enfin une troisième solution, allant dans le sens du principe Single Responsibility consiste à créer une nouvelle classe chargée du retardateur :
public interface DelayCamera extends Camera { void takePicture(int expositionDuration, int triggerDelay); } public class DelayCameraWrapper implements DelayCamera { private Camera baseCamera; public DelayCameraWrapper(Camera baseCamera) { this.baseCamera = baseCamera; } @Override public void takePicture(int expositionDuration, int delay) { Thread.sleep(delay); this.baseCamera.takePicture(expositionDuration); } @Override public void takePicture(int expositionDuration) { this.takePicture(expositionDuration, 0); } }
Cette solution est la plus satisfaisante :
- Nous n'avons pas touché à l'interface Camera (principe closed sur les interfaces qui restent figées) ; le code existant n'est donc pas impacté
- Nous avons rajouté une fonctionnalité grâce à une nouvelle interface et une classe concrète l'implantant (principe open permettant l'extension)
Principe de substitution de Liskov
- Le principe de substitution de Barbara Liskov (informaticienne américaine née en 1939) est lié au principe de sous-typage dans les langages objet
- Si S est un sous-type de T, alors on peut remplacer tout objet de type T par un objet de type S sans altérer les propriétés souhaitées du programme
-
Cela a pour conséquence :
- La contravariance des arguments de méthode
- La covariance des types de retour (ou exceptions levées)
-
En utilisant le concept de programmation par contrat, nous avons :
- Les préconditions d'une méthode qui sont des conditions sur ses paramètres (le typage des paramètres est une forme de précondition vérifiée par le compilateur) ; ces préconditions ne peuvent pas être renforcée dans une classe dérivée.
- Les postconditions d'une méthode qui sont des conditions sur l'élément retourné par la méthode (dont le typage du retour) ; ces postconditions ne peuvent pas être affaiblies dans une classe dérivée (mais elles peuvent être renforcées).
Prenons comme exemple une hiérarchie de formes géométriques :
public interface Shape { int area(); } public interface Rectangle extends Shape { int sideX(); int sideY(); @Override default int area() { return sideX() * sideY(); } } public interface Square extends Rectangle { int side(); @Override default int sideX() { return side(); } @Override default int sideY() { return side(); } } public record SimpleRectangle(int sideX, int sideY) {} public record SimpleSquare(int side) {}
Ecrivons une méthode pour calculer la surface totale d'une liste de Shape :
public class ShapeUtils { public int computeArea(List<Shape> shapes) { return shapes.streams().mapToInt(x -> x.area()).sum(); } }
Calculons maintenant la surface de deux formes :
Shape shape1 = new SimpleSquare(10); Shape shape2 = new SimpleRectangle(10, 5); List<Shape> shapes = List.of(shape1, shape2); System.out.println(ShapeUtils.computeArea(shapes));
Nous pouvons remplacer le typage de shape1 et shape2 pour leurs types exacts SimpleSquare et Rectangle sans impact sur le reste du code.
Nous pouvons aussi redéfinir shapes ainsi sans impact sur le reste du code (par contravariance si computeArea accepte une List<Shape>, elle accepte aussi une ArrayList<Shape>) :
var shapes = new ArrayList<Shape>(); shapes.add(shape1); shapes.add(shape2);
Maintenant déclarons shapes ainsi :
var shapes = new ArrayList<Square>(); shapes.add(new SimpleSquare(10));
Nous ne pouvons plus passer en paramètre de computeArea un ArrayList<Square> : c'est le type paramétré qui pose souci et qui doit être exactement le même que le type original. Cela paraît contre-intuitif car nous ne faisons que lire chaque forme de la liste en appelant la méthode area() qui existe bien sur Square sous-type de Shape. Toutefois imaginons que computeArea modifie le paramètre List<Shape> ainsi :
public int computeArea(List<Shape> shapes) { shapes.add(new SimpleRectangle(5, 10)); return shapes.streams().mapToInt(x -> x.area()).sum(); }
Si nous passons une List<Square>, la méthode ne peut plus ajouter un SimpleRectangle dans la liste. En fait si List<Shape> et List<Square> sont utilisés en lecture uniquement, List<Square> peut être considéré comme un sous-type de List<Shape> (contravariance) mais si le type est mutable (covariance) ce n'est plus le cas.
Pour résoudre ce problème, on peut déclarer que computeArea accepte un argument de type List<? extends Shape>, i.e. toutes les listes paramétrées par un type Shape ou un sous-type de Shape. Ainsi nous pouvons passer une List<Square> à la méthode mais la méthode ne peut plus modifier la liste en insérant un SimpleRectangle dans la liste.
Imaginons maintenant que nous souhaitions introduire des formes MutableSquare et MutableRectangle. Déclarons les interfaces :
public interface MutableRectangle extends Rectangle { void setSideX(int value); void setSideY(int value); } public interface MutableSquare extends MutableRectangle { void setSide(int value); }
Est-ce pertinent ? Non, car MutableSquare ne doit pas hériter de l'interface MutableRectangle : la présence des méthodes setSideX et setSideY n'a pas de sens pour un carré (qui n'a qu'une seule longueur de côté). Nous ne respectons plus le principe de substituabilité de Liskov. Nous devons plutôt réaliser les héritages d'interface ainsi :
public interface MutableRectangle extends Rectangle { ... } public interface MutableSquare extends Square { ... }
Ainsi un MutableSquare est bien un sous-type de Square mais aussi un sous-type de Rectangle (mais pas de MutableRectangle).
☞ Lorsque l'on écrit des interfaces, il est pertinent de prévoir des types immutables au démarrage avec éventuellement des types mutables héritant de ces types ensuites. Cela facilite le sous-typage et permet de distinguer des endroits de code qui ont besoin d'accéder en lecture seule ou également en écriture.
Principe de ségrégation des interfaces
Les interfaces doivent être aussi concises que possible et ne pas rassembler de nombreuses méthodes.
Prenons l'exemple d'une commande sur un site de e-commerce :
public interface Order { Date date(); String name(); String firstName(); String emailAddress(); String phoneNumber(); String street(); String city(); String zip(); String country(); Map<Item, Integer> basket(); }
Cette interface contient trop d'informations, nous pouvons la fractionner ainsi :
public interface Identity { String name(); String firstName(); } public interface Address {} public interface EmailAddress extends Address { String emailAddress(); } public interface PhoneAddress extends Address { String phoneNumber(); } public interface PostalAddress extends Address { String street(); String city(); String zip(); String country(); } public interface Basket { Map<Item, Integer> basketContent(); } public interface Customer { Identity identity(); EmailAddress email(); PhoneAddress phone(); PostalAddress postalAddress(); } public interface Order { Date date(); Customer payer(); // person that pays the order Customer receiver(); // person that receives the order Basket basket; // content of the basket }
La nouvelle interface Order utilise le principe de composition ; deux contacts différents sont introduits pour le payeur ainsi que le destinataire de la commande.
Principe d'inversion des dépendances
Ce principe préconise le respect des concepts suivants :
- Un module de haut niveau (une interface abstraite) ne doit pas dépendre d'une module de bas niveau (une classe concrète)
- Les abstractions ne dépendent pas des détails mais les détails dépendent des abstractions.
Ainsi lorsque l'on développe une classe concrète implantant une interface, ce ne sont pas les besoins de la classe concrète qui doivent guider la modification de l'interface. Si modification de l'interface il y a, elle doit tendre vers plus d'abstraction.
Pour respecter le principer d'inversion des dépendances, il est recommandé que :
- tous les membres d'une classe qui sont typés par un type complexe (type non primitif) soient d'un type interface
- une classe concrète n'hérite pas d'une autre classe concrète (mais d'une classe abstraite et/ou d'interfaces)
- aucune méthode ne redéfinisse une méthode concrète d'une classe ancêtre (seule la redéfinition de méthodes abstraites est autorisée)
- l'initialisation d'un objet complexe se fasse par l'intermédiaire d'une fabrique ou d'un système d'injection de dépendance
- on n'accède pas à des membres profonds d'un objet en paramètre d'une méthode ou un champ de la classe (par exemple myObj.a().b().c().d())
Ecrivons par exemple du code pour traiter une commande sur un site de e-commerce :
public class OrderManager { protected void makeParcel(Order order) { ParcelWrapper wrapper = new MegaParcelWrapper(); Parcel p = wrapper.wrap(order.basket()); return p; } protected TotoDeliveryService deliveryService = new TotoDeliveryService(); protected String sendParcel(Parcel p, Order order) { return deliveryService.send(p, order.receiver()); } protected void sendEmail(Order order, String trackingNumber) { EmailSender es = new JavamailSender(); es.sendEmail("Hello, here is your tracking number for your order: " + trackingNumber, order.receiver().email().emailAddress()); } protected void sendSMS(Order order, String trackingNumber) { SMSSender ss = new SuperCellSender(); ss.sendSMS("Your tracking number: " + trackingNumber, order.receiver().phone().phoneNumber()); } public void manageOrder(Order order) { Parcel parcel = makeParcel(order); String trackingNumber = sendParcel(parcel, order); sendEmail(order, trackingNumber); sendSMS(order, trackingNumber); } }
Il y a plusieurs problèmes avec le code précédent :
- On utilise des classes concrètes que l'on instancie nous-même (TotoDeliveryService, JavamailSender, SuperCellSender, MegaParcelWrapper). Si nous changeons de machine à empaqueter, de transporteur, de bibliothèque d'envoi de mail our d'opérateur telecom pour envoyer des SMS, le code d'instanciation sera à modifier au cœur du code.
- Les méthodes déclarées protected pourraient être redéfinies dans une classe dérivée ; cela permettrait de changer le mode d'empaquetage, d'envoi de mail... mais cela ne serait pas propre
- On pourrait imaginer plusieurs implantations possibles de OrderManager avec des workflows différents pour gérer les commandes
Crééons d'arbord une interface OrderManager :
public interface OrderManager { void manageOrder(Order order); }
Puis une implantation concrète :
private final class StandardOrderManager implements OrderManager { @Inject private ParcelWrapper parcelWrapper; @Inject private DeliveryService deliveryService; @Inject private EmailSender emailSender; @Inject private Template emailTemplate; @Inject private SMSSender smsSender; @Inject private Template smsTemplate; public void manageOrder(Order order) { var parcel = parcelWrapper.wrap(order.basket()); var trackingNumber = deliveryService.send(parcel, order.receiver()); var emailContent = emailTemplate.substitute("trackingNumber", trackingNumber); emailSender.sendEmail(emailContent, order.receiver().email().emailAddress()); var smsContent = smsTemplate.substitute("trackingNumber", trackingNumber); smsSender.sendSMS(smsContent, order.receiver().phone().phoneNumber()); } }
- Le classe est final pour empêcher toute redéfinition
- Les membres de la classe sont initialisées automatiquement par un système d'injection de dépendance que l'on peut paramétrer par un fichier de configuration ; à défaut on aurait pu utiliser des fabriques que nous aurions appelées dans le constructeur.
- De la même façon, on peut initialiser OrderManager dans une classe en faisant usage grâce à l'injection de dépendances :
public final class ECommerceManager { @Inject private OrderManager orderManager; ... }
Ainsi l'interface OrderManager ne dépend d'aucune implantation concrète, ni StandardOrderManager qui se contente d'utiliser des interfaces. La modification d'un composant concret (par exemple le transporteur deliveryService) ne nécessite que la création d'une nouvelle classe concrète et la modification du fichier de configuration régissant l'injection de dépendance. On pourra aussi mettre en œuvre des stratégies plus complexes (par exemple un DeliveryService reposant sur plusieurs DeliveryService choisis en fonction de la destination).