Encapsulation
Problem
Minus €500
In the last lesson we put the withdrawal check in a method. But the field is open, and anyone can write to it directly:
account = Account("Maria", 100)account.withdraw(500)account.balance = -500print(account.balance)-500The method correctly refused to withdraw €500. The next line simply set the balance by hand. The check is there, but you can get around it. We need a way to say "only the account touches the balance".
The idea
_balance: internal to the class
Put an underscore in front of the field name: _balance.
class Account: def __init__(self, owner, balance): self._owner = owner self._balance = balance
# ...Python has no private. The underscore is a convention between programmers: "this is internal to the class, don't touch it from outside". Python doesn't stop anyone. The line
account._balance = -500runs, and the balance becomes -500. But anyone who reads it sees that the rule is broken (PyCharm, for example, underlines it). Code outside the class uses only the methods of the class.
The class hides its data and shows it only through its own methods. This is called encapsulation. Java and C# have keywords for it, access modifiers (private, public). In Python, the underscore plays that role.
More precisely, if you are curious
- With two underscores (
self.__balance), Python renames the field to_Account__balance. From outside,account.__balanceis gone, butaccount._Account__balancestill reaches it. This protects against an accidental name clash in subclasses, not against touching the field on purpose. One underscore is usually enough. - The convention works for methods too:
_check()is a method meant only for the class itself.
The idea
Reading without touching
From outside, we shouldn't even read the balance now. So the class gives a method that only returns it:
class Account: def __init__(self, owner, balance): self._owner = owner self._balance = balance
@property def owner(self): return self._owner
@property def balance(self): return self._balance
def deposit(self, amount): if amount > 0: self._balance += amount
def withdraw(self, amount): if amount > 0 and amount <= self._balance: self._balance -= amount return True return False
account = Account("Maria", 100)account.withdraw(30)account.deposit(50)print(f"{account.owner}: €{account.balance}")Maria: €120balance is a property. @property above the method means that from outside you read it like a field: account.balance, with no parentheses. Inside, it returns the value of _balance. This is the Python getter. The balance changes only through deposit and withdraw, and they do the checks.
A property without a setter can't be written to. The line account.balance = -500 stops with:
AttributeError: property 'balance' of 'Account' object has no setterwithdraw now returns True or False: whether the withdrawal went through. This way, whoever withdraws knows if it worked.
account = Account("Maria", 100)account.withdraw(500)account.deposit(50)print(account.balance)Step by step, in words
- _balance is internal: by convention, code outside does not touch the field. It goes through the methods.
- The withdrawal goes through the check in the method: 500 is more than 100. Refused, and the balance stays as it is.
- The deposit is checked too: 50 is a positive amount. The method adds it.
- The balance property hands out a copy of the balance. The _balance field itself stays inside.
Try it
Refused
What will the program print?
account = Account("Maria", 100)print(account.withdraw(150))account.deposit(-20)print(account.balance)€150 is more than the balance, so withdraw returns False and leaves the balance alone. The deposit of -€20 is refused too, because the amount must be positive. The balance stays 100.
The idea
Setter: a change with a check
Sometimes code outside needs to change a field, but not to just any value. An animal's age can change, but it can't be negative:
class Animal: def __init__(self): self._age = 0
@property def age(self): return self._age
@age.setter def age(self, value): if value >= 0: self._age = value
cat = Animal()cat.age = 4cat.age = -3print(cat.age)4@age.setter makes a setter for the age property: a method with the same name that is called on cat.age = -3. value is the value on the right of =, here -3. The check fails and the age stays 4. From outside, the write looks like a write to a plain field, but it goes through the check.
The idea
Which field gets a setter?
Not every field needs a setter, and some don't even need a getter. Think about what code outside should be able to do with the product:
- the name and the price are set when the product is created and are only read after that: getter only;
- the quantity in the cart changes, but it's at least 1: a getter and a setter with a check.
class Product: def __init__(self, name, price): self._name = name self._price = price self._quantity = 1
@property def name(self): return self._name
@property def price(self): return self._price
@property def quantity(self): return self._quantity
@quantity.setter def quantity(self, value): if value >= 1: self._quantity = value
coffee = Product("Coffee", 7.50)coffee.quantity = 3coffee.quantity = 0print(f"{coffee.name} x{coffee.quantity}")Coffee x3The rule is: start with _ for everything and open only what you need. A setter that stores anything without a check brings back the problem from the first card.
Try it
What's missing?
class Account:
def __init__(self, balance):
self. = balance_balance: the underscore says "internal to the class, don't touch it from outside".Try it
Find the bug
The field is _balance, and yet the balance becomes -500:
account = Account()account.balance = 100account.withdraw(30)account.balance = -500print(account.balance)-500Which line in the class breaks the protection?
Pick the line with the bug.
The setter under @balance.setter stores any value without a check. The field is internal, but the setter opens it again. Remove it and set the starting balance in __init__. After that, it changes only through deposit and withdraw.
Exercise
Hidden data
Solve the tasks in your editor. All fields start with an underscore.
1. Person. Person with a name and an age, a constructor, read-only properties name and age and introduce().
Maria is 34 years old.Hi, I'm Maria and I'm 34 years old.2. An account with no minus. Account with an owner and a balance. A deposit accepts only a positive amount. A withdrawal returns whether it went through and never lets the balance go below 0. Code outside can read the balance, but can't write it.
account = Account("Peter", 50)account.deposit(25)account.deposit(-10)print(account.withdraw(100))print(account.withdraw(40))print(f"Balance: €{account.balance}")FalseTrueBalance: €353. Animal. Animal with a species and an age. The species is set in the constructor and is only read after that. The age has a setter (@age.setter) that refuses a negative number and prints a message. The constructor goes through the same check too: in __init__, write self.age = age, without the underscore.
cat = Animal("cat", 3)cat.age = 4cat.age = -1print(f"{cat.species}, {cat.age} years old")Invalid age: -1cat, 4 years oldMore tasks
- Grade. A
Gradeclass with a subject name and a grade from 2 to 6. The setter refuses anything outside those limits. Try it with 5, 7 and 1. - Product with a discount. Add a method to the
Productfrom the lesson that applies a discount in percent and accepts only 1 to 90%. The price has no setter.
Test