Python course Β· Module 3: Object-Oriented Programming
Encapsulation - protecting habitats
In this lesson11
Welcome back! Darwin here with a key lesson about data security.
Imagine you're maintaining a catalog of endangered species. Data about the locations of rare animals is sensitive - you don't want poachers to have direct access! You need controlled access, validation, protection.
In OOP this mechanism is encapsulation - hiding implementation details and protecting data from unauthorized access!
What is encapsulation?
Encapsulation is one of the 4 pillars of OOP, which involves:
- Grouping data and methods in a single class
- Hiding implementation details
- Controlling access to data through public methods
Safari Analogy: Sensitive data about endangered species locations is protected - access only for authorized personnel, with every change logged!
1# WITHOUT encapsulation - dangerous!
2class Species:
3 def __init__(self, name, population):
4 self.name = name
5 self.population = population # Public - anyone can change!
6
7lion = Species("Lion", 500)
8lion.population = -999999 # Nobody stops this error!
9
10# WITH encapsulation - safe!
11class ProtectedSpecies:
12 def __init__(self, name, population):
13 self.name = name
14 self.__population = population # Private!
15
16 def set_population(self, value):
17 """Controlled access with validation"""
18 if value < 0:
19 raise ValueError("Population cannot be negative!")
20 self.__population = value
21
22 def get_population(self):
23 return self.__population
24
25lion = ProtectedSpecies("Lion", 500)
26# lion.__population = -999 # No error, but it creates a NEW attribute; the private __population stays unchanged
27lion.set_population(550) # Works - validated!Access conventions in Python
Python uses naming conventions to indicate access levels:
| Convention | Meaning | Example |
|---|---|---|
name | Public - accessible from anywhere | self.name |
_name | Protected - only class and subclasses | self._internal_id |
__name | Private - only inside the class | self.__password |
NOTE: In Python these are conventions, not hard restrictions! Python trusts the programmer.
Public attributes
By default all attributes are public - accessible from anywhere:
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# Full access
9print(lion.name) # "Simba"
10lion.age = 4 # Can modify
11print(lion.age) # 4Use public attributes when:
- Data is safe for direct modification
- You don't need validation
- It's part of the class's public API
Protected attributes - _single_underscore
Convention: An attribute with one underscore (_name) means "protected" - use only inside the class and in subclasses.
1class Species:
2 def __init__(self, name):
3 self.name = name # Public
4 self._internal_id = 12345 # Protected - convention!
5
6 def _calculate_risk(self):
7 """Protected method - convention"""
8 return self._internal_id * 2
9
10lion = Species("Lion")
11
12# Technically possible, but violates convention
13print(lion._internal_id) # 12345 - works, but SHOULDN'T!
14
15# Correct usage
16class EndangeredSpecies(Species):
17 def get_risk(self):
18 # Subclass can use _internal_id
19 return self._calculate_risk()_underscore convention:
- Signal: "This is internal implementation, don't use directly"
- Technical: Still accessible, Python doesn't block it
- Documentation: Doesn't appear in
help()by default
Private attributes - __double_underscore
Name Mangling: An attribute with two underscores (__name) is "private" - Python changes the name to make access harder!
1class SecretData:
2 def __init__(self, value):
3 self.__secret = value # Private!
4
5 def get_secret(self):
6 """Controlled access"""
7 return self.__secret
8
9data = SecretData("Top Secret")
10
11# Direct access doesn't work
12# print(data.__secret) # AttributeError!
13
14# Works through method
15print(data.get_secret()) # "Top Secret"
16
17# Name mangling - Python changed the name!
18print(data._SecretData__secret) # "Top Secret" - possible, but DON'T DO THIS!Name Mangling mechanism:
- Python changes
__nameto_ClassName__name - This hinders access but doesn't prevent it
- Protects against accidental overriding in subclasses
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" # No collision!
9
10child = Child()
11print(child._Parent__private) # "Parent"
12print(child._Child__private) # "Child"Properties - @property
Property is an elegant way to create getters and setters that look like attributes!
Basic property (read-only)
The simplest property is a method with the @property decorator that you read without parentheses, like a regular attribute. The area and diameter of the circle are computed at the moment you read them:
1class Circle:
2 def __init__(self, radius):
3 self.radius = radius
4
5 @property
6 def area(self):
7 """Calculate area - automatically!"""
8 return 3.14159 * self.radius ** 2
9
10 @property
11 def diameter(self):
12 """Calculate diameter"""
13 return self.radius * 2
14
15circle = Circle(5)
16
17# Use like an attribute, but calls a method!
18print(circle.area) # 78.53975 - looks like an attribute
19print(circle.diameter) # 10
20
21circle.radius = 10
22print(circle.area) # 314.159 - automatically recalculated!Property with getter and setter
Writing is handled by a second method with the same name and the @celsius.setter decorator. That is where you check the new value before it reaches the object:
1class Temperature:
2 def __init__(self, celsius):
3 self._celsius = celsius # Internal storage
4
5 @property
6 def celsius(self):
7 """Getter - read temperature"""
8 return self._celsius
9
10 @celsius.setter
11 def celsius(self, value):
12 """Setter - write with validation"""
13 if value < -273.15:
14 raise ValueError("Temperature below absolute zero!")
15 self._celsius = value
16
17 @property
18 def fahrenheit(self):
19 """Automatic conversion"""
20 return self._celsius * 9/5 + 32
21
22 @fahrenheit.setter
23 def fahrenheit(self, value):
24 """Set via Fahrenheit"""
25 self._celsius = (value - 32) * 5/9
26
27temp = Temperature(25)
28
29# Getter
30print(temp.celsius) # 25
31
32# Setter with validation
33temp.celsius = 30 # OK
34print(temp.celsius) # 30
35
36# temp.celsius = -300 # ValueError: Temperature below absolute zero!
37
38# Automatic conversion
39print(temp.fahrenheit) # 86.0
40temp.fahrenheit = 32 # Set via Fahrenheit
41print(temp.celsius) # 0.0 - automatic conversion!Read-only property
If you don't define a setter, the value can only be read. That is how a person's age works: it always follows from the birth year and the current date:
1class Person:
2 def __init__(self, birth_year):
3 self._birth_year = birth_year
4
5 @property
6 def birth_year(self):
7 """Read-only - no setter"""
8 return self._birth_year
9
10 @property
11 def age(self):
12 """Dynamically computed"""
13 from datetime import datetime
14 return datetime.now().year - self._birth_year
15
16person = Person(1990)
17print(person.age) # depends on the current year, e.g. 36 in 2026
18try:
19 person.age = 40 # no setter
20except AttributeError as e:
21 print(e) # property 'age' of 'Person' object has no setterSafari example - Endangered species management system
This class combines all access levels: the names are public, the ID and status are protected, and the population, secret locations and access log are private. Properties and public methods control who can change them and how.
1from datetime import datetime
2from typing import List, Dict
3
4class EndangeredSpecies:
5 """
6 Class managing an endangered species with full encapsulation
7
8 Protects sensitive data about location, population, observations
9 """
10
11 # Class stores all instances
12 _all_species: Dict[str, 'EndangeredSpecies'] = {}
13 _next_id = 1
14
15 def __init__(self, scientific_name, common_name, conservation_status):
16 # Public data - safe to share
17 self.scientific_name = scientific_name
18 self.common_name = common_name
19
20 # Protected data - _underscore convention
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 # Private data - sensitive information
27 self.__population = 0
28 self.__secret_locations = [] # Locations - NOT for public!
29 self.__access_log = [] # Access log
30
31 # Register in catalog
32 EndangeredSpecies._all_species[scientific_name] = self
33
34 # === PROPERTY: Population (with validation) ===
35
36 @property
37 def population(self):
38 """Read population - logged"""
39 self.__log_access("population", "read")
40 return self.__population
41
42 @population.setter
43 def population(self, value):
44 """Write population - validation + logging"""
45 self.__log_access("population", "write", f"Change from {self.__population} to {value}")
46
47 if not isinstance(value, int):
48 raise TypeError("Population must be an integer")
49
50 if value < 0:
51 raise ValueError("Population cannot be negative")
52
53 self.__population = value
54 self.__update_conservation_status()
55
56 # === PROPERTY: Conservation Status (read-only) ===
57
58 @property
59 def conservation_status(self):
60 """Conservation status - read-only"""
61 return self._conservation_status
62
63 # === PROPERTY: Threat Level (dynamically computed) ===
64
65 @property
66 def threat_level(self):
67 """
68 Threat level (1-10) - computed based on population
69 Read-only - no setter
70 """
71 if self.__population == 0:
72 return 10 # Critically endangered
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 # Safe
83
84 # === PRIVATE METHODS ===
85
86 def __log_access(self, field, action, details=""):
87 """Private method - access logging"""
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 """Private method - update status based on population"""
98 if self.__population == 0:
99 self._conservation_status = "Extinct in the Wild"
100 elif self.__population < 50:
101 self._conservation_status = "Critically Endangered"
102 elif self.__population < 250:
103 self._conservation_status = "Endangered"
104 elif self.__population < 1000:
105 self._conservation_status = "Vulnerable"
106 else:
107 self._conservation_status = "Least Concern"
108
109 # === PUBLIC METHODS - Controlled access ===
110
111 def add_secret_location(self, latitude, longitude, authorization_code):
112 """
113 Add a sensitive location - requires authorization!
114
115 Args:
116 latitude: Latitude
117 longitude: Longitude
118 authorization_code: Access code (in production: hash, token, etc.)
119 """
120 # Authorization validation
121 if authorization_code != "SAFARI_2024_SECURE":
122 self.__log_access("secret_locations", "denied", "Unauthorized access")
123 raise PermissionError("Unauthorized access to sensitive data!")
124
125 # Coordinate validation
126 if not (-90 <= latitude <= 90):
127 raise ValueError("Invalid latitude")
128 if not (-180 <= longitude <= 180):
129 raise ValueError("Invalid longitude")
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"Added location: {latitude}, {longitude}")
139
140 return f"Location added (ID: {len(self.__secret_locations)})"
141
142 def get_location_count(self):
143 """Return number of locations without revealing details"""
144 return len(self.__secret_locations)
145
146 def get_general_area(self):
147 """Return general area without precise coordinates"""
148 if not self.__secret_locations:
149 return "No location data"
150
151 # Average coordinates for general area
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 # Return rounded to 1 decimal (less precise)
156 return f"Area: {avg_lat:.1f}Β°, {avg_lon:.1f}Β°"
157
158 def get_access_log(self, authorization_code):
159 """Return access log - requires authorization"""
160 if authorization_code != "SAFARI_2024_SECURE":
161 raise PermissionError("Unauthorized access to logs!")
162
163 return self.__access_log.copy() # Return a copy, not the original!
164
165 def generate_public_report(self):
166 """Public report - without sensitive data"""
167 return f"""
168ββββββββββββββββββββββββββββββββββββββββββββββββββ
169 {self.common_name} ({self.scientific_name})
170β βββββββββββββββββββββββββββββββββββββββββββββββββ£
171 Conservation status: {self.conservation_status}
172 Population: {self.population} individuals
173 Threat level: {self.threat_level}/10
174 Known locations: {self.get_location_count()}
175 General area: {self.get_general_area()}
176ββββββββββββββββββββββββββββββββββββββββββββββββββ
177 """.strip()
178
179 def generate_classified_report(self, authorization_code):
180 """Detailed report - requires authorization"""
181 if authorization_code != "SAFARI_2024_SECURE":
182 raise PermissionError("Unauthorized access to classified data!")
183
184 locations_str = "\n".join(
185 f" - Lat: {loc['lat']:.6f}, Lon: {loc['lon']:.6f} (added: {loc['added_at'].strftime('%Y-%m-%d %H:%M')})"
186 for loc in self.__secret_locations
187 )
188
189 return f"""
190ββββββββββββββββββββββββββββββββββββββββββββββββββ
191 CLASSIFIED REPORT - {self.common_name}
192β βββββββββββββββββββββββββββββββββββββββββββββββββ£
193 ID: {self._id}
194 Status: {self.conservation_status}
195 Population: {self.__population}
196 Threat: {self.threat_level}/10
197
198 LOCATIONS (CLASSIFIED):
199{locations_str if locations_str else " No data"}
200
201 Data accesses: {len(self.__access_log)}
202ββββββββββββββββββββββββββββββββββββββββββββββββββ
203 """.strip()
204
205 # === CLASS METHODS ===
206
207 @classmethod
208 def get_species_by_threat_level(cls, min_level):
209 """Return species with threat level >= min_level"""
210 return [
211 species for species in cls._all_species.values()
212 if species.threat_level >= min_level
213 ]
214
215# === USAGE DEMONSTRATION ===
216
217print("=== ENDANGERED SPECIES MANAGEMENT SYSTEM ===\n")
218
219# Creating species
220rhino = EndangeredSpecies(
221 scientific_name="Diceros bicornis",
222 common_name="Black Rhinoceros",
223 conservation_status="Critically Endangered"
224)
225
226mountain_gorilla = EndangeredSpecies(
227 scientific_name="Gorilla beringei beringei",
228 common_name="Mountain Gorilla",
229 conservation_status="Endangered"
230)
231
232# Setting population (with validation)
233rhino.population = 45 # OK
234mountain_gorilla.population = 880 # OK
235
236# Attempting invalid value
237try:
238 rhino.population = -10 # ValueError!
239except ValueError as e:
240 print(f"Validation error: {e}\n")
241
242# Adding location (requires authorization)
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"No authorization: {e}\n")
251
252# Correct authorization
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("Locations added\n")
258
259# Direct access to private data - doesn't work!
260try:
261 print(rhino.__population) # AttributeError
262except AttributeError:
263 print("Cannot directly access __population\n")
264
265# Access through property - works!
266print(f"Rhino population (via property): {rhino.population}\n")
267
268# Public report - safe
269print("=== PUBLIC REPORT ===")
270print(rhino.generate_public_report())
271
272# Classified report - requires authorization
273print("\n=== CLASSIFIED REPORT ===")
274print(rhino.generate_classified_report("SAFARI_2024_SECURE"))
275
276# Read-only properties
277print(f"\n=== READ-ONLY PROPERTIES ===")
278print(f"Conservation status: {rhino.conservation_status}")
279print(f"Threat level: {rhino.threat_level}/10")
280
281# Attempt to set - doesn't work!
282try:
283 rhino.threat_level = 1 # AttributeError - no setter!
284except AttributeError as e:
285 print(f"Cannot set threat_level: {e}")
286
287# Automatic status update when population changes
288print(f"\n=== AUTOMATIC UPDATE ===")
289print(f"Before: population={rhino.population}, status={rhino.conservation_status}")
290rhino.population = 280 # Increase population
291print(f"After: population={rhino.population}, status={rhino.conservation_status}")
292
293# Species with high threat
294print(f"\n=== CRITICALLY THREATENED SPECIES ===")
295critical = EndangeredSpecies.get_species_by_threat_level(7)
296for species in critical:
297 print(f" {species.common_name}: Threat {species.threat_level}/10")Advantages of encapsulation
1. Data validation
A setter can reject a value that makes no sense. A temperature below absolute zero never gets into the object:
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("Below absolute zero!")
13 self.__celsius = value
14
15temp = Temperature()
16try:
17 temp.celsius = -300
18except ValueError as e:
19 print(e) # Below absolute zero!2. Access control
The account balance changes only through a method that first checks whether the withdrawal is possible:
1class BankAccount:
2 def __init__(self, balance):
3 self.__balance = balance
4
5 def withdraw(self, amount):
6 """Controlled access"""
7 if amount > self.__balance:
8 raise ValueError("Insufficient funds")
9 self.__balance -= amount
10
11account = BankAccount(1000)
12# account.__balance = 999999 # Creates a new attribute, the real balance stays unchanged
13account.withdraw(500) # Only through method!3. Implementation flexibility
Code that reads circle.area doesn't know whether the area is stored or computed. That lets you change how it is calculated later without touching the rest of the program:
1class Circle:
2 def __init__(self, radius):
3 self.__radius = radius
4
5 @property
6 def area(self):
7 """User doesn't know this is computed!"""
8 return 3.14159 * self.__radius ** 2
9
10# You can change the implementation without changing the interface
11circle = Circle(5)
12print(circle.area) # User uses it like an attribute4. Hiding complexity
The private __connect() method hides the details of connecting to the database. Users of the class only know the simple query() method:
1class Database:
2 def __init__(self):
3 self.__connection = None
4 self.__cache = {}
5
6 def __connect(self):
7 """Private - user doesn't need to know the details"""
8 print("Connecting to database...")
9
10 def query(self, sql):
11 """Public interface - simple"""
12 if not self.__connection:
13 self.__connect()
14 # ... execute queryEncapsulation best practices
1. Use properties instead of getters/setters
In the Java style you write separate get_name() and set_name() methods. In Python a property gives the same result, and the code using the class looks like plain attribute access:
1# Java-style - verbose
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# Pythonic way - elegant
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. Don't overuse private attributes
Two underscores make sense where you really protect something. A name that anyone can safely read and change can stay a plain attribute:
1# Too paranoid
2class Person:
3 def __init__(self, name):
4 self.__name = name # Unnecessary __
5
6# Trust conventions
7class Person:
8 def __init__(self, name):
9 self.name = name # Simple attribute, if you don't need protection3. Use encapsulation for sensitive data
A password is a good candidate for a private attribute: the object keeps only its hash, compares it in check_password() and never hands the hash out.
1# Protecting sensitive data. Simplified: the built-in hash() is not suitable for passwords,
2# because its result changes on every run; in practice use hashlib or bcrypt
3class User:
4 def __init__(self, username, password):
5 self.username = username # Public
6 self.__password_hash = hash(password) # Private!
7
8 def check_password(self, password):
9 return hash(password) == self.__password_hashSummary
In this lesson you learned:
- What encapsulation is and why it's important
- The difference between public, protected, and private attributes
- The name mangling mechanism (
__name) - Creating properties with
@property, getters and setters - Data validation on write
- Controlled access to sensitive information
- Practical examples from the endangered species management system
Checkpoint
Before moving on:
- You understand the difference between
name,_name, and__name - You can create a property with getter and setter
- You know when to use encapsulation
- You understand the name mangling mechanism
- You can validate data in a setter
Safari Analogy: Encapsulation is a protection system for sensitive data about endangered species - not everyone should have access to the locations of rare animals!
In the next lesson Darwin will teach you magic methods - hidden behaviors that make your classes work like Python's built-in types!
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. How do you mark an attribute as 'private' in Python?
2. What is the @property decorator used for?
Hands-on tasks in the game
- Code editor
Create an Animal(name, age) class with an age property (@property getter) that returns the age stored in self._age. If __init__ gets age < 0, raise ValueError
- Code editor
Add a setter (@age.setter) to the age property that changes the age but rejects negative numbers (raises ValueError)
- Vertical ordering
Arrange the elements of the property pattern:
- Vertical ordering
Arrange the property elements: