Object Oriented Programming
Encapsulation & @property
account.balance = -500 # ✗ nothing stops this — balance is now nonsensical!
Jr Codex Python Notes
Level: Advanced Prerequisites: Chapter 4 Time to complete: ~25 minutes
Table of Contents
- What is Encapsulation?
- Public, Protected & Private — Python's Conventions
- Name Mangling
- Why Hide Data At All?
- The
@propertyDecorator - Adding a Setter
- Read-Only Properties
- Summary & Next Steps
1. What is Encapsulation?
Encapsulation means bundling data and the methods that operate on it together, while restricting direct, uncontrolled access to that data from outside the class. The goal: an object protects its own internal consistency instead of trusting outside code to use it correctly.
class BankAccount:
def __init__(self, balance):
self.balance = balance
account = BankAccount(100)
account.balance = -500 # ✗ nothing stops this — balance is now nonsensical!Encapsulation is about closing that gap — making sure a BankAccount's balance can never be set to something invalid, no matter what calling code tries to do.
2. Public, Protected & Private — Python's Conventions
Unlike Java or C++, Python has no true access modifiers (no private keyword that the interpreter enforces) — instead, it uses naming conventions that everyone agrees to respect.
class Example:
def __init__(self):
self.public_attr = "anyone can access this"
self._protected_attr = "convention: internal use, but still accessible"
self.__private_attr = "name-mangled, harder to access accidentally"
obj = Example()
print(obj.public_attr) # ✓ fully intended for outside use
print(obj._protected_attr) # ⚠️ works, but you're breaking convention by touching it
print(obj.__private_attr) # AttributeError! (see Section 3 for why)| Convention | Syntax | Meaning |
|---|---|---|
| Public | self.name | Freely usable from outside the class |
| Protected | self._name | "Internal use" — a signal to other developers, not enforced |
| Private | self.__name | Name-mangled (Section 3) — strongly discourages outside access |
Key mindset shift from other languages: Python trusts developers ("we're all consenting adults here") rather than locking things down at the language level. A leading underscore is a request for privacy, not a wall.
3. Name Mangling
A double-underscore prefix (__name, no trailing double-underscore) triggers name mangling — Python internally renames the attribute to _ClassName__name, making accidental access (and accidental collisions in subclasses) much less likely.
class Account:
def __init__(self, balance):
self.__balance = balance # becomes _Account__balance internally
def get_balance(self):
return self.__balance # works fine INSIDE the class
account = Account(100)
print(account.get_balance()) # 100 — accessing through a proper method works
print(account.__balance) # AttributeError: 'Account' object has no attribute '__balance'
print(account._Account__balance) # 100 — the REAL mangled name, still technically reachableName Mangling Mechanics
─────────────────────────────────────────
self.__balance (written inside class Account)
↓ Python automatically rewrites this to:
self._Account__balance
─────────────────────────────────────────
Name mangling exists mainly to prevent accidental name clashes in inheritance (if a subclass happens to define its own __balance), not to make data truly unreadable — as shown above, it's still reachable if someone really wants to.
4. Why Hide Data At All?
The real value of encapsulation isn't secrecy — it's controlling how data changes, so invalid states become impossible:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.__balance += amount
def withdraw(self, amount):
if amount > self.__balance:
raise ValueError("Insufficient funds")
self.__balance -= amount
def get_balance(self):
return self.__balance
account = BankAccount("Alice", 100)
account.deposit(50) # ✓ goes through validation
print(account.get_balance()) # 150
account.__balance = -99999 # this does NOT touch the real balance —
# it creates a totally new public attribute!
print(account.get_balance()) # 150 — unaffected, the real data stayed protectedThis "getter method" style (get_balance()) works, but it's verbose compared to what most Python code actually uses — Section 5 shows the more idiomatic approach.
5. The @property Decorator
@property lets you define a method that's accessed like a plain attribute (no parentheses), while still running real logic behind the scenes — giving you validation and control without an ugly get_x()/set_x() API.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance
@property
def balance(self):
"""This method runs whenever someone accesses .balance"""
return self.__balance
account = BankAccount("Alice", 100)
print(account.balance) # 100 — looks like a plain attribute, no ()!
# but it's actually calling the balance() method behind the scenesWithout @property With @property
───────────────────────────── ─────────────────────────────
account.get_balance() account.balance
↑ looks like a method call ↑ looks like a plain attribute
(works, but not idiomatic) (idiomatic — same safety, cleaner API)
───────────────────────────── ─────────────────────────────
6. Adding a Setter
A @property alone is read-only. Add a matching @balance.setter to allow controlled assignment:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative")
self.__balance = value
account = BankAccount("Alice", 100)
print(account.balance) # 100 — calls the getter
account.balance = 200 # calls the SETTER — looks like plain assignment!
print(account.balance) # 200
account.balance = -50 # ValueError: Balance cannot be negative — validation still runs!This is the real payoff: account.balance = 200 looks exactly like setting a plain public attribute, but it's secretly running validation logic every time — the calling code doesn't need to know or care.
# A more complete, realistic example
class Temperature:
def __init__(self, celsius=0):
self.celsius = celsius # goes through the setter immediately, even in __init__
@property
def celsius(self):
return self.__celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero is impossible")
self.__celsius = value
@property
def fahrenheit(self):
"""A COMPUTED property — no matching setter, purely derived from celsius."""
return self.__celsius * 9/5 + 32
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0 — computed on the fly, always in sync with celsius
temp.celsius = 30
print(temp.fahrenheit) # 86.0 — automatically updated, no separate field to keep in sync7. Read-Only Properties
Simply omit the setter — any attempted assignment raises an AttributeError automatically:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2 # always derived, never stored directly
circle = Circle(5)
print(circle.area) # 78.53975
circle.area = 100 # AttributeError: can't set attribute 'area'
# (no setter defined — Python enforces read-only automatically)This is the idiomatic way to expose a computed value (like area, or fahrenheit above) that should never be set directly, only derived from other data.
8. Summary & Next Steps
Key Takeaways
- Python has no enforced
privatekeyword — it uses naming conventions:_protected(soft signal) and__private(name-mangled, harder to reach accidentally). - Encapsulation's real purpose is controlling how data changes, so an object can guarantee it never enters an invalid state.
@propertyturns a method into something accessed like a plain attribute (obj.value, no parentheses) — the idiomatic replacement for manualget_x()methods.- Add
@x.setterto allow validated assignment through the same attribute-like syntax; omit it entirely to make a property read-only.
Concept Check
- What does Python actually do to
self.__balanceinternally (name mangling), and why? - Why is
@propertygenerally preferred over writing aget_balance()method? - How do you make a property computed and read-only, like
Circle.area?
Next Chapter
→ Chapter 6: Abstraction & Abstract Base Classes
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index