POO & Design Patterns

Principe de Programmation Orientée Objet

Les principes SOLID (1/2)

Le but d'une bonne architecture objet est d'être maintenable, testable, réutilisable et évolutive.

  • Les classes doivent représenter des concepts bien identifiés (sinon pas de tests, pas de réutilisabilité)
  • Il faut limite les dépendences entre les classes (pas d'évolutivité et pas maintenable).

et biensûr, il faut faire des classes pour chaque concept !

Les principes SOLID (2/2)

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 principes SOLID

Les 5 principes de SOLID sont:

  • Single-responsibility principle
  • Open-closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

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.

Single-responsibility principle

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.

Un exemple simple

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 ?

Une solution simple

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

Composition

La composition c'est simplement quand un objet en contient un autre.

Délégation

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.

Une meilleure solution

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 {
     ....
}

La version en dessin

Une classe client pour Uber

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.

UberClient : 1 an plus tard

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.

Problèmes

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.

Solution 1

  • Accesseurs sur les champs de la classe UberClient.
  • Définir une interface pour les formateurs.
        @FunctionalInterface
        public interface Formatter {
            String format(UberClient);
        }
    
  • On définit une classe par format d'export qui implémente l'interface Formatter.

Cette solution ne résout pas le problème de l'export possible des notes, d'uid, ... et elle affaiblie l'encapsulation.

Solution 2

  • On introduit une classe transparente (i.e., record) UberClientInfo qui contient les informations exportables de UberClient.
  • La classe 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.

A consommer avec modération

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 !

Open-closed principle

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.

Exemples

  • Possibilité de rajouter une librairie graphique en rajoutant simplement un adapteur.
  • Possibilité de rajouter un format d'exportation pour UberClient en rajouter un UberClientFormatter

Exemple: UberClientV3 (1/4)

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

  • moyenne des notes
  • moyenne des 100 dernières notes
  • ...

Exemple: UberClientV3 (2/4)

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.

Exemple: UberClientV3 (3/4)

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

Exemple: UberClientV3 (3/4)

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.

Dependency inversion principle

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.

Interface Segregation Principle

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.

Liskov substitution principle

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

  • L'employé et le stagiaire
  • Canard
  • La forme Square

Employee

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

Intern

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.

Canard

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

Carré et Rectangle (1/2)

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

}

Carré et Rectangle (2/2)

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

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.

Heritage avec modération

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.

Factorisation du code dans l'interface (1/2)

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.

Factorisation du code dans l'interface (2/2)

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!");
	}
}

Utilisation historique de l'héritage

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.

Servlet par 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);
}