Python Atlas
Core language

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.
  • @classmethod for alternate constructors or behavior involving the class.
  • @staticmethod sparingly 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 + 32

frozen=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**2

Even 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 isinstance chain may indicate missing polymorphism, but a small explicit dispatch can be clearer than a hierarchy.

Practice

  1. Implement an immutable Money value object with currency validation.
  2. Refactor a report class that directly sends email so it receives a Sender.
  3. Replace an inheritance relationship used only for code reuse with composition.
  4. 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

Classes and object-oriented design

1In a cooperative multiple-inheritance method, what does `super().save()` mean?
2A service must send alerts through email, SMS, or a test fake. Which design minimizes coupling?