{}The Interview
Handbook

Tracks / Python

OOP, dunders, decorators & descriptors

mid 10 questions · 5 min read oopdecoratorsmro

Questions in this set 10
  1. 01What is the MRO and how does Python resolve super()?
  2. 02Write a decorator that takes arguments, preserves metadata, and works on methods.
  3. 03@staticmethod vs @classmethod vs a plain method vs a module function.
  4. 04Explain @property and when a property is the wrong tool.
  5. 05What is the descriptor protocol? Implement one.
  6. 06__new__ vs __init__.
  7. 07What do dataclasses give you, and what are the gotchas?
  8. 08Explain context managers, and write one both ways.
  9. 09What are __slots__ and when are they worth it?
  10. 10Abstract base classes vs typing.Protocol.
01

What is the MRO and how does Python resolve super()?

The method resolution order is the linearisation of a class's ancestors, computed by the C3 algorithm, and visible as Cls.__mro__. super() does not mean "my parent" — it means "the next class after me in the MRO of the instance's type".

python
class A:  
    def go(self): return "A"
class B(A):
    def go(self): return "B" + super().go()
class C(A):
    def go(self): return "C" + super().go()
class D(B, C): pass

D().go()          # 'BCA'  — not 'BA'
D.__mro__         # D, B, C, A, object

That C appears in B's super() chain is the whole point of cooperative multiple inheritance. Rules that follow from it: every class in a diamond must call super(), and **kwargs should be forwarded so siblings receive what they need.

Follow-up: "When is an MRO impossible?" When the ordering constraints conflict — class X(A, B) and class Y(B, A) then class Z(X, Y) raises TypeError: Cannot create a consistent method resolution order.

02

Write a decorator that takes arguments, preserves metadata, and works on methods.

python
import functools, time, logging

def retry(times=3, delay=0.1, exceptions=(Exception,)):
    def decorator(fn):
        @functools.wraps(fn)                 # copies __name__, __doc__, __wrapped__
        def wrapper(*args, **kwargs):
            last = None
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except exceptions as exc:
                    last = exc
                    if attempt == times - 1:
                        break
                    time.sleep(delay * 2 ** attempt)   # exponential backoff
            raise last
        return wrapper
    return decorator

class Client:
    @retry(times=5, exceptions=(TimeoutError,))
    def fetch(self, url): ...

Three levels of nesting: arguments → the decorator → the wrapper. It works on methods for free because self just rides along in *args.

Points to volunteer:

  • functools.wraps matters for docs tooling, pytest collection and logging — without it every decorated function is named wrapper.
  • Decorators run at import time; anything expensive there slows startup.
  • For async functions you need a separate async def wrapper with await fn(...) — a sync wrapper silently returns a coroutine nobody awaits.
03

@staticmethod vs @classmethod vs a plain method vs a module function.

receives typical use
instance method self operates on instance state
@classmethod cls alternative constructors (User.from_row(...)), and it respects subclassing
@staticmethod nothing logically grouped helper; namespacing only
module function nothing the honest default if it does not touch the class at all

The classmethod-as-constructor pattern is the one worth showing:

python
class User:
    def __init__(self, id, email): self.id, self.email = id, email
    @classmethod
    def from_row(cls, row): return cls(row["id"], row["email"])   # cls, not User -> subclass-safe
04

Explain @property and when a property is the wrong tool.

property turns attribute access into method calls, letting you add validation or computation without changing the caller's API.

python
class Order:
    def __init__(self, cents): self._cents = cents
    @property
    def dollars(self): return self._cents / 100
    @dollars.setter
    def dollars(self, v):
        if v < 0: raise ValueError("negative total")
        self._cents = round(v * 100)

Wrong tool when: the work is expensive or does I/O (callers assume attribute access is cheap — make it def fetch_total() so the cost is visible), or it can raise for reasons unrelated to validation. functools.cached_property covers the "expensive but pure, compute once" case.

05

What is the descriptor protocol? Implement one.

Any object defining __get__ / __set__ / __delete__ is a descriptor; when it is a class attribute, Python routes attribute access through it. property, classmethod, staticmethod and functions themselves (that is how self gets bound) are all descriptors.

python
class Positive:
    def __set_name__(self, owner, name): self.name = "_" + name
    def __get__(self, obj, objtype=None):
        return self if obj is None else getattr(obj, self.name)
    def __set__(self, obj, value):
        if value <= 0: raise ValueError(f"{self.name} must be > 0")
        setattr(obj, self.name, value)

class Product:
    price = Positive()
    quantity = Positive()

Data descriptors (__set__ present) take priority over the instance __dict__; non-data descriptors do not. That precedence rule is the answer to "why does my instance attribute not shadow the property?"

06

__new__ vs __init__.

__new__ allocates and returns the instance (a static method receiving cls); __init__ initialises the already-created object and must return None. You only need __new__ when subclassing immutables (int, str, tuple), implementing singletons/caching, or with metaclasses.

python
class Currency(str):
    def __new__(cls, code):
        if len(code) != 3: raise ValueError("ISO code must be 3 chars")
        return super().__new__(cls, code.upper())
07

What do dataclasses give you, and what are the gotchas?

@dataclass generates __init__, __repr__, __eq__ (and ordering/hash on request) from annotations.

python
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True, kw_only=True)
class Money:
    amount: int
    currency: str = "USD"
    tags: list[str] = field(default_factory=list)   # mutable default MUST use factory
  • frozen=True → hashable and safe to share; setattr raises.
  • slots=True (3.10+) → no __dict__, less memory, faster attribute access; breaks arbitrary attribute assignment and some multiple-inheritance patterns.
  • field(default_factory=...) is mandatory for mutable defaults — a bare = [] is a ValueError at class creation, which is Python fixing the classic footgun for you.
  • Dataclasses do not validate types. If you need runtime validation, that is Pydantic's job.
08

Explain context managers, and write one both ways.

A context manager guarantees setup/teardown pairing even on exception.

python
class Timer:
    def __enter__(self):
        self.t0 = time.perf_counter(); return self
    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.t0
        return False            # False/None -> propagate the exception; True SWALLOWS it

from contextlib import contextmanager
@contextmanager
def timer():
    t0 = time.perf_counter()
    try:
        yield
    finally:                    # finally is essential — without it, teardown is skipped on error
        print(time.perf_counter() - t0)

Trap: returning a truthy value from __exit__ silently suppresses exceptions. Interviewers ask this because it is a real production bug.

09

What are __slots__ and when are they worth it?

__slots__ replaces the per-instance __dict__ with a fixed array of descriptors: less memory (often 40-50% for small objects), slightly faster attribute access, no ad-hoc attributes. Worth it when you have millions of instances (points, rows, events). Not worth it for ordinary service classes — you lose flexibility, multiple inheritance gets fiddly, and __weakref__ needs to be declared explicitly.

10

Abstract base classes vs typing.Protocol.

abc.ABC is nominal: you must inherit, and instantiation fails if abstract methods are unimplemented — good for a plugin base you control. Protocol is structural (static duck typing): any class with matching methods satisfies it, with no import coupling — good for describing what your function needs from its collaborators.

python
from typing import Protocol
class SupportsRead(Protocol):
    def read(self, n: int = -1) -> bytes: ...

def parse(src: SupportsRead) -> Doc: ...   # accepts files, sockets, BytesIO, your fake

Prefer Protocol at boundaries (it makes testing trivially easy), ABC when you need shared implementation plus enforcement.