If you were writing a small value type in Python, which dunder methods would you implement, what would you let a dataclass generate, and how does super() decide what to call?
Implement the dunders that opt your type into a protocol, treating repr, eq and hash as one decision, and let a dataclass generate the boilerplate when the fields are the identity. super() follows the C3 linearisation of type(self), so it calls the next class in the MRO rather than your own base.
What the interviewer is scoring
- Does the candidate justify each dunder by the protocol it joins rather than reciting a list of method names
- That equality and hashing are treated as a single decision, including that defining __eq__ removes hashability
- Whether the MRO is described as a linearisation of the whole hierarchy rather than a walk up the parent chain
- Whether super() is explained relative to type(self) instead of the enclosing class's base
- Can they name a situation where a plain class is the better choice than a dataclass
Answer
Dunders are the protocol layer
Operators and built-in functions in Python dispatch to specially named methods, and the interpreter looks them up on the type rather than on the instance. len(x) calls type(x).__len__(x), x + y tries type(x).__add__ and falls back to type(y).__radd__, and a with block needs __enter__ and __exit__. So the right question about any dunder is which protocol you want your object to join, not which methods look thorough.
For a value type the useful set is small. __repr__ is for you and for your logs: unambiguous, ideally something you could paste back into a REPL. __str__ is for whoever reads the output, and if you write only one of the two, write __repr__, because str() falls back to it while repr() never falls back to __str__. __eq__ defines what "the same value" means, and it is not a standalone choice: the moment you define __eq__ on a plain class, Python sets __hash__ to None and instances stop being usable in sets or as dict keys. That is deliberate rather than hostile, because the inherited identity hash would no longer agree with your equality, and two objects that compare equal must hash equally. You restore hashing by defining __hash__ over the same fields __eq__ reads, and those fields must not change while the object is in a container. If ordering means something, __lt__ plus functools.total_ordering fills in the rest.
Equally worth saying is what not to reach for. __del__ is not a destructor with a guaranteed moment, and overloading arithmetic on something that is not a number costs the reader more than it saves the writer.
What the decorator writes for you
@dataclass generates __init__, __repr__ and __eq__ from the annotated fields, which is exactly the boilerplate above. The options are where the judgement lives. order=True adds the four comparison dunders using tuple ordering of the fields. frozen=True makes assignment raise FrozenInstanceError and, combined with the default eq=True, gives you a generated __hash__; without frozen you get __hash__ = None for the reason described above. slots=True, available since Python 3.10, returns a new class with __slots__ set. field(default_factory=...) is mandatory for mutable defaults, and __post_init__ is where derived values and validation go.
from dataclasses import dataclass, field, FrozenInstanceError
@dataclass # eq=True by default, so __hash__ becomes None
class Point:
x: int
y: int
tags: list[str] = field(default_factory=list) # a bare [] raises at class creation
try:
{Point(1, 2)}
except TypeError as exc:
print(exc) # unhashable type: 'Point'
@dataclass(frozen=True) # frozen + eq gives a real __hash__
class FrozenPoint:
x: int
y: int
assert len({FrozenPoint(1, 2), FrozenPoint(1, 2)}) == 1
try:
FrozenPoint(1, 2).x = 9
except FrozenInstanceError:
print("immutable, so the hash stays valid for the object's lifetime")
A linearisation, not a parent pointer
With multiple inheritance, attribute lookup does not walk up parents depth-first. Each class gets a single ordered list, its __mro__, computed once at class creation by the C3 algorithm. C3 guarantees two things: every class appears before its own bases, and the left-to-right order in which you listed the bases is preserved. When no ordering can satisfy both, the class statement itself raises TypeError, so you find out at import time rather than at call time.
The consequence people underrate is that __mro__ belongs to the concrete class, so the same base sits in a different position depending on what was mixed in around it. A mixin cannot know its neighbours, and that is the point: it is being composed, not subclassed.
The class that forgets to call super()
Zero-argument super() does not mean "my base class". It means the class after the current class in the MRO of type(self), which at runtime is usually a subclass nobody in that file has heard of. That is what makes cooperative multiple inheritance work, and it is also the single thing that breaks it. If any class in the chain hard-codes Base.setup(self, ...) instead of calling super().setup(...), or omits the call entirely, every class after it in the linearisation is silently skipped. Nothing raises. You get an object that is half-initialised, and the bug surfaces as a missing attribute somewhere far away.
class Base:
def setup(self, **kw):
# The chain terminates here, so this one must NOT call super().
assert not kw, f"unconsumed options: {kw}"
class Timing(Base):
def setup(self, *, timeout=30, **kw):
self.timeout = timeout
super().setup(**kw) # next after Timing in type(self).__mro__: Retrying
class Retrying(Base):
def setup(self, *, retries=3, **kw):
self.retries = retries
super().setup(**kw)
class Client(Timing, Retrying):
pass
client = Client()
client.setup(timeout=5, retries=2)
print([cls.__name__ for cls in Client.__mro__])
assert (client.timeout, client.retries) == (5, 2)
Because Timing and Retrying both accept and forward **kw, either can be dropped or reordered without touching the others. Take out one super() call and Client(Timing, Retrying) quietly loses retries.
Choosing between the two
Reach for a dataclass when the fields are the object: a request payload, a coordinate, a parsed config row, anything you would otherwise hand-write a five-line __init__ and a __repr__ for. Write a plain class when the behaviour is the point, when construction must be validated or overloaded through classmethods, or when identity is deliberately not the field values — a database session compares by identity, not by connection string. Choosing a dataclass for something with real invariants hands you a generated __init__ that lets any caller build an invalid instance.
Defining
__eq__is always two decisions, not one, because it silently takes away__hash__— andsuper()refers to the MRO of the object in front of you, not to the code you are looking at.
Likely follow-ups
- Why does a frozen dataclass get a working __hash__ when a mutable one does not?
- What does passing slots=True to dataclass change, and what does it break?
- How would you validate field values on construction without writing __init__ by hand?
- When is __init_subclass__ a better tool for enforcing a rule than a metaclass?
Related questions
- Why does a mutable default argument keep the values from previous calls?mediumAlso on dataclasses2 min
- When would you choose UDP over TCP, given that TCP is reliable and UDP is not?mediumAlso on protocols4 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumSame kind of round: concept4 min
- Count the subarrays whose elements sum to k, where the values may be negative.mediumSame kind of round: coding4 min