Skip to content

Encapsulation and properties

~20 min ยท prerequisite: Classes and objects

What you will be able to do

  • Keep an object's data safe by changing it only through methods that check the new value.
  • Use @property to read an attribute like a plain value, and add a setter that validates changes.
  • Tell apart the single-underscore and double-underscore naming conventions, and what each one does.

The idea

Encapsulation means keeping an object's data together with the rules for changing it. A bank account's balance should change only through deposits and withdrawals that pass its checks. If any code could set the balance directly, those rules would be easy to break by accident. Think of a vending machine: you press a button, and the machine decides whether to give you the item.

Python does not lock attributes. It uses naming conventions instead. An attribute whose name starts with one underscore, such as _balance, means "internal, please do not use this from outside." An attribute whose name starts with two underscores, such as __pin, is name-mangled: Python renames it to _ClassName__pin, which avoids accidental clashes in subclasses. Mangling is not a security feature, because the new name is still visible.

A property lets code read a method's result without parentheses. You mark the method with the decorator @property, a line starting with @ above the def. You can add a setter with @balance.setter, which runs when someone assigns a value. The setter is the place to check the rule. A property without a setter is read-only.

Worked example

class BankAccount:
    def __init__(self, owner):
        self.owner = owner
        self._balance = 0

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance -= amount


account = BankAccount("Ada")
account.deposit(50)
account.withdraw(20)
print(account.balance)

The output is 30. Line by line, this is what Python does:

  1. self._balance = 0 stores the balance under a name with one underscore. The name tells readers to go through the methods.
  2. @property turns balance into a read-only attribute. The method returns the stored value.
  3. deposit raises a ValueError when the amount is not positive. The raise statement stops the method, so nothing changes.
  4. withdraw refuses any amount larger than the balance, using the same approach.
  5. account.deposit(50) and account.withdraw(20) pass their checks, leaving 30.
  6. account.balance runs the property's method with no parentheses, and print shows 30.

Try it

The first block checks the rules, the second shows name mangling, and the third adds a validating setter.

class BankAccount:
    def __init__(self, owner):
        self.owner = owner
        self._balance = 0

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount


account = BankAccount("Ada")
account.deposit(50)
assert account.balance == 50

rejected = False
try:
    account.deposit(-5)
except ValueError:
    rejected = True
assert rejected
assert account.balance == 50
class Secret:
    def __init__(self):
        self.__code = 1234


s = Secret()
assert not hasattr(s, "__code")
assert s._Secret__code == 1234
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value


t = Temperature(20)
t.celsius = 25
assert t.celsius == 25

blocked = False
try:
    t.celsius = -300
except ValueError:
    blocked = True
assert blocked
assert t.celsius == 25

Common mistakes

  1. Assigning to a read-only property. The worked example's balance has no setter, so account.balance = 1000 gives:

    AttributeError: property 'balance' of 'BankAccount' object has no setter
    

    Change the balance with deposit or withdraw instead.

  2. Expecting __pin to be hidden from outside code. Inside the class, self.__pin works. Outside it, account.__pin gives:

    AttributeError: 'BankAccount' object has no attribute '__pin'
    

    The name was mangled to _BankAccount__pin, so the plain name is not found.

  3. Calling a property like a method. Writing account.balance() gives:

    TypeError: 'int' object is not callable
    

    A property is read without parentheses, as in account.balance.

  4. Returning the property's own name. If the getter returns self.balance instead of self._balance, the getter calls itself forever. Reading account.balance then gives:

    RecursionError: maximum recursion depth exceeded
    

    The getter must read the stored attribute, which is _balance.

Practice

  1. Create a BankAccount for "Ada" and assert that a new account's balance is 0.
  2. Make two calls to deposit, first with 10 and then with 15. Assert that balance is 25.
  3. Add a check to withdraw that raises ValueError when the amount is 0 or less. Assert that withdraw(0) raises it.
  4. Create a class Email with an address property. The setter must raise ValueError when the new value does not contain "@". Assert that Email("ada@example.com").address is the same text, and that Email("ada.example.com") raises ValueError.
  5. Stretch: create a class Temperature that stores celsius in a plain attribute and has a read-only property fahrenheit, calculated as celsius * 9 / 5 + 32. Assert that Temperature(100).fahrenheit is 212.0. Then assert that assigning to fahrenheit raises AttributeError.
Solution
  1. ```python class BankAccount: def init(self, owner): self.owner = owner self._balance = 0

    @property def balance(self): return self._balance

account = BankAccount("Ada") assert account.balance == 0 ```

  1. ```python class BankAccount: def init(self, owner): self.owner = owner self._balance = 0

    @property def balance(self): return self._balance

    def deposit(self, amount): if amount <= 0: raise ValueError("deposit must be positive") self._balance += amount

account = BankAccount("Ada") account.deposit(10) account.deposit(15) assert account.balance == 25 ```

  1. ```python class BankAccount: def init(self, owner): self.owner = owner self._balance = 0

    @property def balance(self): return self._balance

    def deposit(self, amount): if amount <= 0: raise ValueError("deposit must be positive") self._balance += amount

    def withdraw(self, amount): if amount <= 0: raise ValueError("withdrawal must be positive") if amount > self._balance: raise ValueError("not enough money") self._balance -= amount

account = BankAccount("Ada") rejected = False try: account.withdraw(0) except ValueError: rejected = True assert rejected ```

  1. ```python class Email: def init(self, address): self.address = address

    @property def address(self): return self._address

    @address.setter def address(self, value): if "@" not in value: raise ValueError("missing @") self._address = value

assert Email("ada@example.com").address == "ada@example.com" rejected = False try: Email("ada.example.com") except ValueError: rejected = True assert rejected ```

  1. ```python class Temperature: def init(self, celsius): self.celsius = celsius

    @property def fahrenheit(self): return self.celsius * 9 / 5 + 32

assert Temperature(100).fahrenheit == 212.0 blocked = False try: Temperature(100).fahrenheit = 50 except AttributeError: blocked = True assert blocked ```

Recap

  • Encapsulation keeps the rules for changing data next to the data.
  • A single leading underscore marks an attribute as internal by convention. A double leading underscore makes Python rename it, which does not hide it.
  • Use @property to read a value like an attribute, and @name.setter to check a value on assignment.

Sources