Conception orientée objet

Cours de B2 sur la conception orientée objet. Cela fait suite aux quelques cours sur la RDD (responsibility driven developement) de B1.

Présentation générale

Le but est d'utiliser un maximum de programmation orientée objet avancée ainsi qu'un peu de programmation fonctionnelle. Le but est de pouvoir savoir quel patron (orienté objet) utiliser pour résoudre telle ou telle situation de l'activité intégrative.

Programmation fonctionnelle

Les fonctions sont vue comme des valeurs et peuvent être passée à d'autres fonctions. La programmation fonctionnelle est plus abstraite et permet d'être plus efficace au niveau de l'écriture car on peut faire plus de choses en beaucoup moins de lignes.

Acquis visés par le cours (UE36)

Activité intégrative

L'activité intégrative consiste en deux projets, un en Java et un en C# qui dialogue via une base de donnée relationnelle. Le travail est découpé en itérations. Les différents cours (COO, Java, C# et SD) permettent de s'entrainer dans les différents acquis mais c'est bien l'AI qui valide les acquis.

Itérations

L'AI se fait en 4 itérations

Itération Durée Status
1 2 semaines Formative
2 2 semaines Formative
3 3 semaines Certificative
4 3 semaines Certificative

Validation de l'UE

Difficulté

La difficulté n'est pas dans la difficulté du projet ou des algorithmes mais dans l'organisation et la charge de travail. Il faut donc faire très attention à bien s'organiser et ne pas prendre de retard.

Introduction à la conception et patron stratégie

Ressources

Conception

Exemple

Imaginons que dans un cas nous avons un code fonctionnant en hierarchie de classe, comment faire pour transformer une classe en une autre ? Ce n'est pas possible, c'est donc une erreur de conception.

Définition

La conception permet de trouver des solutions permetant de structurer le code pour favoriser sa maintenabilité (modification du code) et son extensibilité (ajouter du code) car une application, quoi qu'il arrive doit absolument grandir et changer, sinon elle va mourir. On veut donc structurer le code pour faire émerger des qualités désirables en fonction des usages (performance, stabilité, fiabilité, etc) Structurer un code c'est faire les liens entre les différents éléments/modules d'une application. Le niveau au dessus c'est l'architecture, qui établi les liens entre différentes applications d'un système.

Principes de la conception

Etape 1 - Séparer les occupations

Tout d'abord on doit se demander :

On va donc séparer ce qui change de ce qui ne change pas (très important!)

2023-09-19_19-33-59_screenshot.png

Par exemple ici, on veut que les personnages puissent changer d'ordre (c'est donc ce qui varie ici), en revanche BaseCharacter change pas. On va donc séparer les ordres des personnages.

Etape 2 - Programmer avec des interfaces et éviter les types concrets

On va donc faire un lien entre ce qui change et ce qui ne change pas. Par exemple ici on peut utiliser une interface pour lier les ordres avec les personnages.

2023-09-19_19-36-34_screenshot.png

Ici on peut créer une interface "Ordre" pour lier les différents ordres. Ainsi si on veut en créer un nouveau, il suffit de créer une nouvelle classe implémentant l'interface.

Etape 3 - Favoriser la composition à l'héritage

Enfin il faut pouvoir mémoriser le lien, pour cela dans notre exemple on peut simplement faire un attribut de type Order (notre interface). Il vaut mieux utiliser l'attribut (qui est plus flexible) plus tot que l'héritage qui est beaucoup plus rigide.

Maintenant il faut pouvoir lier les ordres avec les peronnages. Pour cela on peut créer un attribut "ordre" demandant un type de notre interface "Ordre" dans la classe BaseCharacter.

public class BaseCharacter {
    private final String uniqueName;
    private final int[] points;
    private OrderBehavior order = new NoOrderBehavior(); // ← composition de ordre dans basecharacter
    public static final int HIT_POINTS = 0;
    public static final int DAMAGE_POINTS = 1;

    public BaseCharacter(String uniqueName, int hitPoints, int damagePoints) {
        this.uniqueName = StringExtensions.requireNotBlank(uniqueName, "uniqueName").strip();
        this.points = new int[]{
                NumberUtils.requireGrEq(hitPoints, 0, "hitPoints"),
                NumberUtils.requireGrEq(damagePoints, 0, "damagePoints")
        };
    }

    /*Reste du code*/

    public String attack(BaseCharacter target) {
        return order.attack(this, target);
    }
    public void setOrder(OrderBehavior order) {
        this.order = order;
    }
}

Etape 4 - Injecter les dépendences

Il est important de ne pas avoir de valeurs null pour un attribut, cependant il faut aussi éviter d'avoir un type concret. Alors pour définir la classe sans utiliser de classe concretes dedans, on peut simplement la demander (et l'obliger) dans le constructeur (soit, faire une injection de dépendence).

Ainsi ce code (qui ne respecte pas l'étape 2, car elle utilise un type concret NoOrderBehavior) :

public class BaseCharacter {
    private final String uniqueName;
    private OrderBehavior order = new NoOrderBehavior(); // berk
}

Deviendra à ce code (avec l'injection de dépendence)

public class BaseCharacter {
    private final String uniqueName;
    private OrderBehavior order;

    public BaseCharacter(String uniqueName, OrderBehavior order) {
        this.uniqueName = uniqueName;
        this.order = Objects.requireNonNull(order);
    }
}

Les patrons

La solution que l'on a trouvé dans l'exemple précédent, nous avons utilisé le patron "stratégie" (qui est l'une des plus basique). Les patrons de conceptions sont des solutions éprouvées à des problèmes réccurents et qui peuvent être facilement adapté à des problèmes spécifiques.

2023-09-19_19-56-34_screenshot.png

Catégories

Il existe plusieurs catégories de patrons de conceptions.

En vérité on s'en fout des catégories, ce qui compte c'est l'intention de chaque pattern (patron).

Description d'un patron

Chaque patron compte 4 sections:

Pour voir cette description en action, on peut simplement aller voir le site Refactoring Guru.

Avantages

Eviter les généralisations spéculatives

Il faut vraiment attendre qu'un design pattern soit vraiment nécessaire pour le mettre en place pour ne pas intégrer une complexité inutile au code.

Les observables

Problème

Lorsque l'on a des évènements (par exemple nouvel articles) et que l'on a plusieurs terminaux pour recevoir cet evènement (écrans, notifications, emails, etc). Si on fait simplement tout dans une seule classe, cela enfreint le principe d ouvert-fermé (c'est à dire qu'un module doit être ouvert au changements sans avoir à le modifier).

Car ici pour ajouter ou supprimer des terminaux (observateurs), on est obligé de modifier tout le module.

Solution

2023-09-19_20-12-09_screenshot.png

La classe qui publie les évènements va stoquer une liste d'observateurs et va implémenter des méthodes pour que les observateurs puissent s'abonner ou se désabonner. Les observateurs vont implémenter l'interface "Obervateur" qui va simplement contenir une méthode pour recevoir l'évènement. A present pour ajouter ou supprimer des terminaux il suffit d'abonner ou désabonner un observateur à la classe publisher.

Critique

Ce système a beaucoup d'avantages car il respecte le principe ouvert-fermé, il sépare les préoccupations et permet l'abonnement pendant l'exécution. Cependant, il rends aussi le code plus complexe et plus lent (car si un des observateurs est lent, il ralenti toute l'exécution et c'est généralement assez couteux de le paralléliser). Il ne faut pas l'utiliser tout de suite ou tout le temps. Il faut seulement le faire lorsque c'est vraiment nécessaire (par exemple ne pas le faire si il n'y qu'un observateur).

Les fabriques et ponts

Le nom fabrique est un peu utilisé à toutes les sauces, il existe un article de RefactoringGuru qui liste les différences entre les différentes appellations du mot.

Fabriques

Exemple du problème

On veut créer une classe permettant de créer et entrainer les StormTroopers. On a donc fait ceci :

public class StormtrooperTrainingFacility {
    // Cette méthode va créer et entrainer les stormtroopers
    // MilitarySection est une enum permettant de distinguer les différents types
    public Stormtrooper createAndPrepare(MilitarySection section) {
        // Un Stormtrooper du bon type est généré en fonction de la MilitarySection
        Stormtrooper product = switch(section) {
            case ASSAULT -> new AssaultStormTrooper();
            case GRENADIER -> new GrenadierStormTrooper();
            case PILOT-> new PilotStormTrooper();
            default-> throw new IllegalArgumentException("section");
        };

        // Ensuite les stormtroopers vont être entrainé
        product.equip();
        product.train();
        product.passExam();


        // Puis retourné
        return product;
    }
}

Dans le code précédent, le code a plusieurs problèmes :

Solution : La fabrique simple

Théorie

2023-09-26_16-23-17_screenshot.png Il vaut tout de même utiliser un enum pour définir le nom du de la chose à produire

On extrait la partie de la création dans une classe Factory à part avec une méthode statique "create"

La "fabrique simple" permet de séparer la responsabilité d'utiliser un objet, de celle de la créer. La méthode de fabrique simple peut aussi être appellée "méthode de fabrique statique polymorphe".

Exemple

public class StormtrooperFactory {
    // Cette méthode va créer les stormtroopers
    // MilitarySection est une enum permettant de distinguer les différents types
    public static Stormtrooper create(MilitarySection section) {
        // Un Stormtrooper du bon type est généré en fonction de la MilitarySection
        return switch(section) {
            case ASSAULT -> new AssaultStormTrooper();
            case GRENADIER -> new GrenadierStormTrooper();
            case PILOT-> new PilotStormTrooper();
            default-> throw new IllegalArgumentException("section");
        };
    }
}

Anti-pattern : La méthode de fabrique

Théorie

2023-09-26_16-52-46_screenshot.png

Quand on a vraiment beaucoup de produits et que ça devient un bordel de tout avoir au même endroit, on peut utiliser de patron de conception pour séparer les choses dans plusieurs classes.

Dans ce patron de conception, on a donc plusieurs sous-fabriques qui héritent d'une classe fabrique. La classe va donc créer des Produits et chaque produit concret va implémenter la classe Produit.

Ce patron est également caca, il faut donc éviter de l'utiliser car il y abeaucoup trop de relations d'héritages et cela va créer beaucoup trop de classes.

Exemple

Pour donner un exemple de son utilisation et du bordel qu'il cause, imaginons que l'on aie des StormTroopers Wookie en plus des humains. Selon ce pattern on devrait créer des sous fabriques héritant d'une fabrique principale abstraite. Ce qui donnerait ceci :

2023-09-26_17-30-03_screenshot.png

Anti-pattern : Fabrique abstraite

Théorie

2023-09-26_17-12-28_screenshot.png

La fabrique abstraite est utilisée lorsqu'on a besoin de créer des familles d'objets sans préciser leurs classes concrete. Dans cette fabrique… tout est trop compliqué avec beaucoup trop de relations d'héritages.

Cette fabrique a tous les désavantages du précédent, en pire. Ne surtout pas l'utiliser. Encore une fois, ce patron favorise l'héritage à la composition.

Exemple

Vous ne voulez pas savoir…

Le pont

Théorie

Bien que les méthodes de fabrique (en français simplement appellée "Fabrique") et la fabrique abstraite sont listé comme des patterns dans le site Refactoging Guru, ils sont en vérité plus des anti-patterns (comme toute chose qui utilise l'héritage d'ailleurs).

Le problème de l'exemple avec la méthode de fabrique, est que l'espèce et la spécialisation du Stormtrooper étaient mélangées (par exemple dans une seule classe "WookieAssaultStormTrooper", "HumanAssaultStormTrooper" ou encore "WookieGrenadierStormTrooper") et ce alors que leur spécialisation et leur espèce sont deux choses complètements distinctes.

C'est là que le patron de conception du Pont entre en jeu, il sert à diviser ces grosses classes, permettant donc les espèces et les spécialisations d'évoluer indépendamment.

2023-09-26_17-26-14_screenshot.png

Ce diagramme donne met Refined Abstraction comme héritant de la classe Abstraction mais c'est une meilleure idée d'éviter l'héritage en transformant l'Abstraction en interface à implémenter

Exemple

Pour reprendre notre exemple de départ, à la place d'avoir un WookieAssaultStormTrooper on va avoir un AssaultStormtrooper qui a comme attribut "espèce" "Wookie". On a donc séparé les espèces et les sépcialisations en deux ensembles de classes distinctes (on a donc séparé les préoccupations et favorisé la composition à l'héritage).

2023-09-26_17-21-15_screenshot.png

Résumé

En résumé les deux patrons de conceptions à retenir ici sont ceux de la fabrique simple et du Pont.

Si on utilise correctement le pont on ne devrait jamais avoir besoin d'autre chose que de la fabrique simple pour créer les objets.

Façade

Le patron de la façade permet d'avoir un accès simplifié à un ensemble complexe de classes.

2023-10-03_22-23-13_screenshot.png

Voici à quoi ressemble le patron de la façade :

2023-10-03_22-24-05_screenshot.png

Le fonctoinnement de la facade est que l'on crée une classe facade qui collabore avec les éléments du système pour fournir une interface simple au client. Cependant si la facade devient elle même trop imposante, on peut utiliser faire communiquer une façade avec une autre façade.

Les façades permettent ainsi d'éviter les God objects et les God functions (les objets ou fonctions qui font trop de choses et connaissent trop de classes).

Cohésion et couplage

2023-10-03_22-42-34_screenshot.png

Le patron de la façade a également l'avantage d'augmenter la cohésion, c'est à dire d'augmenter le degré d'interconnexion des membres du système du quel la facade fait partie. Une cohésion forte indique que le type représente un concept clair et réutilisable.

Cependant si le couplage est trop élevé, on arrive dans un God Object (précisément ce que l'on souhaite éviter), c'est pour cela qu'il vaut mieux créer plusieurs façades si une façade devient un peu trop omnisciente.

Adaptateur

Le patron de l'adaptateur sert à faire collaborer des objets qui ont une interface incompatible.

2023-10-03_22-31-41_screenshot.png

Le fonctionnement est que l'on crée des classes adaptateurs entre une interface et un service.

Voici un exemple :

2023-10-03_22-33-04_screenshot.png

Dans cet exemple, l'Examinator est notre client, il utilise l'interface "ExamResultRepository". Les classes CsvExamResultRepository et SqlExamResultRepository sont les adaptateurs qui implémente l'interface.

Ces adaptateurs vont ensuite utiliser les services appropriés, dans le cas présent, les fonctions de java.nio et java.sql. Cela permet ainsi de séparer les préoccupations, de programmer avec des interfaces et d'éviter l'héritage.

Cependant ce patron a le désavantage que si il est utilisé de manière appropriée il peut complexifier le code, il faut donc d'abord se demander si il n'est pas plus simple de changer directement les services pour les faire utiliser une interface commune, plus-tôt que de créer des adaptateurs (voir Conception et découverte du patron Strategie. Dans le cas de l'exemple, les adaptateurs sont une bonne idée car on ne peut pas modifier les services qui font simplement partie du JDK.

MVP (Modèle Vue Présentateur)

Patron architectural

Un patron architectural est une solution générale et réutilisable à un problème architectural, comme les patrons de conceptions mais ont une portée plus large. Dans le cas de celui que l'on va voir ici, on remarquera qu'il est lui même composé des patrons Façade et Adaptateurs vus précédemment.

MVP

Voici ce que l'on obtient quand on combine les deux derniers patterns (façade et adaptateurs) :

2023-10-03_22-53-13_screenshot.png

Ici les classes *ExaminatorView et *ResultRepository sont des adaptateurs, tandis que les classes de java sont les services.

Mais on peut aussi y voir la façade car la classe Examinator agit comme une façade pour le reste du système.

En vérité, cette structure correspond au patron architectural "MVP" (Modèle Vue Présentateur)

2023-10-03_22-56-00_screenshot.png

Dans l'exemple précédent :

2023-10-03_22-59-21_screenshot.png

Caractéristiques du MVP

Les chaines de responsabilités

La chaine de responsabilité est un moyen de faire beaucoup de traitement sur un même objet. Cela peut être un très bon moyen de gérer une cascade de conditions if dans un code. Ainsi chaque bloc de if est séparé et sont liés entre eux. Cela permet aussi d'isoler ces différentes vérifications dans des fichiers séparés.

2023-10-03_19-50-33_screenshot.png

A noter que ce schéma utilise l'héritage mais qu'il faut toujours préférer la composition à l'héritage, il vaut donc mieux simplement avoir des ConcreteHandlers qui implémentent tous Handler.

Aussi pour simplifier l'écriture on peut simplement mettre le setNext dans le constructeur du Handler (ce qui permet de rendre la chaine plus propre par après)

Exemple

Code avant

Voici l'horrible code à réorganiser :

package com.gildedrose;

class GildedRose {
    Item[] items;

    public GildedRose(Item[] items) {
        this.items = items;
    }

    public void updateQuality() {
        for (int i = 0; i < items.length; i++) {
            if (!items[i].name.equals("Aged Brie")
                    && !items[i].name.equals("Backstage passes to a TAFKAL80ETC concert")) {
                if (items[i].quality > 0) {
                    if (!items[i].name.equals("Sulfuras, Hand of Ragnaros")) {
                        items[i].quality = items[i].quality - 1;
                    }
                }
            } else {
                if (items[i].quality < 50) {
                    items[i].quality = items[i].quality + 1;

                    if (items[i].name.equals("Backstage passes to a TAFKAL80ETC concert")) {
                        if (items[i].sellIn < 11) {
                            if (items[i].quality < 50) {
                                items[i].quality = items[i].quality + 1;
                            }
                        }

                        if (items[i].sellIn < 6) {
                            if (items[i].quality < 50) {
                                items[i].quality = items[i].quality + 1;
                            }
                        }
                    }
                }
            }

            if (!items[i].name.equals("Sulfuras, Hand of Ragnaros")) {
                items[i].sellIn = items[i].sellIn - 1;
            }

            if (items[i].sellIn < 0) {
                if (!items[i].name.equals("Aged Brie")) {
                    if (!items[i].name.equals("Backstage passes to a TAFKAL80ETC concert")) {
                        if (items[i].quality > 0) {
                            if (!items[i].name.equals("Sulfuras, Hand of Ragnaros")) {
                                items[i].quality = items[i].quality - 1;
                            }
                        }
                    } else {
                        items[i].quality = items[i].quality - items[i].quality;
                    }
                } else {
                    if (items[i].quality < 50) {
                        items[i].quality = items[i].quality + 1;
                    }
                }
            }
        }
    }
}

Code après

Et voici ce que l'on obient en utilisant le patron de la chaine de reponsabilités.

package com.gildedrose;

import com.gildedrose.handlers.*;

class GildedRose {
    Item[] items;

    public GildedRose(Item[] items) {
        this.items = items;
    }

    public void updateQuality() {
        // On enchaine les handlers
        // Ainsi Sulfuras >> AgedBrie >> BackStage >> Default
        Handler handlers = new Sulfuras(new AgedBrie(new Backstage(new Default())));

        for (int i = 0; i < items.length; i++)
            handlers.update(items[i]);
    }
}
package com.gildedrose.handlers;

import com.gildedrose.Item;

public interface Handler {
    void update(Item item);
}
package com.gildedrose.handlers;

import com.gildedrose.Item;

public class AgedBrie implements Handler {
    private Handler next = null;

    public AgedBrie(Handler next) {
        this.next = next;
    }

    @Override
    public void update(Item item) {
        if (item.name.equals("Aged Brie")) {
            item.sellIn--;
            if (item.quality < 50)
                item.quality++;
            if (item.sellIn < 0 && item.quality < 50)
                item.quality++;
        }

        else if (next != null)
            next.update(item);
    }

}

public class Backstage implements Handler {
    private Handler next = null;

    public Backstage(Handler next) {
        this.next = next;
    }

    @Override
    public void update(Item item) {
        if (item.name.equals("Backstage passes to a TAFKAL80ETC concert")) {
            if (item.quality < 50)
                item.quality++;
            if (item.sellIn < 11 && item.quality < 50)
                item.quality++;
            if (item.sellIn < 6 && item.quality < 50)
                item.quality++;

            // I have no idea what this shit does
            if (item.sellIn < 0)
                item.quality = item.quality - item.quality;
        } else if (next != null) {
            next.update(item);
        }
    }

}

public class Default implements Handler {
    @Override
    public void update(Item item) {
        if (item.quality > 0) {
            item.quality--;
            item.sellIn--;
            if (item.sellIn < 0)
                item.quality--;
        }
    }
}

public class Sulfuras implements Handler {
    private Handler next = null;

    public Sulfuras(Handler next) {
        this.next = next;
    }

    @Override
    public void update(Item item) {
        if (item.name.equals("Sulfuras, Hand of Ragnaros"))
            return;
        if (next != null)
            next.update(item);
    }
}