AI SWE Prep
0/9in Python Foundations
Python Foundations

The Data Model & Dunder Methods

Why everything is an object and how the special methods (__len__, __iter__, __eq__ …) let your own types plug into the language.

~15 minLesson 4 of 60
Your progressNot started

Python feels small because the surface area is consistent. len(x), for item in x, x[0], x == y, with x: and even x() all route through the same idea: objects implement protocols, and syntax calls those protocols for you.

The data model is the contract behind that syntax. Once you can read it, built-in containers stop feeling special, and your own classes can behave like native Python without helper methods like .to_string() or .equals().

The mental model

Everything in Python is an object: integers, functions, classes, modules, exceptions, iterators. Objects have identity, type, and behavior. The behavior Python’s syntax knows about is exposed through dunder methods — names with double underscores on both sides.

class Team:
    def __init__(self, members):
        self._members = list(members)

    def __len__(self):
        return len(self._members)

    def __iter__(self):
        return iter(self._members)

team = Team(["Ada", "Grace", "Katherine"])

len(team)          # calls team.__len__()
list(team)         # calls team.__iter__()

You usually do not call dunders yourself. You implement them so Python can call them at the right time. That keeps the public API idiomatic: callers write len(team), not team.length().

Syntax → dunder dispatchclick a line

Built-in len() delegates to the object's __len__. Return an int.

You implement these; Python calls them. That's what lets your own types behave like built-ins.

Representation: __repr__ and __str__

__repr__ is for developers. It should be unambiguous and, when practical, look like something you could paste into Python to rebuild the object. __str__ is for users. It can be friendlier.

class Job:
    def __init__(self, name, status):
        self.name = name
        self.status = status

    def __repr__(self):
        return f"Job(name={self.name!r}, status={self.status!r})"

    def __str__(self):
        return f"{self.name}: {self.status}"

job = Job("embed-docs", "running")
repr(job)     # "Job(name='embed-docs', status='running')"
str(job)      # "embed-docs: running"

If you only implement one, implement __repr__. Debuggers, logs, notebooks, and failed assertions lean on it constantly.

Equality and hashing travel together

__eq__ defines value equality. __hash__ lets an object live in a set or be a dict key. The rule is non-negotiable: if a == b, then hash(a) == hash(b) for as long as either object is used as a key.

That is why mutable objects are dangerous keys. If a field used in the hash changes after insertion, the object is now stored in the wrong bucket.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __hash__(self):
        return hash((self.x, self.y))

Only write __hash__ when the fields it depends on are effectively immutable. For value objects, @dataclass(frozen=True) is often the cleanest option.

Containers: __len__, __iter__, and __getitem__

A container does not need to inherit from list to feel list-like. Implement the protocols you actually support:

  • __len__ powers len(obj) and truthiness when __bool__ is absent.
  • __iter__ powers for x in obj, unpacking, list(obj), sum(obj), and most standard-library consumers.
  • __getitem__ powers indexing and slicing with obj[i].
class Batch:
    def __init__(self, rows):
        self._rows = tuple(rows)

    def __len__(self):
        return len(self._rows)

    def __iter__(self):
        return iter(self._rows)

    def __getitem__(self, index):
        return self._rows[index]

batch = Batch(["a", "b", "c"])
batch[1]          # "b"
first, *rest = batch

Favor delegation over cleverness: if your class wraps a tuple, let the tuple do the indexing, slicing, and iteration work.

Callable objects and context managers

__call__ lets an instance behave like a function while keeping configuration or state on the object.

class Prefixer:
    def __init__(self, prefix):
        self.prefix = prefix

    def __call__(self, text):
        return f"{self.prefix}{text}"

add_error = Prefixer("ERROR: ")
add_error("disk full")       # "ERROR: disk full"

Context managers use __enter__ and __exit__ to guarantee setup and cleanup around a block. Files, locks, database transactions, temporary config, tracing spans — they all use this protocol.

class Section:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"start {self.name}")
        return self

    def __exit__(self, exc_type, exc, tb):
        print(f"end {self.name}")
        return False       # propagate exceptions

with Section("indexing"):
    build_index()

Returning True from __exit__ suppresses the exception. Almost always, return False or None so failures remain visible.

When to reach for this

Variations

Try it

These are not puzzles. They are the small protocol decisions that make production objects pleasant to inspect, compare, and compose.

ExerciseEasy
  • Dunders
  • Value object

Implement a small Vector type

Create a 2D Vector that supports a + b, compares by value, and prints as Vector(1, 2) in a debugger. Return NotImplemented when adding a non-vector so Python has a chance to try the other operand or raise the right error.

Approach. Store immutable coordinates, implement __repr__ for debugging, __eq__ for value comparison, and __add__ for native + syntax.

Show solution
class Vector:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        return f"Vector({self.x!r}, {self.y!r})"

    def __eq__(self, other: object):
        if not isinstance(other, Vector):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __add__(self, other: object):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)


a = Vector(1, 2)
b = Vector(3, 4)
assert a + b == Vector(4, 6)

The important part is not the arithmetic; it is the contract. repr(a) is useful in failures, == compares values instead of identity, and unsupported operands fail cleanly instead of raising an unrelated AttributeError.

ExerciseMedium
  • Context manager
  • contextlib

Write a timer context manager two ways

Build a timer that can wrap a block with with and expose the elapsed seconds afterward. Implement it once as a class with __enter__ / __exit__, then once with contextlib.contextmanager.

Approach. The class version stores state on self. The generator version puts setup before yield and cleanup in finally, which is exactly the context manager lifecycle in a smaller form.

Show solution
from contextlib import contextmanager
from time import perf_counter


class Timer:
    def __enter__(self):
        self.started_at = perf_counter()
        self.elapsed = 0.0
        return self

    def __exit__(self, exc_type, exc, tb):
        self.elapsed = perf_counter() - self.started_at
        return False


@contextmanager
def timer():
    state = {"elapsed": 0.0}
    started_at = perf_counter()
    try:
        yield state
    finally:
        state["elapsed"] = perf_counter() - started_at


with Timer() as t:
    do_work()
print(t.elapsed)

with timer() as t:
    do_work()
print(t["elapsed"])

Both versions guarantee cleanup even if do_work() raises. Use the class when callers benefit from attributes or helper methods; use contextmanager when the lifecycle is just a few lines around a yield.

GotchaHard
  • Hashing
  • Mutability

Explain the equality/hash gotcha

This class compares users by username. Why can you not put instances in a set, and what is the safe fix?

class User:
    def __init__(self, username):
        self.username = username

    def __eq__(self, other):
        return isinstance(other, User) and self.username == other.username

users = {User("ada")}

Approach. Once you override __eq__, Python disables the inherited identity hash because it would violate the rule that equal objects need equal hashes. Make the value immutable, then let Python generate a compatible hash.

Show solution
from dataclasses import dataclass


@dataclass(frozen=True)
class User:
    username: str


users = {User("ada")}
assert User("ada") in users

The frozen dataclass generates __eq__ and a stable __hash__ from the same field. Do not hash mutable fields. If username could change after insertion, the set would look in the wrong hash bucket and membership would become broken.

The classic pitfall

Your progressNot started