Python Course
Encapsulation
Encapsulation is one of the four pillars of object-oriented programming. At its core, it means bundling data and the methods that operate on that data together inside a class — and controlling how that data is accessed and modified from the outside. Done well, encapsulation protects your objects from invalid state, hides complexity, and gives you the freedom to change internal implementation without breaking anything that uses your class.
Python takes a pragmatic approach to encapsulation — it relies on conventions and tools rather than hard enforcement. This lesson covers everything from naming conventions to the @property decorator to truly private name mangling.
Why Encapsulation Matters
Without encapsulation, any code anywhere can reach into your object and set its data to anything — including nonsensical values that break your logic.
# No encapsulation — object state is unprotected
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
acc = BankAccount("Alice", 500.00)
# Nothing stops this — even though it makes no sense
acc.balance = -99999
print(acc.balance) # -99999 ← invalid, but allowedEncapsulation solves this by making the object responsible for its own state — external code can only interact through a controlled interface.
1. Public, Protected, and Private — Naming Conventions
Python signals intended visibility through naming conventions. There are no hard access keywords like private or protected — the conventions are respected by developers and enforced partially by the language itself.
- Public —
self.name— accessible from anywhere, no restriction - Protected —
self._name— single underscore signals "internal use only — handle with care." Accessible from outside but considered off-limits by convention. - Private —
self.__name— double underscore triggers name mangling. Python renames it to_ClassName__name, making accidental access from outside much harder.
# Public, protected, and private attributes
class Employee:
def __init__(self, name, salary, ssn):
self.name = name # public — freely accessible
self._salary = salary # protected — internal use intended
self.__ssn = ssn # private — name-mangled
def get_info(self):
return f"{self.name} | Salary: ${self._salary:,.2f}"
def verify_identity(self, ssn):
return self.__ssn == ssn # private used internally
emp = Employee("Alice", 85000, "123-45-6789")
print(emp.name) # Alice — fine, public
print(emp._salary) # 85000 — works, but discouraged
# print(emp.__ssn) # AttributeError — name mangled
# Still accessible via mangled name if you really need it
print(emp._Employee__ssn) # 123-45-6789
print(emp.get_info())
print(emp.verify_identity("123-45-6789")) # True85000
123-45-6789
Alice | Salary: $85,000.00
True
- Single underscore
_is a convention — Python does not restrict access - Double underscore
__triggers name mangling:__ssnbecomes_Employee__ssn - Python's philosophy: "We're all consenting adults here" — trust the convention rather than fight it
- Use
__only when you genuinely need to prevent subclass name collisions, not just as a habit
2. Getters and Setters
The traditional way to control attribute access is explicit getter and setter methods. Python supports this, though the @property approach covered next is generally preferred.
# Getters and setters — explicit method-based access control
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = 0.0
self.set_balance(balance) # validation runs here
def get_balance(self):
return self._balance
def set_balance(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError("Balance must be a number.")
if amount < 0:
raise ValueError(f"Balance cannot be negative, got {amount}.")
self._balance = float(amount)
def deposit(self, amount):
self.set_balance(self._balance + amount)
print(f"Deposited ${amount:.2f}. New balance: ${self._balance:.2f}")
acc = BankAccount("Alice", 500.00)
print(acc.get_balance()) # 500.0
acc.deposit(200)
acc.set_balance(750)
print(acc.get_balance()) # 750.0
try:
acc.set_balance(-100)
except ValueError as e:
print("Error:", e)Deposited $200.00. New balance: $700.00
750.0
Error: Balance cannot be negative, got -100.
- Getters and setters work perfectly but require callers to use
acc.get_balance()instead ofacc.balance - This is verbose — Python's
@propertydecorator gives you the same control with cleaner syntax
3. The @property Decorator — Pythonic Encapsulation
@property lets you expose an attribute with clean read syntax while secretly running a method behind the scenes. Paired with a setter, it gives you full validation without the caller ever knowing.
Why it exists: you can start with a plain public attribute, then later add validation by converting it to a property — without changing any code that uses it. The public interface stays identical.
Real-world use: a Product class stores price as a plain number but validates it through a property setter — the code calling product.price = value never knows validation is happening.
# @property — clean encapsulation with validation
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance # calls the setter immediately
@property
def balance(self):
return self._balance # read from private storage
@balance.setter
def balance(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError("Balance must be a number.")
if amount < 0:
raise ValueError(f"Balance cannot be negative, got {amount}.")
self._balance = float(amount)
def deposit(self, amount):
self.balance += amount # setter validates automatically
print(f"Balance: ${self.balance:.2f}")
acc = BankAccount("Alice", 500.00)
print(acc.balance) # 500.0 — clean attribute access
acc.deposit(250) # Balance: $750.00
try:
acc.balance = -50
except ValueError as e:
print("Error:", e)Balance: $750.00
Error: Balance cannot be negative, got -50.
- The caller uses
acc.balance— exactly like a plain attribute — no getter/setter calls needed - Setting
self.balance = balancein__init__goes through the setter — validation runs even at construction - The private
_balanceis the real storage;balanceis the controlled public interface - Read-only properties: define only the getter — any assignment raises
AttributeError
4. Computed Properties
A property does not have to read from stored data. It can compute a value on the fly from other attributes — exposing something dynamic as a simple attribute.
# Computed properties — derived values exposed as attributes
class Order:
def __init__(self, items):
self.items = items # list of (name, price, qty) tuples
@property
def subtotal(self):
return sum(price * qty for _, price, qty in self.items)
@property
def tax(self):
return round(self.subtotal * 0.08, 2) # 8% tax
@property
def total(self):
return round(self.subtotal + self.tax, 2)
order = Order([
("notebook", 4.99, 3),
("pen", 1.50, 10),
("desk", 89.99, 1),
])
print(f"Subtotal: ${order.subtotal:.2f}")
print(f"Tax: ${order.tax:.2f}")
print(f"Total: ${order.total:.2f}")Tax: $9.60
Total: $129.56
- Computed properties stay in sync automatically — update
itemsand every derived property reflects the change - No setter needed — these are naturally read-only
- Cleaner than storing subtotal/tax/total separately and keeping them manually synchronised
5. A Complete Encapsulation Example
Putting it all together — a class that uses public, protected, and private attributes alongside properties to fully control its state.
# Complete encapsulation example — User class
class User:
_user_count = 0 # protected class attribute
def __init__(self, username, email, age):
self.username = username # goes through setter
self.email = email
self.age = age
User._user_count += 1
@property
def username(self):
return self._username
@username.setter
def username(self, value):
if not value or not isinstance(value, str):
raise ValueError("Username must be a non-empty string.")
self._username = value.strip().lower()
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if "@" not in value or "." not in value:
raise ValueError(f"Invalid email: {value}")
self._email = value.lower()
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int) or not (0 < value < 130):
raise ValueError("Age must be an integer between 1 and 129.")
self._age = value
@classmethod
def user_count(cls):
return cls._user_count
def __repr__(self):
return f"User({self.username!r}, {self.email!r}, age={self.age})"
u1 = User(" Alice ", "Alice@Example.COM", 30)
u2 = User("Bob", "bob@test.org", 25)
print(u1)
print(u2)
print("Total users:", User.user_count())
try:
u1.age = 200
except ValueError as e:
print("Error:", e)User('bob', 'bob@test.org', age=25)
Total users: 2
Error: Age must be an integer between 1 and 129.
Summary Table
| Tool / Convention | Syntax | Purpose |
|---|---|---|
| Public attribute | self.name |
Freely accessible from anywhere |
| Protected attribute | self._name |
Internal use — accessible but discouraged externally |
| Private attribute | self.__name |
Name-mangled — prevents accidental external access |
| Getter / Setter | get_x() / set_x() |
Explicit controlled access — verbose but clear |
@property |
@property def name(self): |
Read access with optional computation |
@name.setter |
@name.setter def name(self, val): |
Write access with validation |
Practice Questions
Practice 1. What does a single underscore prefix on an attribute signal in Python?
Practice 2. What does Python do internally when you define self.__ssn inside a class called Employee?
Practice 3. Why is @property preferred over explicit getter and setter methods in Python?
Practice 4. If you define a property getter but no setter, what happens when code tries to assign to it?
Practice 5. Why does a property setter use self._balance for storage instead of self.balance?
Quiz
Quiz 1. What is the main purpose of encapsulation?
Quiz 2. What is the mangled name of self.__balance defined inside a class called BankAccount?
Quiz 3. Which of the following correctly defines a read-only property called area?
Quiz 4. Why is it safe to refactor a plain public attribute into a @property without breaking existing callers?
Quiz 5. A computed property like total derived from subtotal and tax — does it need a setter?
Next up — Abstraction: hiding complexity behind clean interfaces using abstract classes and the abc module.