Classes, inheritance, dunder methods, and the patterns that matter in real code
OOP questions appear at every level — from "what's the difference between @classmethod and @staticmethod" to system design rounds testing composition vs inheritance. Dunder methods show whether you understand Python's object protocol: len(x) calls x.__len__(), a + b calls a.__add__(b), for item in x calls x.__iter__(). Writing Pythonic classes means implementing the right dunders.
Python's object model is protocol-based — not hierarchy-based. To make a class iterable, implement __iter__. To make it work with len(), implement __len__. Python checks for these methods; it doesn't care what you inherit from. Prefer composition ("has-a") over deep inheritance ("is-a") — it's easier to test and easier to change.
A class is a blueprint for objects. Instance variables are unique per object; class variables are shared across all instances. A constructor is a special method automatically called when a new object of a class is created — in Python, that's __init__.
self is just the convention for the first argument — Python passes the instance automatically. Class variables are shared; mutating one on an instance creates an instance variable that shadows it.
Inheritance lets a subclass reuse and extend a parent class. super() calls the parent's version of a method — always use it in __init__ so parent state is properly set up before the child adds its own.
Always call super().__init__() in subclass constructors — skipping it leaves parent state uninitialised. Use isinstance over type(x) == Y; it respects inheritance.
Dunder (double-underscore) methods hook into Python's built-in operations. Implementing them makes your class a first-class citizen — supporting +, len(), ==, iteration, and truthiness checks just like native types.
Without them, your class is a black box Python can't work with:
With dunders implemented:
If you define __eq__ without __hash__, Python sets __hash__ = None and instances become unhashable (can't be used in sets or as dict keys). Always define both together.
Three decorators that control how a method is accessed and what it receives as its first argument — each serves a distinct purpose that comes up constantly in production Python.
@property exposes a method as an attribute — accessed without parentheses, with an optional setter for validation. @classmethod receives the class (cls) as its first argument — the standard pattern for alternative constructors. @staticmethod is a plain utility function scoped inside the class with no access to the instance or class.
@dataclass auto-generates __init__, __repr__, and __eq__ from annotated fields — eliminating boilerplate for classes that mainly hold data.
Use field(default_factory=list) for mutable defaults — same rule as regular functions. frozen=True makes instances hashable and safe as dict keys.