Lesson 8 of 11
Encapsulation means keeping an object's internal details private and exposing a controlled way to interact with it. The goal is that an object can never be put into an invalid state from outside.
The problem
Every attribute so far has been wide open:
class BankAccount:
def __init__(self, balance):
self.balance = balance
acct = BankAccount(100)
acct.balance = -5000
print(acct.balance)
Nothing stopped that. The withdraw() method might carefully check for sufficient funds, but
anyone can bypass it entirely and set balance directly.
Naming conventions
Python has no truly private attributes. Instead it uses naming conventions that everyone respects.
One underscore — "internal, please don't touch":
class BankAccount:
def __init__(self, balance):
self._balance = balance
def get_balance(self):
return self._balance
acct = BankAccount(100)
print(acct.get_balance())
print(acct._balance)
That still works from outside — Python doesn't stop you. It's a signal to other programmers, not
a lock. The convention is strong enough that reaching for someone's _attribute is understood
to be your own risk.
Two underscores — name mangling:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def get_balance(self):
return self.__balance
acct = BankAccount(100)
print(acct.get_balance())
print(acct._BankAccount__balance)
Python renames __balance to _BankAccount__balance behind the scenes. It's not security — as
that last line shows — it just makes accidental access unlikely, and prevents subclasses from
clashing with the name. Most Python code uses a single underscore; save __ for when you
genuinely need to avoid a name collision in a subclass.
Controlling access with methods
The real win is validation. Route all changes through methods, and the invalid state becomes impossible:
class BankAccount:
def __init__(self, balance=0):
self._balance = balance
def deposit(self, amount):
if amount <= 0:
return "Deposit must be positive"
self._balance += amount
return "OK"
def withdraw(self, amount):
if amount <= 0:
return "Amount must be positive"
if amount > self._balance:
return "Insufficient funds"
self._balance -= amount
return "OK"
def get_balance(self):
return self._balance
acct = BankAccount(100)
print(acct.deposit(-50))
print(acct.withdraw(500))
print(acct.deposit(50))
print(acct.get_balance())
Every path that changes the balance now enforces the same rules.
Properties
Getter methods are a bit clunky. A property gives you method behaviour with attribute syntax:
class BankAccount:
def __init__(self, balance=0):
self._balance = balance
@property
def balance(self):
return self._balance
acct = BankAccount(100)
print(acct.balance)
acct.balance reads like a plain attribute but runs the method. Because there's no setter, an
assignment raises AttributeError — read-only, enforced.
@property is a decorator; Module 9 explains how those work. For now, treat it as a label
that turns a method into a read-only attribute.
Try it yourself
Protect the balance so it can only change through validated methods.
Checkpoint
- Encapsulation hides internals and exposes a controlled interface.
_namemeans "internal by convention";__nametriggers name mangling.- Neither is truly enforced — Python trusts the programmer.
- Route changes through methods so validation always runs.
@propertygives attribute syntax with method behaviour.
Questions about this lesson
Log in to ask a question.