OOP in Python
Classes, inheritance, dunder methods, dataclasses, metaclasses, ABCs
Python's object model is richer than most developers realise. Dunder (magic/special) methods let you control how objects behave with operators, iteration, context managers, and attribute access. Dataclasses and metaclasses take this further, enabling Python to act as a meta-programming language.
Key Points
- __init__: initialiser (not constructor — __new__ creates the object, __init__ initialises it)
- __repr__: unambiguous string for debugging — should ideally eval() back to the object
- __str__: human-readable string — falls back to __repr__ if not defined
- __eq__ / __hash__: define equality and hash — if you define __eq__, Python sets __hash__ = None (unhashable) unless you also define __hash__
- __len__, __getitem__, __setitem__, __iter__, __next__: make objects behave like sequences/iterables
- __enter__ / __exit__: context manager protocol — used by with statements; __exit__ receives exception info
- __slots__: restricts instance attributes to a fixed set — reduces memory (no __dict__ per instance) for large object counts
- @dataclass (Python 3.7+): auto-generates __init__, __repr__, __eq__ from field annotations; frozen=True makes it immutable
- Metaclass: class of a class — type is the default metaclass; override __new__ or __init__ to customise class creation (used by ORMs, ABCs)
| Dunder method | Triggered by | Example use |
|---|---|---|
| __init__ | obj = MyClass() | Set initial state |
| __repr__ | repr(obj), interactive shell | Debug-friendly string |
| __eq__ | obj == other | Value equality |
| __hash__ | hash(obj), dict key, set member | Must be consistent with __eq__ |
| __len__ | len(obj) | Container size |
| __getitem__ | obj[key] | Indexing / slicing |
| __iter__ | for x in obj, iter(obj) | Make iterable |
| __enter__/__exit__ | with obj as x: | Context manager |
| __call__ | obj(args) | Make instance callable |
Python OOP: frozen dataclass with validation, context manager protocol, custom iterator, metaclass plugin registry
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass(frozen=True, order=True) # immutable + comparable
class Money:
amount: float
currency: str = "USD"
_instances: ClassVar[int] = 0 # class var, not a field
def __post_init__(self):
if self.amount < 0:
raise ValueError("Negative amount")
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
# Context manager using __enter__/__exit__
class Timer:
def __enter__(self):
import time; self.start = time.perf_counter(); return self
def __exit__(self, *args):
self.elapsed = time.perf_counter() - self.start
with Timer() as t:
expensive_operation()
print(f"Took {t.elapsed:.3f}s")
# Custom iterator — __iter__ + __next__
class CountDown:
def __init__(self, n): self.n = n
def __iter__(self): return self
def __next__(self):
if self.n <= 0: raise StopIteration
self.n -= 1; return self.n + 1
# Metaclass — auto-register subclasses (plugin pattern)
class PluginMeta(type):
registry = {}
def __new__(mcs, name, bases, ns):
cls = super().__new__(mcs, name, bases, ns)
if bases: mcs.registry[name] = cls # skip base class
return cls
class Plugin(metaclass=PluginMeta): pass
class CSVPlugin(Plugin): pass # auto-registeredReal-World Example
Django models, SQLAlchemy's declarative base, and Pydantic all use metaclasses or __init_subclass__ to register and introspect model fields automatically. When you define class User(Base): id = Column(Integer, primary_key=True), SQLAlchemy's metaclass intercepts the class creation and builds the SQL mapping — no explicit registration needed.