Classes and object-oriented design
Design cohesive Python objects, choose composition over inheritance, and recognize when a class is unnecessary.
A class creates a new type and a namespace for behavior. An instance stores state; methods are functions retrieved through attribute lookup and bound to that instance. Classes are valuable when state, invariants, and behavior form one cohesive concept—not simply because a language supports object-oriented syntax.
Instances and bound methods
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class Account:
owner: str
_balance: Decimal = Decimal("0")
@property
def balance(self) -> Decimal:
return self._balance
def deposit(self, amount: Decimal) -> None:
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
account = Account("Ada")
account.deposit(Decimal("12.50"))
assert account.balance == Decimal("12.50")account.deposit is a bound method: Python supplies account as self.
The underscore is a non-public convention, while the property exposes read-only
access and keeps the invariant-changing operation explicit.
Use:
- Instance methods for behavior using instance state.
@classmethodfor alternate constructors or behavior involving the class.@staticmethodsparingly for a function strongly related to the class but needing neither instance nor class state.
Often a module-level function is clearer than a static method.
Data classes are not “just records”
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Temperature:
celsius: float
def __post_init__(self) -> None:
if self.celsius < -273.15:
raise ValueError("temperature is below absolute zero")
@property
def fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32frozen=True prevents ordinary field assignment and supports value-object
semantics. slots=True removes the per-instance __dict__ in common cases,
reducing memory and rejecting accidental attributes. Neither feature makes
objects inside the fields recursively immutable.
Prefer a data class when generated initialization, representation, and equality match the domain. Write a regular class when construction or identity semantics need tighter control.
Composition versus inheritance
Composition delegates work to contained collaborators:
from typing import Protocol
class Sender(Protocol):
def send(self, message: str) -> None: ...
class AlertService:
def __init__(self, sender: Sender) -> None:
self._sender = sender
def alert(self, subject: str) -> None:
self._sender.send(f"ALERT: {subject}")AlertService can work with email, SMS, or a test double without inheriting from
them. The dependency is explicit and replaceable.
Inheritance is appropriate when the derived type is genuinely substitutable for the base type and the base contract is intentionally designed for extension. It is risky when used only to reuse implementation. A subclass then depends on protected internals, constructor details, and method-call ordering—the “fragile base class” problem.
class Rectangle:
def area(self) -> float:
raise NotImplementedError
class Square(Rectangle):
def __init__(self, side: float) -> None:
self.side = side
def area(self) -> float:
return self.side**2Even this harmless hierarchy should prompt a question: does the consumer need a
nominal Rectangle, or merely an object with area()? A protocol may state the
actual requirement with less coupling.
Cooperative multiple inheritance
Python resolves attributes using the method resolution order (MRO). In a
cooperative hierarchy, every implementation calls super() with compatible
signatures:
class TraceMixin:
def save(self) -> None:
print("trace")
super().save()
class Repository:
def save(self) -> None:
print("persist")
class TracedRepository(TraceMixin, Repository):
pass
assert TracedRepository.__mro__[:3] == (
TracedRepository,
TraceMixin,
Repository,
)super() means “continue after this class in the MRO,” not simply “call my
parent.” Mixins should be narrow, stateless where possible, and clear about the
methods they require and provide. Deep multiple-inheritance graphs are difficult
to reason about; composition is usually easier.
When not to use OOP
Do not create a class when:
- A pure function completely describes the operation.
- A dictionary, tuple, or data class only needs to carry data.
- There is no meaningful lifecycle or invariant.
- The class has one method and no state solely to imitate an interface.
- Callers must understand a large internal state machine to use it safely.
def normalize_email(value: str) -> str:
local, separator, domain = value.strip().partition("@")
if not separator:
raise ValueError("email address must contain @")
return f"{local}@{domain.lower()}"Wrapping this transform in EmailNormalizerFactoryManager would add ceremony
without creating a useful boundary.
Tradeoffs and pitfalls
- Encapsulation protects invariants but can hide expensive or stateful behavior.
- Properties preserve attribute syntax but should not perform surprising I/O.
- Mutable class attributes are shared by all instances unless shadowed.
__init__configures an already allocated object; it does not create it.- Calling overridable methods from
__init__can observe incomplete subclass state. - Getters and setters copied from other languages often add no value in Python.
- An
isinstancechain may indicate missing polymorphism, but a small explicit dispatch can be clearer than a hierarchy.
Practice
- Implement an immutable
Moneyvalue object with currency validation. - Refactor a report class that directly sends email so it receives a
Sender. - Replace an inheritance relationship used only for code reuse with composition.
- Find a stateless one-method class and rewrite it as a function. Compare call sites and test setup.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK