AI SWE Prep
0/9in Python Foundations
Python Foundations

Classes, Dataclasses & Protocols

Modeling with classes without over-engineering: dataclasses, composition over inheritance, and structural typing via Protocol.

~15 minLesson 8 of 60
Your progressNot started

A class is not the default answer in Python. Many problems are better as a function plus a few dictionaries or dataclasses. Reach for a class when you need to keep state and behavior together, enforce invariants, or expose a stable interface that several implementations can satisfy.

Good Python OOP is small and boring. Model the domain, compose objects instead of building deep inheritance trees, and use protocols when you care about what an object can do rather than what it inherits from.

The mental model

A class defines how to build objects and which operations those objects support. An instance holds state. Methods are functions that receive the instance as self.

class RateLimiter:
    def __init__(self, max_per_minute: int):
        self.max_per_minute = max_per_minute
        self._used = 0

    def allow(self) -> bool:
        if self._used >= self.max_per_minute:
            return False
        self._used += 1
        return True

The point is not to hide data behind ceremony. The point is to put the rule “only max_per_minute calls are allowed” next to the state that rule protects.

When a class is the right tool

Use a class when at least one of these is true:

  • You have state that changes over time and functions that must update it consistently.
  • You need to enforce invariants at construction or assignment.
  • You have multiple implementations behind one interface.
  • You want a value object with clear fields, equality, and representation.

If the function has no memory and no invariant, keep it a function. If the object is just a bag of fields, start with a dataclass.

Dataclasses remove boilerplate

@dataclass generates the obvious parts: __init__, __repr__, and __eq__. Use it for records and value objects.

from dataclasses import dataclass


@dataclass(slots=True, frozen=True)
class Chunk:
    text: str
    source: str
    score: float = 0.0

Two options matter in production:

  • frozen=True makes instances immutable and, when fields are hashable, safely hashable.
  • slots=True avoids per-instance __dict__, saving memory when you create many objects and preventing accidental new attributes.

Do not force a dataclass to behave like an active service object. If it opens connections, caches state, or owns lifecycle, write a normal class.

Composition over inheritance

Which container for structured data?tap a column to focus
dictNamedTupledataclassPydantic
Fields fixed & namednoyesyesyes
Mutableyesnoyes (opt frozen)yes
Runtime validationnonenonenonefull
Best forquick/dynamic bagslightweight recordsinternal modelsexternal I/O boundaries
Reach for the lightest option that gives you the guarantees you need.

Inheritance says “is a.” Composition says “has a.” Most application code wants composition because it keeps dependencies explicit and testable.

class SearchService:
    def __init__(self, embedder, index):
        self.embedder = embedder
        self.index = index

    def search(self, query: str, k: int = 5):
        vector = self.embedder.embed(query)
        return self.index.nearest(vector, k=k)

SearchService does not need to inherit from an embedding client or a vector index. It coordinates two collaborators. In tests, pass fakes with the same methods.

Class methods, static methods, and properties

Use @classmethod for alternate constructors that need the class. Use @staticmethod rarely, when a helper belongs near a class conceptually but does not need self or cls. Use @property to expose an attribute while protecting an invariant.

class RetryPolicy:
    def __init__(self, attempts: int):
        self.attempts = attempts

    @classmethod
    def conservative(cls):
        return cls(attempts=2)

    @property
    def attempts(self) -> int:
        return self._attempts

    @attempts.setter
    def attempts(self, value: int) -> None:
        if value < 1:
            raise ValueError("attempts must be positive")
        self._attempts = value

A property is not a way to hide arbitrary work. Keep it cheap and unsurprising; expensive operations should be methods.

Protocols: structural typing

An abstract base class asks an object to inherit from a specific parent. A Protocol asks an object to have a specific shape.

from typing import Protocol


class Embedder(Protocol):
    def embed(self, text: str) -> list[float]: ...


def embed_all(model: Embedder, texts: list[str]) -> list[list[float]]:
    return [model.embed(text) for text in texts]

Any object with a compatible embed method satisfies Embedder; no registration or inheritance required. That matches Python’s duck-typing culture while giving a static checker something precise to verify.

When to reach for this

Variations

Try it

These exercises are the shapes you use in application code: turn loose data into explicit objects, type a duck-typed dependency, and protect an invariant.

ExerciseEasy
  • dataclass
  • Records

Convert dicts into dataclasses

You receive user data as a dictionary of dictionaries. Convert it into explicit objects so downstream code no longer reaches through string keys everywhere.

Approach. Model nested records as dataclasses and put the conversion at the boundary. Use tuples for immutable collections.

Show solution
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class Plan:
    name: str
    max_projects: int


@dataclass(frozen=True, slots=True)
class User:
    id: int
    email: str
    plan: Plan
    tags: tuple[str, ...] = ()

    @classmethod
    def from_mapping(cls, raw: dict) -> "User":
        plan = Plan(
            name=raw["plan"]["name"],
            max_projects=raw["plan"]["max_projects"],
        )
        return cls(
            id=raw["id"],
            email=raw["email"],
            plan=plan,
            tags=tuple(raw.get("tags", ())),
        )


raw_user = {
    "id": 7,
    "email": "ada@example.com",
    "plan": {"name": "pro", "max_projects": 20},
    "tags": ["founder", "ml"],
}
user = User.from_mapping(raw_user)

The rest of the program now says user.plan.max_projects, not raw["plan"]["max_projects"]. The parsing risk is isolated at the boundary.

ExerciseMedium
  • Protocol
  • Duck typing

Type a duck-typed embedder

Write a function that ranks documents by dot product with a query embedding. It should accept any object with an embed(text) -> list[float] method, not a specific base class.

Approach. Define a Protocol for the behavior you need. The ranking function uses only that behavior, so real and fake embedders both work.

Show solution
from typing import Protocol


class Embedder(Protocol):
    def embed(self, text: str) -> list[float]: ...


def dot(a: list[float], b: list[float]) -> float:
    return sum(x * y for x, y in zip(a, b))


def rank_documents(query: str, docs: list[str], model: Embedder) -> list[str]:
    query_vec = model.embed(query)
    return sorted(docs, key=lambda doc: dot(query_vec, model.embed(doc)), reverse=True)


class FakeEmbedder:
    def embed(self, text: str) -> list[float]:
        return [float(len(text)), float(text.count("ai"))]


ranked = rank_documents("ai systems", ["ai", "databases"], FakeEmbedder())

FakeEmbedder never inherits from Embedder; it simply has the right method. That keeps tests lightweight while still giving static type checkers a contract.

ExerciseMedium
  • property
  • Invariant

Protect a property with validation

Create a Temperature class whose Celsius value cannot go below absolute zero. Callers should read and assign temp.celsius like an attribute.

Approach. Store the actual value in a private attribute and route assignments through a property setter that enforces the invariant.

Show solution
class Temperature:
    ABSOLUTE_ZERO_C = -273.15

    def __init__(self, celsius: float):
        self.celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < self.ABSOLUTE_ZERO_C:
            raise ValueError("temperature cannot be below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self.celsius * 9 / 5 + 32


temp = Temperature(20)
temp.celsius = 25
assert temp.fahrenheit == 77

The caller sees attribute syntax, but the object never enters an invalid state. Keep property work cheap; validation and derived values are good fits.

The classic pitfall

Your progressNot started