Magic Methods & Operator Overloading
Magic Methods & Operator Overloading: Making Your Classes Feel Native
Magic methods (also called dunder methods — "double underscore") are how Python's built-in operations interact with your objects. When you write a + b, Python calls a.__add__(b). Understanding magic methods lets you build objects that feel natural to work with.
The Most Important Magic Methods
class Vector:
"""A 2D vector with full operator support."""
def __init__(self, x, y):
self.x = x
self.y = y
# String representations
def __str__(self):
"""Human-readable: used by print() and str()"""
return f"Vector({self.x}, {self.y})"
def __repr__(self):
"""Unambiguous: used in REPL, debugging, repr()"""
return f"Vector(x={self.x!r}, y={self.y!r})"
# Arithmetic operators
def __add__(self, other):
"""v1 + v2"""
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
"""v1 - v2"""
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
"""v * 3 (vector times scalar)"""
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
"""3 * v (scalar times vector — right-hand mul)"""
return self.__mul__(scalar)
def __truediv__(self, scalar):
"""v / 2"""
return Vector(self.x / scalar, self.y / scalar)
def __neg__(self):
"""-v"""
return Vector(-self.x, -self.y)
def __abs__(self):
"""abs(v) — magnitude"""
return (self.x ** 2 + self.y ** 2) ** 0.5
# Comparison operators
def __eq__(self, other):
"""v1 == v2"""
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __lt__(self, other):
"""v1 < v2 (compare by magnitude)"""
return abs(self) < abs(other)
def __le__(self, other):
return abs(self) <= abs(other)
# Container-like behavior
def __len__(self):
"""len(v) — always 2 for 2D vector"""
return 2
def __getitem__(self, index):
"""v[0], v[1]"""
if index == 0:
return self.x
elif index == 1:
return self.y
raise IndexError(f"Vector index {index} out of range")
def __iter__(self):
"""for component in v"""
yield self.x
yield self.y
def __contains__(self, value):
"""value in v"""
return value in (self.x, self.y)
# Boolean check
def __bool__(self):
"""bool(v) — False if zero vector"""
return self.x != 0 or self.y != 0
# Usage — all feels natural
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1) # Vector(3, 4)
print(repr(v1)) # Vector(x=3, y=4)
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(2, 2)
print(v1 * 3) # Vector(9, 12)
print(3 * v1) # Vector(9, 12)
print(abs(v1)) # 5.0
print(v1 == Vector(3, 4)) # True
print(v1[0], v1[1]) # 3 4
print(list(v1)) # [3, 4]
print(3 in v1) # True
print(sorted([v1, v2])) # Works because __lt__ defined
Context Manager Protocol: __enter__ and __exit__
The with statement uses these methods:
class DatabaseConnection:
def __init__(self, host, dbname):
self.host = host
self.dbname = dbname
self.connection = None
def __enter__(self):
"""Called at the start of 'with' block — return the resource."""
print(f"Connecting to {self.dbname} at {self.host}")
self.connection = f"<mock connection to {self.dbname}>"
return self # 'as' variable in 'with ... as conn:' gets this
def __exit__(self, exc_type, exc_val, exc_tb):
"""Called at end of 'with' block — cleanup."""
print(f"Closing connection to {self.dbname}")
self.connection = None
if exc_type is not None:
print(f"Exception occurred: {exc_type.__name__}: {exc_val}")
return False # Return True to suppress the exception, False to propagate
# Usage
with DatabaseConnection("localhost", "mydb") as db:
print(f"Connected: {db.connection}")
# Connection automatically closed after this block
# Even if an exception occurs!
# Using contextlib.contextmanager for simpler context managers
from contextlib import contextmanager
@contextmanager
def timer(label=""):
import time
start = time.time()
try:
yield # Code inside 'with' block runs here
finally:
elapsed = time.time() - start
print(f"{label}: {elapsed:.3f}s")
with timer("List comprehension"):
result = [x**2 for x in range(1000000)]
Callable Objects: __call__
Make instances callable like functions:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
triple = Multiplier(3)
print(triple(10)) # 30
print(triple(7)) # 21
# Useful for: stateful functions, configurable callbacks
class RateLimiter:
def __init__(self, max_per_second):
import time
self.max_per_second = max_per_second
self.calls = []
def __call__(self, func):
"""Used as a decorator."""
import functools, time
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
# Remove calls older than 1 second
self.calls = [c for c in self.calls if now - c < 1]
if len(self.calls) >= self.max_per_second:
raise RuntimeError(f"Rate limit exceeded: {self.max_per_second}/sec")
self.calls.append(now)
return func(*args, **kwargs)
return wrapper
rate_limit_5 = RateLimiter(max_per_second=5)
@rate_limit_5
def api_call(endpoint):
return f"Response from {endpoint}"
Descriptor Protocol: __get__, __set__, __delete__
Descriptors control attribute access — this is how @property works under the hood.
class Validated:
"""A descriptor that validates values on assignment."""
def __init__(self, min_val=None, max_val=None, type_=None):
self.min_val = min_val
self.max_val = max_val
self.type_ = type_
self.name = None
def __set_name__(self, owner, name):
self.name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, None)
def __set__(self, obj, value):
if self.type_ is not None and not isinstance(value, self.type_):
raise TypeError(f"{self.name}: expected {self.type_.__name__}, got {type(value).__name__}")
if self.min_val is not None and value < self.min_val:
raise ValueError(f"{self.name}: {value} is below minimum {self.min_val}")
if self.max_val is not None and value > self.max_val:
raise ValueError(f"{self.name}: {value} is above maximum {self.max_val}")
setattr(obj, self.name, value)
class Person:
name = Validated(type_=str)
age = Validated(min_val=0, max_val=150, type_=int)
height = Validated(min_val=0.0, max_val=3.0, type_=float)
def __init__(self, name, age, height):
self.name = name
self.age = age
self.height = height
p = Person("Alice", 30, 1.65)
print(p.name, p.age, p.height)
try:
p.age = -5 # ValueError: below minimum 0
except ValueError as e:
print(e)
try:
p.name = 123 # TypeError: expected str
except TypeError as e:
print(e)
Complete Reference: Magic Methods
# Object lifecycle
__init__(self, ...) # Construction
__new__(cls, ...) # Instance creation (before __init__)
__del__(self) # Destruction (not reliable — avoid)
# String representations
__str__(self) # str(), print()
__repr__(self) # repr(), REPL
__format__(self, spec) # format(), f-strings
# Comparison
__eq__(self, other) # ==
__ne__(self, other) # !=
__lt__(self, other) # <
__le__(self, other) # <=
__gt__(self, other) # >
__ge__(self, other) # >=
# Arithmetic
__add__, __radd__ # +
__sub__, __rsub__ # -
__mul__, __rmul__ # *
__truediv__, __rtruediv__ # /
__floordiv__ # //
__mod__ # %
__pow__ # **
__neg__, __pos__, __abs__ # unary -, +, abs()
# Container
__len__(self) # len()
__getitem__(self, key) # obj[key]
__setitem__(self, key, val) # obj[key] = val
__delitem__(self, key) # del obj[key]
__contains__(self, item) # in
__iter__(self) # for loops
__next__(self) # next()
# Context managers
__enter__(self) # with ... as x:
__exit__(self, ...) # end of with block
# Callable
__call__(self, ...) # obj()
# Attribute access
__getattr__(self, name) # Fallback when attribute not found
__setattr__(self, name, val)# Intercept attribute assignment
__getattribute__(self, name)# Intercept all attribute access
Magic methods are the key to writing libraries and APIs that feel like native Python. Every pandas DataFrame, every PyTorch tensor, every SQLAlchemy model uses them heavily.
Next lesson: Decorators & Context Managers — Python's most powerful abstraction tools.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises