OOP, dunders, decorators & descriptors
Questions in this set 10
- 01What is the MRO and how does Python resolve super()?
- 02Write a decorator that takes arguments, preserves metadata, and works on methods.
- 03@staticmethod vs @classmethod vs a plain method vs a module function.
- 04Explain @property and when a property is the wrong tool.
- 05What is the descriptor protocol? Implement one.
- 06__new__ vs __init__.
- 07What do dataclasses give you, and what are the gotchas?
- 08Explain context managers, and write one both ways.
- 09What are __slots__ and when are they worth it?
- 10Abstract base classes vs typing.Protocol.
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".
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, objectThat 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.
Write a decorator that takes arguments, preserves metadata, and works on methods.
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.wrapsmatters for docs tooling,pytestcollection and logging — without it every decorated function is namedwrapper.- Decorators run at import time; anything expensive there slows startup.
- For async functions you need a separate
async def wrapperwithawait fn(...)— a sync wrapper silently returns a coroutine nobody awaits.
@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:
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-safeExplain @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.
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.
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.
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?"
__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.
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())What do dataclasses give you, and what are the gotchas?
@dataclass generates __init__, __repr__, __eq__ (and ordering/hash on request) from annotations.
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 factoryfrozen=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 aValueErrorat 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.
Explain context managers, and write one both ways.
A context manager guarantees setup/teardown pairing even on exception.
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.
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.
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.
from typing import Protocol
class SupportsRead(Protocol):
def read(self, n: int = -1) -> bytes: ...
def parse(src: SupportsRead) -> Doc: ... # accepts files, sockets, BytesIO, your fakePrefer Protocol at boundaries (it makes testing trivially easy), ABC when you need shared implementation plus enforcement.