Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

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.
  • _name means "internal by convention"; __name triggers name mangling.
  • Neither is truly enforced — Python trusts the programmer.
  • Route changes through methods so validation always runs.
  • @property gives attribute syntax with method behaviour.

Questions about this lesson

Log in to ask a question.