Design Patterns

Introduction

Cet article explore l'application pratique des Design Patterns, ces modèles de conception éprouvés, dans le contexte spécifique de Python. Fort de sa syntaxe claire et de sa grande flexibilité, Python offre un terrain particulièrement fertile pour l'implémentation de ces motifs architecturaux. L'utilisation judicieuse des Design Patterns permet d'améliorer considérablement la lisibilité, la maintenabilité et la réutilisabilité du code, des atouts cruciaux pour tout projet Python digne de ce nom.

Plus qu'une simple collection de recettes, nous plongerons au cœur de la philosophie de chaque pattern, en analysant ses forces, ses faiblesses et les situations où il excelle. Chaque concept sera illustré par des exemples de code Python concrets, originaux et directement applicables. Prenons, par exemple, le cas où nous souhaitons gérer différents types de notifications (email, SMS, push) avec des configurations distinctes. Sans Design Pattern, on pourrait rapidement se retrouver avec un code complexe et difficile à étendre. Le pattern "Strategy" permettrait d'encapsuler chaque type de notification dans une stratégie distincte, facilitant l'ajout de nouveaux types et la modification des existants sans impacter le reste du système.

Pour illustrer ce propos, considérons un exemple simple avec le pattern "Factory" pour la création de véhicules :


# Factory Pattern Example
class Vehicle:
    def __init__(self, model):
        self.model = model

    def drive(self):
        pass # To be implemented by subclasses

class Car(Vehicle):
    def drive(self):
        return "Driving a car"

class Motorcycle(Vehicle):
    def drive(self):
        return "Riding a motorcycle"

class VehicleFactory:
    def create_vehicle(self, type):
        if type == "car":
            return Car("Sedan")
        elif type == "motorcycle":
            return Motorcycle("Sportbike")
        else:
            return None

# Usage
factory = VehicleFactory()
car = factory.create_vehicle("car")
print(car.drive())  # Output: Driving a car
motorcycle = factory.create_vehicle("motorcycle")
print(motorcycle.drive()) # Output: Riding a motorcycle

1. Design Patterns de Création en Python

Le pattern Singleton garantit qu'une classe ne possède qu'une seule instance et fournit un point d'accès global à celle-ci. Ce pattern est particulièrement utile pour gérer des ressources partagées, comme une connexion à une base de données, ou pour maintenir une configuration globale cohérente.


class Singleton:
    _instance = None  # Private attribute to hold the single instance

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
            # Initialization logic can be placed here
            # For example: cls._instance.setup(config)
        return cls._instance

    # You can add methods to the Singleton class
    def some_business_logic(self):
        print("Singleton is doing something!")

# Example usage:
s1 = Singleton()
s2 = Singleton()

print(s1 is s2)  # Output: True, both variables point to the same instance
s1.some_business_logic() # Output: Singleton is doing something!

Dans cet exemple, l'attribut privé _instance stocke l'instance unique de la classe Singleton. La méthode __new__ est surchargée pour contrôler la création de l'objet. Si aucune instance n'existe, elle en crée une et la stocke dans _instance. Les appels suivants à Singleton() retourneront simplement l'instance existante, assurant ainsi l'unicité.

Le pattern Abstract Factory propose une interface pour créer des familles d'objets liés ou dépendants sans spécifier leurs classes concrètes. Il permet de produire différents types d'objets selon la factory utilisée, offrant ainsi une grande flexibilité.


from abc import ABC, abstractmethod

# Abstract Products
class AbstractButton(ABC):
    @abstractmethod
    def render(self):
        pass

class AbstractCheckbox(ABC):
    @abstractmethod
    def render(self):
        pass

# Concrete Products
class WindowsButton(AbstractButton):
    def render(self):
        return "Rendering a Windows button"

class WindowsCheckbox(AbstractCheckbox):
    def render(self):
        return "Rendering a Windows checkbox"

class MacOSButton(AbstractButton):
    def render(self):
        return "Rendering a MacOS button"

class MacOSCheckbox(AbstractCheckbox):
    def render(self):
        return "Rendering a MacOS checkbox"

# Abstract Factory
class AbstractFactory(ABC):
    @abstractmethod
    def create_button(self):
        pass

    @abstractmethod
    def create_checkbox(self):
        pass

# Concrete Factories
class WindowsFactory(AbstractFactory):
    def create_button(self):
        return WindowsButton()

    def create_checkbox(self):
        return WindowsCheckbox()

class MacOSFactory(AbstractFactory):
    def create_button(self):
        return MacOSButton()

    def create_checkbox(self):
        return MacOSCheckbox()

# Client code
def create_ui(factory: AbstractFactory):
    button = factory.create_button()
    checkbox = factory.create_checkbox()
    return button.render(), checkbox.render()

# Example usage:
windows_ui = create_ui(WindowsFactory())
macos_ui = create_ui(MacOSFactory())

print(windows_ui) # Output: ('Rendering a Windows button', 'Rendering a Windows checkbox')
print(macos_ui)   # Output: ('Rendering a MacOS button', 'Rendering a MacOS checkbox')

Dans cet exemple, AbstractButton et AbstractCheckbox définissent les interfaces abstraites pour les produits. WindowsButton, WindowsCheckbox, MacOSButton et MacOSCheckbox sont les implémentations concrètes de ces interfaces, spécifiques à chaque système d'exploitation. AbstractFactory définit l'interface pour la création des produits, tandis que WindowsFactory et MacOSFactory sont les usines concrètes qui implémentent cette interface, retournant les produits correspondants à chaque OS. Le code client utilise l'interface AbstractFactory pour créer des familles d'objets sans se soucier de leurs classes concrètes. Cela permet de changer facilement l'apparence de l'interface utilisateur en changeant simplement la factory utilisée.

Le pattern Builder permet de construire des objets complexes étape par étape. Il sépare la construction d'un objet de sa représentation, permettant ainsi de créer différentes représentations du même objet en utilisant le même processus de construction.


class Pizza:
    def __init__(self):
        self.dough = None
        self.sauce = None
        self.topping = None

    def __str__(self):
        return f"Pizza with dough: {self.dough}, sauce: {self.sauce}, topping: {self.topping}"

class PizzaBuilder:
    def __init__(self):
        self.pizza = Pizza()

    def add_dough(self, dough):
        self.pizza.dough = dough
        return self  # allows method chaining

    def add_sauce(self, sauce):
        self.pizza.sauce = sauce
        return self

    def add_topping(self, topping):
        self.pizza.topping = topping
        return self

    def build(self):
        return self.pizza

# Example usage:
builder = PizzaBuilder()
pizza = builder.add_dough("thin crust").add_sauce("tomato").add_topping("pepperoni").build()

print(pizza) # Output: Pizza with dough: thin crust, sauce: tomato, topping: pepperoni

Cet exemple illustre la construction d'une pizza en utilisant un PizzaBuilder. Chaque méthode add_... ajoute un ingrédient spécifique à la pizza et retourne l'instance du builder, ce qui permet d'enchaîner les appels de méthodes de manière fluide. La méthode build() retourne l'objet Pizza final, prêt à être consommé. Ce pattern est particulièrement utile lorsque la création d'un objet implique de nombreuses étapes ou configurations optionnelles.

1.1 Singleton Pattern: Assurer une Unique Instance

Le pattern Singleton garantit qu'une classe ne possède qu'une seule instance et fournit un point d'accès global à cette instance unique. Il est particulièrement utile pour contrôler l'accès à des ressources partagées, telles qu'une connexion à une base de données, un gestionnaire de logs ou un fichier de configuration.

Une implémentation simple du Singleton peut s'appuyer sur une variable de classe privée pour stocker l'instance et une méthode statique pour y accéder. Voici un exemple avec un gestionnaire de configuration:


class ConfigurationManager:
    _instance = None  # Private class attribute to hold the singleton instance

    def __init__(self):
        # Ensure that the class cannot be directly instantiated after the first instance
        if ConfigurationManager._instance is not None:
            raise Exception("This class is a singleton!")
        else:
            ConfigurationManager._instance = self
            self.config = {}  # Dictionary to store configuration parameters

    @staticmethod
    def get_instance():
        # Static method to provide access to the singleton instance
        if ConfigurationManager._instance is None:
            ConfigurationManager()  # Create the instance if it doesn't already exist
        return ConfigurationManager._instance

    def set_config(self, key, value):
        # Method to set a configuration parameter
        self.config[key] = value

    def get_config(self, key):
        # Method to retrieve a configuration parameter
        return self.config.get(key)

# Example usage of the ConfigurationManager singleton
config_manager = ConfigurationManager.get_instance()
config_manager.set_config("database_url", "localhost:5432")
print(config_manager.get_config("database_url"))

config_manager2 = ConfigurationManager.get_instance()
print(config_manager2.get_config("database_url")) # Accessing the same instance as config_manager
print(config_manager is config_manager2) # Output: True, proving that it's the same instance

Dans cet exemple, ConfigurationManager est implémenté comme un Singleton. La première fois que la méthode get_instance() est appelée, une instance est créée et stockée. Les appels suivants à get_instance() retournent simplement cette instance existante, assurant qu'une seule instance est utilisée.

Une autre approche pour implémenter le pattern Singleton en Python est d'utiliser un décorateur. Les décorateurs offrent une manière concise d'ajouter des fonctionnalités à des classes ou des fonctions.


def singleton(cls):
    # Decorator function to implement the Singleton pattern
    instances = {}  # Dictionary to store the singleton instance
    def get_instance(*args, **kwargs):
        # Check if an instance already exists
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)  # Create the instance if it doesn't exist
        return instances[cls]  # Return the stored instance
    return get_instance

@singleton
class Logger:
    # Example Logger class using the singleton decorator
    def __init__(self):
        self.logs = []  # List to store log messages

    def log(self, message):
        # Method to add a log message
        self.logs.append(message)
        print(f"Log: {message}")

# Example usage of the Logger singleton
logger1 = Logger()
logger1.log("First log message")

logger2 = Logger()  # This will return the same instance as logger1
logger2.log("Second log message")

print(logger1 is logger2) # Output: True, indicating that logger1 and logger2 are the same instance

Le décorateur singleton enveloppe la classe Logger, garantissant qu'une seule instance de Logger est créée. Les appels subséquents à Logger() renvoient la même instance.

Enfin, on peut également implémenter le pattern Singleton en Python en utilisant une métaclasse. Les métaclasses sont des classes qui créent des classes.


class Singleton(type):
    # Metaclass to implement the Singleton pattern
    _instances = {}  # Dictionary to store instances of the singleton classes

    def __call__(cls, *args, **kwargs):
        # Override the __call__ method to control instance creation
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)  # Create a new instance if it doesn't exist
        return cls._instances[cls]  # Return the existing instance

class DatabaseConnection(metaclass=Singleton):
    # Example DatabaseConnection class using the Singleton metaclass
    def __init__(self, database_url):
        self.database_url = database_url
        print(f"Connecting to database: {self.database_url}")

    def execute_query(self, query):
        print(f"Executing query: {query} on {self.database_url}")

# Example usage of the DatabaseConnection singleton
db_connection1 = DatabaseConnection("postgresql://user:password@host:port/database")
db_connection1.execute_query("SELECT * FROM users;")

db_connection2 = DatabaseConnection("another_url") # This will return the same instance as db_connection1
db_connection2.execute_query("SELECT * FROM products;")

print(db_connection1 is db_connection2) # Output: True, verifying that both variables point to the same instance

Dans cet exemple, la métaclasse Singleton intercepte la création d'instances de la classe DatabaseConnection. Elle garantit qu'une seule connexion à la base de données est établie, quel que soit le nombre d'appels à DatabaseConnection().

Le pattern Singleton offre une approche efficace pour gérer des ressources uniques et partagées. Les décorateurs et les métaclasses fournissent des moyens concis et idiomatiques de mettre en œuvre ce pattern en Python, offrant ainsi une flexibilité dans la conception de vos applications.

1.2 Factory Pattern: Création d'Objets Abstraits

Le Factory pattern est un patron de conception de création qui offre une interface pour créer des objets sans spécifier leurs classes concrètes. Il délègue l'instanciation des objets à une classe ou une fonction "factory". En Python, cela se traduit par un découplage fort : le code client n'a pas besoin de connaître les classes concrètes des objets qu'il manipule, ce qui simplifie la maintenance et favorise la flexibilité.

Une implémentation simple du Factory pattern en Python utilise une fonction factory. Imaginez un scénario où vous devez créer différents types de documents (PDF, Word, etc.) en fonction d'une configuration externe.


class PDFDocument:
    def __init__(self, content):
        self.content = content

    def render(self):
        return "Rendering PDF: " + self.content

class WordDocument:
    def __init__(self, content):
        self.content = content

    def render(self):
        return "Rendering Word document: " + self.content

def create_document(document_type, content):
    """
    Factory function to create document objects.
    Args:
        document_type (str): The type of document to create ("pdf" or "word").
        content (str): The content of the document.
    Returns:
        PDFDocument or WordDocument: An instance of the appropriate document class.
    Raises:
        ValueError: If the document type is invalid.
    """
    if document_type == "pdf":
        return PDFDocument(content)
    elif document_type == "word":
        return WordDocument(content)
    else:
        raise ValueError("Invalid document type")

# Usage
pdf_doc = create_document("pdf", "This is a PDF document.")
print(pdf_doc.render())

word_doc = create_document("word", "This is a Word document.")
print(word_doc.render())

Dans cet exemple, la fonction create_document joue le rôle de la factory. Elle prend en paramètre un type de document et un contenu, puis renvoie une instance de la classe de document correspondante. Le code client interagit uniquement avec la fonction create_document et n'a aucune connaissance directe des classes PDFDocument ou WordDocument.

Une approche alternative consiste à utiliser une classe factory. Cette méthode est particulièrement utile lorsque la logique de création des objets est plus complexe et nécessite d'être encapsulée.


class DocumentFactory:
    def create_document(self, document_type, content):
        """
        Factory class to create document objects.
        Args:
            document_type (str): The type of document to create ("pdf" or "word").
            content (str): The content of the document.
        Returns:
            PDFDocument or WordDocument: An instance of the appropriate document class.
        Raises:
            ValueError: If the document type is invalid.
        """
        if document_type == "pdf":
            return PDFDocument(content)
        elif document_type == "word":
            return WordDocument(content)
        else:
            raise ValueError("Invalid document type")

# Usage
factory = DocumentFactory()
pdf_doc = factory.create_document("pdf", "This is a PDF document.")
print(pdf_doc.render())

word_doc = factory.create_document("word", "This is a Word document.")
print(word_doc.render())

L'utilisation d'une classe factory offre plusieurs avantages. Elle permet d'ajouter facilement d'autres méthodes ou attributs à la factory, par exemple, pour gérer la configuration, la journalisation ou la mise en cache des objets créés. De plus, on peut envisager d'utiliser une classe abstraite comme interface pour la factory, avec des classes concrètes implémentant différentes stratégies de création. Cela renforce l'application du principe d'inversion de dépendance, ce qui améliore encore la flexibilité et la testabilité du code.

Il est également possible d'étendre cette implémentation en utilisant un dictionnaire pour mapper les types de documents à leurs classes respectives, ce qui rend l'ajout de nouveaux types de documents plus simple et moins sujet aux erreurs.


class PDFDocument:
    def __init__(self, content):
        self.content = content

    def render(self):
        return "Rendering PDF: " + self.content

class WordDocument:
    def __init__(self, content):
        self.content = content

class DocumentFactory:
    def __init__(self):
        self._document_types = {
            "pdf": PDFDocument,
            "word": WordDocument
        }

    def create_document(self, document_type, content):
        """
        Factory class to create document objects using a dictionary.
        Args:
            document_type (str): The type of document to create ("pdf" or "word").
            content (str): The content of the document.
        Returns:
            PDFDocument or WordDocument: An instance of the appropriate document class.
        Raises:
            ValueError: If the document type is invalid.
        """
        document_class = self._document_types.get(document_type)
        if document_class:
            return document_class(content)
        else:
            raise ValueError("Invalid document type")

# Usage
factory = DocumentFactory()
pdf_doc = factory.create_document("pdf", "This is a PDF document.")
print(pdf_doc.render())

word_doc = factory.create_document("word", "This is a Word document.")
print(word_doc.render())

En conclusion, le Factory pattern est une solution élégante pour la création d'objets en Python, contribuant à un code plus propre, flexible et maintenable. Qu'il s'agisse d'une simple fonction ou d'une classe dédiée, il permet de séparer la création des objets de leur utilisation, ce qui facilite l'évolution du code et améliore sa résilience face aux changements.

1.3 Builder Pattern: Construction d'Objets Complexes

L'anti-pattern du 'telescoping constructor' se manifeste lorsqu'une classe propose une multitude de constructeurs, chacun acceptant un nombre croissant de paramètres optionnels. Cette approche rend le code difficile à comprendre et à maintenir. Le Builder pattern offre une solution élégante à ce problème en fournissant une interface claire et intuitive pour configurer un objet étape par étape.

Prenons l'exemple de la création d'un document. Un document peut contenir divers éléments optionnels, tels qu'un titre, un auteur, une date de création, des sections avec du contenu, et des images. Sans le Builder pattern, on pourrait se retrouver avec un constructeur acceptant un nombre important de paramètres, ce qui compliquerait considérablement son utilisation et sa lisibilité.

Voici une implémentation du Builder pattern en Python pour la construction de documents:


class Document:
    def __init__(self):
        self.title = None
        self.author = None
        self.creation_date = None
        self.sections = []
        self.images = []

    def __str__(self):
        return f"Document(title={self.title}, author={self.author}, creation_date={self.creation_date}, sections={len(self.sections)}, images={len(self.images)})"


class DocumentBuilder:
    def __init__(self):
        self.document = Document()

    def set_title(self, title):
        self.document.title = title
        return self

    def set_author(self, author):
        self.document.author = author
        return self

    def set_creation_date(self, date):
        self.document.creation_date = date
        return self

    def add_section(self, content):
        self.document.sections.append(content)
        return self

    def add_image(self, image_path):
        self.document.images.append(image_path)
        return self

    def build(self):
        return self.document

# Example Usage
builder = DocumentBuilder()
document = builder.set_title("My Awesome Document")\
                  .set_author("Jane Smith")\
                  .set_creation_date("2023-11-15")\
                  .add_section("Introduction: This is the introduction section.")\
                  .add_section("Main Content: This is the main content of the document.")\
                  .add_image("/path/to/image1.jpg")\
                  .build()

print(document)

Dans cet exemple, la classe Document représente l'objet complexe que nous souhaitons construire. La classe DocumentBuilder est responsable de la construction de l'objet Document, étape par étape. Chaque méthode set_xxx ou add_xxx retourne self, ce qui permet d'enchaîner les appels de méthodes de manière fluide (fluent interface). La méthode build() finalise la construction et retourne l'objet Document complet.

L'un des principaux avantages de cette approche réside dans l'amélioration de la lisibilité et de la maintenabilité du code. On peut aisément créer des documents avec différentes configurations sans être submergé par un constructeur surchargé. De plus, l'ordre dans lequel les méthodes du builder sont appelées est généralement indifférent, offrant ainsi une grande souplesse.

Pour illustrer davantage la flexibilité du pattern, on peut ajouter une classe DocumentDirector qui encapsule la logique de construction de documents spécifiques. Cela centralise la logique et promeut la réutilisation.


class DocumentDirector:
    def __init__(self, builder):
        self.builder = builder

    def construct_basic_document(self, title, author, date):
        self.builder.set_title(title)
        self.builder.set_author(author)
        self.builder.set_creation_date(date)

    def construct_full_document(self, title, author, date, sections, images):
        self.construct_basic_document(title, author, date)
        for section in sections:
            self.builder.add_section(section)
        for image in images:
            self.builder.add_image(image)

# Example Usage with Director
builder = DocumentBuilder()
director = DocumentDirector(builder)

director.construct_basic_document("Simple Document", "Alice", "2023-11-16")
simple_document = builder.build()
print(simple_document)

sections = ["Section 1", "Section 2"]
images = ["/path/to/image1.png", "/path/to/image2.png"]
director.construct_full_document("Complete Document", "Bob", "2023-11-16", sections, images)
complete_document = builder.build()
print(complete_document)

En conclusion, le Builder pattern est un outil puissant et flexible pour la création d'objets complexes en Python. Il permet d'éviter l'anti-pattern du 'telescoping constructor' et offre une API plus propre, plus lisible et plus facile à utiliser. Son implémentation favorise un code plus modulaire et maintenable, facilitant ainsi l'évolution du système au fil du temps. L'ajout d'un Director permet d'encapsuler des logiques de construction spécifiques, améliorant ainsi la réutilisabilité du code.

2. Design Patterns Structurels en Python

Les design patterns structurels se concentrent sur la manière d'assembler des classes et des objets pour former des structures plus vastes et complexes. Ils visent à simplifier la conception en identifiant des moyens efficaces d'organiser les relations entre différentes entités du système.

Adaptateur (Adapter) : Ce pattern permet à des classes ayant des interfaces incompatibles de collaborer. Il agit comme un pont, convertissant l'interface d'une classe en une interface attendue par une autre.


# Target interface
class Target:
    def request(self):
        return "Target: The default target's behavior."

# Adaptee class
class Adaptee:
    def specific_request(self):
        return ".eetpadA eht fo roivaheb laicepseht"

# Adapter class
class Adapter(Target):
    def __init__(self, adaptee):
        self.adaptee = adaptee

    def request(self):
        return f"Adapter: (TRANSLATED) {self.adaptee.specific_request()[::-1]}"

# Client code
def client_code(target):
    print(target.request())

if __name__ == "__main__":
    adaptee = Adaptee()
    adapter = Adapter(adaptee)
    client_code(adapter)

Dans cet exemple, la classe Adaptee possède une méthode, specific_request, que la classe Target ne peut pas utiliser directement. L'Adapter traduit l'appel de Target en un format compréhensible par Adaptee en inversant la chaîne de caractères retournée par Adaptee.

Pont (Bridge) : Le pattern Bridge sépare une abstraction de son implémentation, permettant à chacune d'évoluer indépendamment. Il est particulièrement utile lorsque vous avez une hiérarchie de classes qui doit pouvoir évoluer selon deux dimensions orthogonales.


# Abstraction
class Shape:
    def __init__(self, color):
        self.color = color

    def draw(self):
        pass

# Refined Abstraction
class Circle(Shape):
    def __init__(self, color, radius):
        super().__init__(color)
        self.radius = radius

    def draw(self):
        return f"Circle with radius {self.radius} drawn in {self.color.fill()}"

# Implementor
class Color:
    def fill(self):
        pass

# Concrete Implementor
class RedColor(Color):
    def fill(self):
        return "Red"

class BlueColor(Color):
    def fill(self):
        return "Blue"

# Client code
if __name__ == "__main__":
    red = RedColor()
    blue = BlueColor()

    circle1 = Circle(red, 5)
    circle2 = Circle(blue, 10)

    print(circle1.draw())
    print(circle2.draw())

Ici, Shape (l'abstraction) est découplée de Color (l'implémentation). Cela permet de créer différents types de formes et de les colorer avec différentes couleurs sans impacter les autres classes. La couleur est implémentée par les classes RedColor et BlueColor.

Composite : Le pattern Composite permet de traiter des objets individuels et des compositions d'objets de manière uniforme. Il représente une hiérarchie d'objets comme une structure arborescente.


# Component
class Graphic:
    def render(self):
        pass

# Leaf
class CircleGraphic(Graphic):
    def __init__(self, radius):
        self.radius = radius

    def render(self):
        return f"Rendering circle with radius {self.radius}"

# Leaf
class SquareGraphic(Graphic):
    def __init__(self, side):
        self.side = side

    def render(self):
        return f"Rendering square with side {self.side}"

# Composite
class CompoundGraphic(Graphic):
    def __init__(self):
        self.children = []

    def add(self, graphic):
        self.children.append(graphic)

    def remove(self, graphic):
        self.children.remove(graphic)

    def render(self):
        result = ""
        for child in self.children:
            result += child.render() + "\n"
        return result

# Client code
if __name__ == "__main__":
    compound_graphic = CompoundGraphic()
    compound_graphic.add(CircleGraphic(5))
    compound_graphic.add(SquareGraphic(10))

    compound_graphic2 = CompoundGraphic()
    compound_graphic2.add(CircleGraphic(2))
    compound_graphic.add(compound_graphic2)

    print(compound_graphic.render())

Cet exemple illustre comment un CompoundGraphic peut contenir d'autres objets Graphic (feuilles) ou d'autres CompoundGraphic (composites). Ceci permet de créer des structures arborescentes imbriquées complexes.

Décorateur (Decorator) : Le pattern Decorator permet d'ajouter dynamiquement des responsabilités à un objet sans modifier sa structure. Il offre une alternative flexible à l'héritage pour étendre les fonctionnalités.


# Component
class Coffee:
    def get_cost(self):
        return 5

    def get_description(self):
        return "Simple coffee"

# Decorator
class CoffeeDecorator(Coffee):
    def __init__(self, coffee):
        self.coffee = coffee

    def get_cost(self):
        return self.coffee.get_cost()

    def get_description(self):
        return self.coffee.get_description()

# Concrete Decorator
class MilkDecorator(CoffeeDecorator):
    def get_cost(self):
        return super().get_cost() + 2

    def get_description(self):
        return super().get_description() + ", with milk"

# Concrete Decorator
class SugarDecorator(CoffeeDecorator):
    def get_cost(self):
        return super().get_cost() + 1

    def get_description(self):
        return super().get_description() + ", with sugar"

# Client code
if __name__ == "__main__":
    coffee = Coffee()
    print(f"{coffee.get_description()} Cost: ${coffee.get_cost()}")

    milk_coffee = MilkDecorator(coffee)
    print(f"{milk_coffee.get_description()} Cost: ${milk_coffee.get_cost()}")

    sugar_milk_coffee = SugarDecorator(milk_coffee)
    print(f"{sugar_milk_coffee.get_description()} Cost: ${sugar_milk_coffee.get_cost()}")

Dans cet exemple, nous ajoutons dynamiquement du lait et du sucre à un café en utilisant les décorateurs MilkDecorator et SugarDecorator. Chaque décorateur ajoute sa propre fonctionnalité, augmentant le prix et la description, sans modifier la classe de base Coffee.

Façade (Facade) : Le pattern Façade fournit une interface simplifiée et unifiée à un ensemble d'interfaces plus complexes dans un sous-système. Il définit une interface de haut niveau qui rend le sous-système plus facile à utiliser et à comprendre.


# Subsystem classes
class CPU:
    def freeze(self):
        return "Freezing processor"

    def jump(self, position):
        return f"Jumping to position: {position}"

    def execute(self):
        return "Executing"

class Memory:
    def load(self, position, data):
        return f"Loading from position {position}, data: {data}"

class HardDrive:
    def read(self, lba, size):
        return f"Reading sector {lba}, size: {size}"

# Facade
class ComputerFacade:
    def __init__(self):
        self.cpu = CPU()
        self.memory = Memory()
        self.hard_drive = HardDrive()

    def start(self):
        print("Starting computer")
        self.cpu.freeze()
        self.memory.load("0x00", self.hard_drive.read("100", "1024"))
        self.cpu.jump("0x00")
        self.cpu.execute()
        print("Computer started")

# Client code
if __name__ == "__main__":
    computer = ComputerFacade()
    computer.start()

Le ComputerFacade simplifie l'interaction avec les sous-systèmes complexes CPU, Memory et HardDrive. Le client interagit avec le sous-système uniquement via la façade, rendant l'utilisation du système plus simple.

Flyweight : Le pattern Flyweight vise à réduire l'utilisation de la mémoire et les coûts de calcul en partageant autant que possible les données entre des objets similaires. Il est particulièrement utile lorsqu'un grand nombre d'objets doivent être créés et que beaucoup de ces objets partagent des états intrinsèques similaires.


# Flyweight
class Character:
    def __init__(self, char, font_size, font_family):
        self.char = char
        self.font_size = font_size
        self.font_family = font_family

    def render(self, position_x, position_y):
        return f"Character {self.char} rendered at ({position_x},{position_y}) with font {self.font_family} size {self.font_size}"

# Flyweight Factory
class CharacterFactory:
    _characters = {}

    @staticmethod
    def get_character(char, font_size, font_family):
        key = (char, font_size, font_family)
        if key not in CharacterFactory._characters:
            CharacterFactory._characters[key] = Character(char, font_size, font_family)
        return CharacterFactory._characters[key]

# Client code
if __name__ == "__main__":
    text = "Hello World"
    x = 0
    for char in text:
        character = CharacterFactory.get_character(char, 12, "Arial")
        print(character.render(x, 0))
        x += 1

Dans cet exemple, la classe Character représente un caractère avec une police spécifique. La CharacterFactory s'assure que les mêmes caractères avec les mêmes propriétés de police ne sont créés qu'une seule fois et réutilisés. Ceci permet d'économiser de la mémoire, surtout pour de longs textes avec des caractères répétés.

Proxy : Le pattern Proxy fournit un substitut ou un représentant pour un autre objet, permettant de contrôler l'accès à cet objet. Il peut être utilisé pour gérer l'accès, retarder la création d'un objet jusqu'à ce qu'il soit réellement nécessaire, ou ajouter des fonctionnalités supplémentaires avant ou après l'utilisation de l'objet réel.


# Subject interface
class Service:
    def operation(self):
        pass

# Real Subject
class RealService(Service):
    def operation(self):
        return "RealService: Handling request."

# Proxy
class Proxy(Service):
    def __init__(self, real_service):
        self.real_service = real_service

    def operation(self):
        # Add some logic here, like access control, logging, etc.
        print("Proxy: Checking access prior to firing a real request.")
        if self.check_access():
            result = self.real_service.operation()
            self.log_access()
            return result
        else:
            return "Proxy: Access denied."

    def check_access(self):
        # Simulate access check
        return True

    def log_access(self):
        print("Proxy: Logging the time of request.")

# Client code
if __name__ == "__main__":
    real_service = RealService()
    proxy = Proxy(real_service)
    print(proxy.operation())

Dans cet exemple, le Proxy contrôle l'accès au RealService. Avant d'autoriser un appel à RealService, le Proxy effectue des vérifications d'accès et enregistre l'activité. Cela permet d'ajouter des comportements supplémentaires sans modifier la classe RealService elle-même.

2.1 Adapter Pattern: Adaptation d'Interfaces

L'Adapter pattern est un patron de conception structurel qui permet à des interfaces incompatibles de collaborer harmonieusement. Il sert de pont, convertissant l'interface d'une classe existante en une interface différente, celle attendue par les clients. En Python, l'implémentation typique implique la création d'une classe adaptatrice qui encapsule une instance de la classe à adapter et expose ensuite une nouvelle interface, conforme aux exigences du client.

Prenons un exemple concret : une ancienne API de traitement d'images, conçue il y a plusieurs années, utilise un format de données propriétaire. Une nouvelle application, plus moderne, a besoin d'utiliser cette API, mais elle fonctionne avec un format d'image différent. L'Adapter pattern entre en jeu pour permettre à ces deux composants de collaborer sans nécessiter de modifications coûteuses dans le code source de l'API existante. L'adaptateur convertit les données du format de la nouvelle application vers le format attendu par l'ancienne API.


# Assume this is an external library with an incompatible interface
class LegacyImageProcessor:
    def __init__(self):
        pass

    def process_image(self, image_data):
        # Legacy image processing logic
        # Expects data in a specific old format
        print(f"Legacy Image Processor: Processing image data in legacy format")
        return "Processed Image (Legacy)"

# New system that expects a different interface
class NewImageFormat:
    def __init__(self, data):
        self.data = data

    def get_data(self):
        return self.data

# Adapter class to make LegacyImageProcessor compatible with NewImageFormat
class ImageAdapter:
    def __init__(self, image_format, processor):
        self.image_format = image_format
        self.processor = processor

    def process(self):
        # Convert the new image format to the legacy format expected by the processor
        legacy_data = self.convert_to_legacy_format(self.image_format.get_data())
        return self.processor.process_image(legacy_data)

    def convert_to_legacy_format(self, new_data):
        # This is where the adaptation logic resides
        # Convert new_data to the format expected by LegacyImageProcessor
        print("Adapter: Converting new image format to legacy format")
        return f"Legacy Format: {new_data}"

# Usage
legacy_processor = LegacyImageProcessor()
new_image = NewImageFormat("New Image Data")
adapter = ImageAdapter(new_image, legacy_processor)
result = adapter.process()
print(f"Result: {result}")

Une autre approche pour implémenter l'Adapter pattern en Python consiste à utiliser une fonction adaptatrice. Cette technique est particulièrement pertinente lorsqu'il s'agit d'adapter une seule fonction ou une portion de code limitée au sein d'une classe.


def legacy_function(data):
    # Legacy function that expects a certain format
    print(f"Legacy Function: Processing data in legacy format: {data}")
    return f"Legacy processed: {data}"

def adapter_function(new_data):
    # Adapter function to convert new data to the format expected by legacy_function
    adapted_data = f"Adapted: {new_data}"
    print(f"Adapter Function: Converting new data to legacy format")
    return legacy_function(adapted_data)

# Usage
new_data = "New Data Format"
result = adapter_function(new_data)
print(f"Result: {result}")

Ici, adapter_function reçoit les nouvelles données en entrée et les transforme avant de les transmettre à la fonction existante legacy_function. Cela permet d'utiliser legacy_function avec des données mises à jour sans avoir à la modifier directement.

En résumé, l'Adapter pattern constitue une solution flexible et efficace pour intégrer des composants dotés d'interfaces incompatibles. Que ce soit par l'intermédiaire de classes adaptatrices ou de fonctions adaptatrices, il encourage la réutilisation du code existant, simplifie l'évolution des systèmes et préserve la compatibilité avec les versions antérieures.

2.2 Decorator Pattern: Ajout de Responsabilités Dynamiquement

Le Decorator pattern offre une solution élégante pour étendre les fonctionnalités d'un objet sans recourir à l'héritage. Il permet d'ajouter des responsabilités à un objet de manière dynamique, en l'enveloppant dans un ou plusieurs décorateurs. En Python, cette approche se traduit souvent par l'utilisation de décorateurs de fonctions ou de classes, exploitant ainsi la flexibilité du langage.

Prenons un exemple concret. Imaginons une classe de base, TextFormatter, dont la responsabilité est de formater du texte brut. Nous allons ensuite créer des décorateurs pour lui ajouter des fonctionnalités, comme la conversion en majuscules ou l'encapsulation dans des balises HTML.


class TextFormatter:
    """
    Base class for text formatting.
    Initializes with the text to be formatted.
    """
    def __init__(self, text):
        self._text = text

    def format(self):
        """
        Returns the formatted text.
        """
        return self._text


def uppercase_decorator(func):
    """
    Decorator to convert text to uppercase.
    It wraps the original function and modifies its output.
    """
    def wrapper(*args, **kwargs):
        """
        Wrapper function that converts the result to uppercase.
        """
        formatted_text = func(*args, **kwargs)
        return formatted_text.upper()
    return wrapper


def html_decorator(func):
    """
    Decorator to wrap text in HTML paragraph tags.
    It adds 

tags around the formatted text. """ def wrapper(*args, **kwargs): """ Wrapper function that adds HTML paragraph tags. """ formatted_text = func(*args, **kwargs) return f"

{formatted_text}

" return wrapper

Nous avons donc défini une classe de base TextFormatter. Ensuite, nous avons créé deux décorateurs: uppercase_decorator et html_decorator. Ces décorateurs prennent une fonction (ici la méthode format) comme argument et retournent une nouvelle fonction, le "wrapper", qui encapsule et modifie le comportement de la fonction originale. L'intérêt majeur est de pouvoir ajouter ces comportements sans modifier directement la classe de base.

Pour appliquer ces décorateurs, il suffit de les utiliser avec la syntaxe @decorator au-dessus de la méthode format de notre classe:


class TextFormatter:
    """
    Base class for text formatting.
    Initializes with the text to be formatted.
    """
    def __init__(self, text):
        self._text = text

    @html_decorator
    @uppercase_decorator
    def format(self):
        """
        Returns the formatted text.
        Now decorated with uppercase and HTML formatting.
        """
        return self._text

# Example usage
text_formatter = TextFormatter("Hello, world!")
formatted_text = text_formatter.format()
print(formatted_text)  # Output: 

HELLO, WORLD!

Dans cet exemple, nous appliquons les décorateurs html_decorator et uppercase_decorator à la méthode format. L'ordre d'application est crucial : uppercase_decorator est appliqué en premier, convertissant le texte en majuscules, puis html_decorator enveloppe le résultat dans des balises <p>. Si l'ordre était inversé, les balises HTML seraient converties en majuscules.

Le Decorator pattern offre une alternative flexible et puissante à l'héritage pour étendre le comportement des objets. En Python, les décorateurs fournissent une syntaxe concise et élégante pour implémenter ce pattern, permettant d'ajouter des responsabilités de manière dynamique et modulaire, tout en respectant le principe de "Open/Closed" de la conception orientée objet.

2.3 Composite Pattern: Arborescence d'Objets

Le Composite pattern permet de structurer des objets en arborescence pour représenter des hiérarchies part-whole. L'intérêt principal réside dans sa capacité à appliquer une opération de manière uniforme à un objet individuel ou à une composition d'objets, sans se soucier de leur nature spécifique. Cela simplifie grandement le code client et favorise la réutilisation.

En Python, l'implémentation du Composite pattern s'articule autour de la définition d'une interface commune pour les composants (feuilles) et les composites (nœuds). Les composites maintiennent une collection de composants enfants et délèguent les opérations à ces derniers, créant ainsi un comportement récursif.


class Component:
    """
    Declare the interface for objects in the composition.
    This includes methods for adding, removing, and getting child components,
    as well as the 'operation' to be carried out.
    """
    def __init__(self, name):
        self._name = name

    def operation(self):
        """
        The 'Operation' that all components carry out.  Subclasses must implement this.
        """
        raise NotImplementedError("Subclasses must implement operation()")

    def add(self, component):
        """
        Add a child component.  Leaf nodes won't implement this.
        """
        raise NotImplementedError("Subclasses must implement add()")

    def remove(self, component):
        """
        Remove a child component. Leaf nodes won't implement this.
        """
        raise NotImplementedError("Subclasses must implement remove()")

    def get_child(self, i):
        """
        Get a child component. Leaf nodes won't implement this.
        """
        raise NotImplementedError("Subclasses must implement get_child()")


class Leaf(Component):
    """
    Represent the leaf objects in the composition. A leaf has no children and implements the operation directly.
    """
    def operation(self):
        """
        Leaf-specific implementation of the operation.
        """
        return f"Leaf {self._name}: Doing leaf operation."


class Composite(Component):
    """
    Represent a composite object. A composite can have children, which can be either Leafs or other Composites.
    """
    def __init__(self, name):
        super().__init__(name)
        self._children = []

    def add(self, component):
        """
        Adds a child component to the composite.
        """
        self._children.append(component)

    def remove(self, component):
        """
        Removes a child component from the composite.
        """
        self._children.remove(component)

    def get_child(self, i):
        """
        Returns the child component at the specified index.
        """
        return self._children[i]

    def operation(self):
        """
        Executes the operation on all child components and aggregates their results.
        """
        results = []
        for child in self._children:
            results.append(child.operation())
        return f"Branch {self._name}: " + ", ".join(results)

Dans cet exemple, la classe abstraite Component définit l'interface commune, assurant ainsi que les classes Leaf (feuille) et Composite peuvent être traitées de manière uniforme. La classe Leaf représente les objets individuels, tandis que la classe Composite représente les compositions. La méthode operation() est définie dans chaque classe, permettant d'exécuter une action spécifique sur l'ensemble de l'arborescence, qu'il s'agisse d'une simple feuille ou d'une branche complexe.

Voici un exemple d'utilisation concrète du pattern Composite :


# Client code
if __name__ == "__main__":
    # Create a tree structure
    root = Composite("Root")
    branch1 = Composite("Branch1")
    branch2 = Composite("Branch2")

    leaf1 = Leaf("Leaf1")
    leaf2 = Leaf("Leaf2")
    leaf3 = Leaf("Leaf3")

    root.add(branch1)
    root.add(branch2)

    branch1.add(leaf1)
    branch1.add(leaf2)

    branch2.add(leaf3)

    # Execute 'operation' on the root (composite)
    print(root.operation())
    # Expected Output: Branch Root: Branch Branch1: Leaf Leaf1: Doing leaf operation., Leaf Leaf2: Doing leaf operation., Branch Branch2: Leaf Leaf3: Doing leaf operation.

    # Execute 'operation' on a leaf
    print(leaf1.operation())
    # Expected Output: Leaf Leaf1: Doing leaf operation.

    #Demonstration of removing a component
    branch1.remove(leaf1)
    print(root.operation())
    # Expected Output: Branch Root: Branch Branch1: Leaf Leaf2: Doing leaf operation., Branch Branch2: Leaf Leaf3: Doing leaf operation.

Cet exemple illustre la création d'une structure arborescente et l'appel de la méthode operation() sur le nœud racine, qui se propage récursivement à travers toute la hiérarchie. Le pattern Composite simplifie le code client en cachant la complexité de la structure arborescente, permettant de traiter les objets individuels et les groupes d'objets de la même manière.

En conclusion, le Composite pattern est particulièrement pertinent pour la gestion de structures arborescentes en Python. Il offre une manière élégante de traiter les objets individuels et les groupes d'objets de manière uniforme, ce qui améliore significativement la modularité, la maintenabilité et la flexibilité du code. Il est idéal dans les situations où l'on doit représenter des hiérarchies complexes et appliquer des opérations à l'ensemble de la structure.

3. Design Patterns Comportementaux en Python

Les Design Patterns Comportementaux mettent l'accent sur la communication et la répartition des responsabilités entre les objets. Ils simplifient la gestion des algorithmes complexes et des flux de contrôle en établissant des modèles d'interaction clairs et efficaces.

Chaîne de responsabilité : Ce pattern permet de traiter une requête par une série d'objets, chacun décidant soit de la traiter, soit de la transmettre au prochain objet de la chaîne. Cela réduit le couplage entre l'émetteur de la requête et son récepteur.


class Handler:
    """
    Abstract handler class.
    """
    def __init__(self, successor=None):
        self._successor = successor

    def handle_request(self, request):
        """
        Handles the request if possible, otherwise defers to the successor.
        """
        if self._successor is not None:
            self._successor.handle_request(request)

class ConcreteHandler1(Handler):
    """
    Concrete handler that handles requests in the range 0 to 10.
    """
    def handle_request(self, request):
        if 0 < request <= 10:
            print(f"Handler 1 handled request {request}")
        else:
            super().handle_request(request)

class ConcreteHandler2(Handler):
    """
    Concrete handler that handles requests in the range 11 to 20.
    """
    def handle_request(self, request):
        if 10 < request <= 20:
            print(f"Handler 2 handled request {request}")
        else:
            super().handle_request(request)

class DefaultHandler(Handler):
    """
    Default handler that handles any request not handled by previous handlers.
    """
    def handle_request(self, request):
        print(f"End of chain, no handler for request {request}")

# Client code
handler1 = ConcreteHandler1()
handler2 = ConcreteHandler2()
default_handler = DefaultHandler()

handler1._successor = handler2
handler2._successor = default_handler

requests = [2, 5, 14, 22, 18, 3, 35, 27, 20]
for request in requests:
    handler1.handle_request(request)

Dans cet exemple, Handler est la classe abstraite, ConcreteHandler1 et ConcreteHandler2 sont des gestionnaires concrets, et DefaultHandler est un gestionnaire par défaut. Les requêtes sont traitées séquentiellement par les gestionnaires, chacun vérifiant si il peut la prendre en charge, sinon elle est passée au suivant.

Commande : Le pattern Commande transforme une requête en un objet, permettant ainsi de paramétrer les clients avec différentes requêtes, de mettre en attente ou d'enregistrer les requêtes, et de gérer les opérations d'annulation.


class Command:
    """
    Abstract command class.
    """
    def execute(self):
        """
        Executes the command.
        """
        raise NotImplementedError

class Light:
    """
    Receiver class that performs the actual actions.
    """
    def turn_on(self):
        print("Light turned on")

    def turn_off(self):
        print("Light turned off")

class TurnOnCommand(Command):
    """
    Concrete command to turn on the light.
    """
    def __init__(self, light):
        self._light = light

    def execute(self):
        self._light.turn_on()

class TurnOffCommand(Command):
    """
    Concrete command to turn off the light.
    """
    def __init__(self, light):
        self._light = light

    def execute(self):
        self._light.turn_off()

class RemoteControl:
    """
    Invoker class that holds and executes commands.
    """
    def __init__(self):
        self._commands = {}

    def set_command(self, command_name, command):
        """
        Assigns a command to a specific button.
        """
        self._commands[command_name] = command

    def press_button(self, command_name):
        """
        Executes the command associated with the button.
        """
        if command_name in self._commands:
            self._commands[command_name].execute()
        else:
            print(f"No command associated with {command_name}")

# Client code
light = Light()
turn_on_command = TurnOnCommand(light)
turn_off_command = TurnOffCommand(light)

remote = RemoteControl()
remote.set_command("on", turn_on_command)
remote.set_command("off", turn_off_command)

remote.press_button("on")
remote.press_button("off")

Dans cet exemple, Command est la classe abstraite, TurnOnCommand et TurnOffCommand sont des implémentations concrètes, Light est le récepteur, et RemoteControl est l'invocateur. Le RemoteControl exécute des commandes sans connaître les détails de leur exécution.

Itérateur : Le pattern Itérateur offre une méthode séquentielle pour accéder aux éléments d'un objet agrégé sans révéler sa structure interne. Ceci permet de parcourir différents types de collections de manière uniforme.


class Numbers:
    """
    Aggregate class holding a collection of numbers.
    """
    def __init__(self, numbers):
        self._numbers = numbers

    def create_iterator(self):
        """
        Creates and returns an iterator for the numbers collection.
        """
        return NumberIterator(self._numbers)

class NumberIterator:
    """
    Iterator class for traversing the numbers collection.
    """
    def __init__(self, numbers):
        self._numbers = numbers
        self._index = 0

    def has_next(self):
        """
        Checks if there are more elements to iterate over.
        """
        return self._index < len(self._numbers)

    def next(self):
        """
        Returns the next element in the collection.
        """
        if self.has_next():
            value = self._numbers[self._index]
            self._index += 1
            return value
        else:
            raise StopIteration

# Client code
numbers = Numbers([1, 2, 3, 4, 5])
iterator = numbers.create_iterator()

try:
    while True:
        print(iterator.next())
except StopIteration:
    pass

Ici, Numbers est l'agrégat et NumberIterator est l'itérateur. L'itérateur permet de parcourir la liste des nombres sans exposer la structure interne de Numbers. L'exception StopIteration est gérée pour indiquer la fin de l'itération.

En conclusion, les patterns comportementaux fournissent des solutions validées pour gérer les interactions complexes entre les objets, améliorant ainsi la flexibilité, la réutilisabilité et la maintenabilité du code Python.

3.1 Observer Pattern: Communication Un-à-Plusieurs

Le pattern Observer est un patron de conception comportemental qui établit une dépendance de type un-à-plusieurs entre objets. Lorsqu'un objet, désigné comme le sujet, subit un changement d'état, tous ses dépendants, appelés observateurs, sont automatiquement notifiés et mis à jour. Cette approche favorise une communication indirecte entre les objets, réduisant ainsi le couplage et améliorant la flexibilité de l'ensemble.

En Python, l'implémentation du pattern Observer peut être réalisée de différentes manières. Une méthode simple consiste à utiliser une liste de fonctions de rappel (callbacks). Le sujet maintient une liste d'observateurs (qui peuvent être des fonctions ou des méthodes) et les invoque lorsque son état est modifié.


class Subject:
    def __init__(self):
        self._observers = []  # list of observers

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

    def notify(self, *args, **kwargs):
        for observer in self._observers:
            observer(*args, **kwargs)

class Observer:
    def update(self, *args, **kwargs):
        pass

# Example usage
def observer_function(message):
    print(f"Observer received: {message}")

subject = Subject()
subject.attach(observer_function)

subject.notify("State changed!") # Output: Observer received: State changed!

subject.detach(observer_function)
subject.notify("This message will not be received.") # Nothing happens

Dans cet exemple, la classe Subject conserve une liste d'observateurs dans l'attribut _observers. Les méthodes attach et detach permettent respectivement d'ajouter et de supprimer des observateurs de cette liste. La méthode notify parcourt la liste des observateurs et appelle chacun d'eux, en leur transmettant les arguments nécessaires. La fonction observer_function sert d'observateur et reçoit les notifications du sujet.

Une approche plus avancée consiste à exploiter la bibliothèque pydispatcher, qui propose un mécanisme de signal/slot pour l'implémentation du pattern Observer. Cette bibliothèque offre des fonctionnalités plus riches, telles que la gestion des priorités et le filtrage des signaux, permettant un contrôle plus fin des notifications.


from pydispatch import dispatcher

# Define signals
SIGNAL_STATE_CHANGED = "state_changed"

# Example usage
def observer_function(message):
    print(f"Observer received (pydispatch): {message}")

# Connect observer to signal
dispatcher.connect(observer_function, signal=SIGNAL_STATE_CHANGED, sender=dispatcher.Any)

# Send signal
dispatcher.send(signal=SIGNAL_STATE_CHANGED, sender="my_subject", message="State changed via pydispatch!") # Output: Observer received (pydispatch): State changed via pydispatch!

# Disconnect observer
dispatcher.disconnect(observer_function, signal=SIGNAL_STATE_CHANGED, sender=dispatcher.Any)

# Send signal again (nothing happens)
dispatcher.send(signal=SIGNAL_STATE_CHANGED, sender="my_subject", message="This message will not be received.")

Dans ce cas, pydispatch est utilisé pour connecter la fonction observer_function au signal SIGNAL_STATE_CHANGED. La méthode dispatcher.send émet le signal, notifiant ainsi tous les observateurs qui y sont connectés. L'argument sender permet de filtrer les signaux en fonction de l'objet qui les a émis, offrant une plus grande précision dans la gestion des notifications.

Le pattern Observer se révèle particulièrement pertinent dans les applications où la synchronisation ou la mise à jour de plusieurs objets doit être effectuée en réponse à des changements d'état. On le retrouve fréquemment dans les interfaces graphiques, les systèmes de gestion d'événements et les applications temps réel. L'utilisation de ce pattern contribue à un code plus modulaire, flexible et, par conséquent, plus facile à maintenir et à faire évoluer.

3.2 Strategy Pattern: Algorithmes Interchangeables

Une façon simple d'implémenter le pattern Strategy est d'utiliser des classes, où chaque classe représente une stratégie différente. Une interface commune, souvent une classe abstraite ou une classe de base, définit la méthode que toutes les stratégies doivent implémenter. Cela garantit que chaque stratégie peut être utilisée de manière interchangeable.


from abc import ABC, abstractmethod

class PaymentStrategy(ABC):
    """
    Abstract base class for payment strategies.
    Defines the common interface that all strategies must implement.
    """
    @abstractmethod
    def pay(self, amount: float):
        """
        Abstract method to perform payment.
        Subclasses must implement this method.
        """
        pass

class CreditCardPayment(PaymentStrategy):
    """
    Concrete strategy for credit card payments.
    """
    def __init__(self, card_number: str, cvv: str):
        self.card_number = card_number
        self.cvv = cvv

    def pay(self, amount: float):
        print(f"Paying {amount} using Credit Card: {self.card_number}")

class PayPalPayment(PaymentStrategy):
    """
    Concrete strategy for PayPal payments.
    """
    def __init__(self, email: str):
        self.email = email

    def pay(self, amount: float):
        print(f"Paying {amount} using PayPal: {self.email}")

class ShoppingCart:
    """
    Context class that uses the strategy.
    It doesn't know the concrete implementation of the strategy.
    """
    def __init__(self, payment_strategy: PaymentStrategy):
        self.payment_strategy = payment_strategy

    def checkout(self, amount: float):
        self.payment_strategy.pay(amount)

# Example usage
credit_card = CreditCardPayment(card_number="1234-5678-9012-3456", cvv="123")
paypal = PayPalPayment(email="user@example.com")

cart1 = ShoppingCart(payment_strategy=credit_card)
cart1.checkout(100.0)  # Paying 100.0 using Credit Card: 1234-5678-9012-3456

cart2 = ShoppingCart(payment_strategy=paypal)
cart2.checkout(50.0)   # Paying 50.0 using PayPal: user@example.com

Les stratégies peuvent également être implémentées en utilisant des fonctions. Cette approche peut simplifier le code, surtout si les stratégies sont simples et n'ont pas besoin de conserver un état interne.


def credit_card_payment(amount: float, card_number: str, cvv: str):
    """
    Payment strategy using a credit card.
    """
    print(f"Paying {amount} using Credit Card: {card_number}")

def paypal_payment(amount: float, email: str):
    """
    Payment strategy using PayPal.
    """
    print(f"Paying {amount} using PayPal: {email}")

def checkout(amount: float, payment_strategy, **kwargs):
    """
    Checkout function that accepts a payment strategy and its arguments.
    """
    payment_strategy(amount, **kwargs)

# Example usage
checkout(amount=100.0, payment_strategy=credit_card_payment, card_number="1234-5678-9012-3456", cvv="123")
# Paying 100.0 using Credit Card: 1234-5678-9012-3456

checkout(amount=50.0, payment_strategy=paypal_payment, email="user@example.com")
# Paying 50.0 using PayPal: user@example.com

Dans cette version, credit_card_payment et paypal_payment sont des fonctions qui implémentent les stratégies de paiement. La fonction checkout accepte une stratégie de paiement comme argument et l'utilise pour effectuer le paiement. On utilise **kwargs pour passer les arguments nécessaires aux fonctions de paiement, ce qui rend la fonction checkout plus flexible.

3.3 Template Method Pattern: Définition du Squelette d'un Algorithme

Le Template Method est un pattern de conception comportemental qui définit le squelette d'un algorithme dans une méthode, en déléguant la responsabilité de certaines étapes à des sous-classes. Il garantit qu'un algorithme conserve sa structure, tout en permettant des variations dans l'implémentation de ses étapes. En Python, l'héritage est la technique principale utilisée pour implémenter ce pattern.

L'idée clé est de définir une classe abstraite contenant une méthode template, qui spécifie l'ordre d'exécution des étapes de l'algorithme. Certaines de ces étapes sont implémentées directement dans la classe abstraite, tandis que d'autres sont déclarées comme abstraites, obligeant ainsi les sous-classes à fournir leur propre implémentation. Ceci assure que l'algorithme suit toujours la même structure générale.


from abc import ABC, abstractmethod

class HouseBuilder(ABC):
    """
    Abstract class defining the template for building a house.
    """

    def build_house(self):
        """
        Template method to define the order of building steps.
        """
        self.prepare_foundation()
        self.build_walls()
        self.install_roof()
        self.install_windows()
        self.finish_house()

    @abstractmethod
    def prepare_foundation(self):
        """
        Abstract method to prepare the foundation.
        Subclasses must implement this.
        """
        pass

    @abstractmethod
    def build_walls(self):
        """
        Abstract method to build the walls.
        Subclasses must implement this.
        """
        pass

    @abstractmethod
    def install_roof(self):
        """
        Abstract method to install the roof.
        Subclasses must implement this.
        """
        pass

    def install_windows(self):
        """
        Concrete method to install windows.
        """
        print("Installing windows")

    def finish_house(self):
        """
        Concrete method to finish the house.
        """
        print("Finishing house")

class WoodenHouseBuilder(HouseBuilder):
    """
    Concrete class to build a wooden house.
    """

    def prepare_foundation(self):
        print("Preparing wooden foundation")

    def build_walls(self):
        print("Building wooden walls")

    def install_roof(self):
        print("Installing wooden roof")

class StoneHouseBuilder(HouseBuilder):
    """
    Concrete class to build a stone house.
    """

    def prepare_foundation(self):
        print("Preparing stone foundation")

    def build_walls(self):
        print("Building stone walls")

    def install_roof(self):
        print("Installing stone roof")

# Usage
wooden_house_builder = WoodenHouseBuilder()
wooden_house_builder.build_house()

stone_house_builder = StoneHouseBuilder()
stone_house_builder.build_house()

Dans cet exemple, HouseBuilder est la classe abstraite définissant la méthode template build_house. Cette méthode définit l'ordre des étapes de construction. Les classes WoodenHouseBuilder et StoneHouseBuilder héritent de HouseBuilder et implémentent les étapes spécifiques à leur type de maison.

L'étape finish_house est une méthode concrète dans la classe abstraite, ce qui signifie que toutes les sous-classes utilisent la même implémentation pour cette étape. La méthode install_windows est également concrète, démontrant que certaines étapes peuvent avoir une implémentation par défaut. Cela permet de définir des étapes communes à tous les types de maisons et de fenêtres.

L'avantage principal de ce pattern est de fournir une structure rigide pour l'algorithme tout en laissant aux sous-classes la flexibilité d'implémenter les détails spécifiques. Cela réduit la duplication de code, améliore la maintenabilité et favorise la réutilisation.

En résumé, le Template Method est un outil puissant pour standardiser le comportement d'un algorithme tout en permettant une personnalisation précise. Il est particulièrement utile lorsque plusieurs classes partagent une structure algorithmique similaire mais diffèrent dans la manière dont certaines étapes sont réalisées, offrant ainsi un bon compromis entre structure et flexibilité.

4. Utilisation de Metaclasses pour les Design Patterns en Python

Les métaclasses en Python sont des "usines" à classes. Elles permettent de contrôler et de personnaliser la création des classes elles-mêmes. Si les design patterns sont des solutions réutilisables à des problèmes de conception récurrents, les métaclasses peuvent automatiser l'application de certains de ces patterns, rendant le code plus concis et maintenable.

Prenons l'exemple d'un pattern de validation d'attributs. Supposons que nous voulions que toutes les classes d'un certain type aient un attribut spécifique avec un type particulier. Nous pouvons implémenter cela avec une métaclasse :


class AttributeValidator(type):
    def __new__(mcs, name, bases, attrs):
        # Check if the required attribute exists and has the correct type
        required_attribute = "name" # defining the required attribute
        attribute_type = str # defining the attribute type

        if required_attribute not in attrs:
            raise ValueError(f"Class {name} must define attribute '{required_attribute}'")

        if not isinstance(attrs[required_attribute], attribute_type):
            raise TypeError(f"Attribute '{required_attribute}' in class {name} must be of type {attribute_type}")

        return super().__new__(mcs, name, bases, attrs)

class ValidatedClass(metaclass=AttributeValidator):
    name = "Example" # attribute validated by the metaclass

# This class will raise an error because 'name' is missing
# class InvalidClass(metaclass=AttributeValidator):
#    pass

Dans cet exemple, AttributeValidator est une métaclasse qui vérifie si chaque classe qui l'utilise (via metaclass=AttributeValidator) possède un attribut name de type str. Si ce n'est pas le cas, une exception est levée lors de la création de la classe.


class PluginRegistry(type):
    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        if hasattr(cls, 'plugin_name'):
            PluginRegistry.register_plugin(cls.plugin_name, cls)

    _plugins = {}

    @classmethod
    def register_plugin(cls, name, plugin_class):
        cls._plugins[name] = plugin_class

    @classmethod
    def get_plugin(cls, name):
        return cls._plugins.get(name)

class BasePlugin(metaclass=PluginRegistry):
    pass

class ImagePlugin(BasePlugin):
    plugin_name = "image"

class TextPlugin(BasePlugin):
    plugin_name = "text"

# Accessing the registered plugins
image_plugin = PluginRegistry.get_plugin("image")
text_plugin = PluginRegistry.get_plugin("text")

print(PluginRegistry._plugins)  # Output: {'image': , 'text': }

Ici, la métaclasse PluginRegistry enregistre automatiquement toute classe qui hérite de BasePlugin et définit un attribut plugin_name. Cela évite d'avoir à enregistrer manuellement chaque plugin, réduisant ainsi les risques d'erreurs et la quantité de code répétitif.

L'utilisation des métaclasses pour implémenter des design patterns peut améliorer la structure et la maintenabilité du code, en automatisant des tâches répétitives et en centralisant la logique de conception. Cependant, il est important de noter que les métaclasses peuvent rendre le code plus complexe et plus difficile à comprendre si elles sont utilisées de manière excessive ou inappropriée. Il est donc essentiel de les utiliser judicieusement et avec une bonne compréhension de leur fonctionnement.

4.1 Metaclasses pour l'Implémentation du Singleton

Les metaclasses offrent une approche élégante et puissante pour implémenter le pattern Singleton en Python. Elles permettent de contrôler le processus de création des classes elles-mêmes, fournissant ainsi un mécanisme centralisé pour garantir qu'une seule instance d'une classe soit créée, quel que soit le nombre de fois où l'on tente de l'instancier.

L'idée clé est de définir une metaclasse qui surcharge la méthode __call__. En Python, __call__ est invoquée lorsqu'une instance de la classe est "appelée", c'est-à-dire instanciée. En surchargeant cette méthode dans notre metaclasse, nous pouvons intercepter le processus d'instanciation, vérifier si une instance existe déjà et, si c'est le cas, retourner l'instance existante au lieu d'en créer une nouvelle. Ceci permet d'assurer l'unicité de l'instance.


class SingletonMeta(type):
    """
    A metaclass that ensures only one instance of a class exists.
    """
    _instances = {}  # Dictionary to store class instances

    def __call__(cls, *args, **kwargs):
        """
        Override the __call__ method to control instance creation.
        """
        if cls not in cls._instances:
            # If the class is not yet in the _instances dictionary, create a new instance
            cls._instances[cls] = super().__call__(*args, **kwargs)
        # Return the instance stored in the dictionary
        return cls._instances[cls]


class Configuration(metaclass=SingletonMeta):
    """
    A sample class that uses the SingletonMeta metaclass.
    """
    def __init__(self, setting1="default_value", setting2=100):
        self.setting1 = setting1
        self.setting2 = setting2

    def display_settings(self):
        """
        Displays the current settings.
        """
        print(f"Setting 1: {self.setting1}, Setting 2: {self.setting2}")


# Example usage
config1 = Configuration(setting1="new_value", setting2=200)
config1.display_settings()

config2 = Configuration()
config2.display_settings()

# Verify that both variables point to the same object
print(config1 is config2)

Dans cet exemple, SingletonMeta est notre metaclasse Singleton. Elle maintient un dictionnaire, _instances, pour stocker les instances des classes qui l'utilisent. La première fois que Configuration est instanciée, __call__ vérifie si une instance de Configuration existe déjà dans _instances. Comme ce n'est pas le cas lors du premier appel, une nouvelle instance est créée via super().__call__(*args, **kwargs) et stockée dans _instances. Les appels subséquents à Configuration() retournent directement l'instance stockée dans _instances, assurant qu'il n'existe qu'une seule et unique instance.

L'utilisation de metaclasses pour implémenter le pattern Singleton en Python présente plusieurs avantages notables :

  • Clarté et concision : Le code devient plus lisible et plus facile à maintenir. La logique du Singleton est encapsulée dans la metaclasse, évitant la duplication de code.
  • Centralisation : La logique de gestion de l'unicité de l'instance est centralisée au sein de la metaclasse, facilitant les modifications et la maintenance.
  • Réutilisabilité : La metaclasse SingletonMeta peut être réutilisée pour transformer n'importe quelle classe en Singleton, simplement en spécifiant metaclass=SingletonMeta lors de la définition de la classe.
  • Contrôle précis : La metaclasse permet un contrôle précis sur le processus d'instanciation, permettant d'ajouter des vérifications ou des comportements spécifiques si nécessaire.

En conclusion, les metaclasses offrent une méthode élégante, puissante et réutilisable pour implémenter le pattern Singleton en Python. Elles permettent d'encapsuler la logique du Singleton de manière propre et centralisée, assurant qu'une seule instance d'une classe est créée, simplifiant ainsi la gestion des ressources et la cohérence de l'état de l'application.

4.2 Metaclasses pour l'Enregistrement de Classes

Les métaclasses offrent un mécanisme puissant pour automatiser l'enregistrement des classes, une fonctionnalité particulièrement utile dans les patrons de conception tels que Factory et Abstract Factory. L'enregistrement automatique simplifie la gestion des classes disponibles et permet de les instancier dynamiquement en fonction de critères spécifiques, réduisant ainsi la redondance et améliorant la maintenabilité du code.

Voici un exemple concret d'utilisation d'une métaclasse pour enregistrer automatiquement des classes de paiement :


class PaymentRegistry(type):
    """
    Metaclass that automatically registers payment methods.
    """
    def __init__(cls, name, bases, attrs):
        """
        Initializes the class. Called when a class is created using this metaclass.
        """
        super().__init__(name, bases, attrs)
        if not hasattr(cls, 'registry'):
            cls.registry = {}  # Initialize the registry on the base class
        else:
            cls.registry[name] = cls  # Register the concrete class

class PaymentMethod(metaclass=PaymentRegistry):
    """
    Base class for payment methods. All subclasses will be automatically registered.
    """
    pass

class CreditCardPayment(PaymentMethod):
    """
    Payment method using credit card.
    """
    def process_payment(self, amount, card_number, expiry_date, cvv):
        print(f"Processing credit card payment of {amount} using card {card_number}")

class PayPalPayment(PaymentMethod):
    """
    Payment method using PayPal.
    """
    def process_payment(self, amount, paypal_email):
        print(f"Processing PayPal payment of {amount} using email {paypal_email}")

Dans cet exemple, la métaclasse PaymentRegistry intercepte la création de chaque sous-classe de PaymentMethod. Lorsqu'une classe hérite de PaymentMethod, la méthode __init__ de la métaclasse est appelée. Elle maintient un registre des classes disponibles dans l'attribut registry. Ainsi, les classes CreditCardPayment et PayPalPayment sont automatiquement enregistrées sans nécessiter de code d'enregistrement explicite. Cela permet d'accéder dynamiquement aux classes de paiement via leur nom, facilitant l'implémentation de patrons comme le Factory. La base class PaymentMethod n'a pas besoin d'implémentation spécifique, elle sert de marqueur pour l'enregistrement.

Pour illustrer davantage, on peut utiliser ce registre pour créer une "Factory" de méthodes de paiement :


def payment_factory(payment_method_name):
    """
    Factory function to create payment method instances.
    """
    payment_class = PaymentMethod.registry.get(payment_method_name)
    if not payment_class:
        raise ValueError(f"Payment method '{payment_method_name}' not found")
    return payment_class()

# Example usage:
try:
    payment = payment_factory('CreditCardPayment')
    payment.process_payment(100, "1234-5678-9012-3456", "12/24", "123")
except ValueError as e:
    print(e)

try:
    payment = payment_factory('PayPalPayment')
    payment.process_payment(50, "test@paypal.com")
except ValueError as e:
    print(e)

try:
    payment = payment_factory('UnknownPayment')
    payment.process_payment(50, "test@paypal.com")
except ValueError as e:
    print(e)

Ce code démontre comment la fonction payment_factory utilise le registre de PaymentMethod pour instancier dynamiquement la classe de paiement demandée. Si le nom de la méthode de paiement n'est pas trouvé dans le registre, une exception ValueError est levée. L'exemple inclut également une gestion d'erreur pour le cas où une méthode de paiement inconnue est demandée.

En résumé, les métaclasses simplifient considérablement l'enregistrement de classes, rendant le code plus propre et plus maintenable, tout en facilitant l'implémentation de patrons de conception qui nécessitent une gestion dynamique des types. Elles offrent une solution élégante pour automatiser l'enregistrement, réduisant la duplication de code et améliorant la flexibilité de l'application.

5. Fonctionnalités Uniques de Python et Design Patterns

Python, avec sa nature dynamique et sa syntaxe élégante, offre des opportunités uniques pour l'implémentation des design patterns. Certaines caractéristiques du langage simplifient considérablement la mise en œuvre de patterns complexes, les rendant parfois plus intuitifs.

Les décorateurs sont une fonctionnalité puissante de Python, souvent utilisés pour ajouter dynamiquement des responsabilités à des objets, conformément au pattern Décorateur. Plutôt que d'hériter d'une classe, on peut envelopper un objet dans un décorateur qui ajoute des comportements supplémentaires.


# Define a decorator to make text bold
def bold_decorator(func):
    def wrapper(*args, **kwargs):
        return "<strong>" + func(*args, **kwargs) + "</strong>"
    return wrapper

# Define a decorator to make text italic
def italic_decorator(func):
    def wrapper(*args, **kwargs):
        return "<em>" + func(*args, **kwargs) + "</em>"
    return wrapper

# Apply the decorators to the function
@italic_decorator
@bold_decorator
def get_message():
    return "Hello, decorated world!"

# Using the decorated function
message = get_message()
print(message) # Output: <em><strong>Hello, decorated world!</strong></em>

Dans cet exemple, bold_decorator et italic_decorator sont des fonctions qui prennent une autre fonction en argument et renvoient une nouvelle fonction enveloppée. L'opérateur @ est un sucre syntaxique pour appliquer le décorateur. Ainsi, la fonction get_message est modifiée dynamiquement pour ajouter des balises <strong> et <em> au texte renvoyé.

Une autre caractéristique notable de Python est sa gestion des métaclasses, qui offre un contrôle précis sur la création des classes elles-mêmes. Ceci peut être exploité pour implémenter le pattern Abstract Factory d'une manière plus déclarative et centralisée. Au lieu de créer des classes factory explicites, on peut utiliser une métaclasse pour gérer l'instanciation des classes concrètes basées sur une configuration ou un contexte spécifique.


# Define a metaclass for abstract products
class AbstractProductMeta(type):
    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        if not hasattr(cls, 'products'):
            cls.products = {}
        else:
            # Register concrete products
            cls.products[name] = cls

# Define an abstract product class using the metaclass
class AbstractProduct(metaclass=AbstractProductMeta):
    pass

# Define concrete product classes
class ConcreteProductA(AbstractProduct):
    def __init__(self):
        self.name = "Product A"

class ConcreteProductB(AbstractProduct):
    def __init__(self):
        self.name = "Product B"

# Access registered products through the base class
product_a = AbstractProduct.products['ConcreteProductA']()
print(product_a.name)

product_b = AbstractProduct.products['ConcreteProductB']()
print(product_b.name)

Ici, AbstractProductMeta est une métaclasse qui enregistre automatiquement les classes filles de AbstractProduct dans un dictionnaire products. Cela permet d'accéder aux classes concrètes via la classe abstraite, simulant le comportement d'une factory sans avoir à implémenter une classe factory dédiée. Cette approche offre une alternative élégante et centralisée pour gérer la création d'objets.

Les context managers, utilisés avec l'instruction with, sont particulièrement utiles pour implémenter le pattern Template Method. Ils permettent de définir un contexte dans lequel certaines opérations doivent être exécutées avant et après un bloc de code, garantissant ainsi que les ressources sont correctement gérées ou que certaines conditions sont remplies.


import time

# Define a context manager for timing code execution
class TimerContext:
    def __enter__(self):
        self.start_time = time.time()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.end_time = time.time()
        print(f"Elapsed time: {self.end_time - self.start_time:.4f} seconds")

# Define a function to be timed
def my_function():
    time.sleep(1)

# Using the TimerContext with 'with' statement
with TimerContext():
    my_function()

Dans cet exemple, TimerContext mesure le temps d'exécution du bloc de code à l'intérieur de l'instruction with. La méthode __enter__ enregistre le temps de début, et la méthode __exit__ calcule et affiche le temps écoulé. Cela encapsule la logique de mesure du temps et garantit qu'elle est exécutée, même en cas d'exception, offrant une manière propre et fiable de gérer les ressources ou d'effectuer des opérations avant et après l'exécution d'un bloc de code.

En conclusion, Python offre des outils puissants comme les décorateurs, les métaclasses et les context managers qui facilitent l'implémentation élégante et concise de nombreux design patterns. Comprendre ces fonctionnalités permet de créer des solutions plus robustes, maintenables et adaptées aux défis de développement modernes.

5.1 Utilisation des Décorateurs pour les Patterns

Python, avec sa syntaxe limpide et ses puissantes capacités, se révèle particulièrement adapté à une implémentation élégante des design patterns. Les décorateurs, notamment, offrent une approche concise et expressive pour appliquer ces patterns, évitant ainsi la redondance de code et améliorant significativement la lisibilité.

Le pattern Decorator, par exemple, s'implémente aisément grâce aux décorateurs Python. Imaginez une classe de base représentant une simple notification. Supposons que vous souhaitiez ajouter des fonctionnalités telles que le chiffrement ou la compression avant l'envoi. Au lieu de modifier directement la classe de notification, vous pouvez créer des décorateurs qui ajoutent ces comportements de manière dynamique, sans altérer la classe d'origine.


class Notifier:
    def send(self, message):
        print(f"Sending notification: {message}")

def encrypt_message(func):
    def wrapper(self, message):
        encrypted_message = f"Encrypted: {message}"  # Simulate encryption
        return func(self, encrypted_message)
    return wrapper

def compress_message(func):
    def wrapper(self, message):
        compressed_message = f"Compressed: {message}"  # Simulate compression
        return func(self, compressed_message)
    return wrapper

@encrypt_message
@compress_message
class SecureNotifier(Notifier):
    pass

# Example Usage:
secure_notifier = SecureNotifier()
secure_notifier.send("This is a secret message.")

Dans cet exemple, encrypt_message et compress_message sont des décorateurs qui modifient le comportement de la méthode send sans modifier la classe Notifier directement. Ceci illustre la puissance des décorateurs pour l'ajout de responsabilités.

Les décorateurs peuvent aussi simplifier l'implémentation du pattern Observer. On peut créer un décorateur qui enregistre automatiquement une fonction comme observateur pour un événement spécifique, ce qui permet de découpler la logique d'enregistrement des observateurs du code principal.


class EventManager:
    def __init__(self):
        # Dictionary to store observers for each event
        self._observers = {}

    def subscribe(self, event_name, observer):
        # Add an observer to a specific event
        if event_name not in self._observers:
            self._observers[event_name] = []
        self._observers[event_name].append(observer)

    def unsubscribe(self, event_name, observer):
        # Remove an observer from a specific event
        if event_name in self._observers:
            self._observers[event_name].remove(observer)

    def notify(self, event_name, data=None):
        # Notify all observers of a specific event
        if event_name in self._observers:
            for observer in self._observers[event_name]:
                observer(data)

# Event Manager Instance
event_manager = EventManager()

def event_subscriber(event_name):
    # Decorator to subscribe a function to an event
    def decorator(func):
        event_manager.subscribe(event_name, func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
    return decorator

# Example Usage:
@event_subscriber("user_logged_in")
def log_user_login(user_data):
    print(f"User logged in: {user_data['username']}")

@event_subscriber("order_created")
def send_order_confirmation(order_data):
    print(f"Order confirmation sent for order ID: {order_data['order_id']}")

# Simulate events
event_manager.notify("user_logged_in", {"username": "john.doe"})
event_manager.notify("order_created", {"order_id": "12345"})

Dans cet exemple, le décorateur event_subscriber simplifie l'enregistrement des fonctions en tant qu'observateurs pour différents événements. Cela évite d'avoir à appeler explicitement event_manager.subscribe pour chaque observateur.

En conclusion, les décorateurs Python offrent une méthode élégante et efficace pour implémenter des design patterns, fournissant une syntaxe claire et concise pour l'ajout de comportements et la gestion des responsabilités. Ils contribuent à maintenir un code propre, modulaire, DRY (Don't Repeat Yourself) et facile à maintenir, tout en améliorant sa lisibilité et son expressivité.

5.2 Utilisation des Générateurs et Itérateurs

Python offre des mécanismes puissants pour la création d'itérateurs personnalisés grâce aux générateurs et aux itérateurs. Ces fonctionnalités permettent d'implémenter le pattern Iterator de manière élégante et efficace, en particulier lorsqu'il s'agit de parcourir de grandes collections de données sans avoir à les charger entièrement en mémoire. L'utilisation judicieuse de ces outils améliore significativement la performance et la lisibilité du code.

Un itérateur est un objet qui implémente le protocole iterator, c'est-à-dire qu'il doit posséder les méthodes __iter__() et __next__(). La méthode __iter__() retourne l'objet itérateur lui-même, et est utilisée pour initialiser l'itérateur. La méthode __next__() retourne l'élément suivant de la séquence. Lorsqu'il n'y a plus d'éléments disponibles, __next__() lève une exception StopIteration, signalant la fin de l'itération.

Un générateur est une fonction spéciale qui utilise le mot-clé yield pour produire une séquence de valeurs à la demande. Lorsqu'un générateur est appelé, il retourne un objet générateur, qui est un type spécial d'itérateur. Chaque fois que yield est rencontré, l'état du générateur est sauvegardé (y compris les valeurs des variables locales) et la valeur est retournée. L'exécution reprend à partir de cet état lors de l'appel suivant à __next__(). Les générateurs sont particulièrement utiles pour créer des itérateurs complexes de manière concise et mémoire-efficace, car ils ne calculent et ne stockent les valeurs qu'au moment où elles sont demandées.

Voici un exemple d'implémentation du pattern Iterator en utilisant une classe pour parcourir une liste de fichiers dans un répertoire, en ne retournant que ceux ayant une certaine extension :


import os

class FileIterator:
    """
    Iterator that yields files with a specific extension from a directory.
    """
    def __init__(self, directory, extension):
        """
        Initializes the FileIterator.

        Args:
            directory (str): The directory to iterate through.
            extension (str): The file extension to filter by.
        """
        self.directory = directory
        self.extension = extension
        self.file_list = [f for f in os.listdir(directory) if f.endswith(extension)]
        self.index = 0

    def __iter__(self):
        """
        Returns the iterator object itself.
        """
        return self

    def __next__(self):
        """
        Returns the next file with the specified extension.

        Raises:
            StopIteration: If there are no more files with the specified extension.
        """
        if self.index < len(self.file_list):
            file_name = self.file_list[self.index]
            self.index += 1
            return os.path.join(self.directory, file_name)
        else:
            raise StopIteration

# Example usage
directory_path = "/path/to/your/directory" # Replace with your directory
extension_filter = ".txt"
file_iterator = FileIterator(directory_path, extension_filter)

for file_path in file_iterator:
    print(file_path)

Une implémentation plus concise et élégante utilisant un générateur serait :


import os

def file_generator(directory, extension):
    """
    Generator that yields files with a specific extension from a directory.

    Args:
        directory (str): The directory to iterate through.
        extension (str): The file extension to filter by.

    Yields:
        str: The path to the next file with the specified extension.
    """
    for file_name in os.listdir(directory):
        if file_name.endswith(extension):
            yield os.path.join(directory, file_name)

# Example usage
directory_path = "/path/to/your/directory" # Replace with your directory
extension_filter = ".txt"

for file_path in file_generator(directory_path, extension_filter):
    print(file_path)

En résumé, l'utilisation de générateurs et d'itérateurs en Python est un moyen puissant d'implémenter le pattern Iterator. Ils offrent une manière élégante et efficace de parcourir des collections de données, en particulier lorsqu'il s'agit de grandes quantités de données ou de sources de données dynamiques. Cette approche favorise non seulement la lisibilité et la maintenabilité du code, mais aussi son efficacité en termes d'utilisation de la mémoire et de temps d'exécution.

6. Anti-Patterns Courants en Python et Comment les Éviter

En Python, comme dans tout langage de programmation, certains schémas de conception peuvent mener à du code difficile à maintenir, à comprendre ou à étendre. Ces schémas sont appelés "anti-patterns". Reconnaître et éviter ces anti-patterns est crucial pour écrire du code Python propre et efficace.

Arguments Mutables par Défaut: Un anti-pattern courant est l'utilisation d'objets mutables comme arguments par défaut de fonctions. Comme les arguments par défaut sont évalués une seule fois au moment de la définition de la fonction, l'objet mutable sera partagé entre tous les appels à la fonction qui n'explicitent pas cet argument. Cela peut entraîner des comportements inattendus et des effets de bord non désirés.


def append_to_list(item, my_list=[]):
    """
    Appends an item to a list.

    Args:
        item: The item to append.
        my_list: The list to append to (defaults to an empty list).
    """
    my_list.append(item)
    return my_list

# Example usage demonstrating the anti-pattern
list1 = append_to_list(1)
print(list1)  # Output: [1]

list2 = append_to_list(2)
print(list2)  # Output: [1, 2] - Not what we expected!  The list is shared between calls.

list3 = append_to_list(3, [])
print(list3)  # Output: [3] - Correct, because a new list was passed.

list4 = append_to_list(4)
print(list4)  # Output: [1, 2, 4] - Still affected by previous calls!

Pour éviter cet anti-pattern, utilisez None comme valeur par défaut et créez une nouvelle liste à l'intérieur de la fonction si l'argument est None. Ceci garantit que chaque appel à la fonction, sans argument explicite pour my_list, opère sur une nouvelle liste.


def append_to_list_fixed(item, my_list=None):
    """
    Appends an item to a list (fixed version).

    Args:
        item: The item to append.
        my_list: The list to append to (defaults to None).
    """
    if my_list is None:
        my_list = []  # Create a new list if my_list is None
    my_list.append(item)
    return my_list

# Corrected Example usage
list1 = append_to_list_fixed(1)
print(list1) # Output: [1]

list2 = append_to_list_fixed(2)
print(list2) # Output: [2]

list3 = append_to_list_fixed(3, [])
print(list3) # Output: [3]

list4 = append_to_list_fixed(4)
print(list4) # Output: [4]

Gestion d'Exceptions Excessivement Générique: Attraper toutes les exceptions sans discernement (par exemple, avec un simple except:) est un autre anti-pattern. Cela peut masquer des erreurs importantes, comme des NameError ou des FileNotFoundError, et rendre le débogage très difficile. Il est préférable d'attraper uniquement les exceptions que vous pouvez réellement gérer ou celles dont vous pouvez faire quelque chose de spécifique.


def divide(x, y):
    """
    Divides two numbers.
    """
    try:
        result = x / y
    except:  # Avoid this! Catches all exceptions - BAD PRACTICE
        print("An error occurred!")
        return None
    return result

Préférez une gestion plus spécifique des exceptions. Cela permet de mieux comprendre la nature de l'erreur et de prendre des mesures appropriées. De plus, cela évite de masquer des erreurs inattendues.


def divide_fixed(x, y):
    """
    Divides two numbers (fixed version).
    """
    try:
        result = x / y
    except ZeroDivisionError:
        print("Cannot divide by zero!")
        return None
    except TypeError:
        print("Invalid input types. Please use numbers.")
        return None
    except Exception as e: # Catch-all for unexpected errors, log it, and re-raise if needed
        print(f"An unexpected error occurred: {e}")
        # Log the error to a file or monitoring system
        # raise  # Re-raise the exception if you cannot handle it

    else: # Only executed if no exception occurs
        return result
    finally:
        # Optional: Code that always runs, regardless of exceptions (e.g., cleanup)
        pass

Utilisation Abusive de Variables Globales: L'utilisation excessive de variables globales rend le code difficile à comprendre, à tester et à maintenir. Elle introduit un état global mutable, qui peut être modifié depuis n'importe quelle partie du programme, rendant le suivi des changements complexes et augmentant le risque d'effets de bord inattendus. Cela viole le principe de localité et rend le code moins modulaire.


GLOBAL_COUNTER = 0  # Avoid this!

def increment_counter():
    """
    Increments a global counter.
    """
    global GLOBAL_COUNTER
    GLOBAL_COUNTER += 1

increment_counter()
print(GLOBAL_COUNTER)

Il est préférable d'encapsuler l'état dans des classes ou d'utiliser des arguments de fonction et des valeurs de retour pour gérer les données explicitement. Cela favorise l'encapsulation et réduit la dépendance à un état global partagé.


class Counter:
    """
    A simple counter class.
    """
    def __init__(self):
        self.count = 0

    def increment(self):
        """
        Increments the counter.
        """
        self.count += 1

# Example usage
my_counter = Counter()
my_counter.increment()
print(my_counter.count)

Ignorer le Style Guide (PEP 8): Ne pas suivre les conventions de style définies dans PEP 8 rend le code moins lisible et moins cohérent avec le reste de l'écosystème Python. PEP 8 fournit des directives claires sur la mise en forme du code, la nomenclature des variables et des fonctions, et l'organisation des fichiers. Des outils comme flake8, pylint et black peuvent aider à automatiser la vérification du respect de PEP 8 et à formater automatiquement le code.

Duplication de Code (Copy-Paste Programming): Copier et coller des blocs de code est un anti-pattern majeur. Si vous vous retrouvez à dupliquer du code, extrayez-le dans une fonction ou une classe réutilisable. La duplication de code rend la maintenance plus difficile car toute modification doit être effectuée à plusieurs endroits. Le principe DRY (Don't Repeat Yourself) est fondamental pour un code propre et maintenable.


def calculate_area_rectangle(length, width):
    """
    Calculates the area of a rectangle.
    """
    return length * width

def calculate_perimeter_rectangle(length, width):
    """
    Calculates the perimeter of a rectangle.
    """
    return 2 * (length + width)

# Instead, use a class for better organization and reusability

class Rectangle:
    """
    Represents a rectangle.
    """
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def calculate_area(self):
        """
        Calculates the area of the rectangle.
        """
        return self.length * self.width

    def calculate_perimeter(self):
        """
        Calculates the perimeter of the rectangle.
        """
        return 2 * (self.length + self.width)

En résumé, être conscient de ces anti-patterns courants et adopter des pratiques de codage plus propres et plus structurées contribuera grandement à améliorer la qualité, la maintenabilité et l'évolutivité de vos projets Python. L'investissement dans la prévention de ces anti-patterns se traduira par un code plus robuste, plus facile à comprendre et à modifier, et moins sujet aux erreurs.

6.1 God Class

L'anti-pattern "God Class" se manifeste lorsqu'une classe unique assume une responsabilité excessive. Elle devient un point central où convergent de nombreuses fonctionnalités non liées, rendant le code difficile à comprendre, à maintenir et à tester. Cette classe tentaculaire viole le principe de responsabilité unique (Single Responsibility Principle - SRP), qui stipule qu'une classe ne devrait avoir qu'une seule raison de changer.

Prenons l'exemple d'une classe OrderProcessor qui gère à la fois la validation de la commande, le paiement et la gestion de l'inventaire. Un tel design centralise trop de responsabilités au même endroit.


class OrderProcessor:
    def __init__(self, order_data):
        self.order_data = order_data

    def validate_order(self):
        # Order validation logic
        if not all(key in self.order_data for key in ['customer_id', 'items', 'shipping_address']):
            raise ValueError("Invalid order data: Missing required fields.")
        # Additional complex validation rules...
        return True

    def process_payment(self):
        # Payment processing logic
        # Connect to payment gateway, handle transactions, etc.
        print("Payment processed successfully.")
        return True

    def update_inventory(self):
        # Inventory management logic
        # Update stock levels based on the order
        print("Inventory updated.")
        return True

    def process_order(self):
        if self.validate_order():
            if self.process_payment():
                self.update_inventory()
                print("Order processed successfully!")
            else:
                print("Payment failed.")
        else:
            print("Order validation failed.")

# Example usage
order_data = {'customer_id': 123, 'items': ['item1', 'item2'], 'shipping_address': '123 Main St'}
processor = OrderProcessor(order_data)
processor.process_order()

Cette approche rend la classe OrderProcessor difficile à maintenir et à faire évoluer. Chaque modification, même mineure, risque d'affecter d'autres parties de la classe. Les tests unitaires deviennent également complexes, car il est difficile d'isoler et de tester chaque fonctionnalité individuellement.

Pour éviter l'anti-pattern "God Class", il est préférable de décomposer cette classe en plusieurs classes plus petites et spécialisées, chacune ayant une responsabilité unique. Voici un exemple de refactoring:


class Order:
    def __init__(self, order_data):
        self.order_data = order_data

class OrderValidator:
    def validate_order(self, order: Order):
        if not all(key in order.order_data for key in ['customer_id', 'items', 'shipping_address']):
            raise ValueError("Invalid order data: Missing required fields.")
        return True

class PaymentService:
    def process_payment(self, order: Order):
        # Payment processing logic
        print("Payment processed successfully.")
        return True

class InventoryService:
    def update_inventory(self, order: Order):
        # Inventory management logic
        print("Inventory updated.")
        return True

class OrderService:
    def __init__(self, validator: OrderValidator, payment_service: PaymentService, inventory_service: InventoryService):
        self.validator = validator
        self.payment_service = payment_service
        self.inventory_service = inventory_service

    def process_order(self, order: Order):
        if self.validator.validate_order(order):
            if self.payment_service.process_payment(order):
                self.inventory_service.update_inventory(order)
                print("Order processed successfully!")
            else:
                print("Payment failed.")
        else:
            print("Order validation failed.")

# Example Usage
order_data = {'customer_id': 123, 'items': ['item1', 'item2'], 'shipping_address': '123 Main St'}
order = Order(order_data)
validator = OrderValidator()
payment_service = PaymentService()
inventory_service = InventoryService()
order_service = OrderService(validator, payment_service, inventory_service)

order_service.process_order(order)

Dans cet exemple refactorisé, chaque classe a une responsabilité claire et définie: OrderValidator valide les commandes, PaymentService gère les paiements et InventoryService met à jour l'inventaire. OrderService orchestre l'ensemble du processus en utilisant ces services. Cela rend le code plus modulaire, plus facile à tester et à maintenir.

En conclusion, la "God Class" est un anti-pattern à éviter en décomposant les grandes classes en entités plus petites et plus spécialisées, respectant ainsi le Single Responsibility Principle et améliorant la qualité globale du code. L'utilisation de principes de conception tels que l'inversion de dépendance (Dependency Inversion Principle - DIP) et l'injection de dépendances (Dependency Injection - DI) peut également aider à découpler les classes et à rendre le code plus flexible et réutilisable.

6.2 Spaghetti Code

Le "Spaghetti Code" est une métaphore désignant un code source dont la structure est difficile à suivre et à comprendre. Il est caractérisé par un flux de contrôle complexe, avec de nombreux sauts (jumps) d'une partie du code à une autre, rendant la maintenance et le débogage extrêmement ardues.

Voici un exemple de spaghetti code en Python :


def process_data(data, flag):
    result = []
    if flag == 1:
        for item in data:
            if item > 0:
                result.append(item * 2)
            else:
                result.append(0)
    elif flag == 2:
        for item in data:
            if item < 0:
                result.append(abs(item))
            else:
                result.append(item)
    else:
        for item in data:
            result.append(item)

    if len(result) > 5:
        final_result = [x + 1 for x in result]
    else:
        final_result = result

    return final_result

Ce code est difficile à lire et à maintenir car il contient plusieurs blocs conditionnels imbriqués et effectue des opérations différentes en fonction de la valeur de flag. De plus, le traitement final dépend de la longueur de result, ce qui ajoute encore à la complexité.

Pour éviter de tomber dans le piège du spaghetti code, il est crucial d'appliquer des principes de conception solides et d'utiliser des design patterns appropriés. Voici quelques stratégies clés :

  • Découplage : Réduire les dépendances entre les différentes parties du code. Cela permet de modifier une partie du système sans impacter le reste. Utiliser des interfaces et des abstractions pour masquer les détails d'implémentation.
  • Encapsulation : Regrouper les données et les méthodes qui les manipulent au sein de classes ou de modules. Cela permet de contrôler l'accès aux données et de masquer la complexité interne.
  • Modularisation : Décomposer le code en modules plus petits et plus gérables, chacun ayant une responsabilité bien définie. Cela facilite la compréhension et la maintenance du code.
  • Design Patterns : Utiliser des design patterns éprouvés pour résoudre des problèmes de conception récurrents. Par exemple, le pattern Stratégie peut être utilisé pour remplacer les longues chaînes de if/elif/else par des objets interchangeables.

Reprenons l'exemple précédent et appliquons le pattern Stratégie :


class DataProcessor:
    def __init__(self, strategy):
        self.strategy = strategy

    def process(self, data):
        return self.strategy.process(data)

class Strategy:
    def process(self, data):
        raise NotImplementedError("Subclasses must implement this method")

class Strategy1(Strategy):
    def process(self, data):
        return [item * 2 if item > 0 else 0 for item in data]

class Strategy2(Strategy):
    def process(self, data):
        return [abs(item) if item < 0 else item for item in data]

class StrategyDefault(Strategy):
    def process(self, data):
        return data[:]  # Return a copy of the data

# Example usage
data = [1, -2, 3, -4, 5]

# Using Strategy 1
processor = DataProcessor(Strategy1())
result1 = processor.process(data)
print(f"Result with Strategy 1: {result1}")

# Using Strategy 2
processor = DataProcessor(Strategy2())
result2 = processor.process(data)
print(f"Result with Strategy 2: {result2}")

# Using Default Strategy
processor = DataProcessor(StrategyDefault())
result_default = processor.process(data)
print(f"Result with Default Strategy: {result_default}")

Dans cet exemple, chaque stratégie encapsule une logique de traitement des données spécifique. La classe DataProcessor utilise une stratégie pour traiter les données, ce qui rend le code plus modulaire et plus facile à maintenir. De plus, l'ajout de nouvelles stratégies ne nécessite pas de modifier le code existant.

En conclusion, éviter le spaghetti code nécessite une attention constante à la conception du code, l'application de principes de conception solides, et l'utilisation judicieuse de design patterns. L'investissement initial dans une architecture propre et modulaire se traduit par une maintenance simplifiée, une réduction des bugs, et une plus grande flexibilité pour l'évolution future du système.

7. Cas d'utilisation pratiques

Les Design Patterns ne sont pas que des concepts théoriques ; ils trouvent des applications concrètes dans divers scénarios de développement logiciel. Explorons quelques cas d'utilisation pratiques, illustrant la manière dont ces patterns peuvent simplifier et améliorer votre code Python.

Pattern Observateur (Observer) : Gestion d'événements personnalisés

Imaginez une application où plusieurs composants doivent réagir à un événement spécifique, par exemple, la modification d'un fichier de configuration. Le pattern Observateur offre une solution élégante pour gérer ce type de dépendances en permettant à un objet (le sujet) de notifier automatiquement un ensemble d'autres objets (les observateurs) lorsque son état change.


class Event:
    """Represents an event."""
    def __init__(self, data):
        self.data = data

class Observer:
    """Abstract class for observers."""
    def update(self, event: Event):
        """Called when the subject's state changes."""
        raise NotImplementedError("Subclasses must implement update method")

class Subject:
    """Abstract class for subjects."""
    def __init__(self):
        self._observers = []

    def attach(self, observer: Observer):
        """Attaches an observer to the subject."""
        self._observers.append(observer)

    def detach(self, observer: Observer):
        """Detaches an observer from the subject."""
        self._observers.remove(observer)

    def notify(self, event: Event):
        """Notifies all observers about the event."""
        for observer in self._observers:
            observer.update(event)

class ConfigurationSubject(Subject):
    """Concrete subject that manages configuration changes."""
    def __init__(self):
        super().__init__()
        self._config = {}

    def set_config(self, config: dict):
        """Sets the configuration and notifies observers."""
        self._config = config
        self.notify(Event(config))

class LoggingObserver(Observer):
    """Concrete observer that logs configuration changes."""
    def update(self, event: Event):
        """Logs the configuration data."""
        print(f"Logging configuration change: {event.data}")

class AlertingObserver(Observer):
    """Concrete observer that sends alerts on configuration changes."""
    def update(self, event: Event):
        """Sends an alert about the configuration change."""
        print(f"Sending alert: Configuration changed to {event.data}")

# Example usage
config_subject = ConfigurationSubject()
logging_observer = LoggingObserver()
alerting_observer = AlertingObserver()

config_subject.attach(logging_observer)
config_subject.attach(alerting_observer)

config_subject.set_config({"api_key": "new_key", "timeout": 60})

Dans cet exemple, ConfigurationSubject notifie les observateurs LoggingObserver et AlertingObserver lorsqu'un changement de configuration se produit. Ceci démontre comment le pattern Observateur permet de découpler les composants et de réagir de manière flexible aux changements d'état. Les observateurs ne nécessitent aucune connaissance préalable du sujet, ce qui promeut un couplage faible et une grande modularité.

Pattern Décorateur (Decorator) : Ajout de fonctionnalités dynamiques

Supposons que vous ayez une classe de base représentant un service et que vous souhaitiez ajouter des fonctionnalités supplémentaires à ce service de manière dynamique, sans modifier la classe de base. Le pattern Décorateur permet d'encapsuler le service dans un ou plusieurs décorateurs qui ajoutent des responsabilités. C'est une alternative puissante à l'héritage pour étendre les fonctionnalités.


from abc import ABC, abstractmethod

class BaseService(ABC):
    """Abstract base class for services."""
    @abstractmethod
    def execute(self):
        """Executes the service."""
        pass

class ConcreteService(BaseService):
    """Concrete implementation of the base service."""
    def execute(self):
        """Executes the core service functionality."""
        return "Core Service"

class ServiceDecorator(BaseService):
    """Abstract decorator class."""
    def __init__(self, service: BaseService):
        self._service = service

    def execute(self):
        """Delegates execution to the decorated service."""
        return self._service.execute()

class LoggingDecorator(ServiceDecorator):
    """Concrete decorator that adds logging functionality."""
    def execute(self):
        """Logs the execution and then executes the service."""
        result = super().execute()
        return f"Logged: {result}"

class CachingDecorator(ServiceDecorator):
    """Concrete decorator that adds caching functionality."""
    def __init__(self, service: BaseService):
        super().__init__(service)
        self._cache = None

    def execute(self):
        """Checks the cache before executing the service."""
        if self._cache is None:
            self._cache = super().execute()
        return f"Cached: {self._cache}"

# Example usage
service = ConcreteService()
logged_service = LoggingDecorator(service)
cached_logged_service = CachingDecorator(logged_service)

print(service.execute())
print(logged_service.execute())
print(cached_logged_service.execute()) # First execution - caches the result
print(cached_logged_service.execute()) # Second execution - retrieves from cache

Dans cet exemple, LoggingDecorator et CachingDecorator enveloppent ConcreteService pour ajouter des fonctionnalités de journalisation et de mise en cache, respectivement. Les décorateurs peuvent être combinés pour ajouter plusieurs fonctionnalités dynamiquement. Ceci démontre comment le pattern Décorateur permet d'étendre les fonctionnalités d'un objet sans héritage, offrant une plus grande flexibilité et modularité. On peut aisément ajouter d'autres décorateurs, comme un décorateur d'autorisation ou de validation, sans impacter le code existant.

Ces exemples illustrent la puissance des Design Patterns pour résoudre des problèmes courants de conception logicielle en Python. En les utilisant judicieusement, vous pouvez écrire un code plus propre, plus maintenable et plus évolutif, facilitant la collaboration et réduisant les risques d'erreurs.

8. Exercices

Pour consolider votre compréhension des design patterns, voici quelques exercices pratiques. Ces exercices vous aideront à identifier, implémenter et adapter des patterns dans différents contextes.

Exercice 1 : Le pattern Strategy

Imaginez un système de gestion de promotions pour une boutique en ligne. Différents types de promotions peuvent être appliqués à une commande, tels que des réductions fixes, des pourcentages ou des offres spéciales. Utilisez le pattern Strategy pour implémenter un système flexible où la stratégie de promotion peut être changée dynamiquement.


class Order:
    def __init__(self, total, promotion=None):
        self.total = total
        self.promotion = promotion

    def calculate_discount(self):
        if self.promotion:
            return self.promotion.apply_discount(self.total)
        return 0

    def calculate_final_price(self):
        discount = self.calculate_discount()
        return self.total - discount

class Promotion:
    def apply_discount(self, total):
        pass  # Abstract method

class FixedDiscount(Promotion):
    def __init__(self, discount_amount):
        self.discount_amount = discount_amount

    def apply_discount(self, total):
        return min(self.discount_amount, total)

class PercentageDiscount(Promotion):
    def __init__(self, percentage):
        self.percentage = percentage / 100

    def apply_discount(self, total):
        return total * self.percentage

# Example Usage
order1 = Order(100, FixedDiscount(10))
print(f"Final price for order 1: {order1.calculate_final_price()}") # Output: Final price for order 1: 90.0

order2 = Order(100, PercentageDiscount(20))
print(f"Final price for order 2: {order2.calculate_final_price()}") # Output: Final price for order 2: 80.0

order3 = Order(100)
print(f"Final price for order 3: {order3.calculate_final_price()}") # Output: Final price for order 3: 100

Exercice 2 : Le pattern Observer

Considérez un système de notification d'événements. Un objet (le sujet) maintient une liste d'objets dépendants (les observateurs) et les notifie automatiquement de tout changement d'état, par exemple, un changement de prix d'une action en bourse. Créez une simulation simplifiée avec un sujet qui gère un prix et des observateurs qui sont notifiés lorsque le prix change.


class Stock:
    def __init__(self, symbol, price):
        self.symbol = symbol
        self.price = price
        self._observers = []

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

    def notify(self):
        for observer in self._observers:
            observer.update(self)

    def set_price(self, new_price):
        if self.price != new_price:
            self.price = new_price
            self.notify()

class Investor:
    def __init__(self, name):
        self.name = name

    def update(self, stock):
        print(f"{self.name} received update: {stock.symbol} price changed to {stock.price}")

# Example Usage
stock = Stock("AAPL", 150)
investor1 = Investor("Alice")
investor2 = Investor("Bob")

stock.attach(investor1)
stock.attach(investor2)

stock.set_price(155) # Output: Alice received update: AAPL price changed to 155
                      # Output: Bob received update: AAPL price changed to 155

stock.detach(investor1)
stock.set_price(160) # Output: Bob received update: AAPL price changed to 160

Exercice 3 : Le pattern Decorator

Développez un système de création de personnages pour un jeu vidéo. Vous avez une classe de base pour un personnage et vous voulez ajouter des compétences ou des équipements supplémentaires de manière dynamique, comme une armure, des pouvoirs magiques, etc. Utilisez le pattern Decorator pour implémenter cette fonctionnalité.


class GameCharacter:
    def __init__(self, name, health, attack):
        self.name = name
        self.health = health
        self.attack = attack

    def get_description(self):
        return f"{self.name} - Health: {self.health}, Attack: {self.attack}"

class CharacterDecorator:
    def __init__(self, character):
        self.character = character

    def get_description(self):
        return self.character.get_description()

class ArmorDecorator(CharacterDecorator):
    def __init__(self, character, armor_points):
        super().__init__(character)
        self.armor_points = armor_points

    def get_description(self):
        return f"{super().get_description()}, Armor: +{self.armor_points}"

    @property
    def health(self):
        return self.character.health + self.armor_points

class MagicPowerDecorator(CharacterDecorator):
    def __init__(self, character, magic_attack):
        super().__init__(character)
        self.magic_attack = magic_attack

    def get_description(self):
        return f"{super().get_description()}, Magic Attack: +{self.magic_attack}"

    @property
    def attack(self):
        return self.character.attack + self.magic_attack

# Example Usage
hero = GameCharacter("Hero", 100, 20)
print(hero.get_description()) # Output: Hero - Health: 100, Attack: 20

armored_hero = ArmorDecorator(hero, 30)
print(armored_hero.get_description()) # Output: Hero - Health: 100, Attack: 20, Armor: +30
print(f"Armored Hero health: {armored_hero.health}") # Output: Armored Hero health: 130

magic_hero = MagicPowerDecorator(hero, 15)
print(magic_hero.get_description()) # Output: Hero - Health: 100, Attack: 20, Magic Attack: +15
print(f"Magic Hero attack: {magic_hero.attack}") # Output: Magic Hero attack: 35

9. Résumé et Comparaisons

Les design patterns offrent des solutions éprouvées à des problèmes récurrents de conception logicielle. Ils ne sont pas des morceaux de code directement utilisables, mais plutôt des modèles conceptuels à adapter à un contexte spécifique. Choisir le bon patron de conception peut grandement améliorer la maintenabilité, la flexibilité et la réutilisation du code.

Résumé des principaux avantages :

  • Réutilisation : Les design patterns permettent de réutiliser des solutions éprouvées, évitant ainsi de "réinventer la roue".
  • Vocabulaire commun : Ils fournissent un vocabulaire commun pour discuter et documenter la conception logicielle.
  • Flexibilité : Ils contribuent à rendre le code plus flexible et adaptable aux changements.
  • Maintenance : L'utilisation de design patterns facilite la maintenance du code en améliorant sa lisibilité et sa structure.

Comparaisons entre patterns :

Il est crucial de comprendre les nuances entre les différents design patterns pour choisir celui qui convient le mieux à une situation donnée. Voici quelques comparaisons fréquentes :

Singleton vs. Factory Method :

  • Singleton : Assure qu'une seule instance d'une classe existe et fournit un point d'accès global à cette instance. Utile pour les ressources partagées comme la configuration ou les connexions à une base de données.
  • Factory Method : Définit une interface pour créer un objet, mais laisse les sous-classes décider quelle classe instancier. Utile lorsque la création d'objets est complexe et nécessite une certaine flexibilité.

# Example illustrating the difference between Singleton and Factory Method

# Singleton Pattern
class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
        return cls._instance

class Configuration:
    def __init__(self, setting1, setting2):
        self.setting1 = setting1
        self.setting2 = setting2

# Example usage of Singleton
config1 = Singleton()
config1.configuration = Configuration("value1", "value2")

config2 = Singleton()
print(config2.configuration.setting1)  # Accessing the same configuration instance

# Factory Method Pattern
class AbstractProduct:
    def operation(self):
        raise NotImplementedError("Subclasses must implement this method")

class ConcreteProductA(AbstractProduct):
    def operation(self):
        return "Product A"

class ConcreteProductB(AbstractProduct):
    def operation(self):
        return "Product B"

class Factory:
    def create_product(self, product_type):
        if product_type == "A":
            return ConcreteProductA()
        elif product_type == "B":
            return ConcreteProductB()
        else:
            raise ValueError("Unknown product type")

# Example usage of Factory Method
factory = Factory()
product_a = factory.create_product("A")
print(product_a.operation())

Observer vs. Strategy :

  • Observer : Définit une dépendance un-à-plusieurs entre des objets, de sorte que lorsqu'un objet change d'état, tous ses dépendants sont notifiés et mis à jour automatiquement. Utile pour la gestion d'événements et les interfaces utilisateur.
  • Strategy : Définit une famille d'algorithmes, encapsule chacun d'eux, et les rend interchangeables. Strategy permet de faire varier l'algorithme indépendamment des clients qui l'utilisent.

# Observer Pattern Example

class Subject:
    def __init__(self):
        self._observers = []

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

    def notify(self, event):
        for observer in self._observers:
            observer.update(event)

class Observer:
    def update(self, event):
        raise NotImplementedError("Subclasses must implement this method")

class ConcreteObserverA(Observer):
    def update(self, event):
        print(f"Observer A received event: {event}")

class ConcreteObserverB(Observer):
    def update(self, event):
        print(f"Observer B handled event: {event}")

# Strategy Pattern Example

class Strategy:
    def execute(self, data):
        raise NotImplementedError("Subclasses must implement this method")

class ConcreteStrategyA(Strategy):
    def execute(self, data):
        return sorted(data)  # Example: Sort data

class ConcreteStrategyB(Strategy):
    def execute(self, data):
        return reversed(data) # Example: Reverse data

class Context:
    def __init__(self, strategy):
        self._strategy = strategy

    def set_strategy(self, strategy):
        self._strategy = strategy

    def process_data(self, data):
        return self._strategy.execute(data)

# Example Usage:
subject = Subject()
observer_a = ConcreteObserverA()
observer_b = ConcreteObserverB()

subject.attach(observer_a)
subject.attach(observer_b)

subject.notify("New event occurred")

data = [5, 2, 8, 1, 9]
context = Context(ConcreteStrategyA())
sorted_data = context.process_data(data)
print(f"Sorted Data: {sorted_data}")

context.set_strategy(ConcreteStrategyB())
reversed_data = context.process_data(data)
print(f"Reversed Data: {list(reversed_data)}")

Adapter vs. Decorator :

  • Adapter : Convertit l'interface d'une classe en une autre interface attendue par les clients. Adapter permet à des classes de travailler ensemble alors qu'elles ont des interfaces incompatibles.
  • Decorator : Ajoute dynamiquement des responsabilités à un objet. Les décorateurs fournissent une alternative flexible à la sous-classe pour étendre les fonctionnalités.

# Adapter Pattern Example

class Adaptee:
    def specific_request(self):
        return ".eetpadA"

class Target:
    def request(self):
        return "epdatdA"

class Adapter(Target):
    def __init__(self, adaptee):
        self._adaptee = adaptee

    def request(self):
        return self._adaptee.specific_request()[::-1]

# Decorator Pattern Example

class Component:
    def operation(self):
        return "Component"

class Decorator(Component):
    def __init__(self, component):
        self._component = component

    def operation(self):
        return self._component.operation()

class ConcreteDecoratorA(Decorator):
    def operation(self):
        return f"ConcreteDecoratorA({super().operation()})"

class ConcreteDecoratorB(Decorator):
    def operation(self):
        return f"ConcreteDecoratorB({super().operation()})"

# Example Usage
adaptee = Adaptee()
adapter = Adapter(adaptee)
print(adapter.request())

component = Component()
decorator_a = ConcreteDecoratorA(component)
decorator_b = ConcreteDecoratorB(decorator_a)

print(decorator_b.operation())

En conclusion, la maîtrise des design patterns est un atout précieux pour tout développeur Python. Comprendre leurs forces et faiblesses respectives permet de concevoir des applications robustes, maintenables et évolutives.

Conclusion

En conclusion, la maîtrise des Design Patterns se révèle être un atout indispensable pour tout développeur Python aspirant à concevoir des applications robustes et maintenables. Leur application réfléchie permet non seulement d'améliorer la qualité du code, mais aussi de faciliter la collaboration au sein d'une équipe.

L'un des principaux avantages des Design Patterns réside dans leur capacité à résoudre des problèmes récurrents de conception. Par exemple, le pattern Singleton assure qu'une classe n'a qu'une seule instance, ce qui est particulièrement utile pour gérer des ressources partagées.


class Singleton:
    _instance = None  # Private attribute to hold the instance

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
        return cls._instance

# Example usage:
# first_instance = Singleton()
# second_instance = Singleton()
# print(first_instance is second_instance)  # Output: True

De même, le pattern Factory permet de créer des objets sans spécifier leur classe concrète, offrant ainsi une grande flexibilité et facilitant l'ajout de nouvelles classes sans modifier le code existant. Ceci est crucial dans les environnements où l'évolutivité est une priorité.


class Button:
    def __init__(self):
        self.label = "Generic Button"

    def click(self):
        print("Generic Button Clicked")

class WindowsButton(Button):
    def __init__(self):
        self.label = "Windows Button"

    def click(self):
        print("Windows Button Clicked")

class LinuxButton(Button):
    def __init__(self):
        self.label = "Linux Button"

    def click(self):
        print("Linux Button Clicked")

class ButtonFactory:
    def create_button(self, os_type):
        if os_type == "Windows":
            return WindowsButton()
        elif os_type == "Linux":
            return LinuxButton()
        else:
            return Button()  # Default button

# Example Usage:
# factory = ButtonFactory()
# button1 = factory.create_button("Windows")
# print(button1.label)  # Output: Windows Button
# button1.click()      # Output: Windows Button Clicked

En investissant dans la compréhension et l'application de ces concepts, les développeurs Python peuvent non seulement écrire un code plus propre et plus élégant, mais aussi collaborer plus efficacement sur des projets complexes. Les Design Patterns offrent un langage commun et éprouvé pour discuter et résoudre des problèmes de conception, facilitant ainsi la communication et la compréhension au sein des équipes de développement.

That's all folks