image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Single Responsibility Principle

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

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 :

  1. 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).
  2. 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).
  3. 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 :

Principe de substitution de Liskov

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 :

  1. Un module de haut niveau (une interface abstraite) ne doit pas dépendre d'une module de bas niveau (une classe concrète)
  2. 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 :

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 :

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());
    }
}

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).