-
Cours :
- Notions programmation orientée objet et Java (pdf)
- Tests (pdf)
- Principes SOLID (pdf)
- Patrons de conception (pdf)
-
Planches de TD :
- TD 1 (pdf), Corrigé TD 1 (pdf)
- TD 2 (pdf), Corrigé TD 2 (pdf)
- TP 1 (pdf), Corrigé TP 1 (pdf)
Principes SOLID
Principes SOLID
Introduction
Cinq principes pour un code maintenable
En programmation orientée objet, il existe cinq principes de conception destinés (regroupés sous l’acronyme SOLID) qui visent à produire des architectures logicielles plus compréhensibles, flexibles et maintenables. Ces principes sont un sous-ensemble de nombreux principes promus par l’ingénieur logiciel et instructeur américain Robert Cecil Martin (familièrement connu sous le nom Uncle Bob). Bien qu’ils s’appliquent à toute conception orientée objet, les principes SOLID peuvent également former une philosophie de base pour des méthodologies telles que le développement agile. La théorie des principes SOLID a été introduite par Martin dans son article Design Principles and Design Patterns de 2000, bien que l’acronyme SOLID ait été introduit plus tard par Michael Feathers.
Les cinq principes SOLID sont les suivants :
- Single Responsibility Principle (SRP) : Une classe ne doit avoir qu’une seule responsabilité
- Open/Closed Principle (OCP) : Programme ouvert pour l’extension, fermé à la modification
- Liskov Substitution Principle (LSP) : Les sous-types doivent être substituables par leurs types de base
- Interface Segregation Principle (ISP) : Éviter les interfaces qui contiennent beaucoup de méthodes
- Dependency Inversion Principle (DIP) :
- Les modules d’un programme doivent être indépendants
- Les modules doivent dépendre d’abstractions
Le but de ces principes est donc de garantir la maintenabilité d’un programme, c’est-à-dire sa capacité à :
- absorber les changements avec un minimum d’effort ;
- implémenter les nouvelles fonctionnalités sans toucher aux anciennes ;
- modifier les fonctionnalités existantes en modifiant localement le code.
L’application des principes SOLID a pour objectifs :
- de limiter les modules impactés ;
- de simplifier les tests ;
- de rester conforme aux spécifications qui n’ont pas changé.
Approche qualité des 5S
Les principes SOLID sont donc ceux qui seront étudiés dans ce cours et constitueront son ossature. Dans la suite de ce cours, nous allons donc détailler ces cinq principes et expliquer pourquoi il est important de les respecter afin d’obtenir du code maintenable. Mais avant cela, il est utile de nous intéresser à une approche qualité pour la gestion de projet, venue du Japon au milieu du siècle dernier et qui peut s’appliquer à l’informatique. Cette approche ou philosophie des 5S peut être résumé par les cinq points suivants :
- Seiri (“s’organiser”) : les différents éléments d’un code doivent être structurés et aisément identifiables. Ainsi une action aussi anodine que de nommer les identifiants, méthodes et classes ne l’est pas tant que cela et doit requérir toute votre attention ;
- Seiton (“situer”) : un morceau de code doit se trouver là où l’on s’attend logiquement à ce qu’il se trouve. Si ce n’est pas le cas, cela veut dire qu’il n’est pas à sa place et que la structure du code n’a pas été pensée correctement ;
- Seiso (“scintiller”) : l’espace de travail doit être propre ! Pensez à la cuisine d’un grand restaurant : on ne travaille pas sur un plan de travail comportant de la vaisselle sale, des ingrédients d’un autre plat, … Au niveau du code la présence de commentaires non informatifs ou de code ancien désactivé par une mise en commentaire constitue une pollution de l’espace de travail à laquelle il faut remédier ;
- Seiketsu (“standardiser”) : dans un travail en équipe il faut que des conventions soient respectées, que chaque développeur suive les mêmes règles pour que le code conserve une homogénéité ;
- Shutsuke (“suivi”) : suivre le travail des autres permet de s’interroger sur ses propres pratiques et d’évoluer positivement (en tout cas il faut l’espérer…).
On peut se poser la question de pourquoi s’imposer ces règles qui viennent s’ajouter à celles qu’il faut déjà suivre pour qu’un programme soit fonctionnel. En fait, le but de ces règles est de gagner du temps. Le temps de travail des personnes participant au projet est généralement la ressource qui est la plus coûteuse dans un projet. Cela peut paraître contre-intuitif, car appliquer les règles énoncées ci-dessus prend clairement du temps. Cependant, ce temps n’est pas perdu, car l’objectif est d’en gagner dans le futur. En effet, l’objectif est de garder un bon cadre de travail afin d’être efficace. C’est un peu le même principe qu’avoir une pièce bien rangée vous fait globalement gagner du temps, car cela vous permet d’accéder facilement à vos affaires, et ce même si ranger prend du temps.
Comme vous venez de le voir, en termes de bonnes pratiques on parle souvent de règles pour évoquer les principes recommandés par une approche ou l’autre. Il est important de comprendre qu’il s’agit bien de recommandations et non de lois immuables. Tout ce que nous allons voir dans la suite est à adapter au contexte dans lequel vous allez l’appliquer, il n’y a pas de loi universelle permettant d’obtenir à coup sûr un code propre et maintenable, seulement des indications de pratiques qui ont été reconnues profitables. De surcroît, il se peut que certaines considérations, notamment des contraintes de performances, puissent rendre difficile l’application de certaines bonnes pratiques de programmation dans des cas très spécifiques.
La vie d’un programme
On peut se demander pourquoi il est si important d’avoir une certaine méthodologie (et donc de se fixer des règles et d’appliquer des bonnes pratiques) lorsqu’on participe à un projet de développement logiciel d’envergure. Pour justifier cette approche, nous allons considérer l’historique de la vie d’un programme quelconque. On peut faire une analogie avec la vie humaine et découper la vie d’un projet en 5 phases :
- La naissance : tout le monde vient s’extasier sur le beau bébé qui vient de naître. Les parents sont fiers de présenter leur rejeton. Le code est beau, pur, les développeurs ont porté une grande attention à sa création.
- L’enfance : en commençant à marcher, courir, sauter, etc., l’enfant se blesse. Pour réparer un premier bug ou ajouter rapidement une nouvelle fonctionnalité les développeurs travaillent à la va-vite, ils ajoutent une rustine (c’est-à-dire une modification du code sommaire et temporaire visant à corriger rapidement un bug ou dysfonctionnement) au projet. Le code devient donc moins pur et moins beau.
- L’adolescence : le moment de la rébellion. Il faut intervenir de plus en plus souvent, car les bugs se multiplient. Les développeurs multiplient les rustines et le code devient de moins en moins maintenable.
- L’âge adulte : il faut avancer coûte que coûte. Les modifications de code précédentes sont un lourd héritage et toute amélioration ou correction prend énormément de temps. Parfois la correction d’un bug déclenche l’apparition de nombreux autres bugs. Mais il faut continuer à avancer : le programme est en production, il n’y a pas d’autre choix que de perdre un temps précieux dès qu’il faut modifier le code.
- La vieillesse : certaines fonctionnalités sont défaillantes, mais on ne peut plus les réparer. À force d’ajouter des rustines le code n’est plus maintenable, plus aucun développeur ne peut effectuer la moindre modification sans tout casser. Il n’y a plus rien à faire qu’attendre une mort inexorable.
Respecter les bonnes pratiques de conception et de développement permet à un code de vieillir sereinement, de conserver ces fonctionnalités le plus longtemps possible. C’est comme pour un être humain : mener une vie d’excès ne permet pas d’envisager une longue vie en ayant la jouissance complète de ces capacités intellectuelles et physiques. Avec le code informatique, c’est encore plus important, car on développe rarement seul. Une mauvaise hygiène de vie aura des répercussions sur les développeurs faisant également partis du projet et fera nécessairement naître des tensions.
Le zen du développement
En Python, l’un des documents qui définit le langage est une ôde aux bonnes pratiques. Il s’agit du PEP 20 (Python Enhancement Proposal), intitulé le Zen of Python :
En français cela donne :
Dans cette introduction, nous avons pu observer qu’il n’y avait pas que les principes SOLID qui existent en termes de bonnes pratiques de programmation. D’ailleurs nous avions même évoqué d’autres bonnes pratiques dans le premier cours portant sur la gestion de version et les tests. Toutes ces recommandations ont vu le jour à peu près à la même période. Comme on vient de l’expliquer elles ont toutes le même objectif : coder proprement de manière à conserver un code maintenable le plus longtemps possible. Finalement, à y regarder un peu plus en détail, que ce soit la philosophie des 5S, le Zen de Python ou les principes SOLID, tous reprennent plus ou moins les mêmes idées… il y a donc sans doute un enseignement intéressant à en tirer !
Principe de responsabilité unique
Le “S” de SOLID signifie Single Responsability Principle, également généralement noté SRP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :
Single Responsability Principle : A class should have only one reason to change.
En français, cela donne :
Principe de responsabilité unique : une classe ne doit avoir qu’une seule raison de changer.
Les classes (et les méthodes) ne devraient avoir qu’une seule fonctionnalité.
Considérons une implémentation d’un jeu dans lequel il a des tours de
jeu et un comptage de points comme le bowling. On pourrait imaginer
qu’un tel jeu aura une classe Game
qui aura la
responsabilité de se souvenir du numéro du tour en cours (à quel carreau
on est dans le cas du bowling) ainsi que du calcul du score (quel est le
nombre de points obtenu par chacun des joueurs). Pour respecter le
principe SRP, il faudrait séparer ces deux fonctionnalités en deux
classes de sorte que chaque classe n’ait qu’une seule responsabilité. La
classe Game
garderait la responsabilité du suivi des tours
alors qu’une nouvelle classe Scorer
aurait la
responsabilité de calculer le score.
On peut se poser la question de savoir pourquoi il est important de séparer ces deux responsabilités dans des classes distinctes. La raison en est que chaque responsabilité correspond à une direction dans lequel on peut faire un changement dans la classe. Si une classe possède plus d’une responsabilité, elle aura donc plus d’une raison de changer. Si une classe a plusieurs responsabilités, elles sont couplées. Dans ce cas, la modification d’une des responsabilités nécessite de :
- tester à nouveau l’implémentation des autres responsabilités ;
- modifier potentiellement les autres responsabilités (les modifications apportées à une responsabilité pouvant compromettre la capacité de la classe à assurer ses autres responsabilités) ;
- déployer à nouveau les autres responsabilités.
Ce type de couplage conduit à des conceptions fragiles qui se brisent de manière inattendue lorsqu’elles ont besoin d’être modifiées. Une modification dans les spécifications d’une des responsabilités d’une classe peut entraîner l’introduction de bugs et donc une perte de temps.
Séparer les responsabilités et donc respecter SRP a de nombreux avantages :
- Diminution de la complexité du code
- Amélioration de la lisibilité du code
- Meilleure organisation du code
- Modification locale lors des évolutions
- Augmentation de la fiabilité
- Classes davantage réutilisables
Afin d’illustrer ce principe, nous allons décrire un exemple plus
concret. On va considérer une classe Rectangle
qui a deux
méthodes : une méthode draw()
permettant de dessiner le
rectangle et une méthode area()
calculant l’aire de
celui-ci. Deux classes différentes utilisent la classe
Rectangle
: GeometricApplication
et
GraphicalApplication
. La classe
GeometricApplication
utilise Rectangle
pour
faire des calculs mathématiques sur des formes géométriques (pour faire
simple des calculs d’aires), mais ne dessine jamais de rectangle à
l’écran. La classe GraphicalApplication
est de nature
graphique et peut également faire de la géométrie informatique, mais
elle dessine des rectangles à l’écran en utilisant une classe
GraphicalInterface
. Le diagramme de classe ci-dessous
illustre cette architecture.
Cette conception est contraire à SRP. En effet, la classe
Rectangle
a deux responsabilités. La première consiste à
fournir un modèle mathématique de la géométrie d’un rectangle. La
seconde est de dessiner le rectangle pour une interface graphique en
utilisant une interface graphique nommée
GraphicalInterface
.
La violation de SRP entraîne plusieurs problèmes. Tout d’abord, nous
devons inclure l’interface graphique GraphicalInterface
dans l’application de géométrie informatique
GeometricApplication
car Rectangle
a besoin de
cette classe. Cela signifie que GraphicalInterface
devra
être construit et déployé avec l’application de géométrie
GeometricApplication
. Deuxièmement, si une modification de
l’application graphique GraphicalApplication
entraîne une
modification de la classe Rectangle
pour une raison
quelconque, cette modification peut nous obliger à reconstruire,
retester et redéployer l’application de géométrie informatique
GeometricApplication
. Si nous oublions de le faire, cette
application peut dysfonctionner de manière imprévisible.
Une meilleure conception consiste à séparer les deux responsabilités
dans deux classes complètement différentes, comme le montre la figure
ci-dessous. Cette conception déplace les aspects de calcul géométrique
de Rectangle
dans la classe GeometricRectangle
et de garder les fonctionnalités de rendu graphique dans
GraphicalRectangle
. Désormais, les modifications apportées
à la manière dont les rectangles sont rendus ne peuvent pas affecter
GeometricApplication
.
Principe d’ouvert/fermé
Le “O” de SOLID signifie Open-Closed Principle, également noté OCP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :
The Open/Closed Principle (OCP) : Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
En français, cela donne :
Les entités logicielles (classes, modules, fonctions, etc.) doivent être ouvertes à l’extension, mais fermées à la modification.
Si on souhaite respecter OCP pour les classes, il doit donc être possible de rajouter une nouvelle fonctionnalité :
- en ajoutant des classes (ouvert pour l’extension)
- sans modifier le code existant d’une classe (fermé à la modification)
Cela signifie que par exemple pour une classe donnée, on doit pouvoir l’étendre, c’est-à-dire l’utiliser pour créer une nouvelle classe, mais pas la modifier pour y intégrer le comportement de la nouvelle classe. Les Avantages sont nombreux :
- Le code existant n’est pas modifié et on a donc pas besoin de le rester ou de le reconstruire ce qui nous permet de gagner en fiabilité.
- Les classes ont plus de chance d’être réutilisables.
- Simplification de l’ajout de nouvelles fonctionnalités.
Afin d’illustrer les problèmes du non-respect d’OCP, on va considérer
un exemple pour lequel on a déjà deux classes représentant des formes
géométriques : Rectangle
et Circle
ayant le
code suivant :
public class Rectangle {
public Point point1, point2;
public Rectangle(Point point1, Point point2) {
this.point1 = point1;
this.point2 = point2;
}
}
public class Circle {
public Point center;
public int radius;
public Circle(Point center, int radius) {
this.center = center;
this.radius = radius;
}
}
Afin de pouvoir dessiner ces deux types de formes géométriques, on a
aussi le code d’une classe GraphicTools
qui permet de
dessiner une liste d’objets qui représente des formes géométriques.
public class GraphicTools {
static void drawShapes(Graphics graphics, List<Object> shapes) {
for (Object shape : shapes) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
int x = Math.min(rectangle.point1.x, rectangle.point2.x);
int y = Math.min(rectangle.point1.y, rectangle.point2.y);
int width = Math.abs(rectangle.point1.x - rectangle.point2.x);
int height = Math.abs(rectangle.point1.y - rectangle.point2.y);
.drawRect(x, y, width, height);
graphics} else if (shape instanceof Circle) {
= (Circle) shape;
Circle circle int x = circle.center.x - circle.radius;
int y = circle.center.y - circle.radius;
int width = circle.radius * 2;
int height = circle.radius * 2;
.drawOval(x, y, width, height);
graphics}
}
}
}
Parce qu’elle n’est fermée à de nouveaux types de formes, la méthode
drawShapes
n’est pas conforme à OCP. Si on voulait étendre
cette fonction pour pouvoir dessiner une liste de formes comprenant des
triangles, on devrait modifier la fonction. En fait, on devrait modifier
la fonction pour tout nouveau type de forme qu’on aurait besoin de
dessiner.
On va donc modifier le code afin de respecter OCP. La première étape
va consister à simplifier le code de la méthode drawShapes
en extrayant dans des méthodes distinctes les fonctionnalités de dessin
des rectangles et des cercles.
public class GraphicTools {
static void draw(Graphics graphics, List<Object> objects) {
for (Object object : objects) {
if (object instanceof Rectangle) {
Rectangle rectangle = (Rectangle)object;
drawRectangle(graphics, rectangle);
} else if (object instanceof Circle) {
= (Circle)object;
Circle circle drawCircle(graphics, circle);
}
}
}
static void drawRectangle(Graphics graphics, Rectangle rectangle) {
int x = Math.min(rectangle.point1.x, rectangle.point2.x);
int y = Math.min(rectangle.point1.y, rectangle.point2.y);
int width = Math.abs(rectangle.point1.x - rectangle.point2.x);
int height = Math.abs(rectangle.point1.y - rectangle.point2.y);
.drawRect(x, y, width, height);
graphics}
static void drawCircle(Graphics graphics, Circle circle) {
int x = circle.center.x - circle.radius;
int y = circle.center.y - circle.radius;
int width = circle.radius * 2;
int height = circle.radius * 2;
.drawOval(x, y, width, height);
graphics}
}
La deuxième étape de la modification du code consiste à créer une
interface Drawable
qui va contenir une unique méthode
draw
et qui sera implémentée par Circle
et
Rectangle
. Cela nous donne le schéma ci-dessous.
Le code du diagramme est le suivant.
public interface Drawable {
void draw(Graphics graphics);
}
public class GraphicTools {
static void draw(Graphics graphics, List<Drawable> drawables) {
for (Drawable drawable : drawables)
.draw(graphics);
drawable}
}
public class Circle implements Drawable {
public Point center;
public int radius;
public Circle(Point center, int radius) {
this.center = center;
this.radius = radius;
}
public void draw(Graphics graphics) {
int x = center.x - radius;
int y = center.y - radius;
int width = radius * 2;
int height = radius * 2;
.drawOval(x, y, width, height);
graphics}
}
public class Rectangle implements Drawable {
public Point point1, point2;
public Rectangle(Point point1, Point point2) {
this.point1 = point1;
this.point2 = point2;
}
public void draw(Graphics graphics) {
int x = Math.min(point1.x, point2.x);
int y = Math.min(point1.y, point2.y);
int width = Math.abs(point1.x - point2.x);
int height = Math.abs(point1.y - point2.y);
.drawRect(x, y, width, height);
graphics}
}
Cette solution n’est pas totalement satisfaisante, car les classes
Rectangle
et Circle
ont deux responsabilités
distinctes : le stockage des données géométriques des formes (comment on
sauvegarde la position des formes géométriques) et la fonctionnalité de
rendu graphique. La classe Rectangle
a donc deux raisons de
changer : un changement si on souhaite sauvegarder le rectangle avec un
coin, sa largeur et sa hauteur plutôt que les deux coins opposés et un
autre changement dû à un choix différent de bibliothèque graphique qui
changera la méthode draw
. Nous verrons par la suite comment
obtenir une meilleure solution avec l’application du patron de
conception visiteur.
Principe de substitution de Liskov
Un des concepts les plus importants afin de pouvoir respecter OCP est l’extension de classe aussi appelé héritage de code. C’est grâce à l’héritage qu’il est possible de créer des classes dérivées qui implémentent de nouvelles méthodes dans les classes de base. C’est un des outils qu’on peut utiliser pour ajouter des services à une classe sans modifier le code de la classe elle-même. On peut donc se poser la question de savoir quelles sont les règles de conception qui régissent cette utilisation de l’héritage. Comment peut-on reconnaître une bonne hiérarchie d’héritage d’une mauvaise ? Quels sont les pièges qui nous amèneront à créer des hiérarchies non conformes à OCP ? Ce sont les questions qui sont répondues par le principe de substitution de Liskov qui correspond au “L” de SOLID pour Liskov Substitution Principle, également noté LSP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :
The Liskov Substitution Principle : Subtypes must be substitutable for their base types.
En français, cela donne :
Le principe de substitution de Liskov : les sous-types doivent être substituables à leurs types de base.
Barbara Liskov a définit ce principe en 1988 de la manière suivante :
S
est un sous-type (extension) correct deT
Si pour chaque objeto1
de typeS
il existe un objeto2
de typeT
tel que pour tous les programmesP
définis en termes deT
, le comportement deP
est inchangé lorsqueo1
est remplacé paro2
.
Dit autrement, si une classe D
étend une classe
B
(ou implémente une interface B
) alors un
programme P
écrit pour manipuler des instances de type
B
doit avoir le même comportement s’il manipule des
instances de la classe D
. L’importance de ce principe
devient évidente lorsque l’on considère les conséquences de sa
violation. Supposons que nous ayons une fonction f
qui
prend comme argument un objet de type B
. Supposons
également que, lorsqu’on passe à f
un objet de type
D
, on obtient un comportement incorrect de f
.
Dans ce cas, la classe D
viole LSP. C’est ce genre de
problème que l’on souhaite éviter en respectant LSP.
Afin d’illustrer les problèmes du non-respect de LSP, on va
considérer un exemple pour lequel on a déjà deux classes représentant
des formes géométriques. On considère tout d’abord une classe
Rectangle
ayant le code suivant :
public class Rectangle {
private double width;
private double height;
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double getWidth() {
return width;
}
public double getHeight() {
return height;
}
public double getArea() {
return width*height;
}
}
Un carré étant un rectangle, on souhaite définir une classe
Square
qui est une extension de Rectangle
de
la façon suivante :
public class Square extends Rectangle {
public void setWidth(double width) {
super.setWidth(width);
super.setHeight(width);
}
public void setHeight(double height) {
super.setWidth(height);
super.setHeight(height);
}
}
Maintenant, supposons que nous testons la méthode area
de rectangle avec le code suivant :
void testRectangleArea(Rectangle r){
.setWidth(3);
r.setHeight(2);
rassertThat(r.area()).isEqualTo(3*2);
}
Si on appelle la méthode avec un objet de type
Rectangle
, le code s’exécute sans problème, car l’assertion
est vraie. Si on appelle la méthode avec un objet de type
Square
, l’assertion est fausse, car la largeur est fixée à
2
par l’appel r.setHeight(2)
et l’aire est
donc de 4. En fait la bonne question à se poser n’est pas de savoir si
un carré est-il un rectangle, mais plutôt de déterminer si un carré a le
même comportement qu’un rectangle. Puisque la réponse à cette deuxième
question est négative, ce n’est pas une bonne idée de définir la classe
des carrés comme un sous-type de la classe des rectangles.
Pour corriger le problème, une solution consiste à définir une classe
abstraite RectangularShape
qui contiendra les parties
communes de Rectangle
et Square
, c’est-à-dire
le calcul de l’aire et la définition des signatures des accesseurs
(getters). Cette classe sera étendue par Rectangle
et Square
. Cela nous donne le diagramme suivant :
Cela nous donne le code suivant :
public abstract class RectangularShape {
public abstract double getWidth();
public abstract double getHeight();
public double getArea() {
return getWidth() * getHeight();
}
}
public class Rectangle extends RectangularShape {
private double width;
private double height;
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.h = height;
}
public double getWidth() {
return width;
}
public double getHeight() {
return height;
}
}
class Square extends RectangularShape {
private double sideLength;
public void setSideLength(double sideLength) {
this.sideLength = sideLength;
}
public double getWidth() {
return sideLength;
}
public double getHeight() {
return sideLength;
}
}
Principe de ségrégation des interfaces
Le “I” de SOLID signifie Interface Segregation Principle, également noté ISP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :
The Interface Segregation Principle (ISP) : Clients should not be forced to depend upon interfaces that they do not use.
En français, cela donne :
Les clients ne doivent pas être obligés de dépendre d’interfaces qu’ils n’utilisent pas.
Il faut donc éviter qu’un client ne voit une interface qu’il n’utilise pas. Dans la plupart des cas, appliquer ce principe revient à éviter les interfaces qui contiennent beaucoup de méthodes :
- Découper les interfaces en responsabilités distinctes (SRP)
- Quand une interface grossit, se poser la question du rôle de l’interface
- Éviter de devoir implémenter des services qui n’ont pas à être proposés par la classe qui implémente l’interface
- Limiter les modifications lors de la modification de l’interface
Les avantages sont nombreux :
- Le code existant est moins modifié et on a donc une augmentation de la fiabilité ;
- Les classes ont plus de chance d’être réutilisables ;
- Simplification de l’ajout de nouvelles fonctionnalités.
Afin d’illustrer les raisons de ce principe, on va considérer un
exemple dans lequel on considère une interface Element
pour
les éléments d’une interface graphique. Cette interface sera implémentée
par une classe Label
qui permet de représenter des
étiquettes de texte. On considère donc l’architecture décrite par le
diagramme suivant :
Cela nous donne le code suivant :
public interface Element {
public void setText(String text);
public String getText(String text);
public void setPosition(int x, int y);
public void draw(Graphics graphics);
}
public class Label implements Element {
private int x,y;
private String text;
public Label(String text, int x, int y) {
this.text = text; this.x = x; this.y = y;
}
public void setText(String text) {
this.text = text;
}
public String getText(String text) {
return text;
}
public void setPosition(int x, int y) {
this.x = x; this.y = y;
}
public void draw(Graphics graphics) {
.drawString(text, x, y);
graphics}
}
Supposons maintenant que nous souhaitions ajouter une classe image
qui est aussi un élément graphique et qui implémenterait donc
Element
. Cela nous donne le diagramme suivant :
Nous avons un problème, car une image n’a pas de texte. On ne sait
donc pas que faire dans les méthodes setText
et
getText
. La solution est de découper l’interface
Element
en deux en séparant les fonctionnalités liées au
texte des fonctionnalités liées au rendu graphique de l’élément. On
obtient donc le diagramme suivant :
Principe d’inversion des dépendances
Le “D” de SOLID signifie Dependency Inversion Principle, également noté DIP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :
High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces). Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
En français, cela donne :
Les modules de haut niveau ne doivent pas dépendre de ceux de bas niveau. Les deux doivent dépendre d’abstractions (par exemple les interfaces). Les abstractions, elles, ne doivent pas dépendre des détails. Les détails (implémentations concrètes) doivent dépendre des abstractions.
Ainsi, si une classe A
utilise une classe
B
, il doit être possible de remplacer B
par
une autre classe C
. B
et C
sont
alors des implémentations concrètes d’une classe abstraite (ou d’une
interface) qui sera utilisée par A
.
Les modules d’un programme doivent donc être indépendants et doivent dépendre d’abstractions. Pour respecter DIP, il faut donc :
- découpler le plus possible les différents modules de votre programme ;
- les lier quand c’est nécessaire en utilisant des interfaces ;
- spécifier correctement le comportement de chaque module.
Les avantages de l’application de DIP :
- Permet de remplacer un module par un autre module plus facilement.
- Les modules sont plus facilement réutilisables.
- Simplification de l’ajout de nouvelles fonctionnalités.
- L’intégration est rendue plus facile.
Afin d’illustrer l’application de DIP, nous allons considérer un exemple assez simple. Considérons le logiciel qui pourrait contrôler le régulateur d’un four. Le logiciel peut lire la température actuelle à partir d’un canal d’entrée/sortie et demander au four de s’allumer ou de s’éteindre en écrivant des commandes à un autre canal d’entrée/sortie. La structure du programme pourrait ressembler au code suivant :
public class Thermostat {
enum IOChannel{
, FURNACE
THERMOMETER}
enum Action{
, DISENGAGE
ENGAGE}
int read(IOChannel channel){
// TODO : add code for reading on a channel
return 0;
}
void write(IOChannel channel, Action action){
// TODO : add code for writing on a channel
}
void Regulate(double minTemp, double maxTemp)
throws InterruptedException{
for(;;) {
while (read(IOChannel.THERMOMETER) > minTemp)
wait(1);
write(IOChannel.FURNACE, Action.ENGAGE);
while (read(IOChannel.THERMOMETER) < maxTemp)
wait(1);
write(IOChannel.FURNACE, Action.DISENGAGE);
}
}
}
On comprend assez facilement l’intuition derrière l’algorithme. Néanmoins, un certain nombre de détails techniques liés à des aspects bas niveau (lecture et écriture dans des canaux d’entrées/ sorties) rend le code un peu moins lisible. Ce code ne pourra jamais être réutilisé avec un matériel de contrôle différent qui n’utiliserait pas le même protocole de communication (utilisant des canaux). Ce n’est peut-être pas une grande perte, car le code est très petit. Mais même dans ce cas, il est dommage que l’algorithme ne soit pas réutilisable. Il est préférable inverser les dépendances et de concevoir une architecture comme celle décrite par le diagramme ci-dessous.