Python course Β· Module 3: Object-Oriented Programming

Encapsulation - protecting habitats

13 min read
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:

  1. Grouping data and methods in a single class
  2. Hiding implementation details
  3. 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:

ConventionMeaningExample
namePublic - accessible from anywhereself.name
_nameProtected - only class and subclassesself._internal_id
__namePrivate - only inside the classself.__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)   # 4

Use 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 __name to _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 setter

Safari 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 attribute

4. 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 query

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

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

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

Summary

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. 1. How do you mark an attribute as 'private' in Python?

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

Useful articles