RizTech Academy logo
RizTech Academy
Object-Oriented PythonLesson 3 of 725 min

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

  1. Add _balance to BankAccount and a read-only balance property. Try to assign to it.
  2. Add a computed total property to Expense. Change amount and confirm total follows.
  3. Store total in __init__ instead, change amount, and observe it go stale.
  4. Add an amount setter with validation. Confirm it fires from __init__ too.
  5. Write a pointless property that only gets and sets. Then delete it and use a plain attribute, confirming nothing else changed.
  6. Add is_valid_category as a @staticmethod.
  7. Add from_dict as a @classmethod and build an object from a JSON-shaped dictionary. Then add to_dict and round-trip through json.dumps.
  8. 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