C'est quoi un Design Pattern dans le développement d'applications ?

C'est quoi un Design Pattern dans le développement d'applications ?

·

14 min read

Un Design Pattern, ou "patron de conception", est une solution réutilisable à un problème récurrent dans un contexte donné. Ce ne sont pas des lignes de code prêtes à copier-coller, mais plutôt des concepts ou des guides pour structurer ton code de manière plus efficace. Ils ont été popularisés grâce au célèbre livre Design Patterns: Elements of Reusable Object-Oriented Software, écrit par le "Gang of Four" (GoF). Ces idées sont aujourd'hui incontournables dans le développement logiciel.

Pourquoi apprendre les Design Patterns ?

Savoir utiliser les Design Patterns, c'est comme avoir une boîte à outils bien organisée. Ils permettent de :

  • Résoudre des problèmes complexes avec des solutions éprouvées.

  • Rendre ton code plus clair, modulaire et facile à maintenir.

  • Améliorer la collaboration avec d'autres développeurs : si tout le monde connaît les mêmes patterns, la communication est plus fluide.

  • Éviter de réinventer la roue : pourquoi créer une nouvelle solution alors que des experts ont déjà proposé quelque chose qui fonctionne ?

Imagine que tu construis une maison. Plutôt que d’inventer une nouvelle manière de poser des fondations, tu t’appuies sur des méthodes déjà testées et fiables. Les Design Patterns jouent exactement ce rôle dans ton code.

Les principes de conception (Design Principles)

Avant de plonger dans les Design Patterns, il est crucial de comprendre les principes de conception. Ces principes sont comme les fondations d'une maison : ils garantissent que ton code sera robuste et facile à faire évoluer. Quelques exemples :

  • Encapsulation : Limiter l'accès direct aux données pour protéger leur intégrité.

  • Abstraction : Cacher les détails complexes et ne montrer que l'essentiel.

  • Couplage faible et cohésion forte : Assurer que les modules de ton application sont indépendants les uns des autres et que chaque module se concentre sur une tâche spécifique.

Les principes SOLID

Les principes SOLID ont été introduits par Robert C. Martin dans son article de 2000 intitulé « Design Principles and Design Patterns ». Ces concepts ont ensuite été développés par Michael Feathers, qui nous a présenté l'acronyme SOLID. Ces cinq principes ont révolutionné le monde de la programmation orientée objet, en changeant la façon dont nous écrivons les logiciels.

Qu'est-ce que la norme SOLID et comment nous aide-t-elle à écrire un meilleur code ?

Les principes de conception de Martin et Feathers nous incitent à concevoir des logiciels plus faciles à maintenir, plus compréhensibles et plus adaptables. Ainsi, avec la progression de nos applications, nous sommes en mesure de diminuer leur complexité et d'éviter beaucoup de maux de tête par la suite !

Les principes SOLID sont une extension des principes de conception. Ils permettent de construire des systèmes évolutifs et faciles à maintenir. Les cinq concepts suivants constituent nos principes SOLID:

  1. Single Responsibility Principle : Une classe ne doit avoir qu'une seule responsabilité.

Débutons par le principe de responsabilité unique. On peut anticiper que ce principe impose qu'une seule responsabilité soit attribuée à une classe. De plus, elle ne doit justifier qu'une unique intention de changer.

Comment ce principe nous aide-t-il à construire de meilleurs logiciels ? Voyons quelques-uns de ses avantages :

  • Tests - Une classe avec une seule responsabilité aura beaucoup moins de cas de test.

  • Couplage réduit - Moins de fonctionnalités dans une seule classe auront moins de dépendances.

  • Organisation - Des classes plus petites et bien organisées sont plus faciles à rechercher que des classes monolithiques.

Explorons ce concept à l'aide d'un rapide exemple de code:

🛑 Mauvaise approche :

Une seule classe gère à la fois les calculs des intérêts, le formatage des rapports et la sauvegarde des données.

class LoanService {
    public double calculateInterest(double principal, double rate, int years) {
        return principal * rate * years / 100;
    }

    public String generateReport(double principal, double interest) {
        return "Loan Principal: " + principal + ", Interest: " + interest;
    }

    public void saveToDatabase(String report) {
        System.out.println("Saving report to database: " + report);
    }
}

✅ Solution SOLID :

Diviser les responsabilités en plusieurs classes distinctes.

class InterestCalculator {
    public double calculateInterest(double principal, double rate, int years) {
        return principal * rate * years / 100;
    }
}

class ReportGenerator {
    public String generateReport(double principal, double interest) {
        return "Loan Principal: " + principal + ", Interest: " + interest;
    }
}

class DatabaseService {
    public void saveToDatabase(String report) {
        System.out.println("Saving report to database: " + report);
    }
}
  1. Open/Closed Principle : Les classes doivent être ouvertes à l'extension mais fermées à la modification.

Le O de SOLID, connu sous le nom de principe ouvert-fermé(Open/Close). En termes simples, les classes doivent être ouvertes à l'extension mais fermées à la modification. Cela nous empêche de modifier le code déjà en place et de créer d'éventuels bogues potentiels dans une application qui est stable.

La seule exception à la règle est la correction de bogues dans le code existant.

Explorons ce concept à l'aide d'un rapide exemple de code:

🛑 Mauvaise approche :

Ajouter un calcul de frais pour différents types de comptes directement dans la classe, en modifiant le code existant.

class FeeCalculator {
    public double calculateFee(String accountType, double amount) {
        if (accountType.equals("Savings")) {
            return amount * 0.02; // 2% pour les comptes d'épargne
        } else if (accountType.equals("Current")) {
            return amount * 0.01; // 1% pour les comptes courants
        } else {
            throw new IllegalArgumentException("Type de compte inconnu");
        }
    }
}

✅ Solution SOLID :

Utiliser le polymorphisme pour étendre les types de frais sans modifier le code existant.

interface FeeCalculator {
    double calculateFee(double amount);
}

class SavingsAccountFeeCalculator implements FeeCalculator {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.02;
    }
}

class CurrentAccountFeeCalculator implements FeeCalculator {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.01;
    }
}

// Exemple d'utilisation
FeeCalculator calculator = new SavingsAccountFeeCalculator();
System.out.println("Fee: " + calculator.calculateFee(1000)); // Affiche 20.0
  1. Liskov Substitution Principle : Une classe dérivée doit pouvoir remplacer sa classe mère sans casser le code

Le prochain principe sur notre liste est la substitution de Liskov, qui est sans doute le plus complexe des cinq principes. En termes simples, si la classe A est un sous-type de la classe B, nous devrions être en mesure de remplacer B par A sans perturber le comportement de notre programme.

Explorons ce concept à l'aide d'un rapide exemple de code:

🛑 Mauvaise approche :

Une classe dérivée modifie le comportement attendu, ce qui entraîne des résultats incohérents.

class Account {
    public double getBalance() {
        return 1000;
    }
}

class FixedDepositAccount extends Account {
    @Override
    public double getBalance() {
        throw new UnsupportedOperationException("Balance not available for Fixed Deposit accounts");
    }
}

✅ Solution SOLID :

Respecter le comportement attendu en utilisant une hiérarchie adaptée.

abstract class Account {
    public abstract double getBalance();
}

class SavingsAccount extends Account {
    private double balance = 1000;

    @Override
    public double getBalance() {
        return balance;
    }
}

class FixedDepositAccount extends Account {
    private double balance = 5000;

    @Override
    public double getBalance() {
        return balance;
    }
}
  1. Interface Segregation Principle : Ne force pas les classes à implémenter des méthodes dont elles n'ont pas besoin.

Le I de SOLID signifie ségrégation des interfaces, ce qui signifie simplement que les interfaces les plus importantes doivent être divisées en interfaces plus petites. Ce faisant, nous pouvons nous assurer que les classes d'implémentation n'ont à se préoccuper que des méthodes qui les intéressent.

Explorons ce concept à l'aide d'un rapide exemple de code:

🛑 Mauvaise approche :

Une grosse interface obligeant toutes les classes à implémenter des méthodes inutiles.

interface BankOperations {
    void deposit(double amount);
    void withdraw(double amount);
    void calculateInterest();
}

class CurrentAccount implements BankOperations {
    @Override
    public void deposit(double amount) {
        System.out.println("Deposit: " + amount);
    }

    @Override
    public void withdraw(double amount) {
        System.out.println("Withdraw: " + amount);
    }

    @Override
    public void calculateInterest() {
        // Non applicable pour les comptes courants
        throw new UnsupportedOperationException();
    }
}

✅ Solution SOLID :

Diviser l'interface en plusieurs petites interfaces.

interface Depositable {
    void deposit(double amount);
}

interface Withdrawable {
    void withdraw(double amount);
}

interface InterestCalculable {
    void calculateInterest();
}

class CurrentAccount implements Depositable, Withdrawable {
    @Override
    public void deposit(double amount) {
        System.out.println("Deposit: " + amount);
    }

    @Override
    public void withdraw(double amount) {
        System.out.println("Withdraw: " + amount);
    }
}
  1. Dependency Inversion Principle : Dépend des abstractions, pas des implémentations concrètes.

Le principe de l'inversion des dépendances fait référence au découplage des modules logiciels. Ainsi, au lieu que les modules de haut niveau dépendent des modules de bas niveau, les deux dépendront des abstractions.

Explorons ce concept à l'aide d'un rapide exemple de code:

🛑 Mauvaise approche :

Une classe de service dépend directement d'une implémentation spécifique.

class PaymentService {
    private MySQLDatabase database = new MySQLDatabase();

    public void processPayment(String paymentDetails) {
        database.save(paymentDetails);
    }
}

class MySQLDatabase {
    public void save(String data) {
        System.out.println("Saving to MySQL: " + data);
    }
}

✅ Solution SOLID :

Utiliser une abstraction pour découpler les dépendances.

interface Database {
    void save(String data);
}

class MySQLDatabase implements Database {
    @Override
    public void save(String data) {
        System.out.println("Saving to MySQL: " + data);
    }
}

class MongoDBDatabase implements Database {
    @Override
    public void save(String data) {
        System.out.println("Saving to MongoDB: " + data);
    }
}

class PaymentService {
    private Database database;

    public PaymentService(Database database) {
        this.database = database;
    }

    public void processPayment(String paymentDetails) {
        database.save(paymentDetails);
    }
}

// Exemple d'utilisation
Database db = new MySQLDatabase();
PaymentService service = new PaymentService(db);
service.processPayment("Payment of $100");

Les différentes catégories de Design Patterns

Les Design Patterns sont souvent classés en trois grandes catégories :

Les Patterns Créationnels : Ces patterns se concentrent sur la manière de créer des objets.

Les patterns créationnels simplifient et structurent la création d'objets, en veillant à ce qu'ils soient générés de manière adaptée aux besoins spécifiques.

  1. Singleton

    Le pattern Singleton garantit qu'une classe n'a qu'une seule instance et fournit un point d'accès global à celle-ci.

    Utilisation : Gestion de connexions de bases de données, gestionnaires de configuration.

  1. Factory Method

    Le pattern Factory Method définit une interface pour créer un objet, mais laisse les sous-classes décider de l'instance concrète à utiliser.

    Utilisation : Création d'objets avec une logique complexe ou différée selon le contexte.

  1. Prototype

    Le pattern Prototype permet de cloner des objets en copiant leur structure plutôt qu'en les recréant.

    Utilisation : Création rapide d'objets similaires avec des états différents.

  1. Abstract Factory

    Le pattern Abstract Factory Fournit une interface pour créer des familles d'objets apparentés ou dépendants sans spécifier leurs classes concrètes.

    Utilisation : Systèmes interopérables avec plusieurs plateformes ou produits (ex. : interface graphique pour différents OS).

  1. Builder

    Le pattern Builder sépare la construction d'un objet complexe de sa représentation pour permettre différentes configurations.

    Utilisation : Création d'objets avec beaucoup de paramètres ou d'options, comme des rapports ou des fichiers complexes.

Exemple :

Singleton Pattern : Un des patterns les plus populaires, parfait pour montrer comment garantir qu'une classe n'a qu'une seule instance.

class Singleton {
    private static Singleton instance;

    // Constructeur privé pour empêcher l'instanciation directe
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

// Exemple d'utilisation
public class Main {
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();

        System.out.println(s1 == s2); // Affiche "true", une seule instance est créée
    }
}

Les Patterns Sructuraux : Ils définissent comment structurer les objets et les classes pour former de grandes structures.

Les patterns structuraux facilitent la composition et l'organisation des classes ou objets pour former des structures plus grandes et flexibles.

  1. Adapter

    Le pattern Adapter convertit l'interface d'une classe en une autre interface attendue par le client.

    Utilisation : Intégrer des systèmes ou des bibliothèques incompatibles.

  1. Bridge

    Le pattern Bridge sépare une abstraction de son implémentation pour qu'elles puissent évoluer indépendamment.

    Utilisation : Gestion des systèmes où abstraction et implémentation évoluent souvent.

  1. Composite

    Le pattern Composite Permet de composer des objets en structures arborescentes pour traiter les objets composés comme des objets simples.

    Utilisation : Gestion des éléments hiérarchiques (ex. : fichiers et dossiers).

  1. Decorateur

    Le pattern Decorateur permet d’ajouter dynamiquement des responsabilités à un objet sans modifier sa classe.

    Utilisation : Ajouter des fonctionnalités à un objet existant (ex. : composants d'une interface utilisateur).

  1. Facade

    Le pattern Facade fournit une interface simplifiée à un ensemble complexe de classes ou de sous-systèmes.

    Utilisation : Masquer la complexité d'un système sous-jacent (ex. : API).

  2. Flyweight

    Le pattern Flyweight réduit l'utilisation mémoire en partageant des objets similaires.

    Utilisation : Gestion d'objets répétitifs à grande échelle (ex. : caractères dans un éditeur de texte).

  1. Proxy

    Le pattern Proxy fournit un substitut ou un intermédiaire pour contrôler l'accès à un objet.

    Utilisation : Gestion des ressources distantes ou coûteuses (ex. : cache, authentification).

Exemple :

Decorator Pattern (Structurel) Ce pattern illustre comment ajouter dynamiquement des fonctionnalités à une classe sans modifier son code.

interface Component {
    void operation();
}

class ConcreteComponent implements Component {
    @Override
    public void operation() {
        System.out.print("Base");
    }
}

abstract class Decorator implements Component {
    protected Component component;

    public Decorator(Component component) {
        this.component = component;
    }
}

class ConcreteDecoratorA extends Decorator {
    public ConcreteDecoratorA(Component component) {
        super(component);
    }

    @Override
    public void operation() {
        component.operation();
        System.out.print(" + Feature A");
    }
}

class ConcreteDecoratorB extends Decorator {
    public ConcreteDecoratorB(Component component) {
        super(component);
    }

    @Override
    public void operation() {
        component.operation();
        System.out.print(" + Feature B");
    }
}

// Exemple d'utilisation
public class Main {
    public static void main(String[] args) {
        Component base = new ConcreteComponent();
        Component decoratedA = new ConcreteDecoratorA(base);
        Component decoratedB = new ConcreteDecoratorB(decoratedA);

        decoratedB.operation();
        // Affiche : "Base + Feature A + Feature B"
    }
}

Les Patterns Comportementaux : Ils se focalisent sur les interactions et la communication entre objets:

Les patterns comportementaux se concentrent sur les interactions entre objets pour résoudre des problèmes complexes liés au comportement du système.

  1. Chain of Responsibility

    Le pattern Chain of Responsibility permet à plusieurs objets de gérer une demande, sans que l'émetteur sache lequel la traitera.

    Utilisation : Systèmes de gestion de requêtes ou de gestion des événements.

  2. Command

    Le pattern Command encapsule une requête en un objet, permettant ainsi de paramétrer les actions et d'annuler/réexécuter des commandes.

    Utilisation : Boutons d'annulation/répétition dans les logiciels.

  1. Interprete

    Le pattern Interprete fournit un moyen d'évaluer ou de traiter des expressions dans un langage spécifique.

    Utilisation : Analyseurs syntaxiques ou moteurs d'expressions régulières.

  1. Iterator

    Le pattern Iterator fournit un moyen de parcourir une collection sans exposer sa structure interne.

    Utilisation : Parcours d'objets dans des collections complexes.

  1. Mediator

    Le pattern Mediator permet de définir un objet qui centralise la communication entre plusieurs objets.

    Utilisation : Simplification des dépendances dans un système complexe.

  1. Memento

    Le pattern Memento permet de capturer l'état interne d'un objet pour permettre de le restaurer ultérieurement sans violer l'encapsulation.

    Utilisation : Fonctionnalités d'annulation/restauration dans les applications.

  1. Observer

    Le pattern Observer permet de définir une relation de dépendance où un objet notifie automatiquement ses observateurs en cas de changement d'état.

    Utilisation : Systèmes de notification ou mise à jour automatique (ex. : modèles Vue-MVC).

  1. State

    Le pattern State permet à un objet de changer son comportement en fonction de son état interne.

    Utilisation : Gestion des machines à états.

  1. Strategy

    Le pattern Strategy définit une famille d'algorithmes interchangeables dynamiquement selon le contexte.

    Utilisation : Choix d'algorithmes à la volée (ex. : tri, cryptage)

  1. Template Method

    Le pattern Template Methode définit la structure générale d'un algorithme tout en laissant des étapes spécifiques aux sous-classes.

    Utilisation : Standardisation de processus avec des variations mineures.

  1. Visitor

    Le pattern Visitor permet d'ajouter des fonctionnalités à une hiérarchie de classes sans les modifier.

    Utilisation : Traitement d'objets dans des structures complexes (ex. : arbres syntaxiques).

Exemple :

Observer Pattern (Comportemental) Ce pattern est parfait pour illustrer comment notifier plusieurs objets lorsqu'un état

import java.util.ArrayList;
import java.util.List;

// Sujet (Observable)
class Subject {
    private List<Observer> observers = new ArrayList<>();
    private String state;

    public void attach(Observer observer) {
        observers.add(observer);
    }

    public void setState(String state) {
        this.state = state;
        notifyObservers();
    }

    private void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(state);
        }
    }
}

// Observateur
interface Observer {
    void update(String state);
}

class ConcreteObserver implements Observer {
    private String name;

    public ConcreteObserver(String name) {
        this.name = name;
    }

    @Override
    public void update(String state) {
        System.out.println(name + " received update: " + state);
    }
}

// Exemple d'utilisation
public class Main {
    public static void main(String[] args) {
        Subject subject = new Subject();

        Observer observer1 = new ConcreteObserver("Observer 1");
        Observer observer2 = new ConcreteObserver("Observer 2");

        subject.attach(observer1);
        subject.attach(observer2);

        subject.setState("New State");
        // Affiche :
        // Observer 1 received update: New State
        // Observer 2 received update: New State
    }
}

Quelques conseils pour apprendre les Design Patterns :

  • Commence par des patterns simples comme Singleton ou Factory.

  • Applique-les à des projets concrets, même des petits exercices ou des applications personnelles.

  • Regarde du code open source et identifie les patterns utilisés.

  • Ne pas tomber dans le piège de les appliquer partout : utilise-les uniquement là où ils apportent une réelle valeur ajoutée.

Conclusion:

Les Design Patterns et les principes de conception sont des outils essentiels pour tout développeur qui souhaite écrire du code propre, maintenable et professionnel. Mais garde en tête qu’ils ne sont pas une fin en soi : ils doivent être utilisés avec discernement. Apprendre les patterns, c’est aussi apprendre à reconnaître quand les appliquer et quand s’en passer. Alors, prêt à élever ton code à un nouveau niveau ?