Cours Python · Module 3: Programmation orientée objet
L'encapsulation - protéger les habitats
Dans cette leçon11
Bon retour ! Darwin ici, avec une leçon clé sur la sécurité des données.
Imagine que tu maintiens un catalogue d'espèces menacées. Les données sur les emplacements des animaux rares sont sensibles - tu ne veux pas que les braconniers y aient un accès direct ! Il te faut un accès contrôlé, de la validation, de la protection.
En POO, ce mécanisme est l'encapsulation - masquer les détails d'implémentation et protéger les données contre les accès non autorisés !
Qu'est-ce que l'encapsulation ?
L'encapsulation est l'un des 4 piliers de la POO, qui consiste à :
- Regrouper les données et les méthodes dans une seule classe
- Masquer les détails d'implémentation
- Contrôler l'accès aux données via des méthodes publiques
Analogie Safari : les données sensibles sur les emplacements des espèces menacées sont protégées - accès réservé au personnel autorisé, avec chaque modification consignée !
1# SANS encapsulation - dangereux !
2class Species:
3 def __init__(self, name, population):
4 self.name = name
5 self.population = population # Public - n'importe qui peut changer !
6
7lion = Species("Lion", 500)
8lion.population = -999999 # Personne n'empêche cette erreur !
9
10# AVEC encapsulation - sûr !
11class ProtectedSpecies:
12 def __init__(self, name, population):
13 self.name = name
14 self.__population = population # Privé !
15
16 def set_population(self, value):
17 """Accès contrôlé avec validation"""
18 if value < 0:
19 raise ValueError("La population ne peut pas être négative !")
20 self.__population = value
21
22 def get_population(self):
23 return self.__population
24
25lion = ProtectedSpecies("Lion", 500)
26# lion.__population = -999 # Pas d'erreur, mais cela crée un NOUVEL attribut ; le __population privé ne change pas
27lion.set_population(550) # Fonctionne - validé !Conventions d'accès en Python
Python utilise des conventions de nommage pour indiquer les niveaux d'accès :
| Convention | Signification | Exemple |
|---|---|---|
name | Public - accessible de partout | self.name |
_name | Protégé - uniquement la classe et les sous-classes | self._internal_id |
__name | Privé - uniquement à l'intérieur de la classe | self.__password |
REMARQUE : en Python, ce sont des conventions, pas des restrictions strictes ! Python fait confiance au programmeur.
Attributs publics
Par défaut, tous les attributs sont publics - accessibles de partout :
1class Animal:
2 def __init__(self, name, age):
3 self.name = name # Public
4 self.age = age # Public
5
6lion = Animal("Simba", 3)
7
8# Accès complet
9print(lion.name) # "Simba"
10lion.age = 4 # Peut modifier
11print(lion.age) # 4Utilise des attributs publics quand :
- Les données peuvent être modifiées directement sans danger
- Tu n'as pas besoin de validation
- Cela fait partie de l'API publique de la classe
Attributs protégés - _single_underscore
Convention : un attribut avec un underscore (_name) signifie « protégé » - à utiliser seulement à l'intérieur de la classe et dans les sous-classes.
1class Species:
2 def __init__(self, name):
3 self.name = name # Public
4 self._internal_id = 12345 # Protégé - convention !
5
6 def _calculate_risk(self):
7 """Méthode protégée - convention"""
8 return self._internal_id * 2
9
10lion = Species("Lion")
11
12# Techniquement possible, mais viole la convention
13print(lion._internal_id) # 12345 - fonctionne, mais NE DEVRAIT PAS !
14
15# Utilisation correcte
16class EndangeredSpecies(Species):
17 def get_risk(self):
18 # La sous-classe peut utiliser _internal_id
19 return self._calculate_risk()Convention _underscore :
- Signal : « C'est une implémentation interne, ne l'utilise pas directement »
- Technique : reste accessible, Python ne le bloque pas
- Documentation : n'apparaît pas dans
help()par défaut
Attributs privés - __double_underscore
Name Mangling : un attribut avec deux underscores (__name) est « privé » - Python change le nom pour rendre l'accès plus difficile !
1class SecretData:
2 def __init__(self, value):
3 self.__secret = value # Privé !
4
5 def get_secret(self):
6 """Accès contrôlé"""
7 return self.__secret
8
9data = SecretData("Top Secret")
10
11# L'accès direct ne fonctionne pas
12# print(data.__secret) # AttributeError !
13
14# Fonctionne via la méthode
15print(data.get_secret()) # "Top Secret"
16
17# Name mangling - Python a changé le nom !
18print(data._SecretData__secret) # "Top Secret" - possible, mais À NE PAS FAIRE !Mécanisme du Name Mangling :
- Python change
__nameen_ClassName__name - Cela rend plus difficile l'accès mais ne l'empêche pas
- Protège contre le remplacement accidentel dans les sous-classes
1class Parent:
2 def __init__(self):
3 self.__private = "Parent"
4
5class Child(Parent):
6 def __init__(self):
7 super().__init__()
8 self.__private = "Child" # Pas de collision !
9
10child = Child()
11print(child._Parent__private) # "Parent"
12print(child._Child__private) # "Child"Properties - @property
Une property est une manière élégante de créer des getters et setters qui ressemblent à des attributs !
Property de base (lecture seule)
La property la plus simple est une méthode avec le décorateur @property, que tu lis sans parenthèses, comme un attribut ordinaire. L'aire et le diamètre du cercle sont calculés au moment de la lecture :
1class Circle:
2 def __init__(self, radius):
3 self.radius = radius
4
5 @property
6 def area(self):
7 """Calcule l'aire - automatiquement !"""
8 return 3.14159 * self.radius ** 2
9
10 @property
11 def diameter(self):
12 """Calcule le diamètre"""
13 return self.radius * 2
14
15circle = Circle(5)
16
17# S'utilise comme un attribut, mais appelle une méthode !
18print(circle.area) # 78.53975 - ressemble à un attribut
19print(circle.diameter) # 10
20
21circle.radius = 10
22print(circle.area) # 314.159 - recalculé automatiquement !Property avec getter et setter
L'écriture passe par une deuxième méthode du même nom, avec le décorateur @celsius.setter. C'est là que tu vérifies la nouvelle valeur avant qu'elle n'arrive dans l'objet :
1class Temperature:
2 def __init__(self, celsius):
3 self._celsius = celsius # Stockage interne
4
5 @property
6 def celsius(self):
7 """Getter - lit la température"""
8 return self._celsius
9
10 @celsius.setter
11 def celsius(self, value):
12 """Setter - écrit avec validation"""
13 if value < -273.15:
14 raise ValueError("Température sous le zéro absolu !")
15 self._celsius = value
16
17 @property
18 def fahrenheit(self):
19 """Conversion automatique"""
20 return self._celsius * 9/5 + 32
21
22 @fahrenheit.setter
23 def fahrenheit(self, value):
24 """Définit via Fahrenheit"""
25 self._celsius = (value - 32) * 5/9
26
27temp = Temperature(25)
28
29# Getter
30print(temp.celsius) # 25
31
32# Setter avec validation
33temp.celsius = 30 # OK
34print(temp.celsius) # 30
35
36# temp.celsius = -300 # ValueError : Température sous le zéro absolu !
37
38# Conversion automatique
39print(temp.fahrenheit) # 86.0
40temp.fahrenheit = 32 # Définit via Fahrenheit
41print(temp.celsius) # 0.0 - conversion automatique !Property en lecture seule
Si tu ne définis pas de setter, la valeur peut seulement être lue. C'est le cas de l'âge d'une personne, qui découle toujours de l'année de naissance et de la date du jour :
1class Person:
2 def __init__(self, birth_year):
3 self._birth_year = birth_year
4
5 @property
6 def birth_year(self):
7 """Lecture seule - pas de setter"""
8 return self._birth_year
9
10 @property
11 def age(self):
12 """Calculé dynamiquement"""
13 from datetime import datetime
14 return datetime.now().year - self._birth_year
15
16person = Person(1990)
17print(person.age) # dépend de l'année en cours, par ex. 36 en 2026
18try:
19 person.age = 40 # pas de setter
20except AttributeError as e:
21 print(e) # property 'age' of 'Person' object has no setterExemple Safari - Système de gestion des espèces menacées
Cette classe réunit tous les niveaux d'accès : les noms sont publics, l'identifiant et le statut sont protégés, et la population, les emplacements secrets et le journal d'accès sont privés. Les properties et les méthodes publiques contrôlent qui peut les modifier, et comment.
1from datetime import datetime
2from typing import List, Dict
3
4class EndangeredSpecies:
5 """
6 Classe gérant une espèce menacée avec une encapsulation complète
7
8 Protège les données sensibles sur l'emplacement, la population, les observations
9 """
10
11 # La classe stocke toutes les instances
12 _all_species: Dict[str, 'EndangeredSpecies'] = {}
13 _next_id = 1
14
15 def __init__(self, scientific_name, common_name, conservation_status):
16 # Données publiques - sûres à partager
17 self.scientific_name = scientific_name
18 self.common_name = common_name
19
20 # Données protégées - convention _underscore
21 self._id = EndangeredSpecies._next_id
22 EndangeredSpecies._next_id += 1
23 self._conservation_status = conservation_status
24 self._created_at = datetime.now()
25
26 # Données privées - informations sensibles
27 self.__population = 0
28 self.__secret_locations = [] # Emplacements - PAS pour le public !
29 self.__access_log = [] # Journal des accès
30
31 # Enregistrement dans le catalogue
32 EndangeredSpecies._all_species[scientific_name] = self
33
34 # === PROPERTY : Population (avec validation) ===
35
36 @property
37 def population(self):
38 """Lit la population - consigné"""
39 self.__log_access("population", "read")
40 return self.__population
41
42 @population.setter
43 def population(self, value):
44 """Écrit la population - validation + journalisation"""
45 self.__log_access("population", "write", f"Passage de {self.__population} à {value}")
46
47 if not isinstance(value, int):
48 raise TypeError("La population doit être un entier")
49
50 if value < 0:
51 raise ValueError("La population ne peut pas être négative")
52
53 self.__population = value
54 self.__update_conservation_status()
55
56 # === PROPERTY : Statut de conservation (lecture seule) ===
57
58 @property
59 def conservation_status(self):
60 """Statut de conservation - lecture seule"""
61 return self._conservation_status
62
63 # === PROPERTY : Niveau de menace (calculé dynamiquement) ===
64
65 @property
66 def threat_level(self):
67 """
68 Niveau de menace (1-10) - calculé d'après la population
69 Lecture seule - pas de setter
70 """
71 if self.__population == 0:
72 return 10 # En danger critique
73 elif self.__population < 50:
74 return 9
75 elif self.__population < 250:
76 return 7
77 elif self.__population < 1000:
78 return 5
79 elif self.__population < 5000:
80 return 3
81 else:
82 return 1 # Sûre
83
84 # === MÉTHODES PRIVÉES ===
85
86 def __log_access(self, field, action, details=""):
87 """Méthode privée - journalisation des accès"""
88 log_entry = {
89 "timestamp": datetime.now(),
90 "field": field,
91 "action": action,
92 "details": details
93 }
94 self.__access_log.append(log_entry)
95
96 def __update_conservation_status(self):
97 """Méthode privée - met à jour le statut d'après la population"""
98 if self.__population == 0:
99 self._conservation_status = "Éteinte à l'état sauvage"
100 elif self.__population < 50:
101 self._conservation_status = "En danger critique"
102 elif self.__population < 250:
103 self._conservation_status = "En danger"
104 elif self.__population < 1000:
105 self._conservation_status = "Vulnérable"
106 else:
107 self._conservation_status = "Préoccupation mineure"
108
109 # === MÉTHODES PUBLIQUES - Accès contrôlé ===
110
111 def add_secret_location(self, latitude, longitude, authorization_code):
112 """
113 Ajoute un emplacement sensible - nécessite une autorisation !
114
115 Args:
116 latitude: Latitude
117 longitude: Longitude
118 authorization_code: Code d'accès (en production : hash, token, etc.)
119 """
120 # Validation de l'autorisation
121 if authorization_code != "SAFARI_2024_SECURE":
122 self.__log_access("secret_locations", "denied", "Accès non autorisé")
123 raise PermissionError("Accès non autorisé aux données sensibles !")
124
125 # Validation des coordonnées
126 if not (-90 <= latitude <= 90):
127 raise ValueError("Latitude invalide")
128 if not (-180 <= longitude <= 180):
129 raise ValueError("Longitude invalide")
130
131 location = {
132 "lat": latitude,
133 "lon": longitude,
134 "added_at": datetime.now()
135 }
136
137 self.__secret_locations.append(location)
138 self.__log_access("secret_locations", "add", f"Emplacement ajouté : {latitude}, {longitude}")
139
140 return f"Emplacement ajouté (ID : {len(self.__secret_locations)})"
141
142 def get_location_count(self):
143 """Renvoie le nombre d'emplacements sans en révéler les détails"""
144 return len(self.__secret_locations)
145
146 def get_general_area(self):
147 """Renvoie une zone générale sans coordonnées précises"""
148 if not self.__secret_locations:
149 return "Pas de données de localisation"
150
151 # Moyenne des coordonnées pour une zone générale
152 avg_lat = sum(loc["lat"] for loc in self.__secret_locations) / len(self.__secret_locations)
153 avg_lon = sum(loc["lon"] for loc in self.__secret_locations) / len(self.__secret_locations)
154
155 # Renvoie arrondi à 1 décimale (moins précis)
156 return f"Zone : {avg_lat:.1f}°, {avg_lon:.1f}°"
157
158 def get_access_log(self, authorization_code):
159 """Renvoie le journal des accès - nécessite une autorisation"""
160 if authorization_code != "SAFARI_2024_SECURE":
161 raise PermissionError("Accès non autorisé aux journaux !")
162
163 return self.__access_log.copy() # Renvoie une copie, pas l'original !
164
165 def generate_public_report(self):
166 """Rapport public - sans données sensibles"""
167 return f"""
168╔════════════════════════════════════════════════╗
169 {self.common_name} ({self.scientific_name})
170╠════════════════════════════════════════════════╣
171 Statut de conservation : {self.conservation_status}
172 Population : {self.population} individus
173 Niveau de menace : {self.threat_level}/10
174 Emplacements connus : {self.get_location_count()}
175 Zone générale : {self.get_general_area()}
176╚════════════════════════════════════════════════╝
177 """.strip()
178
179 def generate_classified_report(self, authorization_code):
180 """Rapport détaillé - nécessite une autorisation"""
181 if authorization_code != "SAFARI_2024_SECURE":
182 raise PermissionError("Accès non autorisé aux données classifiées !")
183
184 locations_str = "\n".join(
185 f" - Lat : {loc['lat']:.6f}, Lon : {loc['lon']:.6f} (ajouté : {loc['added_at'].strftime('%Y-%m-%d %H:%M')})"
186 for loc in self.__secret_locations
187 )
188
189 return f"""
190╔════════════════════════════════════════════════╗
191 RAPPORT CLASSIFIÉ - {self.common_name}
192╠════════════════════════════════════════════════╣
193 ID : {self._id}
194 Statut : {self.conservation_status}
195 Population : {self.__population}
196 Menace : {self.threat_level}/10
197
198 EMPLACEMENTS (CLASSIFIÉS) :
199{locations_str if locations_str else " Pas de données"}
200
201 Accès aux données : {len(self.__access_log)}
202╚════════════════════════════════════════════════╝
203 """.strip()
204
205 # === MÉTHODES DE CLASSE ===
206
207 @classmethod
208 def get_species_by_threat_level(cls, min_level):
209 """Renvoie les espèces dont le niveau de menace >= min_level"""
210 return [
211 species for species in cls._all_species.values()
212 if species.threat_level >= min_level
213 ]
214
215# === DÉMONSTRATION D'UTILISATION ===
216
217print("=== SYSTÈME DE GESTION DES ESPÈCES MENACÉES ===\n")
218
219# Création des espèces
220rhino = EndangeredSpecies(
221 scientific_name="Diceros bicornis",
222 common_name="Rhinocéros noir",
223 conservation_status="En danger critique"
224)
225
226mountain_gorilla = EndangeredSpecies(
227 scientific_name="Gorilla beringei beringei",
228 common_name="Gorille des montagnes",
229 conservation_status="En danger"
230)
231
232# Définition de la population (avec validation)
233rhino.population = 45 # OK
234mountain_gorilla.population = 880 # OK
235
236# Tentative de valeur invalide
237try:
238 rhino.population = -10 # ValueError !
239except ValueError as e:
240 print(f"Erreur de validation : {e}\n")
241
242# Tentative d'ajout d'emplacement (nécessite une autorisation)
243try:
244 rhino.add_secret_location(
245 latitude=-1.2345,
246 longitude=36.7890,
247 authorization_code="WRONG_CODE"
248 )
249except PermissionError as e:
250 print(f"Pas d'autorisation : {e}\n")
251
252# Autorisation correcte
253rhino.add_secret_location(-1.2345, 36.7890, "SAFARI_2024_SECURE")
254rhino.add_secret_location(-1.5678, 36.9012, "SAFARI_2024_SECURE")
255mountain_gorilla.add_secret_location(-1.4321, 29.6543, "SAFARI_2024_SECURE")
256
257print("Emplacements ajoutés\n")
258
259# Accès direct aux données privées - ne fonctionne pas !
260try:
261 print(rhino.__population) # AttributeError
262except AttributeError:
263 print("Impossible d'accéder directement à __population\n")
264
265# Accès via la property - fonctionne !
266print(f"Population du rhino (via property) : {rhino.population}\n")
267
268# Rapport public - sûr
269print("=== RAPPORT PUBLIC ===")
270print(rhino.generate_public_report())
271
272# Rapport classifié - nécessite une autorisation
273print("\n=== RAPPORT CLASSIFIÉ ===")
274print(rhino.generate_classified_report("SAFARI_2024_SECURE"))
275
276# Properties en lecture seule
277print(f"\n=== PROPERTIES EN LECTURE SEULE ===")
278print(f"Statut de conservation : {rhino.conservation_status}")
279print(f"Niveau de menace : {rhino.threat_level}/10")
280
281# Tentative de définition - ne fonctionne pas !
282try:
283 rhino.threat_level = 1 # AttributeError - pas de setter !
284except AttributeError as e:
285 print(f"Impossible de définir threat_level : {e}")
286
287# Mise à jour automatique du statut quand la population change
288print(f"\n=== MISE À JOUR AUTOMATIQUE ===")
289print(f"Avant : population={rhino.population}, statut={rhino.conservation_status}")
290rhino.population = 280 # Augmente la population
291print(f"Après : population={rhino.population}, statut={rhino.conservation_status}")
292
293# Espèces à forte menace
294print(f"\n=== ESPÈCES EN DANGER CRITIQUE ===")
295critical = EndangeredSpecies.get_species_by_threat_level(7)
296for species in critical:
297 print(f" {species.common_name} : Menace {species.threat_level}/10")Avantages de l'encapsulation
1. Validation des données
Un setter peut refuser une valeur qui n'a pas de sens. Une température sous le zéro absolu n'entre jamais dans l'objet :
1class Temperature:
2 def __init__(self):
3 self.__celsius = 0
4
5 @property
6 def celsius(self):
7 return self.__celsius
8
9 @celsius.setter
10 def celsius(self, value):
11 if value < -273.15:
12 raise ValueError("Sous le zéro absolu !")
13 self.__celsius = value
14
15temp = Temperature()
16try:
17 temp.celsius = -300
18except ValueError as e:
19 print(e) # Sous le zéro absolu !2. Contrôle d'accès
Le solde du compte ne change qu'à travers une méthode qui vérifie d'abord si le retrait est possible :
1class BankAccount:
2 def __init__(self, balance):
3 self.__balance = balance
4
5 def withdraw(self, amount):
6 """Accès contrôlé"""
7 if amount > self.__balance:
8 raise ValueError("Fonds insuffisants")
9 self.__balance -= amount
10
11account = BankAccount(1000)
12# account.__balance = 999999 # Crée un nouvel attribut, le vrai solde ne change pas
13account.withdraw(500) # Uniquement via la méthode !3. Flexibilité de l'implémentation
Le code qui lit circle.area ne sait pas si l'aire est stockée ou calculée. Tu peux donc changer plus tard la façon de la calculer sans retoucher le reste du programme :
1class Circle:
2 def __init__(self, radius):
3 self.__radius = radius
4
5 @property
6 def area(self):
7 """L'utilisateur ne sait pas que c'est calculé !"""
8 return 3.14159 * self.__radius ** 2
9
10# Tu peux changer l'implémentation sans changer l'interface
11circle = Circle(5)
12print(circle.area) # L'utilisateur l'utilise comme un attribut4. Masquer la complexité
La méthode privée __connect() cache les détails de la connexion à la base. L'utilisateur de la classe ne connaît que la méthode simple query() :
1class Database:
2 def __init__(self):
3 self.__connection = None
4 self.__cache = {}
5
6 def __connect(self):
7 """Privé - l'utilisateur n'a pas besoin de connaître les détails"""
8 print("Connexion à la base de données...")
9
10 def query(self, sql):
11 """Interface publique - simple"""
12 if not self.__connection:
13 self.__connect()
14 # ... exécute la requêteBonnes pratiques d'encapsulation
1. Utilise des properties plutôt que des getters/setters
Dans le style Java, on écrit des méthodes séparées get_name() et set_name(). En Python, une property donne le même résultat, et le code qui utilise la classe ressemble à un simple accès à un attribut :
1# Style Java - verbeux
2class Person:
3 def __init__(self, name):
4 self.__name = name
5
6 def get_name(self):
7 return self.__name
8
9 def set_name(self, value):
10 self.__name = value
11
12# Façon pythonique - élégante
13class Person:
14 def __init__(self, name):
15 self._name = name
16
17 @property
18 def name(self):
19 return self._name
20
21 @name.setter
22 def name(self, value):
23 self._name = value2. N'abuse pas des attributs privés
Deux underscores ont du sens là où tu protèges vraiment quelque chose. Un nom que tout le monde peut lire et modifier sans risque peut rester un attribut ordinaire :
1# Trop paranoïaque
2class Person:
3 def __init__(self, name):
4 self.__name = name # __ inutile
5
6# Fais confiance aux conventions
7class Person:
8 def __init__(self, name):
9 self.name = name # Attribut simple, si tu n'as pas besoin de protection3. Utilise l'encapsulation pour les données sensibles
Un mot de passe est un bon candidat pour un attribut privé : l'objet ne garde que son empreinte, la compare dans check_password() et ne la transmet jamais à l'extérieur.
1# Protection des données sensibles. Simplification : hash() intégré ne convient pas aux mots de passe,
2# car son résultat change à chaque exécution ; en pratique on utilise hashlib ou bcrypt
3class User:
4 def __init__(self, username, password):
5 self.username = username # Public
6 self.__password_hash = hash(password) # Privé !
7
8 def check_password(self, password):
9 return hash(password) == self.__password_hashRésumé
Dans cette leçon, tu as appris :
- Ce qu'est l'encapsulation et pourquoi elle est importante
- La différence entre attributs publics, protégés et privés
- Le mécanisme du name mangling (
__name) - Créer des properties avec
@property, getters et setters - La validation des données à l'écriture
- L'accès contrôlé aux informations sensibles
- Des exemples concrets tirés du système de gestion des espèces menacées
Point de contrôle
Avant de passer à la suite :
- Tu comprends la différence entre
name,_nameet__name - Tu sais créer une property avec getter et setter
- Tu sais quand utiliser l'encapsulation
- Tu comprends le mécanisme du name mangling
- Tu sais valider des données dans un setter
Analogie Safari : l'encapsulation est un système de protection des données sensibles sur les espèces menacées - tout le monde ne devrait pas avoir accès aux emplacements des animaux rares !
Dans la prochaine leçon, Darwin t'apprendra les méthodes magiques - des comportements cachés qui font fonctionner tes classes comme les types intégrés de Python !
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Comment marque-t-on un attribut comme « privé » en Python ?
2. À quoi sert le décorateur @property ?
Tâches pratiques dans le jeu
- Éditeur de code
Crée une classe Animal(name, age) avec une property age (getter @property) qui renvoie l'âge stocké dans self._age. Si __init__ reçoit age < 0, lève ValueError
- Éditeur de code
Ajoute à la property age un setter (@age.setter) qui modifie l'âge mais refuse les nombres négatifs (lève ValueError)
- Tri vertical
Range les éléments du motif property :
- Tri vertical
Range les éléments d'une property :