Le but d'une bonne architecture objet est d'être maintenable, testable, réutilisable et évolutive.
et biensûr, il faut faire des classes pour chaque concept !
En 2000, Robert C. Martin (dit Uncle Bob) définit 5 grands principes qui sont des guides pour arriver à une bonne architecture objet. Ces principes nous donnent un idéal à atteindre mais ne disent pas comment l'atteindre dans des situations concrètes. Les design patterns donneront des briques de base qui respectent ces principes et qui résolvent des problèmes classiques de POO.
Les 5 principes de SOLID sont:
L'Encapsulation ne fait pas partie des principes mais cela pourrait ...
Tous les principes n'ont pas la même importance, nous allons donc les présenter dans le désordre.
Une classe ne doit avoir qu'une seule responsabilité.
Autrement dit, une classe ne doit pas être impactée par deux aspects différents de la spécification du programme.
Si un objet a trop de responsabilités, il faut faire apparaitre d'autres objets pour gérer ces différentes responsabilités.
Il faut le faire avec modération et surtout être capable de se poser la question au fur et à mesure de l'évolution de votre code.
class Client { private String firstName; private String lastName; private int streetNumber; private String streetName; private int zipCode; private String city; ... public String getName() {...}; public String printAddress() {...}; }
Que se passe-t-il quand on commence à gérer des clients de différents pays ?
public class Client { private String firstName; private String lastName; private Address address; ... public String getName() {...}; public String printAddress(){ return address.print(); } }
public class Address { private int streetNumber; private String streetName; private int zipCode; private String city; public String print() {...}; }
Composition + Délégation
La composition c'est simplement quand un objet en contient un autre.
public class Client { private String firstName; private String lastName; private Address address; ... public String getName() {...}; public String printAddress(){ return address.print(); } }
public class Address { private int streetNumber; private String streetName; private int zipCode; private String city; public String print() {...}; }
La délégation c'est quand un objet fait faire une partie du travail par un autre.
public class Client { private String firstName; private String lastName; private Address address; ... public String getName() {...}; public String printAddress(){ return address.print(); } }
public interface Address { public String print(); }
public class FrenchAddress implements Address { .... } public class BritishAddress implements Address { .... }
class UberClient { private String firstName; private String lastName; private long uid; private Email email; private PhoneNumber phoneNumber; private double score; private ArrayList<Double> grades; ... public String toString() {...}; public String toJSON() {...}; }
La classe ClientUber
représente les informations d'un client. Dans cette version, il n'y a rien de choquant.
class UberClient { private String firstName; private String lastName; private long uid; private Email email; private PhoneNumber phoneNumber; private double score; private ArrayList<Double> grades; ... public String toString() {...}; public String toJSON() {...}; public String toHTML() {...}; public String toHTMLFull() {...}; public String toXML() {...}; public String toXMLBackEnd() {...}; }
Clairement, ClientUber
a aussi le rôle d'exporter les informations clients.
Toute l'équipe de développement modifie de la classe UberClient
dès qu'il y a besoin d'exporter les données d'un client.
Probablement beaucoup de duplication de code entre les différents méthodes d'export.
Les fonctions d'export peuvent renvoyer des informations qui n'ont pas à se retrouver affichées comme la liste des notes du client.
Nous allons faire comme si cette dernière préocupation était importante.
@FunctionalInterface public interface Formatter { String format(UberClient); }
Formatter
.Cette solution ne résout pas le problème de l'export possible des notes, d'uid, ... et elle affaiblie l'encapsulation.
UberClientInfo
qui contient les informations exportables de UberClient
.UberClient
n'offre aucune méthode public ou package pour calculer la UberClientInfo
. Elle offre une méthode public qui accepte un UberClientFormatter
et renvoie le résultat:
public class UberClient { ... private UberClientInfo infos() {...}; public String export(UberClientFormatter formatter) { return formatter.fomat(getInfos()); }; }
Cette solution est encore mieux car on n'a pas directement accès à la vue d'exportation et seuls les UberClientInfo
sont impactés.
Une classe = une responsabilité
avec modération ...
public class AWTColorConverter { public static Color convert (CanvasColor c) { return switch (c) { case BLACK -> Color.BLACK; case WHITE -> Color.WHITE; case ORANGE -> Color.ORANGE; }; } }
La méthode peut être rangée dans l'adapter correspondant sans problème !
Une classe (un package, ou une librairie) devrait être fermée aux modifications mais ouverte aux extensions.
Une classe (un package, ou une librairie) est fermée quand elle a été testée, versionnée. L'idée est alors qu'on ne touchera plus au code pour apporter de nouvelles fonctionnalités.
On veut cependant pouvoir étendre ses fonctionnalités sans toucher à une ligne de code de la classe (package, librairie).
Les extensions se feront en rajoutant des classes et pas en modifiant le code existant.
On veut pouvoir choisir comment sont calculés les scores des clients.
On ne connait pas toutes les manières de calculer les scores à partir des notes qui pourront être envisagé.
Solution 1: Mettre un getter sur la liste des notes.
Case l'encapsulation !
Solution 2: Externaliser l'algorithme de calcul du score.
La classe UberClient
est toujours propriétaire des données et ne les exposent pas. Mais au moment de calculer le score, elle prend en paramètre l'algorithme pour faire ce calcul.
On peut étendre le code avec de nouvelle façons de calculer le score, sans rouvrir la classe UberClient
.
public class UberClient { private ArrayList<Double> grades; double getScore(ScoreComputationStrategy strategy){ return strategy.computeScore(grades); } }
@FunctionalInterface public interface ScoreComputationStrategy { public double computeScore(List<Double> grades); }
public class AverageStrategy implements ScoreComputationStrategy{ public double computeScore(List<Double> grades){ return grades.stream() .mapToDouble(x->x) .average() .orElse(5); } }
public class AverageBoundedStrategy implements ScoreComputationStrategy{ private final int maxNbGrades; public AverageBoundedStrategy(int maxNbGrades){ this.maxNbGrades = maxNbGrades; } public double computeScore(List<Double> grades){ return grades.stream() .limit(maxNbGrades) .mapToDouble(x->x) .average() .orElse(5); } }
public static void main(String[] args){ var ClientUber client = ... System.out.println(client.getScore(new AverageStrategy())); System.out.println(client.getScore(new AverageBoundedStrategy(100))); }
Cette idée d'externaliser l'algorithme est un design-pattern qui s'appelle le design pattern Strategy.
Il faut écrire son code pour dépendre d'abstractions et pas de classes concrètes.
En Java, cela veut dire dépendre d'Interface plutôt que de classes concrètes.
⇛ Permet le polymorphisme et limite la dépendance sur des classes concrètes
Exemples:
Drawing
dépend de Shape
pas de Line
, Rectangle
, ...Drawing
dépend de Canvas
pas de SimpleGraphics
ou de CoolGraphics
.Pas d'interface fourre-tout.
Il vaut mieux faire des interfaces spécifiques aux besoins de chaque client.
Ici, on parle d'interface aux sens de contrats. Ce principe est valable pour les classes, packages et les librairies.
Avoir une interface claire est un moyen de rendre le code compréhensible et donc maintenable.
Attention, il ne faut pas être trop extrême sinon on se retrouve avec une interface par méthode.
Ce point parle de l'héritage. Il n'encourage pas à l'utiliser bien au contraire, il dit ce que l'on doit garantir lorsque l'on utilise l'héritage.
Les classes qui héritent d'une classe doivent pouvoir être utilisées à la place de cette classe sans rendre le programme incorrect.
Autrement dit, quand on hérite d'une classe, on doit préserver le contrat de cette classe.
Exemples
Square
public class Employee { private String Name; private int salary; public String getName() {...}; public void setSalary(int salary) {...}; public int getSalary() {...}; }
On veut rajouter des stagiaires qui sont payés 400€.
public class Intern extends Employee { @Override public void getSalary() { return 400; }; }
Contrat de Employee
:
Employee e = ...; e.setSalary(500); assert(e.getSalary()==500);
Le contrat n'est pas respecté pour Intern
.
Le principe de substitution de Liskov parle en fait des sous-types (pas seulement de l'héritage). Il s'applique aussi aux classes implémentant une interface.
public interface Duck{ public void fly(); public String quack(); } public class RegularDuck implements Duck{ ... } public class Canary implements Duck{ ... public String quack(){ throw new UnsupportedMethodException(); } }
On veut rajouter une forme Square
à l'interface Shape
Peut-on faire hériter Square
de Rectangle
?
public class Rectangle implements Shape { public Rectangle(int upperLeftX, int upperLeftY, int width, int height) {...}; @Override public double distance2(int x, int y) {...}; @Override public void draw(Graphics2D graphics2D) {...} }
public class Rectangle implements Shape{ public Rectangle(int upperLeftX, int upperLeftY, int width, int height) {...}; public void setWidth(int width) {...}; public int getWidth() {...}; public void setHeight(int height) {...}; public int getHeight() {...}; @Override public double distance2(int x, int y) {...}; @Override public void draw(Graphics2D graphics2D) {...} }
Square
peut-elle hériter de Rectangle
?
Rectange
peut-elle hériter de Square
?
L'héritage a été mis très (trop) en avant comme un moyen d'obtenir un code réutilisable.
En pratique, il introduit un couplage très fort de la sous-classe avec la classe dont on hérite. Il permet de factoriser du code mais rarement d'obtenir un code réutilisable.
En pratique, pour utiliser l'héritage, il faut lire le code de la classe dont on hérite.
Quasiment aucun des design patterns n'utilisent l'héritage. Ils utilisent la composition et la délégation.
On peut toutefois s'en servir pour factoriser du code avec uneinterface, une classe abstraite (de visibilité package) et des classes concrètes qui héritent de la classe abstraite (cf. Rectangle
et Ellipse
).
L'interface sert à donner le type commun. Il ne faut surtout pas utiliser la classe abstraite comme type commun.
public interface Duck{ public void quack(); }
public class RegularDuck implements Duck{ public void quack(){ System.out.println("Quack !"); } public void quackManyTimes(int nbTimes){ IntStream.range(0,nbTimes).forEach(quack()); } }
public class AtomicDuck implements Duck{ public void quack(){ System.out.println("Atomic Quack!"); } public void quackManyTimes(int nbTimes){ IntStream.range(0,nbTimes).forEach(quack()); } }
On veut factoriser le code de quackManyTimes.
public interface Duck{ public void quack(); public default void quackManyTimes(int nbTimes){ IntStream.range(0,nbTimes).forEach(quack()); } }
public class RegularDuck implements Duck{ public void quack(){ System.out.println("Quack !"); } }
public class AtomicDuck implements Duck{ public void quack(){ System.out.println("Atomic Quack!"); } }
Les HttpServlet sont une classe de JEE permettant de décrire le code à réaliser quand un serveur web reçoit une requête HTTP.
public abstract class HttpServlet { ... public abstract void doGet(HttpServletRequest req,HttpServelResponse resp); public abstract void doPost(HttpServletRequest req,HttpServelResponse resp); }
Pour définir son propre Servlet, il faut hériter de HttpServlet
et redéfinir les méthodes doGet, doPost,...
C'est le seul design pattern qui utilise l'héritage et on lui préfère maintenant la composition.
public class HttpServlet{ private final HttpProcessor get; private final HttpProcessor post; public HttpServlet(HTTPProcessor get,HttpProcessor post){...} public void doGet(HttpServletRequest req,HttpServelResponse resp){ get.process(req,resp); } public void doPost(HttpServletRequest req,HttpServelResponse resp){ post.process(req,resp); } }
@FunctionalInterface public interface HTTPProcessor{ public void process(HttpServletRequest req,HttpServelResponse resp); }