Encapsulation means hiding an object's internal data and controlling access to it through methods. Callers use what the object exposes and never need to know how it works inside. Think of an ATM: you press buttons and get cash, but you never touch the bank's internal records.
Access levels in Python#
Python controls access purely by naming convention:
class BankAccount:
def __init__(self, balance):
self.balance = balance # public
self._balance = balance # protected (by convention)
self.__balance = balance # private (name-mangled)
| Syntax | Level | Reachable from outside? | Meant for |
|---|---|---|---|
balance |
Public | Yes | Anyone |
_balance |
Protected | Yes, but you shouldn't | The class and its subclasses |
__balance |
Private | Only via the mangled name | The class itself |
Python has no true private#
Unlike languages such as C++, Python does not strictly enforce privacy. A double-underscore name is quietly renamed to _ClassName__name. This is name mangling.
acc = BankAccount(1000)
print(acc.__balance) # AttributeError
print(acc._BankAccount__balance) # 1000: still reachable
Python's philosophy is "we are all consenting adults here": it trusts you to respect conventions instead of locking things down. Name mangling mainly prevents accidental clashes in subclasses.
Why public attributes are risky#
If an attribute is public, anyone can set it to anything, including invalid values:
class BankAccount:
def __init__(self, balance):
self.balance = balance
acc = BankAccount(1000)
acc.balance = -5000 # nothing stops this
Getters and setters#
Make the attribute private and expose methods that validate changes:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def get_balance(self):
return self.__balance
def set_balance(self, amount):
if amount < 0:
print("Invalid! Balance can't be negative")
else:
self.__balance = amount
acc = BankAccount(1000)
acc.set_balance(-500) # Invalid! Balance can't be negative
acc.set_balance(2000)
print(acc.get_balance()) # 2000
@property: the Pythonic way#
Calling get_balance() works but feels clunky. @property lets callers use plain attribute syntax while your validation still runs:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
@property
def balance(self): # getter
return self.__balance
@balance.setter
def balance(self, amount): # setter with validation
if amount < 0:
print("Invalid! Balance can't be negative")
else:
self.__balance = amount
acc = BankAccount(1000)
print(acc.balance) # 1000 (getter runs)
acc.balance = -500 # Invalid! Balance can't be negative
acc.balance = 2000 # setter runs
| Getters and setters | @property |
|
|---|---|---|
| Read | acc.get_balance() |
acc.balance |
| Write | acc.set_balance(2000) |
acc.balance = 2000 |
| Feels like | A method call | Attribute access |
| Idiomatic Python? | Not really | Yes |
A useful consequence: you can start with a plain public attribute and later turn it into a property without changing any caller.
Read-only and computed properties#
Define only the getter and the attribute becomes read-only. Properties can also compute values on the fly.
import math
class Circle:
def __init__(self, radius):
self.__radius = radius
@property
def radius(self):
return self.__radius
@property
def area(self): # computed each time, read-only
return math.pi * self.__radius ** 2
c = Circle(5)
print(c.radius) # 5
print(c.area) # 78.53...
c.radius = 10 # AttributeError: no setter defined
Full example#
A bank account brings the pieces together:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public
self.__balance = balance # private
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, amount):
if amount < 0:
print("Invalid! Balance can't be negative")
else:
self.__balance = amount
def deposit(self, amount):
if amount > 0:
self.__balance += amount
print(f"Deposited {amount}. New balance: {self.__balance}")
def withdraw(self, amount):
if amount <= 0:
print("Invalid amount!")
elif amount > self.__balance:
print("Insufficient funds!")
else:
self.__balance -= amount
print(f"Withdrew {amount}. New balance: {self.__balance}")
acc = BankAccount("Bruno", 1000)
acc.deposit(500) # Deposited 500. New balance: 1500
acc.withdraw(200) # Withdrew 200. New balance: 1300
acc.balance = -100 # Invalid! Balance can't be negative
acc.withdraw(9999) # Insufficient funds!
Notice that deposit and withdraw are the real interface. Exposing operations rather than raw setters keeps the rules (no overdraft, positive amounts) inside the class.
Key takeaways#
- Encapsulation = hide data + control access through methods.
- Python privacy is convention:
_xmeans "internal",__xtriggers name mangling to_ClassName__x. - Use
@propertyfor validated access that still looks like a plain attribute. - A property with no setter is read-only; properties can also be computed.
- Prefer exposing meaningful operations (
deposit) over raw setters.