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
@propertyto 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:
self._balance = 0stores the balance under a name with one underscore. The name tells readers to go through the methods.@propertyturnsbalanceinto a read-only attribute. The method returns the stored value.depositraises aValueErrorwhen the amount is not positive. Theraisestatement stops the method, so nothing changes.withdrawrefuses any amount larger than the balance, using the same approach.account.deposit(50)andaccount.withdraw(20)pass their checks, leaving30.account.balanceruns the property's method with no parentheses, andprintshows30.
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 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¶
-
Assigning to a read-only property. The worked example's
balancehas no setter, soaccount.balance = 1000gives:Change the balance with
depositorwithdrawinstead. -
Expecting
__pinto be hidden from outside code. Inside the class,self.__pinworks. Outside it,account.__pingives:The name was mangled to
_BankAccount__pin, so the plain name is not found. -
Calling a property like a method. Writing
account.balance()gives:A property is read without parentheses, as in
account.balance. -
Returning the property's own name. If the getter returns
self.balanceinstead ofself._balance, the getter calls itself forever. Readingaccount.balancethen gives:The getter must read the stored attribute, which is
_balance.
Practice¶
- Create a
BankAccountfor"Ada"and assert that a new account'sbalanceis0. - Make two calls to
deposit, first with10and then with15. Assert thatbalanceis25. - Add a check to
withdrawthat raisesValueErrorwhen the amount is0or less. Assert thatwithdraw(0)raises it. - Create a class
Emailwith anaddressproperty. The setter must raiseValueErrorwhen the new value does not contain"@". Assert thatEmail("ada@example.com").addressis the same text, and thatEmail("ada.example.com")raisesValueError. - Stretch: create a class
Temperaturethat storescelsiusin a plain attribute and has a read-only propertyfahrenheit, calculated ascelsius * 9 / 5 + 32. Assert thatTemperature(100).fahrenheitis212.0. Then assert that assigning tofahrenheitraisesAttributeError.
Solution
-
```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 ```
-
```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 ```
-
```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 ```
-
```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 ```
-
```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
@propertyto read a value like an attribute, and@name.setterto check a value on assignment.