🔰 Bonnes pratiques du dĂ©veloppement et design patterns

Cette partie est largement basée des concepts et exemples présentés dans le livre :

Refactoring, Improving the design of existing code. Martin Fowler, Kent Beck 1999.

Ce livre prĂ©sente un ensemble de problĂšmes que l’on peut retrouver dans une base de code et propose une rĂ©solution de ces problĂšmes par le respect de pratiques et de normes de dĂ©veloppement.

Les exemples présentés dans le cours seront disponibles ici : https://github.com/conception-logicielle-ensai/exemples-cours/tree/main/cours-2

Code smells

Les code smells sont des indices dans le code qui suggÚrent des problÚmes potentiels, rendant le code difficile à comprendre, à maintenir ou à faire évoluer. Ils ne sont pas nécessairement des bugs, mais indiquent souvent des faiblesses dans la conception, comme des fonctions trop longues, des dépendances complexes ou des répétitions inutiles.

L’identification des code smells aide Ă  repĂ©rer les zones du code qui pourraient bĂ©nĂ©ficier d’un refactoring.

Pour aller plus loin : On retrouve différents des principaux code smells ici : https://refactoring.guru/refactoring/smells

Magic Numbers

Le terme “magic number” dĂ©signe l’utilisation de constantes numĂ©riques non nommĂ©es dans le code source d’un programme. Cette pratique peut rendre le code difficile Ă  comprendre et Ă  maintenir, car elle obscurcit le sens des valeurs utilisĂ©es. En nommant explicitement toutes les constantes, on amĂ©liore la lisibilitĂ©, la comprĂ©hension et la maintenabilitĂ© du code, ce qui facilite la collaboration entre dĂ©veloppeurs.

Exemple de Code avec Magic Numbers

Voici un exemple illustrant l’utilisation de magic numbers dans le calcul de valeurs extrĂȘmes et de moyennes glissantes :

import numpy as np

def extreme(data):
    # Calcul de la moyenne et de l'écart-type
    mean = np.mean(data)
    std_dev = np.std(data)
    resultat = []  # Liste pour stocker les valeurs extrĂȘmes
    for x in data:
        if abs(x - mean) > 3 * std_dev: 
            resultat.append(x)  # Utilisation de append pour ajouter des éléments
    return resultat  # Retourne la liste des valeurs extrĂȘmes

# Calcul de la moyenne glissante sur une fenĂȘtre de taille 3
def moyenne_glissante(data):
    """
    Calcule une moyenne glissante sur une fenĂȘtre de taille 3.
    Les bords oĂč il n'y a pas assez de valeurs retournent None.
    """
    if len(data) < 3:
        return [None] * len(data)

    resultats = [None]  # Padding initial pour le bord gauche
    for i in range(1, len(data) - 1):
        moyenne = np.mean(data[i - 1:i + 2])  # Calcul de la moyenne glissante
        resultats.append(moyenne)
    resultats.append(None)  # Padding final pour le bord droit
    return resultats

# Données
data = [100, 102, 98, 97, 250, 101, 99, 102]
# Identification des valeurs extrĂȘmes (plus de 3 Ă©carts-types de la moyenne)
extremes = extreme(data)
print(f"Valeurs extrĂȘmes : {extremes}")
# Calcul et affichage de la moyenne glissante
glissement = moyenne_glissante(data)
print(f"Moyenne glissante sur 3 : {glissement}")
Pourquoi éviter les Magic Numbers ? (Cliquez ici pour en savoir plus)

Pourquoi Éviter les Magic Numbers ?

L’utilisation de magic numbers dans le code prĂ©sente plusieurs inconvĂ©nients majeurs :

  • Manque de clartĂ© : Lorsqu’un dĂ©veloppeur lit le code, il peut se demander : « Pourquoi cette valeur ? ». Par exemple, une condition impliquant la valeur 3 pour un Ă©cart-type peut sembler arbitraire. Cela complique la comprĂ©hension rapide du code et nĂ©cessite une rĂ©flexion supplĂ©mentaire pour deviner la signification de la valeur.

  • AmbiguĂŻtĂ© : Les magic numbers peuvent prĂȘter Ă  confusion lorsqu’ils sont utilisĂ©s pour reprĂ©senter des concepts diffĂ©rents dans le mĂȘme programme. Par exemple, si deux variables distinctes partagent la mĂȘme valeur, cela complique l’identification de leur rĂŽle respectif.

  • DifficultĂ© de maintenance : Modifier une valeur magique peut entraĂźner des erreurs, car cette valeur est souvent utilisĂ©e Ă  plusieurs endroits. Par exemple, pour passer d’une taille de moyenne glissante de 3 Ă  5, un dĂ©veloppeur pourrait remplacer toutes les occurrences de 3 par 5. Cela risque d’introduire des bugs, notamment si certaines parties du code, comme une expression du type [i - 1:i + 2], ne sont pas correctement adaptĂ©es.

Amélioration du Code

Pour Ă©viter l’utilisation de magic numbers, il est prĂ©fĂ©rable de dĂ©finir des constantes nommĂ©es :

import numpy as np

# Définition des constantes
TAILLE_FENETRE_GLISSANTE = 3
MULTIPLICATEUR_SEUIL = 3

def extreme(data):
    # Calcul de la moyenne et de l'écart-type
    mean = np.mean(data)
    std_dev = np.std(data)
    resultat = []  # Liste pour stocker les valeurs extrĂȘmes
    for x in data:
        if abs(x - mean) > MULTIPLICATEUR_SEUIL * std_dev:  # Utilisation de la constante
            resultat.append(x)
    return resultat  # Retourne la liste des valeurs extrĂȘmes

# Calcul de la moyenne glissante sur une fenĂȘtre de taille dĂ©finie
def moyenne_glissante(data):
    """
    Calcule une moyenne glissante sur une fenĂȘtre de taille dĂ©finie.
    Les bords oĂč il n'y a pas assez de valeurs retournent None.
    """
    if len(data) < TAILLE_FENETRE_GLISSANTE:
        return [None] * len(data)

    resultats = [None]  # Padding initial pour le bord gauche
    for i in range(1, len(data) - 1):
        moyenne = np.mean(data[i - 1:i + TAILLE_FENETRE_GLISSANTE - 1])  # Utilisation de la constante
        resultats.append(moyenne)
    resultats.append(None)  # Padding final pour le bord droit
    return resultats

# Données
data = [100, 102, 98, 97, 250, 101, 99, 102]
# Identification des valeurs extrĂȘmes (plus de 3 Ă©carts-types de la moyenne)
extremes = extreme(data)
print(f"Valeurs extrĂȘmes : {extremes}")
# Calcul et affichage de la moyenne glissante
glissement = moyenne_glissante(data)
print(f"Moyenne glissante sur 3 : {glissement}")

Dans ce code amĂ©liorĂ©, la modification de la valeur de TAILLE_FENETRE_GLISSANTE ou MULTIPLICATEUR_SEUIL ne nĂ©cessite qu’une seule intervention, ce qui facilite la maintenance.

Comment Éviter les Magic Numbers ?

Pour éviter les magic numbers :

  • Utilisez des Constantes NommĂ©es : DĂ©clarez les constantes en MAJUSCULES au dĂ©but de vos fichiers ou fonctions. Cela facilite leur recherche et modification. Par exemple, MULTIPLICATEUR_SEUIL = 3 peut ĂȘtre considĂ©rĂ© comme une vĂ©ritĂ© statistique, donc il est peu probable qu’elle change.

  • ParamĂštres de Fonction : Pour les valeurs qui peuvent nĂ©cessiter des ajustements, passez-les en paramĂštres de fonction. Par exemple, dans moyenne_glissante, cela a plus de sens de les traiter comme un paramĂštre, ce qui permet d’associer une valeur explicite Ă  la fenĂȘtre glissante.

Les problĂšmes liĂ©s aux magic numbers ne se limitent pas aux constantes numĂ©riques. Ce terme s’applique Ă©galement Ă  d’autres types de donnĂ©es. Par exemple, dĂ©clarer const string testNomUtilisateur = “Jean” est prĂ©fĂ©rable Ă  l’utilisation directe du mot “Jean” Ă  plusieurs endroits dans le programme.

En gros dĂšs que vous avez des string ou des nombre (entier ou flotant) Ă©crit en dur, c’est que vous avez mal fait les chose.

Code Dupliqué

Le code dupliquĂ© est un problĂšme frĂ©quent qui peut rendre votre programme plus difficile Ă  maintenir. Chaque fois que vous dupliquez des structures similaires dans votre code, vous introduisez un risque d’incohĂ©rence. Si vous devez modifier une de ces copies, vous devez vous assurer que toutes les autres sont mises Ă  jour en consĂ©quence. Cela augmente la probabilitĂ© d’erreurs et complique la lecture et l’Ă©volution du code. La solution consiste Ă  unifier ces duplications en extrayant les parties communes.


1. Si le code dupliquĂ© se trouve dans la mĂȘme classe

Code avec des lignes dupliquées :

class Calculateur:
    def calculer_area_rectangle(self, largeur, hauteur):
        print("L'aire d'un rectangle est largeur * hauteur")
        return largeur * hauteur

    def calculer_area_carre(self, cote):
        print("L'aire d'un carré est cote * cote")
        return cote * cote

Dans cet exemple, la logique de multiplication est répétée dans les deux méthodes. Pour éviter cette duplication, nous pouvons extraire cette logique dans une méthode utilitaire.

Code mutualisé :

class Calculateur:
    def definition_aire(self, forme, formule):
        return f"L'aire d'un {forme} est {formule}."  # Méthode extraite pour éviter la duplication

    def calculer_area_rectangle(self, largeur, hauteur):
        print(self.definition_aire("rectangle", "largeur*hauteur"))
        return largeur*hauteur

    def calculer_area_carre(self, cote):
        print(self.definition_aire("carre", "cote*cote"))
        return cote*cote

2. Si le code dupliqué se trouve dans des sous-classes

Code avec des lignes dupliquées :

class Forme:
    def area(self):
        pass  # Méthode à implémenter dans les sous-classes

class Rectangle(Forme):
    def __init__(self, largeur, hauteur):
        self.largeur = largeur
        self.hauteur = hauteur

    def area(self):
        print("L'aire d'un rectangle est largeur * hauteur")
        return self.largeur * self.hauteur

class Carre(Forme):
    def __init__(self, cote):
        self.cote = cote

    def area(self):
        print("L'aire d'un carré est cote * cote")
        return self.cote * self.cote

Ici, la logique de multiplication est dupliquée dans les méthodes area de Rectangle et Carre. Pour éviter cette duplication, nous pouvons utiliser la méthode Pull Up, qui consiste à déplacer le code commun dans la classe de base.

Code mutualisé avec la méthode Pull Up :

class Forme:
    def multiplier(self, a, b):
        return a * b  # Méthode partagée pour éviter la duplication

    def definition_aire(self, forme, formule):
        return f"L'aire d'un {forme} est {formule}." # Méthode générique à spécialiser dans les sous-classes

class Rectangle(Forme):
    def __init__(self, largeur, hauteur):
        self.largeur = largeur
        self.hauteur = hauteur

    def area(self):
        print(self.definition_aire("rectangle", "largeur*hauteur"))
        return largeur*hauteur

class Carre(Forme):
    def __init__(self, cote):
        self.cote = cote

    def area(self):
        print(self.definition_aire("carre", "cote*cote"))
        return cote*cote

Le refactoring est un processus itératif : commencez par des petits changements, testez réguliÚrement et continuez à améliorer le code progressivement.

Fonctions trop longues

Dans la programmation, il est souvent observĂ© qu’une fonction trop longue devient difficile Ă  comprendre. Dans le passĂ©, les anciens langages de programmation avaient un inconvĂ©nient : appeler une sous-fonction Ă©tait coĂ»teux en termes de performance, ce qui dissuadait les dĂ©veloppeurs d’utiliser des petites fonctions. Cependant, avec les langages modernes, ce coĂ»t est presque inexistant lorsqu’on reste dans le mĂȘme processus.

Aujourd’hui, la vĂ©ritable contrainte liĂ©e aux petites fonctions rĂ©side dans le fait qu’elles demandent parfois un effort supplĂ©mentaire Ă  ceux qui lisent le code, car il faut ouvrir chaque fonction pour comprendre son rĂŽle. Heureusement, les outils de dĂ©veloppement modernes nous simplifient la tĂąche : on peut naviguer facilement vers la dĂ©finition d’une fonction ou afficher son contenu d’un simple clic.

Le vĂ©ritable avantage des petites fonctions, c’est leur nom. Un nom clair et prĂ©cis permet souvent de comprendre rapidement ce que fait la fonction, sans mĂȘme avoir besoin d’en lire le contenu.

Prenons un exemple de fonction qui calcule le total d’une commande :

public double calculerTotalCommande(List<Article> articles) {
    int SEUIL_QUANTITE_REMISE_SUPPLEMENTAIRE = 5;
    double REMISE_PAR_ARTICLE = 0.1;
    double REMISE_SUPPLEMENTAIRE_PAR_ARTICLE = 0.05;

    double total = 0;
    for (Article article : articles) {
        if (article.isEnPromotion()) {
            total += article.getPrix() * (1- REMISE_PAR_ARTICLE); // Remise de 10%
            if (article.getQuantite() > SEUIL_QUANTITE_REMISE_SUPPLEMENTAIRE) {
                total -= article.getPrix() * REMISE_SUPPLEMENTAIRE_PAR_ARTICLE; // Remise supplémentaire pour les gros achats
            }
        } else {
            total += article.getPrix();
        }
    }
    return total;
}

Ici, un bloc de code qui est accompagnĂ© d’un commentaire peut ĂȘtre remplacĂ© par une mĂ©thode dont le nom rĂ©sume cette explication. MĂȘme une simple ligne de code peut justifier son extraction si elle nĂ©cessite un Ă©claircissement. Les conditions et les boucles nous donnent Ă©galement des pistes pour effectuer cette extraction. Par exemple, un gros switch peut ĂȘtre divisĂ© en appels de fonctions individuelles, et si plusieurs switch utilisent les mĂȘmes conditions, on peut appliquer le polymorphisme pour amĂ©liorer la lisibilitĂ©.

Pour les boucles, il est Ă©galement judicieux d’extraire la boucle et son contenu dans une mĂ©thode sĂ©parĂ©e. Si vous avez du mal Ă  nommer une boucle extraite, cela peut indiquer qu’elle rĂ©alise deux tĂąches diffĂ©rentes. Dans ce cas, n’hĂ©sitez pas Ă  diviser les boucles pour isoler les diffĂ©rentes tĂąches.

Voici comment nous pourrions refactoriser la fonction pour la rendre plus lisible :

public double calculerTotalCommande(List<Article> articles) {
    double total = 0;
    for (Article article : articles) {
        total += calculerPrixArticle(article);
    }
    return total;
}

private double calculerPrixArticle(Article article) {
    double REMISE_PAR_ARTICLE = 0.1;

    double prixFinal = article.getPrix();
    if (article.isEnPromotion()) {
        prixFinal *= (1 - REMISE_PAR_ARTICLE); // Remise de 10%
        prixFinal -= calculerRemiseSupplementaire(article);
    }
    return prixFinal;
}

private double calculerRemiseSupplementaire(Article article) {
    int SEUIL_QUANTITE_REMISE_SUPPLEMENTAIRE = 5;
    double REMISE_SUPPLEMENTAIRE_PAR_ARTICLE = 0.05;

    return article.getQuantite() > SEUIL_QUANTITE_REMISE_SUPPLEMENTAIRE ? article.getPrix() * REMISE_SUPPLEMENTAIRE_PAR_ARTICLE : 0; // Remise supplémentaire pour les achats de plus de 5 articles
}

Liste de paramĂštres longues

Lorsque vous programmez, vous avez probablement appris à passer tous les éléments nécessaires à une fonction en tant que paramÚtres. Cependant, avoir une longue liste de paramÚtres peut rapidement devenir source de confusion.

Je vais vous présenter deux cas pour illustrer ce point.

Cas 1 : Tous les paramĂštres proviennent de la mĂȘme classe

Prenons l’exemple d’une classe Voiture qui a plusieurs attributs :

class Voiture:
    def __init__(self, marque, modele, annee, couleur, kilometrage, prix):
        self.marque = marque
        self.modele = modele
        self.annee = annee
        self.couleur = couleur
        self.kilometrage = kilometrage
        self.prix = prix
        # D'autres attributs non nécessaires pour la méthode d'affichage
        self.type_carburant = None
        self.nombre_portes = None

class GestionnaireDeVoiture:
    @staticmethod
    def afficher_informations_voiture(marque, modele, annee, couleur, kilometrage, prix):
        print(f"Voiture: {marque} {modele}, {annee} - "
              f"Couleur: {couleur}, Kilométrage: {kilometrage} km, "
              f"Prix: {prix} €.")

Ici, lorsque nous voulons afficher les informations de la voiture, nous devons passer tous ses attributs comme paramĂštres :

# Utilisation
ma_voiture = Voiture("Toyota", "Corolla", 2020, "Rouge", 15000, 20000)

# Appel de la méthode avec une longue liste de paramÚtres
GestionnaireDeVoiture.afficher_informations_voiture(
    ma_voiture.marque,
    ma_voiture.modele,
    ma_voiture.annee,
    ma_voiture.couleur,
    ma_voiture.kilometrage,
    ma_voiture.prix
)

Dans ce cas, mĂȘme si tous les attributs ne sont pas nĂ©cessaires Ă  la mĂ©thode, il est prĂ©fĂ©rable de passer l’objet complet. C’est ce qu’on appelle le principe de PrĂ©server l’Objet Complet :

class GestionnaireDeVoiture:
    @staticmethod
    def afficher_informations_voiture(voiture):
        print(f"Voiture: {voiture.marque} {voiture.modele}, {voiture.annee} - "
              f"Couleur: {voiture.couleur}, Kilométrage: {voiture.kilometrage} km, "
              f"Prix: {voiture.prix} €.")

Maintenant, nous pouvons appeler la méthode de cette maniÚre :

# Appel de la méthode statique avec l'objet complet
GestionnaireDeVoiture.afficher_informations_voiture(ma_voiture)

Cas 2 : Les paramÚtres proviennent de classes différentes

Imaginons maintenant que nous ayons une classe Personne et une classe Adresse :

class Personne:
    def __init__(self, nom, prenom, age):
        self.nom = nom
        self.prenom = prenom
        self.age = age
        # D'autres attributs
        self.sexe = None
        self.date_naissance = None

class Adresse:
    def __init__(self, rue, ville, code_postal):
        self.rue = rue
        self.ville = ville
        self.code_postal = code_postal
        # D'autres attributs
        self.pays = None
        self.region = None

def envoyer_invitation(nom, prenom, age, rue, ville, code_postal):
    print(f"Invitation envoyée à {prenom} {nom}, {age} ans, à l'adresse suivante : {rue}, "
          f"{ville}, {code_postal}.")

Dans cet exemple, nous devons passer plusieurs paramÚtres provenant de deux classes différentes :

# Utilisation
personne = Personne("Dupont", "Jean", 30)
adresse = Adresse("10 Rue des Fleurs", "Paris", "75000")

# Appel de la fonction avec une longue liste de paramĂštres
envoyer_invitation(
    personne.nom,
    personne.prenom,
    personne.age,
    adresse.rue,
    adresse.ville,
    adresse.code_postal
)

Pour simplifier cela, nous pouvons combiner les informations de la personne et de l’adresse en un seul objet. C’est ce qu’on appelle le principe d’Introduire un Objet ParamĂštre. Cela est encore plus utile si ces attributs sont souvent utilisĂ©s ensemble :

class InformationsClient:
    def __init__(self, personne, adresse):
        self.nom = personne.nom
        self.prenom = personne.prenom
        self.age = personne.age
        self.rue = adresse.rue
        self.ville = adresse.ville
        self.code_postal = adresse.code_postal

class GestionnaireDeClient:
    @staticmethod
    def envoyer_invitation(informations_client):
        print(f"Invitation envoyée à {informations_client.prenom} {informations_client.nom}, "
              f"{informations_client.age} ans, Ă  l'adresse suivante : {informations_client.rue}, "
              f"{informations_client.ville}, {informations_client.code_postal}.")

Voici comment nous utilisons ce nouveau systĂšme :

# Utilisation
personne = Personne("Dupont", "Jean", 30)
adresse = Adresse("10 Rue des Fleurs", "Paris", "75000")
informations_client = InformationsClient(personne, adresse)

GestionnaireDeClient.envoyer_invitation(informations_client)

Il existe de nombreux autres types de code smells, tels que les Noms mystĂ©rieux, qui dĂ©signent des noms de variables ou de fonctions peu clairs et ambigus, et la MutabilitĂ© des variables, qui se rĂ©fĂšre Ă  la modification d’une variable aprĂšs sa crĂ©ation, rendant le code moins prĂ©visible. Bien que nous n’ayons pas le temps d’explorer ces concepts en cours, je vous encourage Ă  faire des recherches Ă  leur sujet, notamment dans le livre mentionnĂ© dans l’introduction.

Normalisation des nommages : Introduction aux normes de nommage en Python

Des normes et des conventions de nommage existent pour améliorer la lisibilité, la maintenabilité et la compréhension du code. Chaque langage a ses propres normes et conventions, adaptées à ses particularités.

Par exemple :

  • En Python, les variables sont gĂ©nĂ©ralement nommĂ©es en snake_case.
  • En Java, on prĂ©fĂšre le camelCase pour les noms de variables et de mĂ©thodes.

Les conventions de codage en Python sont définies dans la PEP 8, tandis que celles de Java sont décrites dans le document officiel Code Conventions for the Java Programming Language.

Nous vous encourageons vivement Ă  consulter ces ressources pour dĂ©couvrir davantage d’exemples prĂ©cis et des explications dĂ©taillĂ©es sur ces conventions. Leur respect permet de garantir un code clair, cohĂ©rent et facilement comprĂ©hensible, notamment dans un contexte de collaboration.

1. Nommage des fichiers et des modules

  • Convention : Utilisez des lettres minuscules et des underscores pour sĂ©parer les mots.
  • Exemple : mon_script.py, utils.py

2. Nommage des classes

  • Convention : Adoptez le style PascalCase pour les noms de classes, oĂč chaque mot commence par une majuscule.
  • Exemple : MaClasse, GestionnaireDeFichiers

3. Nommage des variables et des arguments de fonction

  • Convention : Utilisez des lettres minuscules avec des underscores pour sĂ©parer les mots.
  • Exemple : ma_variable, nombre_utilisateurs
  • Remarque : Évitez les noms de variables ambigus ou trop gĂ©nĂ©riques.

4. Nommage des fonctions et des méthodes

  • Convention : Utilisez des lettres minuscules avec des underscores pour les noms de fonctions, comme pour les variables.
  • Exemple : calculer_somme, obtenir_utilisateur
  • Remarque : Les noms de fonctions doivent ĂȘtre descriptifs et indiquer clairement leur objectif.

5. Nommage des constantes

  • Convention : Utilisez des lettres majuscules avec des underscores pour sĂ©parer les mots. Les constantes doivent ĂȘtre dĂ©finies en haut du fichier ou du module.
  • Exemple : MAX_TAILLE, NOMBRE_MAXI_UTILISATEURS

6. Nommage des packages et des dossiers

  • Convention : Utilisez des lettres minuscules et Ă©vitez les underscores.
  • Exemple : monpackage, utilitaires

7. Autres bonnes pratiques

  • Utilisez des mots significatifs : Choisissez des noms qui dĂ©crivent clairement le rĂŽle d’une variable, d’une fonction ou d’une classe.
  • Évitez les abrĂ©viations obscures : Les noms doivent ĂȘtre comprĂ©hensibles pour toute personne lisant le code.

Les fonctions et mĂ©thodes doivent ĂȘtre nommĂ©es en commençant par un verbe Ă  l’infinitif, tandis que tous les autres Ă©lĂ©ments commencent par un nom.

Il est prĂ©fĂ©rable d’utiliser des noms longs et explicites plutĂŽt que des noms courts dont la signification n’est claire qu’au moment du dĂ©veloppement. N’oubliez pas que dans un vrai projet, vous collaborez avec d’autres personnes, mais aussi avec vous-mĂȘme dans le futur, qui pourrait avoir oubliĂ© le contexte de ce bout de code.

Patrons de conception

Cette partie est largement basée sur les 23 design patterns présentés dans le livre :

Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley Professional Computing Series), Gamma Erich, Richard Helm, Ralph Johnson, John Vissidies, 1994

Les patrons de conception ou design patterns sont des arrangements caractĂ©ristiques logiques permettant la bonne conception de modules applicatifs logiciels en Programmation OrientĂ©e Objet. Il en existe une multitude rĂ©pondant a des problĂ©matiques de rĂ©utilisabilitĂ© et d’implĂ©mentation qualitative.

En général, on présente un patron en réponse a une problématique

Gang of Four (GoF), ou le conseil des 23 (et non 4)

On distingue classiquement 3 catégories des 23 design patterns classiques :

  • Les CrĂ©ationnels (structurent la maniĂšre de crĂ©er des objets).
  • Les Structuraux (organisent les relations entre les classes et les objets).
  • Les Comportementaux (dĂ©finissent comment les objets interagissent entre eux).

Remarque : L’on retrouve une liste exhaustive des 23 ici : https://refactoring.guru/fr/design-patterns/catalog

Dans les faits, leur mise en place est spĂ©cifique a diffĂ©rentes situations, il est important de garder a l’esprit leur existence pour l’application dans des cas appropriĂ©s.

Quelques exemples

Dans cette partie on retrouve diffĂ©rents patterns que l’on prĂ©sente dans le cadre de ce cours et que l’on souhaiterait voir appliquĂ©s a diffĂ©rents Ă©lĂ©ments de vos logiciels.

1. Singleton

Dans votre projet , l’utilitaire de connexion a la base de donnĂ©es Ă©tait unique.

Le design pattern singleton isole le constructeur d’une classe et en fournit une implĂ©mentation unique par le biais d’un remplacement de ce constructeur a un get d’une instance statique. Cela permet d’avoir une seule et unique configuration dans tout le code.

Cas d’application :

  • Logger
  • Connecteur SGBD
  • Gestionnaire d’authentification

Pour l’exemple : vous voulez un logger dans votre application, logguer les erreurs graves dans un fichier et les autres dans la console :

import logging
from typing import Optional, Literal

class Singleton(type):
    """ A metaclass that creates a Singleton base class when called. """
    _instances = {}
    
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]

class Logger(metaclass=Singleton):
    _logger = None  # Variable pour stocker le logger singleton

    def setup(self, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
              log_file: Optional[str] = None):
        """
        Configure le logger au démarrage avec le niveau de log et le fichier de log. 
        Pour info le niveau de log, c'est le niveau minimal de log que le logger accepte d'output
        """
        if Logger._logger is not None:
            raise RuntimeError("Logger déjà configuré!")

        Logger._logger = logging.getLogger(__name__)
        Logger._logger.setLevel(log_level)

        # Créer un formatteur
        formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')

        # Si un fichier est spécifié, alors on utilise un FileHandler
        if log_file:
            file_handler = logging.FileHandler(log_file, encoding='utf-8')
            file_handler.setFormatter(formatter)
            Logger._logger.addHandler(file_handler)
    @staticmethod
    def get_logger():
        if Logger._logger is None:
            raise RuntimeError("Logger non configuré, appelez setup() d'abord.")
        return Logger._logger

    def log_info(self, message: str):
        Logger._logger.info(message)

    def log_debug(self, message: str):
        Logger._logger.debug(message)

    def log_warning(self, message: str):
        Logger._logger.warning(message)

    def log_error(self, message: str):
        Logger._logger.error(message)

    def log_critical(self, message: str):
        Logger._logger.critical(message)

# Exemple d'utilisation
if __name__ == "__main__":
    # Configuration du logger au démarrage avec un fichier de log et niveau INFO
    logger_initializer = Logger()
    logger_initializer.setup(log_level='INFO', log_file='output.log')
    # Obtenir l'instance initialisée ailleurs
    logger1: logging.Logger = Logger.get_logger()
    logger1.info("L'application précise l'invocation d'une fonction normale mais utile pour la compréhension de son execution <=> print")
    logger1.debug("L'application fait des opérations bas niveau qu'on ne doit regarder qu'en cas de débuggage")

    # Dans une autre classe on peut importer le logger avec le get_logger statique 
    logger2: logging.Logger = Logger.get_logger()
    logger2.warning("L'application précise un comportement anormal ou un problÚme non bloquant")
    logger2.error("L'application précise un comportement critique qui précise un dysfonctionnement bloquant")
    # output.log
    ## XXXXXXXXXXXXXXX - INFO - L'application précise l'invocation d'une fonction normale mais utile pour la compréhension de son execution <=> print
    ## XXXXXXXXXXXXXXX - WARNING - L'application précise un comportement anormal ou un problÚme non bloquant
    ## XXXXXXXXXXXXXXX - ERROR - L'application précise un comportement critique qui précise un dysfonctionnement bloquant

2. Builder

L’idĂ©e du pattern builder est de permettre de construire des classes qui contiennent beaucoup d’attributs de maniĂšre customisĂ©e. Cela permet donc d’alimenter la construction petit a petit et d’aboutir toutefois a un objet cohĂ©rent, puisque le builder s’appuie sur le rĂ©el constructeur de la classe.

Cela permet au global une création plus lisible et plus flexible.

exemple avec une classe de constitution d’un sandwich

class Sandwich:
    def __init__(self):
        self.bread:str | None = None
        self.protein:str | None = None
        self.cheese:str | None = None
        self.vegetables:list[str] = []
        self.sauces:list[str] = []
    @property
    def bread(self):
        return self._bread

    @bread.setter
    def bread(self, type_bread:str):
        self._bread = type_bread

    @property
    def protein(self):
        return self._protein

    @protein.setter
    def protein(self, type_protein: str):
        self._protein = type_protein

    @property
    def cheese(self):
        return self._cheese

    @cheese.setter
    def cheese(self, cheese_type:str):
        self._cheese = cheese_type

    @property
    def vegetables(self):
        return self._vegetables
    @vegetables.setter
    def vegetables(self, vegetables:list[str]):
        self._vegetables = vegetables
    def add_vegetable(self, vegetable:str):
        if vegetable:
            self._vegetables.append(vegetable)

    @property
    def sauces(self):
        return self._sauces
    @sauces.setter
    def sauces(self, sauces:list[str]):
        self._sauces = sauces
    def add_sauce(self, sauce:str):
        if sauce:
            self._sauces.append(sauce)

    def __str__(self):
        return (
            f"Sandwich(bread={self.bread}, protein={self.protein}, "
            f"cheese={self.cheese}, vegetables={self.vegetables}, sauces={self.sauces})"
        )

class SandwichBuilder:
    def __init__(self):
        self.sandwich = Sandwich()

    def set_bread(self, bread:str):
        self.sandwich.bread = bread
        return self

    def set_protein(self, protein:str):
        self.sandwich.protein = protein
        return self

    def add_cheese(self, cheese:str):
        self.sandwich.cheese = cheese
        return self

    def add_vegetable(self, vegetable:list[str]):
        self.sandwich.vegetables.append(vegetable)
        return self

    def add_sauce(self, sauce:list[str]):
        self.sandwich.sauces.append(sauce)
        return self

    def build(self):
        return self.sandwich

# Exemple d'utilisation
if __name__ == "__main__":
    builder = SandwichBuilder()
    custom_sandwich = (
        builder.set_bread("Italian")
               .set_protein("Chicken Teriyaki")
               .add_cheese("Swiss")
               .add_vegetable("Lettuce")
               .add_vegetable("Tomato")
               .add_vegetable("Pickles")
               .add_sauce("Honey Mustard")
               .add_sauce("Chipotle Southwest")
               .build()
    )
    print(custom_sandwich)
    # Sandwich(bread=Italian, protein=Chicken Teriyaki, cheese=Swiss, vegetables=['Lettuce','Tomato','Pickles'], sauces=['Honey Mustard', 'Chipotle Southwest'])

3. Factory

Le principe du pattern Factory et de construire un objet a partir d’une classe pĂšre sans donner la classe, cela permet de dĂ©coupler les constructeurs des crĂ©ations d’objets, puisqu’on peut ĂȘtre amenĂ©s a faire Ă©voluer les constructeurs de maniĂšre indĂ©pendante pour plusieurs classes filles par exemple.

Exemple avec des implémentations de nuages :

from abc import abstractmethod
from typing import Literal


class Cloud:
    """Classe de base pour tous les types de nuages."""
    def __init__(self, size: str, altitude: str):
        self.size = size  # Taille du nuage (petit, moyen, grand)
        self.altitude = altitude  # Altitude du nuage (basse, moyenne, haute)
        self.color = self._default_color()  # Couleur par défaut selon le type de nuage
    @abstractmethod
    def _default_color(self) -> Literal["gris", "noir", "blanc"]:
        raise NotImplementedError("Chaque type de nuage doit définir sa couleur par défaut.")

    def _default_event(self) -> str:
        return  "reste calme et n'a aucun effet météorologique."
    def generate_weather(self) -> str:
        return f"Le nuage {self.size}, de couleur {self._default_color()}, situé à {self.altitude} altitude, {self._default_event()}."

    def __str__(self):
        return f"Cloud(size={self.size}, altitude={self.altitude}, color={self.color})"

class RainCloud(Cloud):
    """Nuage produisant de la pluie."""
    def _default_color(self) -> Literal["gris"]:
        return "gris"
    def _default_event(self) -> str:
        return "produit de la pluie"

class ThunderCloud(Cloud):
    """Nuage produisant des orages."""
    def _default_color(self) -> Literal["noir"]:
        return "noir"
    def _default_event(self) -> str:
        return " produit des éclairs et du tonnerre !"

class NeutralCloud(Cloud):
    """Nuage sans effet météorologique."""
    def _default_color(self) -> Literal["blanc"]:
        return "blanc"

class CloudFactory:
    """Factory pour créer des nuages en fonction du type et des paramÚtres."""
    @staticmethod
    def create_cloud(cloud_type: Literal["rain", "thunder", "neutral"], size: str, altitude: str) -> Cloud:
        if cloud_type == "rain":
            return RainCloud(size, altitude)
        elif cloud_type == "thunder":
            return ThunderCloud(size, altitude)
        elif cloud_type == "neutral":
            return NeutralCloud(size, altitude)
        else:
            raise ValueError(f"Type de nuage inconnu : {cloud_type}")

# Exemple d'utilisation
if __name__ == "__main__":
    # Créer un nuage de pluie
    rain_cloud = CloudFactory.create_cloud("rain", size="grand", altitude="moyenne")
    print(rain_cloud)
    print(rain_cloud.generate_weather())  
    # Le nuage grand, de couleur gris, situé à moyenne altitude, produit de la pluie.

    # Créer un nuage d'orage
    thunder_cloud = CloudFactory.create_cloud("thunder", size="immense", altitude="haute")
    print(thunder_cloud)
    print(thunder_cloud.generate_weather()) 
    # Le nuage immense, de couleur noir, situé à haute altitude,  produit des éclairs et du tonnerre !.

    # Créer un nuage neutre
    neutral_cloud = CloudFactory.create_cloud("neutral", size="petit", altitude="basse")
    print(neutral_cloud)
    print(neutral_cloud.generate_weather())  
    # Le nuage petit, de couleur blanc, situé à basse altitude, reste calme et n'a aucun effet météorologique..

4. Data Transfer Object

Le pattern DTO (Data Transfer Object) est utilisĂ© pour transporter des donnĂ©es entre diffĂ©rentes couches d’une application.

Il a été défini dans le livre : Patterns of Enterprise Application Architecture https://martinfowler.com/books/eaa.html (donc a posteriori)

Il permet :

  • De sĂ©parer les donnĂ©es de transfert des entitĂ©s mĂ©tier pour Ă©viter de les exposer directement.
  • De centraliser la validation des donnĂ©es avant qu’elles ne soient utilisĂ©es par les couches mĂ©tier.
  • D’amĂ©liorer la sĂ©curitĂ© et la maintenabilitĂ© en contrĂŽlant prĂ©cisĂ©ment ce qui est transfĂ©rĂ© et exposĂ©.

Un DTO est gĂ©nĂ©ralement utilisĂ© dans le contexte d’une API ou d’un systĂšme distribuĂ© pour sĂ©rialiser et dĂ©sĂ©rialiser les donnĂ©es entrantes/sortantes.

Exemple dans le cadre d’une api FastAPI pour la validation d’un User avant de l’entrĂ©e dans le systĂšme :

from fastapi import HTTPException, FastAPI
from typing import List

from pydantic import BaseModel, Field

app = FastAPI()

import sqlite3
from typing import Optional, Literal

class Singleton(type):
    """ A metaclass that creates a Singleton base class when called. """
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]

class DatabaseConnector(metaclass=Singleton):
    _connection = None

    def __init__(self, db_type: Literal['sqlite'] = 'sqlite', 
                 db_name: Optional[str] = 'default.db'):
        self.db_type = db_type
        self.db_name = db_name
        self.connect()       
    def _connect_sqlite(self, db_name):
        try:
            DatabaseConnector._connection = sqlite3.connect(db_name)
        except sqlite3.Error as e:
            print(f"Erreur de connexion SQLite : {e}")

    def connect(self):
        if DatabaseConnector._connection is not None:
            raise RuntimeError("Base de données déjà connectée!")
        
        if self.db_type == 'sqlite':
            self._connect_sqlite(self.db_name)
        else:
            raise ValueError("Type de base de données inconnu. Choisissez 'sqlite' ou '?'.")
 
    def init_db(self):
        if self.db_type == 'sqlite':
            connexion = self.get_connection()
            cursor = connexion.cursor()
            # creation table users
            cursor.execute('''DROP TABLE IF EXISTS users;''')
            cursor.execute('''CREATE TABLE IF NOT EXISTS users (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        username TEXT NOT NULL
                    );''')
            cursor.execute('''DROP TABLE IF EXISTS roles_users;''')
            cursor.execute('''CREATE TABLE IF NOT EXISTS roles_user (
                        user_id INTEGER NOT NULL,
                        role TEXT NOT NULL,
                        FOREIGN KEY (user_id) REFERENCES users (id)
                    );''')
            connexion.commit()
            # Insertion d'un utilisateur 'admin'
            cursor.execute("INSERT INTO users (username) VALUES (?)", ("adm",))
            user_id = cursor.lastrowid  # RécupÚre l'id du nouvel utilisateur
            # Insertion du rĂŽle 'admin' pour cet utilisateur
            cursor.execute("INSERT INTO roles_user (user_id, role) VALUES (?, ?)", (user_id, "admin"))
            cursor.execute("INSERT INTO roles_user (user_id, role) VALUES (?, ?)", (user_id, "dev"))
            connexion.commit()
            user_id = cursor.lastrowid 
            cursor.execute("INSERT INTO users (username) VALUES (?)", ("pasadm",))
            cursor.execute("INSERT INTO roles_user (user_id, role) VALUES (?, ?)", (user_id, "dev"))
             # Commit des changements
            connexion.commit()
            self.close_connection()

    def get_connection(self):
        if DatabaseConnector._connection is None:
            self.connect()
        return DatabaseConnector._connection
    def close_connection(self):
        """ Ferme la connexion à la base de données si elle est ouverte. """
        if DatabaseConnector._connection is not None:
            DatabaseConnector._connection.close()
            DatabaseConnector._connection = None

# --- Entité métier ---
class UserDTO(BaseModel):
    username: str = Field(examples=["adm","pasadm"])
    def to_user(self):
        """Convert DTO to a plain User object."""
        return User(
            id=None,
            username=self.username,
            roles=None
        )
class User:
    def __init__(self, id: Optional[str], username: str, roles: Optional[List[str]]):
        self.id = id
        self.username = username
        self.roles = roles
    def __str__(self):
        return f"User(username={self.username},roles={self.roles})"
    def __repr__(self):
        return f"User(username={self.username}, roles={self.roles})"
    def is_admin(self):
        return "admin" in self.roles

class UserDAO:
    def __init__(self, database_connector:DatabaseConnector):
        self.database_connector = database_connector
    def get_user_by_username(self, username:str ) :
        connexion = self.database_connector.get_connection()
        cursor = connexion.cursor() 
        cursor.execute("SELECT * FROM users a WHERE username = ?", (username,))
        user_dict = cursor.fetchone()
        if user_dict is None:
            raise ValueError(f"Pas d'utilisateur avec username {username}")
        cursor.execute("SELECT role from roles_user where user_id = ?",(str(user_dict[0])))
        roles = cursor.fetchall()
        distinct_roles = list(set(role[0] for role in roles))
        user = User(id=user_dict[0], username=user_dict[1],roles=distinct_roles)
        self.database_connector.close_connection()
        return user
    def save_user(self, name: str, email: str) -> int:
        cursor = self._conn.cursor()
        cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", (name, email))
        self._conn.commit()
        return cursor.lastrowid
#
# --- Méthode décorée ---
class UserService:
    def __init__(self, user_dao:UserDAO):
        self.user_dao = user_dao
    def peut_se_connecter(self,user: User):
        """Vérifie si l'utilisateur peut se connecter (uniquement pour les administrateurs)."""
        return user.is_admin()
    def get_user(self,user_dto:UserDTO):
        user = user_dto.to_user()
        updated_user = self.user_dao.get_user_by_username(user.username)
        return updated_user 
# --- API Endpoint ---
@app.post("/connect")
def connect(user_dto: UserDTO):
    """
    Endpoint qui utilise la méthode `peut_se_connecter` pour vérifier si l'utilisateur peut accéder.
    Un utilisateur avec le rÎle "admin" est nécessaire.
    """
    user_dao = UserDAO(database_connector=DatabaseConnector())
    user_service = UserService(user_dao=user_dao)
    try:
        user = user_service.get_user(user_dto=user_dto)
        return {"message": f"User {user.username} can connect as admin."} if user_service.peut_se_connecter(user=user) else {"message": f"User {user.username} cannotconnect as admin."}
    except ValueError as e:
        raise HTTPException(404,str(e))

if __name__ == "__main__":
    import uvicorn
    # initialisation du connector
    database_connector = DatabaseConnector("sqlite","default.db")
    # initialisation de la bdd : schema et donnees
    database_connector.init_db()
    # Run server
    uvicorn.run(app, host="0.0.0.0", port=8000)

L’avantage est l’isolation des donnĂ©es d’entrĂ©e et de sortie du systĂšme et de ne pas demander la saisie intĂ©grale du champ user alors que l’on a seulement besoin d’un identifiant ou identifiant et mail par exemple.

ïžâ€đŸ”„ïžâ€đŸ”„ïžâ€đŸ”„ Si l’on doit faire Ă©voluer, on peut faire Ă©voluer seulement l’entrĂ©e, ou seulement l’objet a l’intĂ©rieur du systĂšme. đŸ”„ïžâ€đŸ”„ïžâ€đŸ”„

Remarque il en existe Ă©videmment beaucoup d’autres, et chacune de ces implĂ©mentations Ă  ces limites donc il faut veiller a les utiliser lorsque c’est pertinent.