Python Classes & OOP: From Basics to Protocols
Master Python OOP — classes, inheritance, dataclasses, ABCs, Protocols, dunder methods, and the patterns used in real framework and pipeline development.
Why OOP in Python?
Python lets you write perfectly good code without any classes. But when you're building pipelines, CLI tools, or frameworks, classes give you:
- Encapsulation — bundle state and logic that belong together
- Reuse — inheritance and composition avoid repetition
- Contracts — ABCs and Protocols define what something must support
- Readability —
user.deactivate()is clearer thandeactivate_user(user)
1. Class Basics
class User:
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email
self.is_active: bool = True
def deactivate(self) -> None:
self.is_active = False
def __repr__(self) -> str:
return f"User(name={self.name!r}, email={self.email!r})"
user = User("Asma", "asma@example.com")
user.deactivate()
print(user) # User(name='Asma', email='asma@example.com')Instance vs class vs static
class Counter:
total_created: int = 0 # class variable — shared across all instances
def __init__(self) -> None:
self.count: int = 0
Counter.total_created += 1
def increment(self) -> None: # instance method — has `self`
self.count += 1
@classmethod
def reset_total(cls) -> None: # class method — has `cls` (the class itself)
cls.total_created = 0
@staticmethod
def validate_count(n: int) -> bool: # static method — no implicit arg
return n >= 02. Inheritance
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
raise NotImplementedError
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self) -> str:
return f"{self.name} says Meow!"
animals: list[Animal] = [Dog("Rex"), Cat("Whiskers")]
for a in animals:
print(a.speak())super() — calling the parent
class AdminUser(User):
def __init__(self, name: str, email: str, role: str) -> None:
super().__init__(name, email) # call User.__init__
self.role = role
def __repr__(self) -> str:
base = super().__repr__()
return f"Admin({base}, role={self.role!r})"Multiple inheritance and MRO
Python resolves method calls using the Method Resolution Order (MRO) — left to right, then up.
class Logger:
def log(self, msg: str) -> None:
print(f"[LOG] {msg}")
class Serializable:
def to_dict(self) -> dict:
return self.__dict__
class Pipeline(Logger, Serializable): # inherits both
def run(self) -> None:
self.log("Starting")
p = Pipeline()
p.log("Running") # from Logger
p.to_dict() # from Serializable
# inspect MRO
print(Pipeline.__mro__)3. Properties
Use @property to control attribute access with getter/setter logic.
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius # underscore = "private by convention"
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9/5 + 32
t = Temperature(100)
print(t.fahrenheit) # 212.0
t.celsius = -300 # raises ValueError4. Dataclasses
@dataclass auto-generates __init__, __repr__, __eq__, and optionally __hash__ and __lt__.
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Order:
id: int
product: str
quantity: int
price: float
created_at: datetime = field(default_factory=datetime.utcnow)
tags: list[str] = field(default_factory=list)
@property
def total(self) -> float:
return self.quantity * self.price
order = Order(id=1, product="Widget", quantity=5, price=9.99)
print(order.total) # 49.95
print(order) # Order(id=1, product='Widget', ...)Frozen dataclasses (immutable)
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
p.x = 3.0 # raises FrozenInstanceErrorDataclass vs dict: when to use each
| Situation | Use |
|-----------|-----|
| Structured config with defaults | @dataclass |
| Validated API models | Pydantic BaseModel |
| Arbitrary key-value data | dict |
| Immutable record | @dataclass(frozen=True) |
5. Abstract Base Classes (ABCs)
ABCs define a contract — subclasses must implement certain methods.
from abc import ABC, abstractmethod
class DataSource(ABC):
@abstractmethod
def read(self) -> list[dict]:
...
@abstractmethod
def close(self) -> None:
...
def read_all(self) -> list[dict]: # concrete method — shared logic
try:
return self.read()
finally:
self.close()
class CSVSource(DataSource):
def __init__(self, path: str) -> None:
self.path = path
def read(self) -> list[dict]:
import csv
with open(self.path) as f:
return list(csv.DictReader(f))
def close(self) -> None:
pass # files are closed by context manager
class DatabaseSource(DataSource):
def read(self) -> list[dict]:
... # query the database
def close(self) -> None:
... # close connection
# DataSource() # raises TypeError — can't instantiate abstract class
source = CSVSource("data.csv")6. Protocols (Structural Typing)
Protocols check for capability, not ancestry. A class doesn't need to inherit — it just needs the right methods.
from typing import Protocol
class Runnable(Protocol):
def run(self) -> None:
...
class Worker:
def run(self) -> None:
print("Worker running")
class Pipeline:
def run(self) -> None:
print("Pipeline running")
def start(task: Runnable) -> None:
task.run()
start(Worker()) # works — Worker has .run()
start(Pipeline()) # works — Pipeline has .run()
# No inheritance from Runnable neededProtocol with runtime check
from typing import runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
class Connection:
def close(self) -> None:
...
isinstance(Connection(), Closable) # True7. Dunder (Magic) Methods
Dunder methods let your class integrate with Python's built-in syntax.
from typing import Iterator
class NumberRange:
def __init__(self, start: int, stop: int) -> None:
self.start = start
self.stop = stop
def __len__(self) -> int:
return self.stop - self.start
def __contains__(self, item: int) -> bool:
return self.start <= item < self.stop
def __iter__(self) -> Iterator[int]:
return iter(range(self.start, self.stop))
def __repr__(self) -> str:
return f"NumberRange({self.start}, {self.stop})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, NumberRange):
return NotImplemented
return self.start == other.start and self.stop == other.stop
r = NumberRange(1, 11)
len(r) # 10
5 in r # True
list(r) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]Context manager protocol
class Timer:
def __enter__(self) -> "Timer":
import time
self._start = time.perf_counter()
return self
def __exit__(self, *args: object) -> None:
self.elapsed = time.perf_counter() - self._start
print(f"Elapsed: {self.elapsed:.3f}s")
with Timer() as t:
sum(range(1_000_000))
print(t.elapsed)8. Composition Over Inheritance
Inheritance is often overused. Prefer composition when you want to reuse behaviour without coupling classes.
class FileWriter:
def write(self, path: str, content: str) -> None:
with open(path, "w") as f:
f.write(content)
class JsonSerializer:
def serialize(self, data: dict) -> str:
import json
return json.dumps(data, indent=2)
class ReportExporter:
def __init__(self) -> None:
self._writer = FileWriter() # composed
self._serializer = JsonSerializer() # composed
def export(self, data: dict, path: str) -> None:
content = self._serializer.serialize(data)
self._writer.write(path, content)9. Real Pattern: Base Pipeline Step
This pattern is used in ETL pipelines, data processing tools, and framework internals.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class StepResult:
success: bool
data: list[dict]
errors: list[str]
class PipelineStep(ABC):
name: str = "unnamed"
@abstractmethod
def process(self, data: list[dict]) -> StepResult:
...
def __call__(self, data: list[dict]) -> StepResult:
print(f"[{self.name}] Processing {len(data)} records")
result = self.process(data)
if not result.success:
print(f"[{self.name}] Errors: {result.errors}")
return result
class FilterNulls(PipelineStep):
name = "filter_nulls"
def process(self, data: list[dict]) -> StepResult:
clean = [r for r in data if all(v is not None for v in r.values())]
removed = len(data) - len(clean)
return StepResult(success=True, data=clean, errors=[f"removed {removed} null rows"])
class NormalizeEmails(PipelineStep):
name = "normalize_emails"
def process(self, data: list[dict]) -> StepResult:
for row in data:
if "email" in row:
row["email"] = row["email"].strip().lower()
return StepResult(success=True, data=data, errors=[])
# run the pipeline
steps: list[PipelineStep] = [FilterNulls(), NormalizeEmails()]
current_data = [{"email": " USER@EXAMPLE.COM "}, {"email": None}]
for step in steps:
result = step(current_data)
current_data = result.dataExercises
Exercise 1: Create a BankAccount class with deposit, withdraw, and a balance property. Raise ValueError on negative deposits or withdrawals that exceed balance.
Exercise 2: Define a Sortable Protocol with a sort_key property that returns a float. Write a function sort_items(items: list[Sortable]) -> list[Sortable] that uses it.
Exercise 3: Write a retry_on_error context manager class (using __enter__/__exit__) that catches exceptions and re-runs the block up to N times.
Summary
| Tool | Use When |
|------|---------|
| Plain class | Stateful objects with methods |
| @dataclass | Structured records, config, DTOs |
| Inheritance | "is-a" relationship, shared concrete logic |
| ABC | Enforcing contracts in base classes |
| Protocol | Duck-typing — structural interface without inheritance |
| Properties | Computed attrs, validation on set |
| Composition | Combining behaviours without tight coupling |
| Dunder methods | Integrating with Python syntax (len, in, with) |
Next: file handling, scripting patterns, and CLI tools with argparse/Typer.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.