Module : L'Encapsulation en Programmation Orientée Objet
Objectif
Ce module a pour but de vous introduire au principe d'encapsulation en Python. Vous apprendrez comment protéger les données d'un objet contre les modifications accidentelles ou non autorisées en utilisant des conventions pour les attributs "privés" et "protégés".
1. Qu'est-ce que l'Encapsulation ?
L'encapsulation est l'un des piliers de la programmation orientée objet (POO). Elle consiste à regrouper les données (attributs) et les méthodes qui les manipulent au sein d'un même objet.
Un aspect clé de l'encapsulation est le contrôle d'accès : cacher les détails internes de l'implémentation d'un objet et n'exposer qu'une interface publique et contrôlée.
Analogies :
- Une voiture : Vous interagissez avec une interface publique (volant, pédales, levier de vitesse). Vous n'avez pas besoin de connaître le fonctionnement interne du moteur pour la conduire. Le moteur est "encapsulé".
- Une API : Vous utilisez les points d'accès (endpoints) documentés sans avoir à connaître le code du serveur.
Avantages :
- Sécurité : Empêche les modifications invalides de l'état interne d'un objet.
- Maintenance : Permet de modifier l'implémentation interne d'une classe sans casser le code qui l'utilise, tant que l'interface publique ne change pas.
- Simplicité : L'utilisateur de la classe n'a qu'à se soucier de l'interface publique.
2. Conventions de Nommage en Python
Contrairement à des langages comme Java ou C++, Python n'a pas de mots-clés private ou public pour forcer le contrôle d'accès. Tout est basé sur des conventions de nommage.
a. Attributs Publics
Par défaut, tous les attributs et méthodes sont publics.
class CompteBancaire:
def __init__(self, solde_initial):
self.solde = solde_initial # Attribut public
compte = CompteBancaire(1000)
print(compte.solde) # Accès direct autorisé
# Le problème : on peut assigner une valeur invalide
compte.solde = -500 # L'état de l'objet est maintenant incohérent
print(compte.solde)
b. Attributs "Protégés" (un seul underscore _)
Un attribut préfixé par un seul underscore (_) est, par convention, destiné à un usage interne. C'est un signal pour les autres développeurs : "Ne touchez pas à cet attribut directement depuis l'extérieur de la classe, sauf si vous savez ce que vous faites."
Python ne bloque pas l'accès à ces attributs. C'est une simple convention.
class CompteBancaire:
def __init__(self, solde_initial):
self._solde = solde_initial # Attribut "protégé"
def deposer(self, montant):
if montant > 0:
self._solde += montant
print(f"Dépôt de {montant}€ effectué. Nouveau solde : {self._solde}€")
def get_solde(self):
# On fournit une méthode publique pour lire le solde
return self._solde
compte = CompteBancaire(1000)
# On utilise la méthode publique
compte.deposer(200)
# On peut toujours y accéder directement, mais on ne devrait pas
print(compte._solde) # 1200
Cette convention est surtout utilisée dans le contexte de l'héritage, pour indiquer qu'un attribut peut être utilisé par les classes filles.
c. Attributs "Privés" (deux underscores __)
Un attribut préfixé par deux underscores (__) est traité différemment par Python. Ce mécanisme s'appelle le "Name Mangling" (décoration de nom).
Python renomme automatiquement l'attribut pour le rendre plus difficile d'accès depuis l'extérieur. Il le transforme en _NomDeLaClasse__nom_attribut.
class MaClasse:
def __init__(self):
self.__variable_privee = 42
def get_variable(self):
return self.__variable_privee
obj = MaClasse()
# Essayer d'y accéder directement lève une AttributeError
# print(obj.__variable_privee) # AttributeError: 'MaClasse' object has no attribute '__variable_privee'
# On peut toujours y accéder si on connaît le nom "décoré"
print(obj._MaClasse__variable_privee) # 42
# L'accès normal se fait via une méthode publique
print(obj.get_variable()) # 42
Pourquoi ce mécanisme ?
Le but principal du "name mangling" n'est pas tant la sécurité que d'éviter les conflits de noms dans le contexte de l'héritage. Si une classe fille définit un attribut avec le même nom __variable_privee, il sera lui aussi renommé (_ClasseFille__variable_privee) et n'écrasera pas l'attribut de la classe mère.
3. Getters, Setters et Propriétés
Pour contrôler l'accès aux attributs, on utilise des méthodes publiques :
- Getter : Une méthode pour lire la valeur d'un attribut (ex:
get_solde()). - Setter : Une méthode pour modifier la valeur d'un attribut, en y ajoutant une logique de validation (ex:
set_solde()).
class Personne:
def __init__(self, nom, age):
self.nom = nom
self._age = age # On le met en "protégé"
# Getter pour l'âge
def get_age(self):
return self._age
# Setter pour l'âge
def set_age(self, nouvel_age):
if 0 < nouvel_age < 130:
self._age = nouvel_age
else:
print("Erreur : L'âge est invalide.")
p = Personne("Alice", 30)
print(p.get_age()) # 30
p.set_age(31)
print(p.get_age()) # 31
p.set_age(-5) # Erreur : L'âge est invalide.
print(p.get_age()) # 31 (l'âge n'a pas changé)
La manière "Pythonic" : les Propriétés (@property)
L'utilisation de getters et setters explicites (get_..., set_...) est un peu lourde et n'est pas très "Pythonic". Python offre une syntaxe beaucoup plus élégante pour cela : le décorateur @property.
Une propriété permet de définir des méthodes getter, setter et deleter pour un attribut, tout en conservant une syntaxe d'accès simple et directe.
class Personne:
def __init__(self, nom, age):
self.nom = nom
self._age = age # L'attribut réel qui stocke la donnée
@property
def age(self):
"""Ceci est le 'getter'. Il est appelé quand on fait obj.age"""
print("(Accès en lecture à l'âge)")
return self._age
@age.setter
def age(self, nouvel_age):
"""Ceci est le 'setter'. Il est appelé quand on fait obj.age = valeur"""
print(f"(Tentative de modification de l'âge à {nouvel_age})")
if 0 < nouvel_age < 130:
self._age = nouvel_age
else:
print("Erreur : L'âge est invalide.")
p = Personne("Bob", 40)
# Accès en lecture (appelle le getter)
current_age = p.age # (Accès en lecture à l'âge)
print(f"L'âge de Bob est {current_age}")
# Accès en écriture (appelle le setter)
p.age = 41 # (Tentative de modification de l'âge à 41)
p.age = -10 # (Tentative de modification de l'âge à -10) -> Erreur : L'âge est invalide.
print(f"L'âge final de Bob est {p.age}")
Avec @property, on combine le meilleur des deux mondes :
- On a une syntaxe d'accès simple et naturelle (
p.age). - On bénéficie du contrôle et de la validation offerts par les méthodes.
Conclusion
L'encapsulation en Python repose sur des conventions (_) et un mécanisme pour éviter les conflits de noms (__). Bien que Python ne l'impose pas de manière stricte, c'est un principe de conception essentiel pour écrire du code POO propre et maintenable. En cachant les détails d'implémentation et en exposant une interface contrôlée, idéalement via des propriétés (@property), vous rendez vos classes plus robustes, plus sûres et plus faciles à faire évoluer.
Exercice 01 : Création d'une Classe Temperature avec Propriétés
Objectif
Cet exercice a pour but de vous faire utiliser le décorateur @property pour créer une classe qui gère la température et permet de la lire en Celsius ou en Fahrenheit, tout en ne stockant la valeur que dans une seule unité (Celsius).
Contexte
Lorsque vous manipulez des données qui peuvent être représentées dans différentes unités (comme des températures, des distances, des poids), il est courant de choisir une unité de stockage interne et de fournir des méthodes ou des propriétés pour convertir cette valeur dans d'autres unités à la volée.
Les propriétés sont parfaites pour cela, car elles donnent l'impression d'accéder à des attributs normaux (temp.celsius, temp.fahrenheit) tout en exécutant une logique de conversion en arrière-plan.
Formules de conversion :
- De Celsius à Fahrenheit :
F = C * 9/5 + 32 - De Fahrenheit à Celsius :
C = (F - 32) * 5/9
Énoncé
-
Créez un nouveau fichier Python nommé
temperature.py. -
Définissez une classe
Temperature.- Le constructeur
__init__doit accepter une température en Celsius et la stocker dans un attribut "privé" ou "protégé", par exemple_celsius.
- Le constructeur
-
Créez une propriété
celsius.- Utilisez
@propertypour définir un "getter" pourcelsius. Cette méthode doit simplement retourner la valeur de_celsius. - Utilisez
@celsius.setterpour définir un "setter". Cette méthode doit accepter une nouvelle valeur en Celsius, effectuer une validation simple (par exemple, s'assurer que la température n'est pas inférieure au zéro absolu, -273.15 °C), puis mettre à jour_celsius.
- Utilisez
-
Créez une propriété
fahrenheit.- Utilisez
@propertypour définir un "getter" pourfahrenheit. Cette méthode ne doit rien stocker. Elle doit calculer la température en Fahrenheit à partir de la valeur de_celsiusstockée et la retourner. - Utilisez
@fahrenheit.setterpour définir un "setter". Cette méthode doit accepter une température en Fahrenheit, la convertir en Celsius, et assigner le résultat à la propriétécelsius(ce qui déclenchera le setter decelsiuset sa logique de validation).
- Utilisez
-
Testez votre classe.
- Créez une instance de
Temperatureavec une valeur en Celsius (ex: 25 °C). - Affichez la température en Celsius et en Fahrenheit (
temp.celsiusettemp.fahrenheit). - Modifiez la température en utilisant la propriété
celsius(temp.celsius = 30). Vérifiez que la valeur en Fahrenheit a été mise à jour automatiquement. - Modifiez la température en utilisant la propriété
fahrenheit(temp.fahrenheit = 50). Vérifiez que la valeur en Celsius a été mise à jour correctement. - Essayez d'assigner une température invalide (ex: -300 °C) et vérifiez que votre validation fonctionne.
- Créez une instance de
Résultat Attendu
L'exécution de votre script de test doit montrer que les deux propriétés sont synchronisées, même si une seule valeur est réellement stockée.
Température initiale : 25.0 °C, ce qui équivaut à 77.0 °F.
---
Modification à 0 °C...
Nouvelle température : 0.0 °C, ce qui équivaut à 32.0 °F.
---
Modification à 50 °F...
Nouvelle température : 10.0 °C, ce qui équivaut à 50.0 °F.
---
Tentative de modification à -300 °C...
Erreur : La température ne peut pas être inférieure au zéro absolu (-273.15 °C).
Température finale : 10.0 °C.
Cliquez ici pour voir un exemple de code de solution
# temperature.py
class Temperature:
"""
Classe pour gérer des températures avec conversion automatique
entre Celsius et Fahrenheit.
"""
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius: float = 0):
# La seule donnée réellement stockée est la température en Celsius.
# On utilise le setter dès le constructeur pour la validation.
self.celsius = celsius
@property
def celsius(self) -> float:
"""Getter pour la température en Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value: float):
"""Setter pour la température en Celsius avec validation."""
if value < self.ABSOLUTE_ZERO_C:
raise ValueError(f"La température ne peut pas être inférieure au zéro absolu ({self.ABSOLUTE_ZERO_C} °C).")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""Getter pour la température en Fahrenheit (calculée à la volée)."""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float):
"""
Setter pour la température en Fahrenheit.
Convertit la valeur en Celsius et utilise le setter de celsius.
"""
# On ne stocke rien ici. On délègue la modification à la propriété celsius.
self.celsius = (value - 32) * 5/9
# --- Tests ---
if __name__ == "__main__":
try:
temp = Temperature(25)
print(f"Température initiale : {temp.celsius:.1f} °C, ce qui équivaut à {temp.fahrenheit:.1f} °F.")
print("---")
print("Modification à 0 °C...")
temp.celsius = 0
print(f"Nouvelle température : {temp.celsius:.1f} °C, ce qui équivaut à {temp.fahrenheit:.1f} °F.")
print("---")
print("Modification à 50 °F...")
temp.fahrenheit = 50
print(f"Nouvelle température : {temp.celsius:.1f} °C, ce qui équivaut à {temp.fahrenheit:.1f} °F.")
print("---")
print("Tentative de modification à -300 °C...")
temp.celsius = -300
except ValueError as e:
print(f"Erreur : {e}")
print(f"Température finale : {temp.celsius:.1f} °C.")