Object-Oriented Programming in Python
Learn classes, instances, inheritance, dunder methods, and encapsulation with real-world examples.
Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — bundles of data (attributes) and behaviour (methods). The benefit is encapsulation: related state and logic live together, making systems easier to reason about, test, and extend. Python is a fully object-oriented language where even primitive types like int and str are objects.
Defining a Class
A class is a blueprint. Each time you call it, Python creates a new independent instance. The __init__ method is the constructor — it runs automatically when a new instance is created and is where you set up the initial state of the object.
class BankAccount:
"""Represents a simple bank account."""
# Class-level attribute — shared across all instances of this class
bank_name: str = "PyBank"
def __init__(self, owner: str, balance: float = 0.0) -> None:
# Instance attributes — unique to each individual object
self.owner = owner
self._balance = balance # leading _ signals "protected by convention"
def deposit(self, amount: float) -> None:
# Validate inputs to keep the object in a consistent state
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self._balance += amount
def withdraw(self, amount: float) -> None:
if amount > self._balance:
raise ValueError("Insufficient funds.")
self._balance -= amount
def get_balance(self) -> float:
return self._balance
# __str__ is called by print() and str() — intended for end users
def __str__(self) -> str:
return f"BankAccount(owner={self.owner!r}, balance={self._balance:.2f})"
# __repr__ is called in the REPL and by repr() — intended for developers
def __repr__(self) -> str:
return f"BankAccount({self.owner!r}, {self._balance!r})"
Creating and using instances:
acc = BankAccount("Alice", 1000.0)
acc.deposit(500.0)
acc.withdraw(200.0)
print(acc) # BankAccount(owner='Alice', balance=1300.00)
print(acc.get_balance()) # 1300.0
# Class attributes are accessible on the class itself or any instance
print(BankAccount.bank_name) # PyBank
print(acc.bank_name) # PyBank
Inheritance
Inheritance lets a child class reuse and extend a parent class without copying code. The child gains all the parent’s methods and attributes, and can override any of them or add new ones. This is how Python’s standard library extends base classes — list, dict, Exception, and so on.
class SavingsAccount(BankAccount):
"""A bank account that earns periodic interest."""
def __init__(self, owner: str, balance: float, rate: float) -> None:
# super() delegates to the parent's __init__ — always call it first
super().__init__(owner, balance)
self.rate = rate # new attribute not present in the parent
def apply_interest(self) -> None:
# Extend the parent's functionality without touching its source
interest = self._balance * self.rate
self._balance += interest
print(f"Interest applied: +{interest:.2f}")
def __str__(self) -> str:
# Override the parent's __str__ to include the interest rate
return (
f"SavingsAccount(owner={self.owner!r}, "
f"balance={self._balance:.2f}, rate={self.rate:.1%})"
)
savings = SavingsAccount("Bob", 5000.0, rate=0.05)
savings.deposit(500.0) # inherited from BankAccount — no reimplementation needed
savings.apply_interest() # Interest applied: +275.00
print(savings) # SavingsAccount(owner='Bob', balance=5775.00, rate=5.0%)
Key Dunder Methods
Dunder (double-underscore) methods define how your objects respond to Python’s built-in operations. Implementing them lets your class integrate seamlessly with the language — objects that support len(), +, ==, and with feel like first-class Python types rather than bolted-on structures.
| Method | Triggered by |
|---|---|
__init__ | MyClass() — object construction |
__str__ | str(obj), print(obj) |
__repr__ | repr(obj), interactive shell display |
__len__ | len(obj) |
__eq__ | obj1 == obj2 |
__lt__ | obj1 < obj2 |
__add__ | obj1 + obj2 |
__enter__ / __exit__ | with statement context manager |
class Vector:
"""A 2D mathematical vector that supports arithmetic operators."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __add__(self, other: "Vector") -> "Vector":
# Called when you write v1 + v2
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other: object) -> bool:
# Called when you write v1 == v2
if not isinstance(other, Vector):
return NotImplemented # let Python handle the comparison the other way
return self.x == other.x and self.y == other.y
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6) — __add__ is called automatically
print(v1 == v2) # False — __eq__ is called automatically
Properties — Controlled Attribute Access
The @property decorator lets you expose an attribute through a getter and optionally a setter, while keeping the calling syntax clean (no explicit method call). The benefit is that you can add validation, computation, or caching without changing how the attribute is accessed — existing code that reads obj.celsius doesn’t need to change when you add validation logic.
class Temperature:
"""Stores a temperature with automatic Fahrenheit conversion."""
def __init__(self, celsius: float) -> None:
# Store as the internal name; public access goes through the property
self._celsius = celsius
@property
def celsius(self) -> float:
"""Read the temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
"""Set the temperature in Celsius, enforcing physical limits."""
if value < -273.15:
raise ValueError("Temperature below absolute zero.")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""Compute Fahrenheit on the fly — no redundant stored state."""
return self._celsius * 9 / 5 + 32
t = Temperature(100)
print(t.fahrenheit) # 212.0
t.celsius = 0 # calls the setter — triggers validation
print(t.fahrenheit) # 32.0
# t.fahrenheit = 50 # would raise AttributeError — no setter defined
Class and Static Methods
Regular methods receive the instance as self. Class methods receive the class as cls and are useful for alternative constructors. Static methods receive neither — they’re plain functions namespaced inside the class.
class Date:
def __init__(self, year: int, month: int, day: int) -> None:
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_string: str) -> "Date":
"""Alternative constructor — parse a 'YYYY-MM-DD' string."""
year, month, day = map(int, date_string.split("-"))
return cls(year, month, day) # cls() calls Date() or any subclass
@staticmethod
def is_valid_month(month: int) -> bool:
"""Utility check — doesn't need instance or class state."""
return 1 <= month <= 12
d = Date.from_string("2024-06-15") # alternative constructor
print(d.year) # 2024
print(Date.is_valid_month(13)) # False