S.O.L.I.D

Exemple des formes inspiré par cette page.

S - The Single Responsibility Principle

Une classe ne doit avoir qu'une seule responsabilité.
Ou encore :
Une classe doit avoir une, et seulement une, raison de changer.
Une fonction - une fonctionnalité ; une classe - une responsabilité.

Par exemple, on a des formes :
interface Shape{}

class Circle implements Shape{
    private double radius;
    public Circle(double radius) { this.radius = radius; }
    public double getRadius() { return radius; }
}

class Square implements Shape {
    private double length;
    public Square(double length) { this.length = length; }
    public double getLength() { return length; }
}
On veut afficher la somme des aires de ces formes ; on pourrait faire :
class AreaCalculator {

    protected Shape[] shapes;

    public AreaCalculator(Shape[] shapes) {
        this.shapes = shapes;
    }

    public double sum() {
        double sum = 0.0;
        for(Shape shape : shapes){
            if(shape instanceof Circle){
                sum += Math.PI * ((Circle)shape).getRadius() * ((Circle)shape).getRadius();
            }
            else if(shape instanceof Square){
                sum += ((Square)shape).getLength() * ((Square)shape).getLength();
            }
        }
        return sum;
    }

    public String output() {
        return "Somme des aires : " + Double.toString(sum());
    }
}
public class Solid1{
    public static void main(String[] args){
        Shape[] shapes = new Shape[]{
            new Circle(3.0),
            new Square(2.0)
        };
        AreaCalculator calculator = new AreaCalculator(shapes);
        System.out.println(calculator.output());
    }
}
(code dans Solid1.java)

Un premier problème vient du fait que la classe AreaCalculator mélange deux responsabilités : calculer la somme et afficher le résultat.
La classe peut donc avoir plusieurs raisons de changer, par ex :
1 - si on rajoute une forme
2 - si on veut différents formats de sortie (JSON, HTML ...)

Pour y remédier, on peut scinder la classe en deux et mettre la méthode output() dans une classe dédiée à l'affichage :
class AreaOuputter {
    private AreaCalculator calculator;
    
    public AreaOuputter(AreaCalculator calculator) {
        this.calculator = calculator;
    }
    
    public String outputText() {
        return "Somme des aires : " + Double.toString(calculator.sum());
    }
    
    public String outputJSON() { return "not implemented"; }
}
public class Solid2{
    public static void main(String[] args){
        Shape[] shapes = new Shape[]{
            new Circle(3.0),
            new Square(2.0)
        };
        AreaCalculator calculator = new AreaCalculator(shapes);
        AreaOuputter outputter = new AreaOuputter(calculator);
        System.out.println(outputter.outputText());
    }
}
(code dans Solid2.java)

O - The Open Closed Principle

Une classe doit etre ouverte à l'extension, mais fermée à la modification.
En d'autres termes (dans un monde idéal...) on ne devrait jamais avoir besoin de modifier du code ou une classe existante : toute nouvelle fonctionnalité devrait pouvoir être ajoutée en ajoutant de nouvelles sous-classes ou méthodes, ou en réutilsant du code existant par délégation.

Cela empêche d'introduire des nouveaux bugs dans du code existant (si on ne change rien, rien ne sera cassé).
Appliqué de manière extrême, ce principe empêche de fixer des bugs dans du code existant.

Ce qui pose problème dans l'exemple, c'est la méthode sum() : si on rajoute d'autres formes dans l'API, on doit rajouter un if else, donc changer l'intérieur de la classe.
La solution évidente est de mettre les calculs des aires dans les classes des formes.
interface Shape{
    public double area();
}

class Circle implements Shape{
    public double radius;
    public Circle(double radius) { this.radius = radius; }
    public double area() { return Math.PI * radius * radius; }
}

class Square implements Shape {
    public double length;
    public Square(double length) { this.length = length; }
    public double area() { return length * length; }
}

class AreaCalculator {
    
    // ...
    
    public double sum() {
        double sum = 0.0;
        for(Shape shape : shapes){
            sum += shape.area();
        }
        return sum;
    }

}
(code dans Solid3.java)

L - The Liskov Substitution Principle (LSP)

Liskov
Si q ( x ) est une propriété démontrable pour tout objet x de type T,
alors q ( y ) est vraie pour tout objet y de type S tel que S est un sous-type de T.
Autre formulation :
Si S est un sous-type de T, alors tout objet de type T peut être remplacé
par un objet de type S sans altérer les propriétés du programme concerné.
class Voiture{ /* ... */ }
class Ambulance extends Voiture{ /* ... */ }
Tout code du type :
Voiture v = new Voiture();
doit fonctionner exactement de la même manière que si on faisait :
Voiture v = new Ambulance();
Une Ambulance est une Voiture qui sait faire des choses supplémentaires (c'est une sous-classe).
Le principe de Liskov dit que l'aspect Voiture de l'Ambulance doit tester inchangé.

Il semble y avoir plusieurs interprétations de ce principe. Par exemple, appliqué de manière stricte, ce principe ne permet pas l'overriding.
Une autre manière de le comprendre consiste à dire que le comportement du sous-type peut différer de son parent, du moment que le contrat honoré par le parent est respecté (voir stackexchange).

I - The Interface Segregation Principle

La dépendance d'une classe envers une autre doit dépendre de l'interface la plus petite possible.
(interface ici au sens littéral).
Ou encore :
Du code ne devrait jamais être forcé d'implémenter une interface qu'il n'utilise pas,
ou être forcé à dépendre de méthodes qu'il n'utilise pas.
Dans l'exemple des formes, si on veut rajouter la gestion des volumes, on pourrait modifier l'interface Shape :
interface Shape{
    public double area();
    public double volume();
}
Mais cela obligerait par exemple Square à implémenter volume().

On pourrait faire :
interface Shape {
    public double area();
}

interface Volume extends Shape {
    public double volume();
}
dans la mesure où cela aurait un sens pour notre API (si on voulait vraiment implémenter area() aussi pour les volumes).

ATTENTION : respecter ce principe peut amener à faire des restructurations dans le code.
Si on voulait calculer les surfaces pour les objets 2D et uniquement les volumes pour les objets 3D, on pourrait faire :
interface Shape {
    public double area();
}

interface Volume {
    public double volume();
}
Mais si on veut conserver la possibilité de manipuler surfaces et volumes comme des objets du même type, il faudrait alors une 3ème interface, par exemple :
interface Objetct2D {
    public double area();
}

interface Objetct3D {
    public double volume();
}

interface Calculable {
    public double calculate();
}

class Circle implements Objetct2D, Calculable {
    public double radius;
    public Circle(double radius) { this.radius = radius; }
    public double area() { return Math.PI * radius * radius; }                                                                                   
    public double calculate() { return area(); }
}

class Sphere implements Objetct3D, Calculable {
    public double radius;
    public Sphere(double radius) { this.radius = radius; }
    public double volume() { return Math.PI * radius * radius * radius * 4.0 / 3.0; }
    public double calculate() { return volume(); }
}

class Square implements Objetct2D, Calculable {
    public double length;
    public Square(double length) { this.length = length; }
    public double area() { return length * length; }
    public double calculate() { return area(); }
}

class Cube implements Objetct3D, Calculable {
    public double length;
    public Cube(double length) { this.length = length; }
    public double volume() { return length * length * length; }
    public double calculate() { return volume(); }
}
class Calculator {

    protected Calculable[] calculables;

    public Calculator(Calculable[] calculables) {
        this.calculables = calculables;
    }

    public double sum() {
        double sum = 0.0;
        for(Calculable calculable : calculables){
            sum += calculable.calculate();
        }
        return sum;
    }
}
A l'utilisation :
public class Solid4{
    public static void main(String[] args){
        Calculable[] calculables = new Calculable[]{
            new Circle(3.0),
            new Sphere(2.0),
            new Square(4.0),
            new Cube(2.0)
        };
        Calculator calculator = new Calculator(calculables);
        Ouputter outputter = new Ouputter(calculator);
        System.out.println(outputter.outputText());
    }

}
(code dans Solid4.java)

D - The Dependency Inversion Principle

Le code de haut niveau ne doit pas dépendre des implémentations concrètes, mais doit dépendre d'abstractions.
Code de haut niveau et de bas niveau doivent tous les deux dépendre d'abstractions.
Exemple :
class PasswordReminder {
    private MySQLConnection dbConnection;

    public PasswordReminder(MySQLConnection dbConnection) {
        this.dbConnection = dbConnection;
    }
}                              
MySQLConnection est le code de bas niveau ; PasswordReminder est le code de haut niveau.
Ici, le code de haut niveau dépend du code de bas niveau.
Si on décide de changer de DBMS, il faudra modifier PasswordReminder, ce qui est contraire au O (open-closed principle).

Pour remédier à cela, on peut faire :
interface DBConnectionInterface {
    public void connect();
}

class MySQLConnection implements DBConnectionInterface {
    public void connect() { /* ... */ }
}
class PasswordReminder {
    private DBConnectionInterface dbConnection;

    public PasswordReminder(DBConnectionInterface dbConnection) {
        this.dbConnection = dbConnection;
    }
}
Le code de haut niveau et de bas niveau dépendent maintenant tous les deux d'une abstraction.

Voir aussi Injection de dépendance