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 de3
par5
. 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.