Methods, attributes and encapsulation
Python does not have private attributes. Anything on an object can be read and changed by anyone. That sounds like a problem and mostly is not — but it does mean encapsulation here is about convention and design rather than enforcement.
The problem
account = BankAccount("Priya", 1000)
account.balance = -999999
Every rule in withdraw bypassed, in one line. The class carefully prevented an
overdraft through its methods, and direct assignment walked round the front.
The underscore convention
A leading underscore means "this is internal; do not touch it from outside":
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self._balance = balance
self._transactions: list[str] = []
Python does not stop you. account._balance = -999 works. What it does is
communicate — and in practice that is enough, because the convention is
universally understood. Anyone touching a _name from outside knows they are
doing something unsupported, and any reviewer will say so.
No underscore means public: part of the class's promise, safe to use, and you should think before changing it. One underscore means internal: free to change without warning.
That distinction is genuinely useful when you return to code later. It tells you which names other code may depend on.
Double underscores
Two leading underscores trigger name mangling:
class Account:
def __init__(self):
self.__secret = 42
a = Account()
print(a.__secret)
AttributeError: 'Account' object has no attribute '__secret'
It still exists, renamed to _Account__secret:
print(a._Account__secret) # 42
This is not privacy. Its actual purpose is avoiding name collisions in
inheritance — a subclass defining __secret gets its own, not the parent's.
Use one underscore. Double underscores make debugging awkward for no real protection, and most Python codebases avoid them.
Properties
When you want a public attribute that runs code on access, @property gives you
a method that is used like an attribute:
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self._balance = balance
@property
def balance(self) -> float:
"""The current balance. Read-only from outside."""
return self._balance
account = BankAccount("Priya", 1000)
print(account.balance) # 1000 — no brackets
account.balance = -999
AttributeError: property 'balance' of 'BankAccount' object has no setter
Readable, and genuinely not writable. The value is kept in _balance and
changed only by deposit and withdraw.
The @ line is a decorator — a function that wraps another function to
change how it behaves. You met @contextmanager in module 7 without
explanation; this is the same mechanism. You can use decorators long before you
write one, which is the normal order.
Computed properties
A property need not store anything:
class Expense:
GST_RATE = 0.18
def __init__(self, amount: float):
self.amount = amount
@property
def total(self) -> float:
"""Amount including GST."""
return round(self.amount * (1 + self.GST_RATE), 2)
e = Expense(250)
print(e.total) # 295.0
e.amount = 500
print(e.total) # 590.0
total is always correct because it is worked out on access. Storing it in
__init__ would leave it stale the moment amount changed.
Compute derived values rather than storing them, unless the computation is expensive. Stale derived data is a classic bug.
Setters with validation
class Expense:
def __init__(self, amount: float):
self.amount = amount # goes through the setter below
@property
def amount(self) -> float:
return self._amount
@amount.setter
def amount(self, value: float) -> None:
if value < 0:
raise ValueError(f"Amount cannot be negative, got {value}")
self._amount = value
e = Expense(250)
e.amount = -50 # ValueError
Now validation applies everywhere, including inside __init__, because the
assignment there goes through the setter too.
Do not do this reflexively. A property that only gets and sets with no logic is noise:
@property
def owner(self):
return self._owner # pointless
Just use self.owner. This is where Python differs from Java: you do not write
getters and setters up front in case you need them later, because adding a
property later does not break callers. self.amount stays self.amount whether
it is a plain attribute or a property.
Start with a plain attribute. Add a property when you need behaviour.
Static and class methods
Two more decorators, both occasionally useful.
@staticmethod — a function that lives in the class for organisational
reasons and uses neither self nor the class:
class Expense:
@staticmethod
def is_valid_category(category: str) -> bool:
return category in {"food", "transport", "rent", "other"}
print(Expense.is_valid_category("food")) # True
Callable without an object. If you find yourself with several of these and no instance state, that is usually a module of plain functions rather than a class.
@classmethod — receives the class as cls rather than an instance. The
main use is an alternative constructor:
class Expense:
def __init__(self, amount: float, category: str):
self.amount = amount
self.category = category
@classmethod
def from_dict(cls, data: dict) -> "Expense":
"""Build an Expense from a dictionary, as read from JSON."""
return cls(amount=float(data["amount"]), category=data["category"])
e = Expense.from_dict({"amount": "250", "category": "food"})
This is a genuinely good pattern, and it connects directly to module 7: JSON
gives you dictionaries, and from_dict is the one place that converts them into
validated objects. The matching to_dict method goes the other way for saving.
Note cls(...) rather than Expense(...) — it means subclasses get the right
type back.
Designing the surface
A useful way to think about a class: what can somebody do with this, and what should they not have to know?
For BankAccount, the public surface is owner, balance, deposit() and
withdraw(). How transactions are stored is nobody's business, which is why it
is _transactions.
Keep the public part small. Everything public is a promise you have to keep.
Check your work
The read-only property.
AttributeError: property 'balance' of 'BankAccount' object has no setter
Readable, genuinely not writable, and the value is changed only by deposit and
withdraw.
Stale derived data. Storing total in __init__ and then changing amount
leaves the two disagreeing. A computed property is worked out on access and
cannot be wrong. Compute derived values rather than storing them, unless the
computation is expensive.
The setter fires from __init__ too, because self.amount = amount there
goes through the property. That is what makes validation apply everywhere.
The pointless property.
@property
def owner(self):
return self._owner # adds nothing
Deleting it and using self.owner changes nothing for any caller — which is the
point. In Python, adding a property later does not break callers, so start
with a plain attribute and add behaviour when you need it. This is where
Python differs from Java.
from_dict as a classmethod is the one place that turns external
dictionaries into validated objects, and cls(...) rather than Expense(...)
means subclasses get the right type back.
Double underscores.
a.__secret # AttributeError
a._Account__secret # 42
Not privacy — name mangling, whose actual purpose is avoiding collisions in inheritance. Use one underscore.
Practice
- Add
_balancetoBankAccountand a read-onlybalanceproperty. Try to assign to it. - Add a computed
totalproperty toExpense. Changeamountand confirmtotalfollows. - Store
totalin__init__instead, changeamount, and observe it go stale. - Add an
amountsetter with validation. Confirm it fires from__init__too. - Write a pointless property that only gets and sets. Then delete it and use a plain attribute, confirming nothing else changed.
- Add
is_valid_categoryas a@staticmethod. - Add
from_dictas a@classmethodand build an object from a JSON-shaped dictionary. Then addto_dictand round-trip throughjson.dumps. - Create an attribute with two leading underscores and try to read it from outside. Then find its mangled name.
Next: inheritance, and the alternative that is usually better.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship