Object-oriented Python for interviews¶
TL;DR¶
- Model values as
@dataclass(frozen=True, slots=True)and entities as@dataclass(slots=True)with an id and guarded transitions. That one distinction removes half the bugs in a machine-coding round. - Use
Protocolfor a pure interface (structural, no inheritance) andABConly when subclasses inherit real behaviour. - Implement the dunders the language already calls (
__eq__/__hash__,__len__,__iter__,__enter__) instead of inventingget_all()methods. - Prefer composition; keep inheritance for genuine "is a" plus shared code.
Concepts¶
Everything below is one runnable module, code/fundamentals/oop_toolkit.py, built around a single domain: the seat map of a show and the bookings made against it. Tests live in code/fundamentals/tests/test_oop_toolkit.py.
Classes, instances and the three kinds of attribute¶
An attribute lives in one of three places, and mixing them up is the first thing a reviewer notices. Instance attributes are set in __init__ (or by a dataclass field) and belong to one object. Class attributes are shared by every instance - fine for constants, a disaster for anything mutable, because one instance's append is visible to all. Slots replace the per-instance __dict__ with a fixed layout, which forbids typos like seat.colunm = "B" at runtime rather than at review time.
class Show:
attendees: list[str] = [] # shared by every Show ever created
show_a, show_b = Show(), Show()
show_a.attendees.append("ana")
assert show_b.attendees == ["ana"] # the bug, two lines later
The fix: field(default_factory=list) on a dataclass, or self.attendees = [] in __init__.
Value objects: frozen dataclasses¶
A value object has no identity of its own: two seats in row 12, column A are the same seat. Freeze it, slot it, validate it in __post_init__, and it becomes a hashable dictionary key that is safe to share between threads because nothing about it can change.
@dataclass(frozen=True, slots=True, order=True)
class Seat:
"""A value object: identified by *what it is*, never by an id. ``frozen=True``
gives a ``__hash__`` and makes it safe to share between threads, ``slots=True``
drops ``__dict__``, ``order=True`` derives all six comparisons from field order.
"""
row: int
column: str
seat_class: SeatClass = SeatClass.ECONOMY
amenities: Amenity = field(default=Amenity.NONE, compare=False)
def __post_init__(self) -> None:
"""Validate and normalise once, so nobody ever holds a broken ``Seat``."""
if self.row < 1:
raise ValidationError(f"row must be positive, got {self.row}")
if len(self.column) != 1 or not self.column.isalpha():
raise ValidationError(f"column must be a single letter, got {self.column!r}")
object.__setattr__(self, "column", self.column.upper())
@classmethod
def parse(cls, label: str, seat_class: SeatClass = SeatClass.ECONOMY) -> Self:
"""A named constructor. ``Self`` keeps the annotation correct in subclasses."""
row, column = label[:-1], label[-1:]
if not row.isdigit():
raise ValidationError(f"{label!r} is not a seat label such as '12A'")
return cls(int(row), column, seat_class)
@staticmethod
def aisle_columns() -> frozenset[str]:
"""No ``self``, no ``cls``: a helper that belongs to the class by topic only."""
return frozenset({"C", "D"})
@property
def label(self) -> str:
"""Computed, read-only, and no parentheses at the call site."""
return f"{self.row}{self.column}"
def upgraded(self, seat_class: SeatClass) -> Seat:
"""Immutable update: ``replace`` copies the object with one field changed."""
return replace(self, seat_class=seat_class)
def __str__(self) -> str:
return f"{self.label} ({self.seat_class})"
Four decisions worth narrating while you type:
frozen=Truegenerates__hash__from the compared fields; without it a dataclass is unhashable and cannot be adictkey or a set member.slots=Truedrops__dict__, so an attribute typo raisesAttributeErrorinstead of silently creating a new field.order=Truederives<,<=,>,>=from the field order, which is whyrowis declared beforecolumn.field(compare=False)keepsamenitiesout of equality, hashing and ordering: a seat is identified by where it is, not by whether it has a power socket.
__post_init__ is the only place a frozen dataclass may write to itself, and it needs object.__setattr__ to do it. Validate there so the rest of the system can assume every Seat it receives is well formed.
Entities: mutable dataclasses with guarded transitions¶
An entity is the opposite: identified by an id, expected to change. Generated equality is wrong for it, because a booking that gains a seat is still the same booking. Pass eq=False and write the pair yourself.
@dataclass(slots=True, eq=False)
class Booking:
"""An entity: identified by its id, mutable, with guarded transitions. ``eq=False``
stops the dataclass generating a field-by-field ``__eq__``, which is wrong here -
a booking that gains a seat is still the same booking.
"""
booking_id: str
show_id: str
seats: list[Seat] = field(default_factory=list)
status: BookingStatus = BookingStatus.HELD
@property
def seat_labels(self) -> list[str]:
return [seat.label for seat in self.seats]
def confirm(self) -> None:
if self.status is not BookingStatus.HELD:
raise InvalidStateError(f"booking {self.booking_id} is {self.status}, not held")
if not self.seats:
raise ValidationError("a booking needs at least one seat")
self.status = BookingStatus.CONFIRMED
def __eq__(self, other: object) -> bool:
if not isinstance(other, Booking):
return NotImplemented # let the other operand try; never return False here
return self.booking_id == other.booking_id
def __hash__(self) -> int:
return hash(self.booking_id)
field(default_factory=list) is how each instance gets its own list; a bare seats: list[Seat] = [] raises ValueError at class-creation time, which is the language refusing to let you write the classic shared-default bug. confirm shows the shape every state change should have: check the current state, check the invariant, then assign - never assign first.
Enums for every closed set¶
Any time a string or an integer means "one of a fixed set", it should be an enum. You get autocompletion, an error on a typo, and a name that appears in logs and diagrams.
class SeatClass(StrEnum):
"""``StrEnum`` members *are* strings: they serialise straight to JSON and compare
equal to the raw value. ``auto()`` yields the lower-cased member name.
"""
ECONOMY = auto()
PREMIUM = auto()
BUSINESS = auto()
class BookingStatus(StrEnum):
HELD = auto()
CONFIRMED = auto()
CANCELLED = auto()
class Amenity(Flag):
"""``Flag`` is the enum for a *set* of options: members combine with ``|``, are
tested with ``in``, and the whole set travels as one field.
"""
NONE = 0
WINDOW = auto()
AISLE = auto()
POWER = auto()
EXTRA_LEGROOM = auto()
StrEnum is the default choice for a status or a category that crosses a serialisation boundary: SeatClass.PREMIUM == "premium" is True, so JSON and database columns need no conversion. Plain Enum is better when you want the members to not be interchangeable with strings. Flag is for combinable options: Amenity.WINDOW | Amenity.POWER is one value that answers Amenity.WINDOW in ..., which beats four boolean fields.
ABC or Protocol: nominal and structural typing¶
Protocol is structural. A class satisfies it by having the right methods; it never imports or inherits anything, and a fake defined inside a test qualifies automatically. ABC is nominal. A subclass declares the relationship, inherits real code, and cannot be instantiated until it supplies every @abstractmethod.
PricingRule is a Protocol that classes satisfy by shape; Notifier is an ABC that subclasses inherit behaviour from.
classDiagram
class PricingRule {
<<interface>>
+price(seat: Seat) Money
}
class ClassPricing {
+base: Money
+price(seat: Seat) Money
}
class FlatPricing {
+fare: Money
+price(seat: Seat) Money
}
class Notifier {
<<abstract>>
+notify(booking: Booking) str
+channel() str
+render(booking: Booking)* str
}
class EmailNotifier {
+render(booking: Booking) str
}
class SmsNotifier {
+render(booking: Booking) str
}
PricingRule <|.. ClassPricing : structural
PricingRule <|.. FlatPricing : structural
Notifier <|-- EmailNotifier
Notifier <|-- SmsNotifier
Dotted arrows mark realisation, solid ones inheritance.
@runtime_checkable
class PricingRule(Protocol):
"""Structural typing: anything with this method qualifies, including a test double
defined three lines into a test. Nobody inherits from it.
"""
def price(self, seat: Seat) -> Money: ...
@dataclass(frozen=True, slots=True)
class ClassPricing:
"""Satisfies ``PricingRule`` without importing it: no base class, no registry."""
base: Money = BASE_FARE
def price(self, seat: Seat) -> Money:
extra = {
SeatClass.ECONOMY: Money(0),
SeatClass.PREMIUM: PREMIUM_EXTRA,
SeatClass.BUSINESS: BUSINESS_EXTRA,
}[seat.seat_class]
return self.base + extra
@dataclass(frozen=True, slots=True)
class FlatPricing:
fare: Money = BASE_FARE
def price(self, seat: Seat) -> Money:
return self.fare
class Notifier(ABC):
"""Nominal typing: subclasses declare the relationship and inherit ``notify``,
the shared algorithm. ``render`` is the one hook a subclass must supply;
forgetting it makes the subclass uninstantiable.
"""
def notify(self, booking: Booking) -> str:
return f"[{self.channel()}] {self.render(booking)}"
@abstractmethod
def render(self, booking: Booking) -> str: ...
def channel(self) -> str:
return type(self).__name__.removesuffix("Notifier").lower()
class EmailNotifier(Notifier):
@override # a typo in the name is now an error, not a silently dead method
def render(self, booking: Booking) -> str:
return f"Booking {booking.booking_id}: {', '.join(booking.seat_labels)}"
class SmsNotifier(Notifier):
@override
def render(self, booking: Booking) -> str:
return f"{booking.booking_id} confirmed for {len(booking.seats)} seat(s)"
@runtime_checkable lets isinstance(rule, PricingRule) work, but it checks method names only, not signatures. Rule of thumb: reach for Protocol first, promote to ABC when two implementations would otherwise copy the same method.
Equality, hashing and ordering¶
Three contracts the language enforces and interviewers probe. First: if a == b then hash(a) == hash(b), so __eq__ and __hash__ must read the same fields; defining __eq__ alone silently sets __hash__ = None and your object stops working in sets. Second: return NotImplemented, not False, for a type you do not understand, so Python can try the other operand's __eq__. Third: when the natural order is not the field order, write one key function.
@total_ordering
@dataclass(frozen=True, slots=True, eq=False)
class WaitlistEntry:
"""When the natural order is *not* the field order, define one key, write ``__eq__``
and ``__lt__`` over it, and let ``total_ordering`` derive the other four.
"""
booking_id: str
priority: int
sequence: int
def _key(self) -> tuple[int, int]:
return (-self.priority, self.sequence) # highest priority first, then arrival
def __eq__(self, other: object) -> bool:
if not isinstance(other, WaitlistEntry):
return NotImplemented
return self._key() == other._key()
def __lt__(self, other: WaitlistEntry) -> bool:
return self._key() < other._key()
def __hash__(self) -> int:
return hash(self._key())
@total_ordering fills in <=, > and >= from __lt__ and __eq__. Negating priority inside the key is how you get "highest priority first, then arrival order" out of a plain ascending sorted. Also implement __repr__ on anything you will debug: it is what pdb, a failing assertion and a list of objects print.
Containers and context managers¶
If your class holds things, implement the dunders and stop writing accessors - len(seat_map), for seat in seat_map, "2C" in seat_map and seat_map["2C"] are free.
class LabelMixin:
"""Behaviour and no state: a mixin adds methods, never an ``__init__``. Annotating
``self`` documents the only thing the host class must provide.
"""
def labels(self: Iterable[Seat]) -> list[str]:
return [seat.label for seat in self]
class SeatMap(LabelMixin):
"""A container: ``len()``, ``for``, ``in``, ``[]`` and ``repr()`` all route through
the dunders below, so ``SeatMap`` never needs a ``get_all_seats()`` accessor.
"""
def __init__(self, seats: Iterable[Seat]) -> None:
self._seats: dict[str, Seat] = {seat.label: seat for seat in sorted(seats)}
def __len__(self) -> int:
return len(self._seats)
def __iter__(self) -> Iterator[Seat]:
return iter(self._seats.values())
def __contains__(self, item: object) -> bool:
"""Accept a ``Seat`` or a label, so ``"2C" in seat_map`` reads naturally."""
if isinstance(item, Seat):
return item.label in self._seats
return isinstance(item, str) and item.upper() in self._seats
def __getitem__(self, label: str) -> Seat:
try:
return self._seats[label.upper()]
except KeyError:
raise NotFoundError(f"no seat {label!r} on this map") from None
@property
def rows(self) -> int:
return len({seat.row for seat in self._seats.values()})
def __repr__(self) -> str:
"""Unambiguous, for logs and pdb - not a user-facing string."""
return f"SeatMap(rows={self.rows}, seats={len(self)})"
A context manager is the same idea applied to a lifetime: acquire in __enter__, release in __exit__, and the release happens even when the block raises.
class SeatHold:
"""A context manager: whatever happens inside the block, the hold is released.
``__exit__`` returns ``None`` (falsy), so an exception inside still propagates.
"""
def __init__(self, seat_map: SeatMap, labels: Sequence[str], held: set[str]) -> None:
self._seat_map = seat_map
self._labels = [label.upper() for label in labels]
self._held = held
self.seats: list[Seat] = []
def __enter__(self) -> Self:
clash = sorted(set(self._labels) & self._held)
if clash:
raise ConflictError(f"already held: {', '.join(clash)}")
self.seats = [self._seat_map[label] for label in self._labels]
self._held.update(self._labels)
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self._held.difference_update(self._labels)
Two details to say out loud: a falsy __exit__ lets the exception propagate (returning True swallows it, which you almost never want), and if __enter__ raises then __exit__ never runs - so acquire everything or nothing inside __enter__.
Constructors that are not __init__¶
__init__ should take exactly the fields of the object. Every other way of building one is a @classmethod with a name that says where the data came from - Seat.parse("12A"), Booking.from_request(payload), Money.of("8.00"). Returning Self rather than the class name keeps the annotation correct in subclasses.
@classmethod
def from_row(cls, values: dict[str, str]) -> Self:
return cls(int(values["row"]), values["column"])
A @staticmethod takes neither self nor cls; use it for a helper that belongs to the class only by topic, such as Seat.aisle_columns(). If a static method never touches the class at all, a module-level function is simpler.
Composition, inheritance and mixins¶
Inheritance couples you to the base class forever: every method it grows, you inherit. Composition gives you a seam you control.
class Row(list[Seat]):
def append(self, seat: Seat) -> None:
if len(self) >= 6:
raise ValidationError("row is full")
super().append(seat)
row = Row()
row.extend([seat] * 9) # the guard never runs: extend does not call append
row[0] = other_seat # nor does __setitem__
A booking and a seat map both reference the same shared Seat values, and the audit wrapper composes rather than extends.
classDiagram
class Seat {
+row: int
+column: str
+label: str
+upgraded(seat_class: SeatClass) Seat
}
class Booking {
+booking_id: str
+seats: List~Seat~
+confirm()
}
class LabelMixin {
+labels() List~str~
}
class SeatMap {
-_seats: Dict~str,Seat~
+__len__() int
+__iter__() Iterator
+__getitem__(label: str) Seat
}
class AuditedSeatMap {
-_inner: SeatMap
+reads: tuple
+__getitem__(label: str) Seat
}
LabelMixin <|-- SeatMap : mixin
Booking "1" o-- "many" Seat
SeatMap "1" o-- "many" Seat
AuditedSeatMap o-- SeatMap : wraps
class AuditedSeatMap:
"""Composition over inheritance: hold a ``SeatMap``, expose the same call surface,
add the audit. Subclassing would inherit every method the base grows later.
"""
def __init__(self, inner: SeatMap) -> None:
self._inner = inner
self._reads: list[str] = []
def __len__(self) -> int:
return len(self._inner)
def __iter__(self) -> Iterator[Seat]:
return iter(self._inner)
def __contains__(self, item: object) -> bool:
return item in self._inner
def __getitem__(self, label: str) -> Seat:
self._reads.append(label.upper())
return self._inner[label]
@property
def reads(self) -> tuple[str, ...]:
return tuple(self._reads)
A mixin is the acceptable use of multiple inheritance: methods and no state, no __init__, and a documented expectation of what the host class provides. LabelMixin gives any iterable-of-seats a labels() method. Keep the method resolution order shallow - one mixin plus one base is readable, four is not.
Typing that pays for itself¶
Type hints are documentation the reader cannot skip and a diagram you did not have to draw. Four features carry most of the weight in an LLD round.
type SeatPredicate = Callable[[Seat], bool]
class Registry[T]:
"""PEP 695 generics: ``class Registry[T]`` needs no ``TypeVar`` line. The key
function is injected, so the registry never assumes an ``id`` attribute."""
def __init__(self, key: Callable[[T], str]) -> None:
self._key = key
self._items: dict[str, T] = {}
def add(self, item: T) -> Self:
"""Returning ``Self`` (not ``Registry``) keeps chaining typed in subclasses."""
self._items[self._key(item)] = item
return self
def get(self, key: str) -> T:
try:
return self._items[key]
except KeyError:
raise NotFoundError(f"no item {key!r} in the registry") from None
def list(self, order: Literal["insertion", "key"] = "insertion") -> list[T]:
"""``Literal`` pins the accepted strings without inventing an enum for two."""
keys = list(self._items) if order == "insertion" else sorted(self._items)
return [self._items[key] for key in keys]
def seats_matching(seat_map: SeatMap, predicate: SeatPredicate) -> list[Seat]:
return [seat for seat in seat_map if predicate(seat)]
class Registry[T] is the Python 3.12 spelling of a generic; no TypeVar line, and the parameter is scoped to the class. Self as a return type makes add chainable and stays correct in subclasses. Literal["insertion", "key"] pins two accepted strings without the ceremony of an enum. The type SeatPredicate = ... statement names a callable shape once so signatures read like sentences. Add @override on every method that overrides one: a renamed base method then breaks the build instead of leaving a dead override behind.
Three gotchas an interviewer watches for¶
def hold(labels: list[str], into: list[str] = []) -> list[str]: # evaluated once
into.extend(labels)
return into
pickers = [lambda seat: seat.row == row for row in (1, 2, 3)] # all three see row == 3
Seat(1, "A") is Seat(1, "A") # False; == is True
Default arguments are evaluated once at def time, so a mutable default is shared by every call that omits it. Closures capture the variable, not its value, so every lambda built in a loop sees the loop's final value. And is compares identity while == compares value: is is correct for None and for enum members (which are singletons) and wrong for almost everything else.
def hold_labels(labels: Sequence[str], into: list[str] | None = None) -> list[str]:
"""Defaults are evaluated once, at ``def`` time, so a ``[]`` default is shared by
every call that omits it. Take ``None`` and allocate inside."""
result = [] if into is None else into
result.extend(label.upper() for label in labels)
return result
def _is_in_row(row: int, seat: Seat) -> bool:
return seat.row == row
def row_pickers(rows: Sequence[int]) -> list[SeatPredicate]:
"""Closures capture the *variable*, not its value, so lambdas built in a loop all
see the final row. ``partial`` binds the value now."""
return [partial(_is_in_row, row) for row in rows]
def identity_vs_equality(label: str) -> tuple[bool, bool, bool]:
"""``is`` asks "the same object?", ``==`` asks "the same value?". Enum members are
singletons, so ``is`` is the right test for them and only for them."""
parsed, twin = Seat.parse(label), Seat.parse(label)
return parsed is twin, parsed == twin, parsed.seat_class is SeatClass.ECONOMY
Running python -m fundamentals.oop_toolkit exercises the whole module:
--- SeatMap(rows=3, seats=12) ---
mixin labels: 1A, 1B, 1C, 1D ...; '2c' in map -> True
prices: 1A (business) = 20.00 USD, 2B (premium) = 12.00 USD, 3C (economy) = 8.00 USD
2C: amenities AISLE, AISLE in them -> True
replace: 2C (premium) -> 2C (business); original still 2C (premium)
inside the with block, held = ['3C', '3D']
after the with block, held = []
BK-1 is confirmed for 3C, 3D
[email] Booking BK-1: 3C, 3D
[sms] BK-1 confirmed for 2 seat(s)
registry by key: ['BK-1', 'BK-2']
waitlist order: ['BK-5', 'BK-4', 'BK-3']
composition: 12 seats wrapped, reads audited ['1A', '2B']
is -> False; == -> True; enum member is a singleton -> True
each picker kept its own row: [[1], [3]]
no shared default: ['4A'] then ['4B']
rejected: 'row twelve' is not a seat label such as '12A'
Applying it in the interview¶
This vocabulary shows up at two moments of the LLD interview framework. At the entity step, say which nouns are values and which are entities: "Seat and Money are frozen value objects, Booking and Show are entities with ids and lifecycles." That single sentence tells the interviewer you know why one gets a __hash__ and the other gets a status field. At the interface step, say which abstractions are Protocols and why: "pricing is a Protocol because the gate only calls one method and my tests supply a fake; Notifier is an ABC because both channels share the rendering pipeline."
When you write code on the board, write the enums first, then the dataclasses, then the services: it is the fastest route to something an interviewer can read, and each enum kills an if-ladder later. Guard every transition with an explicit check and inject the clock and the ID generator, as Design a parking lot does throughout.
Interview tip
When you are asked "why a dataclass here?", answer in guarantees, not features: "frozen gives me a hash so a Seat can key the reservation dict, and immutability means two gate threads can share it without a lock." Guarantees are what senior engineers trade in; @dataclass on its own is just syntax.
Pitfalls¶
- Anemic models. A
Bookingwith nothing but fields, and aBookingServicethat pokes at them, is a struct with extra steps. Put the invariant next to the data:confirm()belongs on the booking. - Inheriting for reuse. Subclassing
list,dictor another concrete class to add one rule leaves every inherited method as a way around it. Wrap and delegate instead. @propertythat does work. Callers assume attribute access is cheap and side-effect free. If it queries a repository or mutates state, make it a method with a verb in its name.- Enum members compared by string.
status == "confirmed"works for aStrEnumand silently fails for a plainEnum; compare members withis. - Over-abstracting. A
Protocolwith one implementation and no test double is speculative. Add the seam when the second implementation exists.
Common mistake
Defining __eq__ without __hash__. Python sets __hash__ = None on any class that defines __eq__ itself, so your objects work perfectly until the first set() or dict key and then raise TypeError: unhashable type. Either use a frozen dataclass and get both for free, or write the pair together over exactly the same fields.
Exercises¶
-
Make
Seatsortable by aisle proximity. Seats should sort so that aisle columns (C,D) come before window columns within the same row, without changing the field order.Solution
order=Truecannot express it. Drop it and use the@total_orderingshape fromWaitlistEntry:_key(self)returns(self.row, 0 if self.column in Seat.aisle_columns() else 1, self.column), and__lt__plus__eq__read that key. Keep__hash__on the same fields as__eq__. -
Add a
LoyaltyPricingrule that gives members a fixed discount. It must work with the existing code without touchingClassPricingor the Protocol.Solution
A frozen dataclass holding
inner: PricingRuleanddiscount: Money, whoseprice(seat)returnsinner.price(seat) - discount. It satisfiesPricingRulestructurally, so nothing else changes, and wrapping rather than subclassing means it composes with any rule. -
Turn
SeatHoldinto a reusable generator-based context manager. Keep the release-on-exception behaviour.Solution
@contextlib.contextmanageron a generator: validate, add the labels toheld,yieldthe seats inside atry, remove them infinally. Thefinallyreplaces__exit__; without it an exception leaks the hold. Prefer the class when the object also needs methods or arepr. -
Explain why
Bookinguseseq=FalsebutSeatdoes not.Solution
Seatis a value: equality is field equality, so the generated__eq__is right andfrozen=Truesupplies a matching__hash__.Bookingis an entity: its fields change over its lifetime, and two bookings with identical fields are still different bookings, so equality must read the id alone.
Related¶
- SOLID in Python - the principles these constructs are used to satisfy
- The LLD interview framework - where entity and interface modelling sit in the 45 minutes
- Design a parking lot - the same toolkit applied to a full problem
- Strategy - Protocols and frozen dataclasses as interchangeable rules
- PEP 544 - Protocols: structural subtyping
- Python documentation: the data model
- Python documentation: dataclasses