SOLID est l’acronyme de 5 principes fondamentaux en programmation orientée objet qui permettent de programmer des applications, maintenables, évolutives, flexibles, bref tout ce qu’on recherche d’une manière générale. Il signifie :
Lettre | Signification |
---|---|
S | Single Responsibility principle (Principe de Responsabilité Unique) |
O | Open/Closed Principle (Principe Ouvert/Fermé) |
L | Liskov Substitution Principle (Principe de Substitution de Liskov) |
I | Interface Segregation Principle (Principe de Ségrégation des Interfaces) |
D | Dependency Inversion Principle (Principe d'Inversion des Dépendances) |
Nous allons ces cinq principes qui, je le rappelle, sont fondamentaux en programmation orientée objet et, à ce titre, sont incontournables si vous souhaitez programmer proprement vos applications.
Définition
Une classe ne doit avoir qu’une seule responsabilité, c’est-à-dire que son rôle dans l’application est clairement défini et se limite à une seule fonctionnalité.
Dans une application web, une classe dont le rôle serait de gérer à la fois l’interface utilisateur, la gestion de la base de données et les transferts réseau d’informations violerait le principe de responsabilité unique.
But du principe: scinder l’application en plusieurs classes, chacune n’ayant qu’une seule responsabilité. Ainsi, le code des classes est simplifié (puisque chacune ne s’occupe que d’une petite partie de l’application) et il est également plus facile à maintenir.
Voici un autre exemple : ci-dessous la classe SUV, dont le but est de gérer une voiture, propose une méthode pour créer une série de voitures. Cela sort de son rôle : ce devrait être une autre classe qui a la responsabilité de créer un parc de voitures. On a donc violé le principe de responsabilité unique.
package fr.polytech.aco.vehicule.voiture;
import java.util.Date;
public class SUV {
Date date_circulation;
public SUV(Date date) {
this.date_circulation = new Date(date.getTime());
}
public void print() {
System.out.println("SUV " + this.date_circulation);
}
public static SUV[] serie(Date date) {
var res = new SUV[10];
for (int i = 0; i < 10; ++i) {
res[i] = new SUV(date);
}
return res;
}
}
Définition
Le principe Ouvert/Fermé est l’un des 5 principes du SOLID (décrit plus bas dans cette page). Il stipule qu’une classe doit être conçue pour être ouverte en extension (on doit pouvoir l’étendre) mais fermée en modification (ces extensions ne doivent pas nous obliger à modifier la classe).
Ainsi, si une classe hérite d’une autre classe, elle doit certes pouvoir ajouter des nouveaux comportements avec de nouvelles méthodes mais la redéfinition d’une méthode ne doit pas engendrer un comportement trop différent de celui de la classe parente. De plus, la classe ne doit pas pouvoir modifier le fonctionnement décrit par la classe parente. En faisant en sorte que les attributs et méthodes soient déclarés private le plus possible, on tend à garantir cette propriété.
Ci-dessous un exemple de classe violant le principe Ouvert/Fermé : dans la méthode calculeSurface de la classe CalcSurface, on teste explicitement si la forme est un rectangle ou un cercle. Par conséquent, si l’on rajoute une nouvelle classe Triangle, on devra modifier le code de calculeSurface : la classe CalcSurface n’est donc pas fermée en modification.
public Interface Forme {
}
public class Rectangle implements Forme {
public double largeur;
public double hauteur;
public Rectangle(double largeur, double hauteur) {
this.largeur = largeur;
this.hauteur = hauteur;
}
}
public class Cercle implements Forme {
public double rayon;
public Cercle(double rayon) {
this.rayon = rayon;
}
}
public class CalcSurface {
public double calculeSurface(Forme forme) {
if (forme instanceof Rectangle) {
Rectangle rectangle = (Rectangle) forme;
return rectangle.largeur * rectangle.hauteur;
}
else if (forme instanceof Cercle) {
Cercle cercle = (Cercle) forme;
return Math.PI * cercle.rayon * cercle.rayon;
}
else {
return 0;
}
}
}
Une solution qui respecte le principe Ouvert/Fermé consiste à exploiter le polymorphisme : étant donné que calculeSurface ne doit pas faire de if/else, cette méthode ne perçoit forme que comme une Forme et seule cette dernière connaît la formule pour calculer correctement sa surface. On rajoute donc une méthode abstraite à l’interface Forme afin de calculer la surface et on implémente celle-ci dans les classes Rectangle et Cercle :
public interface Forme {
abstract public double calculeSurface();
}
class Rectangle implements Forme {
public double largeur;
public double hauteur;
public Rectangle(double largeur, double hauteur) {
this.largeur = largeur;
this.hauteur = hauteur;
}
@Override
public double calculeSurface() {
return this.largeur * this.hauteur;
}
}
class Cercle implements Forme {
public double rayon;
public Cercle(double rayon) {
this.rayon = rayon;
}
@Override
public double calculeSurface() {
return Math.PI * this.rayon * this.rayon;
}
}
public class CalcSurface {
public double calculeSurface(Forme forme) {
return forme.calculeSurface();
}
}
Vous avez déjà vu le principe de substitution de Liskov : c’est précisément la définition de l’héritage vue dans la séance 3 : les sous-classes doivent pouvoir être utilisées comme des substituts de leurs classes parents/ancêtres sans que cela n’altère le comportement du programme. Lorsque l’on avait considéré les classes Rectangle et Carre lors de cette séance, on avait vu que si Carre hérite de Rectangle, alors si on appelle en lui passant un Carre une méthode qui prend un Rectangle en paramètre et qui l’applatit, on va applatir le Carre, donc le comportement du programme devient erroné. si Rectangle hérite de Carre, on peut avoir une méthode surface qui renvoie la surface d’un Carre en calculant le carré de sa longueur. Mais, dans ce cas, la surface du Rectangle n’est pas calculée correctement. Dans les deux cas, on viole donc le principe de substitution de Liskov et il ne faut donc pas que l’une de ces classes hérite de l’autre.
Definition
Les interfaces doivent être spécifiques à des besoins, et non générales.
L’idée de ce principe est que si une interface expose beaucoup de méthodes, les classes (non abstraites) qui l’implémentent sont obligées de les définir toutes, y compris des méthodes qu’elles n’utilisent pas. Il est donc préférable de scinder ce type d’interfaces en des interfaces plus « petites » et plus spécifiques.
Exemple : la classe MyArray<T> que vous avez écrite implémente une interface Pushable<T> qui indique que MyArray<T> doit avoir deux méthodes pushFront() et pushBack(). On pourrait imaginer également que MyArray<T> possède des itérateurs pour parcourir ses éléments. Dans ce cas, on pourrait ajouter à l’interface Pushable<T> une méthode iterator() qui renverrait un itérateur. Mais cela obligerait toutes les classes implémentant Pushable<T> à avoir non seulement les méthodes pushFront() et pushBack() mais également la méthode iterator(), même si elles n’en ont pas besoin. Il n’y a donc pas ségrégation des interfaces. En revanche, si l’on crée une interface Iterator<T> qui contient la méthode iterator(), dans ce cas, MyArray<T> implémente Pushable<T> et Iterator<T> et les autres classes qui n’ont besoin que des pushFront() et pushBack() n’implémentent que l’interface Pushable<T>. Dit autrement, l’interface Pushable<T> avait vocation à fournir des méthodes abstraites pour faire des push, il n’y avait donc aucune raison de lui rajouter des itérateurs. Il vaut mieux avoir, pour ces derniers, une interface spécifique.
Attention
La ségrégation des interfaces doit donc vous amener à créer de petites interfaces dont le but est bien ciblé et dont vous donnez un nom qui représente bien cet objectif.
Le principe d’inversion des dépendances est lié à la notion de couplage.
Définition
Le couplage est le degré de dépendance entre deux classes, packages ou modules. Un couplage fort indique une forte dépendance. Dans ce type de couplage, si on modifie une des deux classes, on aura très probablement à modifier l’autre. Un couplage faible indique une faible dépendance et, donc, une modification de l’une des classes devrait nécessiter peu ou pas de modification dans l’autre classe.
D’une manière générale, vous avez intérêt à faire en sorte que les couplages soient le plus faible possible.
L’objectif du principe d’inversion des dépendances est de diminuer le couplage qu’il peut y avoir entre des classes/modules/packages de « haut niveau » et celles de « bas niveau ».
Définition
Le principe d’inversion des dépendances stipule que les modules/classes/packages de haut niveau ne doivent pas dépendre de ceux de bas niveau.
Par exemple, si l’on écrit une classe de haut niveau EcritureTexte qui permet d’écrire du texte dans un fichier, celle-ci ne devrait pas dépendre du code de la classe bas niveau DisqueDur qui contient le pilote (driver) pour interagir avec le disque dur. La « bonne idée » consiste plutôt à fournir une interface (API) pour communiquer avec un device, à faire en sorte que le driver DisqueDur implémente cette API et que EcritureTexte n’exploite que l’API, pas le code bas niveau du driver. Autrement dit, les details du code de DisqueDur n’ont pas besoin d’être connus ni utilisés par EcritureTexte. L’avantage réside dans le fait que, si on change le medium sur lequel on écrit (on passe d’une disque dur mécanique classique à un disque SSD ou bien à une écriture sur le cloud via internet, par exemple), on n’a rien à modifier dans EcritureTexte.
Une entreprise de restauration en ligne propose à ses clients un site web permettant de passer des commandes sur le web (via un smartphone ou un navigateur classique). Lorsque l’on commande, on peut payer par Paypal ou par carte de crédit. Lorsque la commande est validée, le client peut recevoir un SMS ou bien un email pour récapituler la commande.
Définissez l’architecture de l’application web de l’entreprise (les classes, les interfaces et surtout leurs relations). Pour l’instant, on n’a pas encore vu UML qui permettra de faire cela précisément, mais essayez de réaliser l’exercice du mieux que vous pouvez : l’idée est d’essayer d’anticiper le fonctionnement de votre application. En outre, vous respecterez évidemment les principes SOLID.