AiTechWorlds
AiTechWorlds
Picture a vending machine in a break room. You walk up, press B7, and a packet of chips slides down. You get exactly what you ordered, at the price displayed, every time.
Now imagine the machine had no front panel — just an open cabinet full of snacks and a cash box. Anyone could reach in, take whatever they want, swap price tags, or drop the cash box. Within a week the machine would be chaos: missing items, wrong prices, stolen money.
The front panel encapsulates the machine. It bundles the snacks, the mechanism, and the cash together, and it controls every interaction through defined slots: money in, selection in, snack out. You never touch the internals directly.
That is precisely what encapsulation does in OOP.
Encapsulation is the practice of:
Without encapsulation, any piece of code anywhere in your program can reach into an object and set its balance to -99999 or its age to "banana". Encapsulation makes objects responsible for their own consistency.
Python uses naming conventions rather than hard keywords like private or public. Every Python programmer recognises these instantly:
| Convention | Syntax | Meaning |
|---|---|---|
| Public | self.name | Anyone can read or write this attribute freely. |
| Protected | self._name | "Please don't touch this from outside the class." Convention only — not enforced. |
| Private | self.__name | Python applies name mangling: the attribute is renamed to _ClassName__name, making accidental access much harder. |
Python trusts developers. Conventions are a contract, not a lock. The double underscore raises the cost of accidental access — it does not make it impossible.
Prevent invalid state. If balance is public, nothing stops account.balance = -500. If balance is private, only your withdraw() method can change it — and your method enforces the rules.
Reduce coupling. If external code reads account._raw_balance_in_cents directly, then you can never refactor that internal detail without breaking all the callers. A property lets you change the internals without changing the interface.
@propertyThe @property decorator turns a method into something that looks like a plain attribute but runs your code whenever it is accessed or set.
class BankAccount:
def __init__(self, owner, initial_balance=0):
self.owner = owner
self.__balance = initial_balance # Private: double underscore
@property
def balance(self):
"""Anyone can READ the balance through this property."""
return self.__balance # Returns the hidden value
@balance.setter
def balance(self, amount):
"""Only allow SETTING balance if it is non-negative."""
if amount < 0:
raise ValueError("Balance cannot be negative.")
self.__balance = amount
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self.__balance += amount
print(f"Deposited ${amount:.2f}. New balance: ${self.__balance:.2f}")
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal must be positive.")
if amount > self.__balance:
raise ValueError("Insufficient funds.")
self.__balance -= amount
print(f"Withdrew ${amount:.2f}. New balance: ${self.__balance:.2f}")
Line-by-line breakdown:
self.__balance = initial_balance — Python silently renames this to _BankAccount__balance. Outside code can't hit it by accident.@property on balance — defines the getter. account.balance now calls this method and returns the value.@balance.setter — defines the setter. account.balance = 500 calls this method, which validates before storing.deposit and withdraw methods are the only authorised ways to change the balance with business logic applied.# Create an account
alice = BankAccount("Alice", 1000)
# Reading balance through the property (safe)
print(f"Current balance: ${alice.balance:.2f}")
# Depositing and withdrawing
alice.deposit(500)
alice.withdraw(200)
# Trying to set a negative balance through the property
try:
alice.balance = -100
except ValueError as e:
print(f"Blocked: {e}")
# Trying to access the private attribute directly
try:
print(alice.__balance) # AttributeError — name was mangled
except AttributeError as e:
print(f"Blocked: {e}")
# The mangled name technically exists but signals "hands off"
print(alice._BankAccount__balance) # Works, but screams "don't do this"
Output:
Current balance: $1000.00
Deposited $500.00. New balance: $1500.00
Withdrew $200.00. New balance: $1300.00
Blocked: Balance cannot be negative.
Blocked: 'BankAccount' object has no attribute '__balance'
1300.0
# WITHOUT encapsulation (fragile)
class BadAccount:
def __init__(self):
self.balance = 0 # Fully public — no protection
bad = BadAccount()
bad.balance = -99999 # Nothing stops this
print(bad.balance) # -99999 — corrupted state!
# WITH encapsulation (safe)
good = BankAccount("Bob", 0)
good.deposit(300)
good.balance = -99999 # Raises ValueError — protected by setter
The difference is not cosmetic. In a real system, an account with a negative balance caused by a stray assignment is a silent bug that may not surface until a financial report is wrong.
_protected to mark internal implementation details that subclasses might need but outsiders should ignore.__private for data that must only be modified through your class's own methods.@property getters (and setters only when needed) to expose a clean, validated interface._, __) rather than access keywords — the convention is a social contract enforced by culture and by name mangling for __.@property and @setter give you the syntax of a plain attribute while running validation code underneath.Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises