Vous êtes-vous déjà demandé comment Python fait fonctionner les objets avec des opérateurs tels que +
ou -
? Ou comment il sait comment afficher les objets lorsque vous les imprimez? La réponse réside dans les méthodes magiques de Python, également connues sous le nom de méthodes d’underscore (double underscore).
Les méthodes magiques sont des méthodes spéciales qui vous permettent de définir le comportement de vos objets en réponse à diverses opérations et fonctions intégrées. Elles sont ce qui rend la programmation orientée objet de Python si puissante et intuitive.
Dans ce guide, vous apprendrez comment utiliser les méthodes magiques pour créer un code plus élégant et plus puissant. Vous verrez des exemples pratiques qui montrent comment ces méthodes fonctionnent dans des scénarios réels.
Prérequis
-
Compréhension de base de la syntaxe Python et des concepts de programmation orientée objet.
-
Connaissance des classes, des objets et de l’héritage.
-
Connaissance des types de données intégrés de Python (listes, dictionnaires, etc.).
-
Une installation fonctionnelle de Python 3 est recommandée pour interagir activement avec les exemples ici.
Table des matières
Quels sont les méthodes magiques?
Les méthodes magiques en Python sont des méthodes spéciales qui commencent et se terminent par des doubles tirets (__
). Lorsque vous utilisez certaines opérations ou fonctions sur vos objets, Python appelle automatiquement ces méthodes.
Par exemple, lorsque vous utilisez l’opérateur +
sur deux objets, Python recherche la méthode __add__
dans l’opérande de gauche. S’il la trouve, il appelle cette méthode avec l’opérande de droite comme argument.
Voici un exemple simple qui montre comment cela fonctionne:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
p1 = Point(1, 2)
p2 = Point(3, 4)
p3 = p1 + p2 # Cela appelle p1.__add__(p2)
print(p3.x, p3.y) # Sortie: 4 6
Explorons ce qui se passe ici:
-
Nous créons une classe
Point
qui représente un point dans l’espace 2D -
La méthode
__init__
initialise les coordonnées x et y -
La méthode
__add__
définit ce qui se passe lorsque nous ajoutons deux points -
Lorsque nous écrivons
p1 + p2
, Python appelle automatiquementp1.__add__(p2)
-
Le résultat est un nouveau
Point
avec les coordonnées (4, 6)
Ce n’est que le début. Python possède de nombreuses méthodes magiques qui vous permettent de personnaliser le comportement de vos objets dans différentes situations. Explorons quelques-unes des plus utiles.
Représentation d’objet
Lorsque vous travaillez avec des objets en Python, vous avez souvent besoin de les convertir en chaînes de caractères. Cela se produit lorsque vous imprimez un objet ou essayez de l’afficher dans la console interactive. Python fournit deux méthodes magiques à cet effet : __str__
et __repr__
.
str vs repr
Les méthodes __str__
et __repr__
servent à des fins différentes :
-
__str__
: Appelée par la fonctionstr()
et par la fonctionprint()
. Elle doit renvoyer une chaîne de caractères lisible pour les utilisateurs finaux. -
__repr__
: Appelée par la fonctionrepr()
et utilisée dans la console interactive. Elle doit renvoyer une chaîne de caractères qui, idéalement, pourrait être utilisée pour recréer l’objet.
Voici un exemple qui montre la différence :
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __str__(self):
return f"{self.celsius}°C"
def __repr__(self):
return f"Temperature({self.celsius})"
temp = Temperature(25)
print(str(temp)) # Résultat : 25°C
print(repr(temp)) # Résultat : Température(25)
Dans cet exemple :
-
__str__
renvoie une chaîne conviviale montrant la température avec un symbole degré -
__repr__
renvoie une chaîne montrant comment créer l’objet, ce qui est utile pour le débogage
La différence devient claire lorsque vous utilisez ces objets dans différents contextes :
-
Lorsque vous imprimez la température, vous voyez la version conviviale :
25°C
-
Lorsque vous inspectez l’objet dans la console Python, vous voyez la version détaillée :
Température(25)
Exemple pratique : Classe d’erreur personnalisée
Créons une classe d’erreur personnalisée qui fournit de meilleures informations de débogage. Cet exemple montre comment vous pouvez utiliser __str__
et __repr__
pour rendre vos messages d’erreur plus utiles :
class ValidationError(Exception):
def __init__(self, field, message, value=None):
self.field = field
self.message = message
self.value = value
super().__init__(self.message)
def __str__(self):
if self.value is not None:
return f"Error in field '{self.field}': {self.message} (got: {repr(self.value)})"
return f"Error in field '{self.field}': {self.message}"
def __repr__(self):
if self.value is not None:
return f"ValidationError(field='{self.field}', message='{self.message}', value={repr(self.value)})"
return f"ValidationError(field='{self.field}', message='{self.message}')"
# Utilisation
try:
age = -5
if age < 0:
raise ValidationError("age", "Age must be positive", age)
except ValidationError as e:
print(e) # Sortie : Erreur dans le champ 'age' : L'âge doit être positif (obtenu : -5)
Cette classe d’erreur personnalisée offre plusieurs avantages :
-
Elle inclut le nom du champ où l’erreur s’est produite
-
Elle affiche la valeur réelle qui a causé l’erreur
-
Elle fournit à la fois des messages d’erreur conviviaux et détaillés
-
Cela facilite le débogage en incluant toutes les informations pertinentes
Suralimentation d’opérateurs
La surcharge d’opérateurs est l’une des fonctionnalités les plus puissantes des méthodes magiques de Python. Elle vous permet de définir le comportement de vos objets lorsqu’ils sont utilisés avec des opérateurs tels que +
, -
, *
et ==
. Cela rend votre code plus intuitif et lisible.
Opérateurs arithmétiques
Python fournit des méthodes magiques pour toutes les opérations arithmétiques de base. Voici un tableau montrant quelle méthode correspond à quel opérateur :
Opérateur | Méthode magique | Description |
+ |
__add__ |
Addition |
- |
__sub__ |
Soustraction |
* |
__mul__ |
Multiplication |
/ |
__truediv__ |
Division |
// |
__floordiv__ |
Division entière |
% |
__mod__ |
Modulo |
** |
__pow__ |
Exponentiation |
Opérateurs de comparaison
De même, vous pouvez définir comment vos objets sont comparés en utilisant ces méthodes magiques :
Opérateur | Méthode Magique | Description |
== |
__eq__ |
Égal à |
!= |
__ne__ |
Non égal à |
< |
__lt__ |
Inférieur à |
> |
__gt__ |
Supérieur à |
<= |
__le__ |
Inférieur ou égal à |
>= |
__ge__ |
Supérieur ou égal à |
Exemple pratique : Classe Money
Créons une classe Money
qui gère correctement les opérations de devise. Cet exemple montre comment implémenter plusieurs opérateurs et gérer les cas limites :
from functools import total_ordering
from decimal import Decimal
@total_ordering # Implémente toutes les méthodes de comparaison basées sur __eq__ et __lt__
class Money:
def __init__(self, amount, currency="USD"):
self.amount = Decimal(str(amount))
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot add different currencies: {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot subtract different currencies: {self.currency} and {other.currency}")
return Money(self.amount - other.amount, self.currency)
def __mul__(self, other):
if isinstance(other, (int, float, Decimal)):
return Money(self.amount * Decimal(str(other)), self.currency)
return NotImplemented
def __truediv__(self, other):
if isinstance(other, (int, float, Decimal)):
return Money(self.amount / Decimal(str(other)), self.currency)
return NotImplemented
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.currency == other.currency and self.amount == other.amount
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot compare different currencies: {self.currency} and {other.currency}")
return self.amount < other.amount
def __str__(self):
return f"{self.currency} {self.amount:.2f}"
def __repr__(self):
return f"Money({repr(float(self.amount))}, {repr(self.currency)})"
Décomposons les principales fonctionnalités de cette classe Money
:
-
Gestion de la précision : Nous utilisons
Decimal
au lieu defloat
pour éviter les problèmes de précision des calculs monétaires liés aux nombres à virgule flottante. -
Sécurité de la devise : La classe empêche les opérations entre différentes devises pour éviter les erreurs.
-
Vérification du type : Chaque méthode vérifie si l’autre opérande est du bon type en utilisant
isinstance()
. -
NotImplemented : Lorsqu’une opération n’a pas de sens, nous retournons
NotImplemented
pour permettre à Python d’essayer l’opération inverse. -
@total_ordering: Ce décorateur implémente automatiquement toutes les méthodes de comparaison basées sur
__eq__
et__lt__
.
Voici comment utiliser la classe Money
:
# Opérations arithmétiques de base
wallet = Money(100, "USD")
expense = Money(20, "USD")
remaining = wallet - expense
print(remaining) # Résultat: USD 80.00
# Travailler avec différentes devises
salary = Money(5000, "USD")
bonus = Money(1000, "USD")
total = salary + bonus
print(total) # Résultat: USD 6000.00
# Division par un scalaire
weekly_pay = salary / 4
print(weekly_pay) # Résultat: USD 1250.00
# Comparaisons
print(Money(100, "USD") > Money(50, "USD")) # Résultat: True
print(Money(100, "USD") == Money(100, "USD")) # Résultat: True
# Gestion des erreurs
try:
Money(100, "USD") + Money(100, "EUR")
except ValueError as e:
print(e) # Résultat: Impossible d'ajouter des devises différentes : USD et EUR
Cette classe Money
illustre plusieurs concepts importants:
-
Comment gérer différents types d’opérandes
-
Comment implémenter une gestion d’erreurs appropriée
-
Comment utiliser le décorateur
@total_ordering
-
Comment maintenir la précision dans les calculs financiers
-
Comment fournir à la fois des méthodes de chaîne et de représentation
Méthodes de conteneur
Les méthodes de conteneur vous permettent de faire en sorte que vos objets se comportent comme des conteneurs intégrés tels que des listes, des dictionnaires ou des ensembles. Cela est particulièrement utile lorsque vous avez besoin d’un comportement personnalisé pour stocker et récupérer des données.
Protocole de séquence
Pour que votre objet se comporte comme une séquence (comme une liste ou un tuple), vous devez implémenter ces méthodes:
Méthode | Description | Exemple d’utilisation |
__len__ |
Renvoie la longueur du conteneur | len(obj) |
__getitem__ |
Permet l’indexation avec obj[key] |
obj[0] |
__setitem__ |
Permet l’assignation avec obj[key] = value |
obj[0] = 42 |
__delitem__ |
Permet la suppression avec del obj[key] |
del obj[0] |
__iter__ |
Renvoie un itérateur pour le conteneur | for item in obj: |
__contains__ |
Implémente l’opérateur in |
42 in obj |
Protocole de mappage
Pour un comportement similaire à un dictionnaire, vous voudrez implémenter ces méthodes :
Méthode | Description | Exemple d’utilisation |
__getitem__ |
Obtenir la valeur par clé | obj["clé"] |
__setitem__ |
Définir la valeur par clé | obj["clé"] = valeur |
__delitem__ |
Supprimer la paire clé-valeur | del obj["clé"] |
__len__ |
Obtenir le nombre de paires clé-valeur | len(obj) |
__iter__ |
Itérer sur les clés | for clé in obj: |
__contains__ |
Vérifier si la clé existe | "clé" in obj |
Exemple pratique : Cache personnalisé
Implémentons un cache basé sur le temps qui expire automatiquement les anciennes entrées. Cet exemple montre comment créer un conteneur personnalisé qui se comporte comme un dictionnaire mais avec des fonctionnalités supplémentaires :
import time
from collections import OrderedDict
class ExpiringCache:
def __init__(self, max_age_seconds=60):
self.max_age = max_age_seconds
self._cache = OrderedDict() # {clé : (valeur, horodatage)}
def __getitem__(self, key):
if key not in self._cache:
raise KeyError(key)
value, timestamp = self._cache[key]
if time.time() - timestamp > self.max_age:
del self._cache[key]
raise KeyError(f"Key '{key}' has expired")
return value
def __setitem__(self, key, value):
self._cache[key] = (value, time.time())
self._cache.move_to_end(key) # Déplacer à la fin pour maintenir l'ordre d'insertion
def __delitem__(self, key):
del self._cache[key]
def __len__(self):
self._clean_expired() # Nettoyer les éléments expirés avant de signaler la longueur
return len(self._cache)
def __iter__(self):
self._clean_expired() # Nettoyer les éléments expirés avant l'itération
for key in self._cache:
yield key
def __contains__(self, key):
if key not in self._cache:
return False
_, timestamp = self._cache[key]
if time.time() - timestamp > self.max_age:
del self._cache[key]
return False
return True
def _clean_expired(self):
"""Remove all expired entries from the cache."""
now = time.time()
expired_keys = [
key for key, (_, timestamp) in self._cache.items()
if now - timestamp > self.max_age
]
for key in expired_keys:
del self._cache[key]
Décomposons le fonctionnement de ce cache:
-
Stockage: Le cache utilise un
OrderedDict
pour stocker des paires clé-valeur avec des horodatages. -
Expiration: Chaque valeur est stockée sous forme de tuple
(valeur, horodatage)
. Lors de l’accès à une valeur, nous vérifions si elle a expiré. -
Méthodes du conteneur: La classe implémente toutes les méthodes nécessaires pour se comporter comme un dictionnaire:
-
__getitem__
: Récupère les valeurs et vérifie l’expiration -
__setitem__
: Stocke les valeurs avec l’horodatage actuel -
__delitem__
: Supprime les entrées -
__len__
: Renvoie le nombre d’entrées non expirées -
__iter__
: Itère sur les clés non expirées -
__contains__
: Vérifie si une clé existe
-
Voici comment utiliser le cache :
# Créez un cache avec une expiration de 2 secondes
cache = ExpiringCache(max_age_seconds=2)
# Stockez certaines valeurs
cache["name"] = "Vivek"
cache["age"] = 30
# Accédez aux valeurs
print("name" in cache) # Sortie : True
print(cache["name"]) # Sortie : Vivek
print(len(cache)) # Sortie : 2
# Attendez l'expiration
print("Waiting for expiration...")
time.sleep(3)
# Vérifiez les valeurs expirées
print("name" in cache) # Sortie : False
try:
print(cache["name"])
except KeyError as e:
print(f"KeyError: {e}") # Sortie : KeyError: 'name'
print(len(cache)) # Sortie : 0
Cette implémentation de cache offre plusieurs avantages :
-
Expiration automatique des anciennes entrées
-
Interface de type dictionnaire pour une utilisation facile
-
Efficacité mémoire en supprimant les entrées expirées
-
Opérations sûres pour les threads (en supposant un accès à un seul thread)
-
Préserve l’ordre d’insertion des entrées
Accès aux attributs
Les méthodes d’accès aux attributs vous permettent de contrôler la manière dont vos objets gèrent la récupération, la définition et la suppression d’attributs. Cela est particulièrement utile pour implémenter des propriétés, la validation et le journalisation.
getattr et getattribute
Python fournit deux méthodes pour contrôler l’accès aux attributs :
-
__getattr__
: Appelée uniquement lorsqu’une recherche d’attribut échoue (c’est-à-dire, lorsque l’attribut n’existe pas) -
__getattribute__
: Appelée pour chaque accès à un attribut, même pour les attributs existants
La principale différence est que __getattribute__
est appelée pour tous les accès aux attributs, tandis que __getattr__
n’est appelée que lorsque l’attribut n’est pas trouvé par les moyens normaux.
Voici un exemple simple montrant la différence :
class AttributeDemo:
def __init__(self):
self.name = "Vivek"
def __getattr__(self, name):
print(f"__getattr__ called for {name}")
return f"Default value for {name}"
def __getattribute__(self, name):
print(f"__getattribute__ called for {name}")
return super().__getattribute__(name)
demo = AttributeDemo()
print(demo.name) # Sortie : __getattribute__ appelée pour name
# Vivek
print(demo.age) # Sortie : __getattribute__ appelée pour age
# __getattr__ appelée pour age
# Valeur par défaut pour age
setattr et delattr
De même, vous pouvez contrôler la manière dont les attributs sont définis et supprimés :
-
__setattr__
: Appelé lorsque un attribut est défini -
__delattr__
: Appelé lorsque un attribut est supprimé
Ces méthodes vous permettent de mettre en place une validation, un suivi ou un comportement personnalisé lorsque les attributs sont modifiés.
Exemple pratique : Propriétés d’auto-enregistrement
Créons une classe qui enregistre automatiquement toutes les modifications de propriétés. Cela est utile pour le débogage, l’audit ou le suivi des changements d’état de l’objet :
import logging
# Configuration de l'enregistrement
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
class LoggedObject:
def __init__(self, **kwargs):
self._data = {}
# Initialisation des attributs sans déclencher __setattr__
for key, value in kwargs.items():
self._data[key] = value
def __getattr__(self, name):
if name in self._data:
logging.debug(f"Accessing attribute {name}: {self._data[name]}")
return self._data[name]
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
def __setattr__(self, name, value):
if name == "_data":
# Autoriser la définition de l'attribut _data directement
super().__setattr__(name, value)
else:
old_value = self._data.get(name, "<undefined>")
self._data[name] = value
logging.info(f"Changed {name}: {old_value} -> {value}")
def __delattr__(self, name):
if name in self._data:
old_value = self._data[name]
del self._data[name]
logging.info(f"Deleted {name} (was: {old_value})")
else:
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
Expliquons comment cette classe fonctionne :
-
Stockage : La classe utilise un dictionnaire privé
_data
pour stocker les valeurs des attributs. -
Accès aux attributs :
-
__getattr__
: Renvoie les valeurs de_data
et enregistre des messages de débogage -
__setattr__
: Stocke les valeurs dans_data
et enregistre les modifications -
__delattr__
: Supprime les valeurs de_data
et enregistre les suppressions
-
-
Gestion spéciale: L’attribut
_data
lui-même est traité différemment pour éviter une récursion infinie.
Voici comment utiliser la classe :
# Créer un objet enregistré avec des valeurs initiales
user = LoggedObject(name="Vivek", email="[email protected]")
# Modifier les attributs
user.name = "Vivek" # Journaux : Changement de nom : Vivek -> Vivek
user.age = 30 # Journaux : Changement d'âge : <indéfini> -> 30
# Accéder aux attributs
print(user.name) # Sortie : Vivek
# Supprimer les attributs
del user.email # Journaux : Email supprimé (était : [email protected])
# Essayer d'accéder à l'attribut supprimé
try:
print(user.email)
except AttributeError as e:
print(f"AttributeError: {e}") # Sortie : AttributeError : l'objet 'LoggedObject' n'a pas d'attribut 'email'
Cette implémentation offre plusieurs avantages :
-
Journalisation automatique de tous les changements d’attribut
-
Journalisation de niveau débogage pour l’accès aux attributs
-
Messages d’erreur clairs pour les attributs manquants
-
Suivi facile des changements d’état de l’objet
-
Utile pour le débogage et l’audit
Gestionnaires de contexte
Les gestionnaires de contexte sont une fonctionnalité puissante en Python qui vous aide à gérer correctement les ressources. Ils garantissent que les ressources sont correctement acquises et libérées, même en cas d’erreur. L’instruction with
est la façon la plus courante d’utiliser les gestionnaires de contexte.
entrée et sortie
Pour créer un gestionnaire de contexte, vous devez implémenter deux méthodes spéciales :
-
__enter__
: Appelée lors de l’entrée dans le blocwith
. Elle doit renvoyer la ressource à gérer. -
__exit__
: Appelée lors de la sortie du blocwith
, même en cas d’exception. Elle doit effectuer les opérations de nettoyage.
La méthode __exit__
reçoit trois arguments :
-
exc_type
: Le type de l’exception (le cas échéant) -
exc_val
: L’instance de l’exception (le cas échéant) -
exc_tb
: La trace de la pile (le cas échéant)
Exemple pratique : Gestionnaire de connexion à une base de données
Créons un gestionnaire de contexte pour les connexions à la base de données. Cet exemple montre comment gérer correctement les ressources de la base de données et gérer les transactions :
import sqlite3
import logging
# Configuration des journaux
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
class DatabaseConnection:
def __init__(self, db_path):
self.db_path = db_path
self.connection = None
self.cursor = None
def __enter__(self):
logging.info(f"Connecting to database: {self.db_path}")
self.connection = sqlite3.connect(self.db_path)
self.cursor = self.connection.cursor()
return self.cursor
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
logging.error(f"An error occurred: {exc_val}")
self.connection.rollback()
logging.info("Transaction rolled back")
else:
self.connection.commit()
logging.info("Transaction committed")
if self.cursor:
self.cursor.close()
if self.connection:
self.connection.close()
logging.info("Database connection closed")
# Renvoyer False pour propager les exceptions, True pour les supprimer
return False
Décomposons le fonctionnement de ce gestionnaire de contexte:
-
Initialisation:
-
La classe prend un chemin de base de données
-
Elle initialise la connexion et le curseur à None
-
-
Méthode Enter:
-
Crée une connexion à la base de données
-
Crée un curseur
-
Renvoie le curseur pour une utilisation dans le bloc
with
-
-
Méthode de sortie:
-
Gère la gestion des transactions (commit/rollback)
-
Ferme le curseur et la connexion
-
Enregistre toutes les opérations
-
Renvoie False pour propager les exceptions
-
Voici comment utiliser le gestionnaire de contexte:
# Créer une base de données de test en mémoire
try:
# Transaction réussie
with DatabaseConnection(":memory:") as cursor:
# Créer une table
cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT
)
""")
# Insérer des données
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Vivek", "[email protected]")
)
# Interroger les données
cursor.execute("SELECT * FROM users")
print(cursor.fetchall()) # Sortie: [(1, 'Vivek', '[email protected]')]
# Démonstration du rollback de la transaction en cas d'erreur
with DatabaseConnection(":memory:") as cursor:
cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT
)
""")
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Wewake", "[email protected]")
)
# Cela provoquera une erreur - la table 'nonexistent' n'existe pas
cursor.execute("SELECT * FROM nonexistent")
except sqlite3.OperationalError as e:
print(f"Caught exception: {e}")
Ce gestionnaire de contexte offre plusieurs avantages:
-
Les ressources sont gérées automatiquement (par exemple, les connexions sont toujours fermées).
-
Avec la sécurité des transactions, les modifications sont validées ou annulées de manière appropriée.
-
Les exceptions sont capturées et gérées avec élégance
-
Toutes les opérations sont enregistrées pour le débogage
-
La déclaration
with
rend le code clair et concis
Objets appelables
La méthode magique __call__
vous permet de faire en sorte que les instances de votre classe se comportent comme des fonctions. Cela est utile pour créer des objets qui maintiennent un état entre les appels ou pour implémenter un comportement similaire à une fonction avec des fonctionnalités supplémentaires.
appel
La méthode __call__
est appelée lorsque vous essayez d’appeler une instance de votre classe comme si c’était une fonction. Voici un exemple simple :
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
# Créer des instances qui se comportent comme des fonctions
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # Sortie : 10
print(triple(5)) # Sortie : 15
Ce exemple montre comment __call__
vous permet de créer des objets qui maintiennent un état (le facteur) tout en étant appelables comme des fonctions.
Exemple pratique : Décorateur de mémoïsation
Implémentons un décorateur de mémorisation en utilisant __call__
. Ce décorateur mettra en cache les résultats de la fonction pour éviter les calculs redondants:
import time
import functools
class Memoize:
def __init__(self, func):
self.func = func
self.cache = {}
# Préserver les métadonnées de la fonction (nom, docstring, etc.)
functools.update_wrapper(self, func)
def __call__(self, *args, **kwargs):
# Créer une clé à partir des arguments
# Pour simplifier, nous supposons que tous les arguments sont hashables
key = str(args) + str(sorted(kwargs.items()))
if key not in self.cache:
self.cache[key] = self.func(*args, **kwargs)
return self.cache[key]
# Utilisation
@Memoize
def fibonacci(n):
"""Calculate the nth Fibonacci number recursively."""
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Mesurer le temps d'exécution
def time_execution(func, *args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__}({args}, {kwargs}) took {end - start:.6f} seconds")
return result
# Sans mémorisation, cela serait extrêmement lent
print("Calculating fibonacci(35)...")
result = time_execution(fibonacci, 35)
print(f"Result: {result}")
# La deuxième appel est instantané grâce à la mémorisation
print("\nCalculating fibonacci(35) again...")
result = time_execution(fibonacci, 35)
print(f"Result: {result}")
Décomposons le fonctionnement de ce décorateur de mémorisation:
-
Initialisation:
-
Prend une fonction en argument
-
Crée un dictionnaire de cache pour stocker les résultats
-
Préserve les métadonnées de la fonction en utilisant
functools.update_wrapper
-
-
Méthode d’appel:
-
Crée une clé unique à partir des arguments de la fonction
-
Vérifie si le résultat est dans le cache
-
Sinon, calcule le résultat et le stocke
-
Renvoie le résultat mis en cache
-
-
Utilisation:
-
Appliqué comme un décorateur à n’importe quelle fonction
-
Met automatiquement en cache les résultats pour les appels répétés
-
Préserve les métadonnées et le comportement de la fonction
-
Les avantages de cette implémentation incluent :
-
Meilleure performance, car elle évite les calculs redondants
-
Meilleur, transparence, car cela fonctionne sans modifier la fonction originale
-
C’est flexible et peut être utilisé avec n’importe quelle fonction
-
C’est efficace en mémoire et met en cache les résultats pour réutilisation
-
Il maintient la documentation de la fonction
Méthodes Magiques Avancées
Explorons maintenant certaines des méthodes magiques plus avancées de Python. Ces méthodes vous donnent un contrôle granulaire sur la création d’objets, l’utilisation de la mémoire et le comportement des dictionnaires.
new pour la Création d’Objets
La méthode __new__
est appelée avant __init__
et est responsable de la création et du retour d’une nouvelle instance de la classe. Cela est utile pour mettre en œuvre des modèles comme les singletons ou les objets immuables.
Voici un exemple d’un modèle singleton utilisant __new__
:
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, name=None):
# Cela sera appelé chaque fois que Singleton() est appelé
if name is not None:
self.name = name
# Utilisation
s1 = Singleton("Vivek")
s2 = Singleton("Wewake")
print(s1 is s2) # Sortie : True
print(s1.name) # Sortie : Wewake (la seconde initialisation a écrasé la première)
Décomposons comment ce singleton fonctionne :
-
Variable de classe:
_instance
stocke l’unique instance de la classe -
Nouvelle méthode:
-
Vérifie si une instance existe
-
En crée une si elle n’existe pas
-
Renvoie l’instance existante si elle existe
-
-
méthode init:
-
Appelée à chaque fois que le constructeur est utilisé
-
Met à jour les attributs de l’instance
-
emplacements pour l’optimisation de la mémoire
La variable de classe __slots__
restreint les attributs qu’une instance peut avoir, ce qui permet d’économiser de la mémoire. Cela est particulièrement utile lorsque vous avez de nombreuses instances d’une classe avec un ensemble fixe d’attributs.
Voici une comparaison entre des classes normales et des classes avec des emplacements.
import sys
class RegularPerson:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
class SlottedPerson:
__slots__ = ['name', 'age', 'email']
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
# Comparer l'utilisation de la mémoire
regular_people = [RegularPerson("Vivek" + str(i), 30, "[email protected]") for i in range(1000)]
slotted_people = [SlottedPerson("Vivek" + str(i), 30, "[email protected]") for i in range(1000)]
print(f"Regular person size: {sys.getsizeof(regular_people[0])} bytes") # Sortie : Taille de la personne ordinaire : 48 octets
print(f"Slotted person size: {sys.getsizeof(slotted_people[0])} bytes") # Sortie : Taille de la personne avec emplacements : 56 octets
print(f"Memory saved per instance: {sys.getsizeof(regular_people[0]) - sys.getsizeof(slotted_people[0])} bytes") # Sortie : Mémoire économisée par instance : -8 octets
print(f"Total memory saved for 1000 instances: {(sys.getsizeof(regular_people[0]) - sys.getsizeof(slotted_people[0])) * 1000 / 1024:.2f} KB") # Sortie : Mémoire totale économisée pour 1000 instances : -7,81 Ko
En exécutant ce code, un résultat intéressant est obtenu :
Regular person size: 48 bytes
Slotted person size: 56 bytes
Memory saved per instance: -8 bytes
Total memory saved for 1000 instances: -7.81 KB
De manière surprenante, dans cet exemple simple, l’instance avec emplacements est en réalité 8 octets plus grande que l’instance ordinaire ! Cela semble contredire le conseil commun sur l’économie de mémoire avec __slots__
.
Alors, que se passe-t-il ici ? Les véritables économies de mémoire avec __slots__
proviennent de :
-
Élimination des dictionnaires : Les objets Python ordinaires stockent leurs attributs dans un dictionnaire (
__dict__
), ce qui entraîne un surcoût. La fonctionsys.getsizeof()
ne tient pas compte de la taille de ce dictionnaire. -
Stockage des attributs : Pour les petits objets avec peu d’attributs, le surcoût des descripteurs d’emplacement peut l’emporter sur les économies de dictionnaire.
-
Scalabilité : Le véritable avantage apparaît lorsque :
-
Vous avez de nombreuses instances (des milliers ou des millions)
-
Vos objets ont de nombreux attributs
-
Vous ajoutez des attributs dynamiquement
-
Voyons une comparaison plus complète :
# Une mesure de mémoire plus précise
import sys
def get_size(obj):
"""Get a better estimate of the object's size in bytes."""
size = sys.getsizeof(obj)
if hasattr(obj, '__dict__'):
size += sys.getsizeof(obj.__dict__)
# Ajoutez la taille du contenu du dict
size += sum(sys.getsizeof(v) for v in obj.__dict__.values())
return size
class RegularPerson:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
class SlottedPerson:
__slots__ = ['name', 'age', 'email']
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
regular = RegularPerson("Vivek", 30, "[email protected]")
slotted = SlottedPerson("Vivek", 30, "[email protected]")
print(f"Complete Regular person size: {get_size(regular)} bytes") # Sortie : Taille complète d'une personne régulière : 610 octets
print(f"Complete Slotted person size: {get_size(slotted)} bytes") # Sortie : Taille complète d'une personne avec slots : 56 octets
Avec cette mesure plus précise, vous verrez que les objets avec slots utilisent généralement moins de mémoire totale, surtout à mesure que vous ajoutez plus d’attributs.
Points clés concernant __slots__
:
-
Réels avantages en mémoire : Les économies de mémoire principales proviennent de l’élimination de l’instance
__dict__
-
Restrictions dynamiques: Vous ne pouvez pas ajouter des attributs arbitraires aux objets insérés
-
Considérations d’héritage: Utiliser
__slots__
avec l’héritage nécessite une planification minutieuse -
Utilisations: Idéal pour les classes avec de nombreuses instances et des attributs fixes
-
Bonus de performance: Peut également offrir un accès plus rapide aux attributs dans certains cas
manquant pour les valeurs par défaut du dictionnaire
La méthode __missing__
est appelée par les sous-classes de dictionnaires lorsqu’une clé n’est pas trouvée. Cela est utile pour implémenter des dictionnaires avec des valeurs par défaut ou une création automatique de clé.
Voici un exemple de dictionnaire qui crée automatiquement des listes vides pour les clés manquantes:
class AutoKeyDict(dict):
def __missing__(self, key):
self[key] = []
return self[key]
# Utilisation
groups = AutoKeyDict()
groups["team1"].append("Vivek")
groups["team1"].append("Wewake")
groups["team2"].append("Vibha")
print(groups) # Sortie: {'team1': ['Vivek', 'Wewake'], 'team2': ['Vibha']}
Cette implémentation offre plusieurs avantages:
-
Pas besoin de vérifier si une clé existe, ce qui est plus pratique.
-
L’initialisation automatique crée des valeurs par défaut selon les besoins.
-
Réduit le code standard pour l’initialisation des dictionnaires.
-
C’est plus flexible et peut implémenter n’importe quelle logique de valeur par défaut.
-
Crée des valeurs uniquement lorsque c’est nécessaire, ce qui le rend plus efficace en mémoire.
Considérations de performance
Bien que les méthodes magiques soient puissantes, elles peuvent affecter la performance si vous ne les utilisez pas avec soin. Explorons quelques considérations de performance courantes et comment les mesurer.
Impact des méthodes magiques sur la performance
Différentes méthodes magiques ont des implications de performance différentes :
Méthodes d’accès aux attributs:
-
__getattr__
,__getattribute__
,__setattr__
et__delattr__
sont appelées fréquemment -
Des opérations complexes dans ces méthodes peuvent ralentir considérablement votre code
Méthodes de conteneur:
-
__getitem__
,__setitem__
et__len__
sont souvent appelés dans des boucles -
Des implémentations inefficaces peuvent rendre votre conteneur beaucoup plus lent que les types intégrés
Surcharger les opérateurs:
-
Les opérateurs arithmétiques et de comparaison sont fréquemment utilisés
-
Des implémentations complexes peuvent ralentir de manière inattendue des opérations simples
Mesurons l’impact sur les performances de __getattr__
par rapport à un accès direct à l’attribut
import time
class DirectAccess:
def __init__(self):
self.value = 42
class GetAttrAccess:
def __init__(self):
self._value = 42
def __getattr__(self, name):
if name == "value":
return self._value
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
# Mesurer les performances
direct = DirectAccess()
getattr_obj = GetAttrAccess()
def benchmark(obj, iterations=1000000):
start = time.time()
for _ in range(iterations):
x = obj.value
end = time.time()
return end - start
direct_time = benchmark(direct)
getattr_time = benchmark(getattr_obj)
print(f"Direct access: {direct_time:.6f} seconds")
print(f"__getattr__ access: {getattr_time:.6f} seconds")
print(f"__getattr__ is {getattr_time / direct_time:.2f}x slower")
En exécutant ce test, on observe des différences significatives de performances
Direct access: 0.027714 seconds
__getattr__ access: 0.060646 seconds
__getattr__ is 2.19x slower
Comme vous pouvez le constater, l’utilisation de __getattr__
est plus de deux fois plus lente que l’accès direct à l’attribut. Cela peut ne pas être important pour des attributs occasionnellement accédés, mais cela peut devenir significatif dans du code critique en termes de performances qui accède aux attributs dans des boucles serrées
Stratégies d’optimisation
Heureusement, il existe différentes façons d’optimiser les méthodes magiques
-
Utilisez des slots pour une efficacité mémoire: Cela réduit l’utilisation de mémoire et améliore la vitesse d’accès aux attributs. C’est idéal pour les classes avec de nombreuses instances.
-
Mettez en cache les valeurs calculées : Vous pouvez stocker les résultats d’opérations coûteuses et mettre à jour le cache uniquement lorsque c’est nécessaire. Utilisez
@property
pour les attributs calculés. -
Minimisez les appels de méthode : Assurez-vous d’éviter les appels de méthode magique inutiles et utilisez l’accès direct aux attributs lorsque c’est possible. Envisagez d’utiliser
__slots__
pour les attributs fréquemment accédés.
Meilleures pratiques
Lors de l’utilisation des méthodes magiques, suivez ces meilleures pratiques pour garantir que votre code est maintenable, efficace et fiable.
1. Soyez cohérent
Lors de la mise en œuvre de méthodes magiques liées, maintenez la cohérence dans le comportement :
from functools import total_ordering
@total_ordering
class ConsistentNumber:
def __init__(self, value):
self.value = value
def __eq__(self, other):
if not isinstance(other, ConsistentNumber):
return NotImplemented
return self.value == other.value
def __lt__(self, other):
if not isinstance(other, ConsistentNumber):
return NotImplemented
return self.value < other.value
2. Renvoyez NotImplemented
Lorsqu’une opération n’a pas de sens, renvoyez NotImplemented
pour permettre à Python d’essayer l’opération inverse :
class Money:
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
# ... reste de l'implémentation
3. Restez simple
Les méthodes magiques doivent être simples et prévisibles. Évitez la logique complexe qui pourrait entraîner un comportement inattendu:
# Bon : Simple et prévisible
class SimpleContainer:
def __init__(self):
self.items = []
def __getitem__(self, index):
return self.items[index]
# Mauvais : Complex et potentiellement déroutant
class ComplexContainer:
def __init__(self):
self.items = []
self.access_count = 0
def __getitem__(self, index):
self.access_count += 1
if self.access_count > 100:
raise RuntimeError("Too many accesses")
return self.items[index]
4. Documenter le comportement
Documentez clairement le comportement de vos méthodes magiques, surtout s’il diffère des attentes standard:
class CustomDict(dict):
def __missing__(self, key):
"""
Called when a key is not found in the dictionary.
Creates a new list for the key and returns it.
This allows for automatic list creation when accessing
non-existent keys.
"""
self[key] = []
return self[key]
5. Prendre en compte les performances
Soyez conscient des implications de performance, en particulier pour les méthodes appelées fréquemment:
class OptimizedContainer:
__slots__ = ['items'] # Utilisez __slots__ pour de meilleures performances
def __init__(self):
self.items = []
def __getitem__(self, index):
return self.items[index] # L'accès direct est plus rapide
6. Gérer les cas particuliers
Tenez toujours compte des cas particuliers et gérez-les de manière appropriée:
class SafeContainer:
def __getitem__(self, key):
if not isinstance(key, (int, slice)):
raise TypeError("Index must be integer or slice")
if key < 0:
raise ValueError("Index cannot be negative")
# ... reste de l'implémentation
Conclusion
Les méthodes magiques de Python offrent un moyen puissant de faire en sorte que vos classes se comportent comme des types intégrés, ce qui permet un code plus intuitif et expressif. Tout au long de ce guide, nous avons exploré le fonctionnement de ces méthodes et comment les utiliser efficacement.
Points clés
-
Représentation de l’objet:
-
Utilisez
__str__
pour une sortie conviviale -
Utilisez
__repr__
pour le débogage et le développement
-
-
Surcharge d’opérateurs :
-
Implémentez des opérateurs arithmétiques et de comparaison
-
Retournez
NotImplemented
pour les opérations non prises en charge -
Utilisez
@total_ordering
pour des comparaisons cohérentes
-
-
Comportement de conteneur :
-
Implémentez des protocoles de séquence et de mappage
-
Considérez la performance pour les opérations fréquemment utilisées
-
Gérez les cas particuliers de manière appropriée
-
-
Gestion des ressources:
-
Utilisez des gestionnaires de contexte pour une manipulation appropriée des ressources
-
Implémentez
__enter__
et__exit__
pour le nettoyage -
Gérez les exceptions dans
__exit__
-
-
Optimisation des performances:
-
Utilisez
__slots__
pour l’efficacité mémoire -
Mettez en cache les valeurs calculées lorsque c’est approprié
-
Minimisez les appels de méthodes dans le code fréquemment utilisé
-
Quand utiliser les méthodes magiques
Les méthodes magiques sont les plus utiles lorsque vous en avez besoin pour :
-
Créez des structures de données personnalisées
-
Implémentez des types spécifiques au domaine
-
Gérez correctement les ressources
-
Ajoutez des comportements spéciaux à vos classes
-
Rendez votre code plus « pythonique »
Quand éviter les méthodes magiques
Évitez les méthodes magiques lorsque :
-
L’accès simple aux attributs est suffisant
-
Le comportement serait déroutant ou inattendu
-
Les performances sont cruciales et que les méthodes magiques ajouteraient des frais généraux
-
L’implémentation serait trop complexe
Rappelez-vous que « Un grand pouvoir implique de grandes responsabilités ». Utilisez judicieusement les méthodes magiques, en gardant à l’esprit leurs implications sur les performances et le principe de la moindre surprise. Lorsqu’elles sont utilisées de manière appropriée, les méthodes magiques peuvent considérablement améliorer la lisibilité et l’expressivité de votre code.
Références et lectures complémentaires
Documentation officielle de Python
-
Modèle de données Python – Documentation officielle – Guide complet du modèle de données de Python et des méthodes magiques.
-
functools.total_ordering – Documentation pour le décorateur total_ordering qui remplit automatiquement les méthodes de comparaison manquantes.
-
Noms de méthodes spéciales Python – Référence officielle pour les identifiants de méthodes spéciales en Python.
-
Classes de base abstraites des collections – Découvrez les classes de base abstraites pour les conteneurs qui définissent les interfaces que vos classes de conteneurs peuvent implémenter.
Ressources communautaires
- Un guide des méthodes magiques de Python – Rafe Kettler – Exemples pratiques de méthodes magiques et cas d’utilisation courants.
Lectures complémentaires
Si vous avez aimé cet article, vous pourriez trouver ces articles liés à Python sur mon blog personnel utiles :
-
Expériences pratiques pour l’optimisation des requêtes Django ORM – Apprenez à optimiser vos requêtes Django ORM avec des exemples pratiques et des expériences.
-
Le coût élevé de uWSGI synchrone – Comprenez les implications de performance du traitement synchrone dans uWSGI et comment cela affecte vos applications web Python.
Source:
https://www.freecodecamp.org/news/python-magic-methods-practical-guide/