Dunder Methods, Dataclasses and Design Questions
55 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Interviewers use these topics for practical rounds: write a small class, make it behave like a built-in, then explain why you chose parts over a parent class.
Dunder methods make objects feel built in
Dunder method is a special method such as __len__ or __eq__, named with double underscores. Python calls it when you use matching syntax or a built-in function.
Python calls special methods with double underscores when you use syntax such as print, len, == and +. You write the method; the syntax uses it.
class Cart: def __init__(self, items): self.items = items
def __len__(self): return len(self.items)
def __repr__(self): return f"Cart({self.items!r})"
c = Cart(["pen", "book"])print(len(c), c)Example 1 · Equality, hashing and ordering by hand
class Money: def __init__(self, amount): self.amount = amount
def __eq__(self, other): return isinstance(other, Money) and self.amount == other.amount
def __hash__(self): return hash(self.amount)
def __lt__(self, other): return self.amount < other.amount
def __repr__(self): return f"Money({self.amount})"
print(Money(5) == Money(5))print(len({Money(5), Money(5), Money(9)}))print(sorted([Money(9), Money(2), Money(5)]))True 2 [Money(2), Money(5), Money(9)]
What this code does
__eq__and__hash__must agree: equal objects need equal hashes, so sets treat them as one.sortedonly needs__lt__.
Example 2 · Behaving like a container
class Playlist: def __init__(self, songs): self._songs = list(songs)
def __len__(self): return len(self._songs)
def __getitem__(self, index): return self._songs[index]
def __contains__(self, song): return song in self._songs
p = Playlist(["A", "B", "C"])print(len(p), p[1], "C" in p)for song in p: print(song)3 B True A B C
What this code does
__getitem__also lets aforloop work, because Python falls back to index 0, 1, 2 untilIndexError.__iter__is the direct way when you need more control.
len(c) calls c.__len__() and print(c) falls back to __repr__. You rarely call a dunder method by name.
Dataclasses
Dataclass is a class decorated with @dataclass. Python generates __init__, __repr__ and __eq__ from the annotated fields, so you only describe the data.
A dataclass writes __init__, __repr__ and __eq__ for a class that mostly holds data. You list the fields with type hints.
from dataclasses import dataclass
@dataclassclass Product: name: str price: int stock: int = 0
p = Product("Pen", 20)print(p)print(p == Product("Pen", 20, 0))Example 3 · Frozen, ordered dataclasses
from dataclasses import dataclass, field, replace
@dataclass(frozen=True, order=True)class Item: price: int name: str = "item" tags: tuple = ()
a = Item(20, "Pen")b = replace(a, price=15)print(a, b)print(sorted([a, b])[0].price)print(len({a, Item(20, "Pen")}))try: a.price = 1except AttributeError: print("frozen")Item(price=20, name='Pen', tags=()) Item(price=15, name='Pen', tags=()) 15 1 frozen
What this code does
frozen=Trueblocks assignment and makes instances hashable.order=Truecompares fields in the order they are declared;replacebuilds a changed copy.
Fields with defaults must come last. Options such as frozen=True and order=True add immutability and comparison.
Composition over inheritance
Composition means building a class from other objects it holds as attributes and delegates to. It is the has-a relationship, a flexible alternative to inheritance.
Inheritance says "is a". Composition says "has a": the object keeps another object and asks it to do part of the work.
class PetrolEngine: def start(self): return "vroom"
class Car: def __init__(self, engine): self.engine = engine
def start(self): return self.engine.start()
print(Car(PetrolEngine()).start())Example 4 · Refactoring a subclass tree into parts
class SmsSender: def send(self, text): return f"SMS: {text}"
class EmailSender: def send(self, text): return f"Email: {text}"
class FakeSender: def __init__(self): self.sent = []
def send(self, text): self.sent.append(text) return "ok"
class OrderService: def __init__(self, sender): self.sender = sender
def place(self, order_id): return self.sender.send(f"order {order_id} placed")
print(OrderService(SmsSender()).place(7))print(OrderService(EmailSender()).place(7))fake = FakeSender()OrderService(fake).place(7)print(fake.sent)SMS: order 7 placed Email: order 7 placed ['order 7 placed']
What this code does
- Instead of
SmsOrderServiceandEmailOrderService, one service takes a sender. - A fake sender makes the service easy to test.
Swapping the engine needs no new subclass. Any object with start() works, thanks to duck typing.
Next step
Check your understanding
12 quick questions