Cours Python · Module 3: Programmation orientée objet

L'encapsulation - protéger les habitats

15 min de lecture
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 à :

  1. Regrouper les données et les méthodes dans une seule classe
  2. Masquer les détails d'implémentation
  3. 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 :

ConventionSignificationExemple
namePublic - accessible de partoutself.name
_nameProtégé - uniquement la classe et les sous-classesself._internal_id
__namePrivé - uniquement à l'intérieur de la classeself.__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)   # 4

Utilise 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 __name en _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 setter

Exemple 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 attribut

4. 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ête

Bonnes 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 = value

2. 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 protection

3. 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_hash

Ré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, _name et __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. 1. Comment marque-t-on un attribut comme « privé » en Python ?

  2. 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 :

Articles utiles