Skip to content

Encapsulation: public, protected and private

In the example below, BankAccount demonstrates the three levels of access control Python uses by convention. owner is fully public, anyone can read or change it. _account_no is protected, accessible but signals “internal use only”. __balance is private, name-mangled by Python to make accidental access harder, and exposed only through the controlled get_balance() method.

class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public → accessible anywhere
self._account_no = "12345" # protected → convention: don't touch outside class
self.__balance = balance # private → name-mangled, hidden
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self): # Controlled access via method
return self.__balance
acc = BankAccount("Alice", 1000)
print(acc.owner) # Alice ✅
print(acc._account_no) # 12345 ⚠️ works but discouraged
# print(acc.__balance) # ❌ AttributeError
print(acc.get_balance()) # 1000 ✅

You will see @property in the next chapter — for now just remember this:

SituationWhat to use
No @propertyself.name — plain attribute, no underscore needed
Has @propertyself._name inside the class, self.name outside

Not all methods need to work on a specific instance. Python gives you two alternatives:

  • @classmethod for methods that work on the class itself
  • @staticmethod for pure utility functions that don’t need access to either the class or an instance.

In the example below:

  • Employee uses a class attribute _count to track how many employees have been created
  • get_count() reads that class-level data
  • from_string() acts as an alternative constructor, a common pattern for creating objects from different input formats
  • is_valid_salary() is a plain utility that could live anywhere but belongs here logically.
class Employee:
company = "TechCorp"
_count = 0
def __init__(self, name, salary):
self.name = name
self.salary = salary
Employee._count += 1
@classmethod
def get_count(cls): # Works on the CLASS, not instance
return f"Total employees: {cls._count}"
@classmethod
def from_string(cls, data): # Alternative constructor
name, salary = data.split(",")
return cls(name, int(salary))
@staticmethod
def is_valid_salary(salary): # Utility — no access to class/instance
return salary > 0
e1 = Employee("Alice", 50000)
e2 = Employee.from_string("Bob,60000") # Alternative constructor
print(Employee.get_count()) # Total employees: 2
print(Employee.is_valid_salary(50000)) # True

A summary of the access control conventions and method types covered in this chapter.

ConventionSyntaxAccessible fromMeaning
Publicself.nameAnywhereNo restrictions
Protectedself._nameClass and subclassesConvention — don’t touch outside
Privateself.__nameClass onlyName-mangled by Python
DecoratorFirst argumentHas access toUse for
noneselfInstance + classRegular behaviour
@classmethodclsClass onlyAlternative constructors, class-level state
@staticmethodnoneNeitherUtility functions that belong logically
SituationUse
Regular behaviour on an instanceInstance method
Tracking class-level data like a counter@classmethod
Creating objects from different input formats@classmethod as alternative constructor
Pure utility with no class or instance needed@staticmethod
Hiding implementation details_name or __name
Controlled access to private dataExpose via a public method

Exercise 1 — Class & instance attributes

Section titled “Exercise 1 — Class & instance attributes”

Create a BankAccount class that:

  • Tracks total number of accounts created (class attribute)
  • Has owner and balance as instance attributes
  • Has deposit(amount) and withdraw(amount) methods
  • withdraw should raise ValueError if funds are insufficient
  • Has get_account_count() as a @classmethod
  • Has __str__ showing owner and balance
Expected output
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob", 500)
acc1.deposit(200)
acc1.withdraw(100)
print(acc1) # BankAccount(owner=Alice, balance=1100)
print(BankAccount.get_account_count()) # 2
acc2.withdraw(1000) # ValueError: Insufficient funds
Solution
exercise_01.py
class BankAccount:
"""
`totalAccounts` is a class attribute, it lives on the class, not on any instance, so it counts across all accounts.
`get_account_count()` uses `cls` instead of `self`, it works on the class itself, not an instance.
`withdraw` validates before modifying, if the check fails, `balance` is never touched.
"""
totalAccounts = 0
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
BankAccount.totalAccounts += 1
def deposit(self, amount):
self.balance = self.balance + amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
def get_balance(self):
return self.balance
def get_owner(self):
return self.owner
@classmethod
def get_account_count(self):
return self.totalAccounts
def __str__(self):
return f"BankAccount(owner={self.owner}, balance={self.balance})"
test_exercise_01.py
import pytest
from exercises.ch01_oop.encapsulation.exercise_01 import BankAccount
def test_balance_account():
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob", 500)
acc1.deposit(200)
acc1.withdraw(100)
print(acc1) # BankAccount(owner=Alice, balance=1100)
assert acc1.get_balance() == 1100
assert acc1.get_owner() == "Alice"
print(BankAccount.get_account_count())
assert BankAccount.get_account_count() == 2
with pytest.raises(ValueError, match="Insufficient funds"):
acc2.withdraw(1000)