What’s one thing you learned? What’s still confusing?
Decorators, Generators & Context Managers
Decorators for timing/logging/caching, generators with yield, and context managers.
Python's Memory Model: Objects, References & Identity
Everything is an object: references, id(), integer caching, mutable vs immutable.
Python Internals: CPython, Bytecode & Memory
AST, bytecode, refcounting, cyclic GC, the GIL, integer caching, and cProfile.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
a + b for matrix addition and why Pandas can write df["age"] > 21 for boolean masks. Dunder methods (__add__, __iter__, __enter__) are the protocol every framework hooks into. Mastering them is the leap from "Python user" to "Python framework author."len(x), it does not have special knowledge of your object -- it simply calls x.__len__(). This means you can make any class behave like a built-in type.# The simplest demonstration: what Python actually does behind the scenes
class Reveal:
"""A class that prints every dunder call it receives."""
def __len__(self):
print(" __len__ was called")
return 42
def __getitem__(self, key):
print(f" __getitem__ was called with key={key!r}")
return f"item_{key}"
def __add__(self, other):
print(f" __add__ was called with other={other!r}")
return "addition result"
def __str__(self):
print(" __str__ was called")
return "I am a Reveal object"
r = Reveal()
print("--- len(r) ---")
result = len(r) # calls r.__len__()
print(f"result: {result}")
print("\n--- r[5] ---")
item = r[5] # calls r.__getitem__(5)
print(f"result: {item!r}")
print("\n--- r + 99 ---")
total = r + 99 # calls r.__add__(99)
print(f"result: {total!r}")
print("\n--- str(r) ---")
s = str(r) # calls r.__str__()
print(f"result: {s!r}")| Dunder Method | Purpose | Triggered By | Example Use Case |
|---|---|---|---|
| __repr__ | Developer-facing string | repr(obj), REPL, containers | Logging, debugging, [obj] in lists |
| __str__ | User-facing string | print(obj), str(obj), f"{obj}" | Display to end users |
| __len__ | Object length | len(obj) | Custom collections, DataFrames |
| __getitem__ | Index/key access | obj[key], obj[0:5] | Sequences, mappings, slicing |
| __iter__ | Iteration entry point | for x in obj, list(obj) | Custom iterables, data loaders |
| __eq__ | Equality check | a == b | Value comparison, deduplication |
| __hash__ | Hash value | hash(obj), sets, dict keys | Must match __eq__ fields |
Here is a map of the most important dunders and the Python syntax that triggers each one:
| Python syntax | Dunder called |
|---|---|
len(x) | x.__len__() |
x[key] | x.__getitem__(key) |
x[key] = v | x.__setitem__(key, v) |
del x[key] | x.__delitem__(key) |
item in x | x.__contains__(item) |
__repr__ and __str__: String RepresentationsYou define only __repr__ on a class (no __str__). What does print(obj) output?
Every object has two string representations with distinct purposes:
__repr__ is for developers. It appears in the REPL, in logs, inside containers, and when you use repr(obj). The contract: return a string that, when passed to eval(), would recreate the object. If only __repr__ is defined, Python uses it for str() as well.__str__ is for end users. It appears when you call print(obj) or str(obj). It can be friendlier and more readable than __repr__.class Vector:
"""An immutable 2D mathematical vector."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
# Should look like valid Python that recreates the object
return f"Vector({self.x!r}, {self.y!r})"
def __str__(self) -> str:
# Human-friendly: show magnitude and direction hint
magnitude = (self.x ** 2 + self.y ** 2) ** 0.5
return f"<Vector ({self.x}, {self.y}), |v|={magnitude:.2f}>"
v = Vector(3, 4)
# In a container -- Python uses __repr__ for items inside lists/dicts
print([v])
# [Vector(3, 4)]
# Direct print -- Python uses __str__
print(v)
# <Vector (3, 4), |v|=5.00>
# REPL / repr() call -- uses __repr__
print(repr(v))
# Vector(3, 4)
# f-string without !r -- uses __str__
print(f"My vector: {v}")
# My vector: <Vector (3, 4), |v|=5.00>
# f-string with !r -- uses __repr__
print(f"Debug: {v!r}")
# Debug: Vector(3, 4)__repr__. Define __str__ only if a different user-facing format adds real value.__repr__ is found on Vector (you defined it). But __str__ walks past Vector (no override) and lands on object.__str__, whose default implementation calls back into __repr__. That's exactly why "if you only define __repr__, print(obj) still gives sensible output." It's not a hidden Python rule — it's just MRO traversal hitting object's default.Python maps all six comparison operators to dunder methods:
| Operator | Dunder |
|---|---|
== | __eq__ |
!= | __ne__ (auto-derived if __eq__ is defined) |
< | __lt__ |
<= | __le__ |
> | __gt__ |
functools.total_ordering decorator is a productivity shortcut: define __eq__ plus one ordering method (__lt__, __le__, __gt__, or __ge__), and Python fills in all the others automatically.from functools import total_ordering
@total_ordering
class Temperature:
"""A physical temperature with unit conversion."""
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius: float) -> None:
if celsius < self.ABSOLUTE_ZERO_C:
raise ValueError(f"Temperature below absolute zero: {celsius}°C")
self.celsius = celsius
@property
def fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32
@property
def kelvin(self) -> float:
return self.celsius - self.ABSOLUTE_ZERO_C
def __repr__(self) -> str:
return f"Temperature({self.celsius!r})"
def __str__(self) -> str:
return f"{self.celsius:.1f}°C ({self.fahrenheit:.1f}°F)"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius == other.celsius
def __lt__(self, other: "Temperature") -> bool:
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsius
# total_ordering provides __le__, __gt__, __ge__ for free!
# Test comparisons
boiling = Temperature(100)
freezing = Temperature(0)
body = Temperature(37)
print(boiling > freezing) # True (from __gt__, derived by total_ordering)
print(freezing < body) # True (from __lt__, defined directly)
print(boiling == Temperature(100)) # True
# Works with sorted() and min()/max() because ordering is defined
temps = [boiling, freezing, body, Temperature(-40)]
print(sorted(temps)) # [Temperature(-40), Temperature(0), Temperature(37), Temperature(100)]
print(f"Coldest: {min(temps)}") # -40.0°C (-40.0°F)
print(f"Hottest: {max(temps)}") # 100.0°C (212.0°F)__hash__ Trap__eq__, Python automatically sets __hash__ = None, making your objects unhashable (cannot be used as dict keys or in sets). This is a deliberate safety measure: two objects that compare equal should have the same hash. If you need your objects to be hashable, define __hash__ explicitly:@total_ordering
class FrozenPoint:
"""An immutable point that can be used as a dict key."""
def __init__(self, x: float, y: float) -> None:
self._x = x
self._y = y
@property
def x(self) -> float:
return self._x
@property
def y(self) -> float:
return self._y
def __repr__(self) -> str:
return f"FrozenPoint({self._x!r}, {self._y!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, FrozenPoint):
return NotImplemented
return (self._x, self._y) == (other._x, other._y)
def __hash__(self) -> int:
# Hash based on the same fields used in __eq__
return hash((self._x, self._y))
def __lt__(self, other: "FrozenPoint") -> bool:
if not isinstance(other, FrozenPoint):
return NotImplemented
return (self._x, self._y) < (other._x, other._y)
p1 = FrozenPoint(1, 2)
p2 = FrozenPoint(1, 2)
p3 = FrozenPoint(3, 4)
print(p1 == p2) # True
print(hash(p1) == hash(p2)) # True -- equal objects must have equal hashes
# Now usable as dict keys or in sets
seen: dict[FrozenPoint, int] = {p1: 10, p3: 30}
print(seen[p2]) # 10 -- p2 equals p1, so same hash bucket
visited = {p1, p3}
print(p2 in visited) # TrueIf you write 5 + my_vector, which dunder method gets called on my_vector?
__radd__. Python first tries int.__add__(5, my_vector). The built-in int does not know about your vector, so it returns NotImplemented. Python then tries the reflected (right-hand) method on the other operand: my_vector.__radd__(5). This two-step fallback is what allows custom types to support commutative operations with built-in types.import math
class Vector:
"""A 2D vector supporting full arithmetic."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Vector({self.x!r}, {self.y!r})"
# --- Arithmetic ---
def __add__(self, other: "Vector") -> "Vector":
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Vector") -> "Vector":
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar: float) -> "Vector":
"""Vector * scalar."""
if not isinstance(scalar, (int, float)):
return NotImplemented
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar: float) -> "Vector":
"""scalar * Vector -- delegates to __mul__ since multiplication is commutative."""
return self.__mul__(scalar)
def __truediv__(self, scalar: float) -> "Vector":
if scalar == 0:
raise ZeroDivisionError("Cannot divide a vector by zero")
return Vector(self.x / scalar, self.y / scalar)
def __neg__(self) -> "Vector":
"""Unary negation: -v."""
return Vector(-self.x, -self.y)
def __abs__(self) -> float:
"""abs(v) returns the Euclidean magnitude."""
return math.sqrt(self.x ** 2 + self.y ** 2)
def __iadd__(self, other: "Vector") -> "Vector":
"""In-place addition: v += other. Avoids creating a new object."""
if not isinstance(other, Vector):
return NotImplemented
self.x += other.x
self.y += other.y
return self # must return self for in-place ops
# Test all arithmetic operations
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(2, 2)
print(v1 * 3) # Vector(9, 12) -- __mul__
print(3 * v1) # Vector(9, 12) -- __rmul__
print(v1 / 2) # Vector(1.5, 2.0)
print(-v1) # Vector(-3, -4) -- __neg__
print(abs(v1)) # 5.0 -- __abs__
# In-place: modifies v2 instead of creating a new object
v2 += Vector(10, 10)
print(v2) # Vector(11, 12)for loops, in checks, and len() on your objects.class Matrix:
"""A 2D matrix with full container protocol support."""
def __init__(self, rows: list[list[float]]) -> None:
if not rows:
raise ValueError("Matrix must have at least one row")
ncols = len(rows[0])
if any(len(row) != ncols for row in rows):
raise ValueError("All rows must have the same number of columns")
# Store as a flat list for efficiency
self._rows = len(rows)
self._cols = ncols
self._data = [cell for row in rows for cell in row]
def __repr__(self) -> str:
rows = [
[self._data[r * self._cols + c] for c in range(self._cols)]
for r in range(self._rows)
]
return f"Matrix({rows!r})"
def __len__(self) -> int:
"""len(m) returns the number of rows."""
return self._rows
def __getitem__(self, key: int) -> list[float]:
"""m[row] returns the row as a list; supports m[row][col]."""
if key < 0 or key >= self._rows:
raise IndexError(f"Row index {key} out of range [0, {self._rows})")
start = key * self._cols
return self._data[start : start + self._cols]
def __setitem__(self, key: int, value: list[float]) -> None:
"""m[row] = new_row — replace an entire row."""
if len(value) != self._cols:
raise ValueError(f"Expected {self._cols} values, got {len(value)}")
start = key * self._cols
self._data[start : start + self._cols] = value
def __contains__(self, item: float) -> bool:
"""item in m — check if any cell equals item."""
return item in self._data
def __iter__(self):
"""for row in m — yields rows one at a time."""
for r in range(self._rows):
yield self[r]
m = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(f"Rows: {len(m)}") # 3
print(f"Row 0: {m[0]}") # [1, 2, 3]
print(f"Cell [1][2]: {m[1][2]}") # 6
print(f"5 in m: {5 in m}") # True
print(f"99 in m: {99 in m}") # False
# Iteration works because __iter__ is defined
for row in m:
print(row)
# [1, 2, 3]
# [4, 5, 6]
# [7, 8, 9]
# Mutate a row
m[0] = [10, 20, 30]
print(m[0]) # [10, 20, 30]__iter__). An iterator is an object that knows how to yield items one by one (__next__). These two protocols work together to power every for loop in Python.class CountDown:
"""An iterable that counts down from n to 1."""
def __init__(self, start: int) -> None:
self.start = start
def __iter__(self) -> "CountDownIterator":
"""Return a fresh iterator each time iteration starts."""
return CountDownIterator(self.start)
def __repr__(self) -> str:
return f"CountDown({self.start!r})"
class CountDownIterator:
"""The actual iterator that tracks state."""
def __init__(self, current: int) -> None:
self.current = current
def __iter__(self) -> "CountDownIterator":
"""Iterators must also implement __iter__ -- they return themselves."""
return self
def __next__(self) -> int:
if self.current <= 0:
raise StopIteration # signal that we are done
value = self.current
self.current -= 1
return value
# For loop: Python calls iter(cd) -> CountDownIterator, then next() repeatedly
cd = CountDown(5)
for n in cd:
print(n, end=" ")
print()
# 5 4 3 2 1
# You can iterate again because __iter__ creates a fresh iterator each time
print(list(cd)) # [5, 4, 3, 2, 1]
# Manual stepping
it = iter(cd)
print(next(it)) # 5
print(next(it)) # 4A common shortcut: combine iterable and iterator into a single class when you only need one active iteration at a time:
class FibSequence:
"""Yields the first n Fibonacci numbers. Iterable + iterator in one class."""
def __init__(self, n: int) -> None:
self.n = n
self._count = 0
self._a = 0
self._b = 1
def __iter__(self) -> "FibSequence":
self._count = 0
self._a, self._b = 0, 1
return self
def __next__(self) -> int:
if self._count >= self.n:
raise StopIteration
value = self._a
self._a, self._b = self._b, self._a + self._b
self._count += 1
return value
for fib in FibSequence(8):
print(fib, end=" ")
# 0 1 1 2 3 5 8 13for statement, demystified: iter(obj) then repeated next(it) until StopIteration is raised. Every for loop in Python — over lists, dicts, files, generators, your custom class — uses this exact two-step protocol.__call__)__call__ method can be invoked like a function. This creates function-like objects that maintain state -- something a plain function cannot do.class Multiplier:
"""A callable object that multiplies its input by a fixed factor."""
def __init__(self, factor: float) -> None:
self.factor = factor
self._call_count = 0
def __call__(self, value: float) -> float:
self._call_count += 1
return value * self.factor
def __repr__(self) -> str:
return f"Multiplier(factor={self.factor!r}, calls={self._call_count})"
# Create specialized "functions" with baked-in configuration
double = Multiplier(2)
triple = Multiplier(3)
scale_down = Multiplier(0.1)
print(double(5)) # 10.0
print(triple(7)) # 21.0
print(scale_down(100)) # 10.0
# State is preserved between calls
double(10)
double(20)
print(repr(double)) # Multiplier(factor=2, calls=3)
# Works wherever functions are accepted (map, filter, sorted key, etc.)
data = [1, 2, 3, 4, 5]
print(list(map(double, data))) # [2, 4, 6, 8, 10]nn.Module defines __call__ to run the forward pass. When you write output = model(batch), you are invoking model.__call__(batch), which in turn calls model.forward(batch) plus hooks for gradients, profiling, and quantization.class SimpleLinearLayer:
"""A stripped-down version of PyTorch's nn.Linear logic."""
def __init__(self, in_features: int, out_features: int) -> None:
self.in_features = in_features
self.out_features = out_features
# Weights initialized to small values (in real PyTorch, Kaiming init)
self.weight = [[0.1 * (i + j) for j in range(in_features)]
for i in range(out_features)]
self.bias = [0.0] * out_features
def __call__(self, x: list[float]) -> list[float]:
"""Forward pass: output = weight @ x + bias."""
output = []
for i in range(self.out_features):
total = sum(self.weight[i][j] * x[j] for j in range(self.in_features))
output.append(total + self.bias[i])
return output
def __repr__(self) -> str:
return f"SimpleLinearLayer(in={self.in_features}, out={self.out_features})"
layer = SimpleLinearLayer(3, 2)
x = [1.0, 2.0, 3.0]
print(layer(x)) # Callable just like a function!with statement calls __enter__ on entry and __exit__ on exit -- even if an exception occurs. This guarantees cleanup without requiring try/finally boilerplate everywhere.import time
class Timer:
"""A context manager for timing code blocks."""
def __enter__(self) -> "Timer":
"""Called at the start of the with block. Return value binds to 'as' clause."""
self._start = time.perf_counter()
return self # bound to `t` in `with Timer() as t:`
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
"""Called when the with block ends -- exception or not.
exc_type/exc_val/exc_tb are None if no exception occurred.
Return True to suppress the exception; False/None to re-raise it.
"""
self.elapsed = time.perf_counter() - self._start
print(f"Elapsed: {self.elapsed:.6f}s")
return False # do not suppress exceptions
@property
def elapsed_ms(self) -> float:
return self.elapsed * 1000
with Timer() as t:
total = sum(range(1_000_000))
print(f"Sum: {total}, took {t.elapsed_ms:.2f}ms")class ManagedResource:
"""Simulated database connection that guarantees cleanup."""
def __init__(self, name: str) -> None:
self.name = name
self._connected = False
self._queries: list[str] = []
def __enter__(self) -> "ManagedResource":
print(f"[CONNECT] Opening connection to '{self.name}'")
self._connected = True
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
print(f"[CLOSE] Closing connection to '{self.name}' "
f"(ran {len(self._queries)} queries)")
self._connected = False
if exc_type is not None:
print(f"[ERROR] Exception during session: {exc_val}")
return False # propagate any exception
def query(self, sql: str) -> list[dict]:
if not self._connected:
raise RuntimeError("Cannot query: not connected")
self._queries.append(sql)
print(f" [QUERY] {sql}")
return [{"id": 1}, {"id": 2}]
# Safe usage -- __exit__ is called no matter what
with ManagedResource("ml_training.db") as db:
rows = db.query("SELECT * FROM samples LIMIT 100")
print(f" Got {len(rows)} rows")
# [CONNECT] Opening connection to 'ml_training.db'
# [QUERY] SELECT * FROM samples LIMIT 100
# Got 2 rows
# [CLOSE] Closing connection to 'ml_training.db' (ran 1 queries)__getattr__, __setattr__)These dunders give you fine-grained control over how attribute access and assignment work. They are powerful -- and easy to misuse.
class LoggedObject:
"""An object that logs every attribute write."""
def __setattr__(self, name: str, value: object) -> None:
"""Called on EVERY self.x = value, including inside __init__."""
print(f" [SETATTR] {name} = {value!r}")
# MUST call super().__setattr__ -- otherwise the value never gets stored!
super().__setattr__(name, value)
def __getattr__(self, name: str) -> str:
"""Called ONLY when normal attribute lookup fails (name not in __dict__)."""
print(f" [GETATTR] {name!r} not found, returning default")
return f"<missing:{name}>"
obj = LoggedObject()
obj.x = 10 # [SETATTR] x = 10
obj.label = "hi" # [SETATTR] label = 'hi'
print(obj.x) # 10 (normal lookup, __getattr__ NOT called)
print(obj.missing) # [GETATTR] 'missing' not found, returning default -> <missing:missing>A practical use case is lazy loading -- compute expensive attributes only on first access:
class LazyModel:
"""Loads a large model only when first accessed."""
def __init__(self, path: str) -> None:
self._path = path
self._model = None # not loaded yet
def __getattr__(self, name: str):
"""Called only when attribute is not found via normal lookup."""
if name == "model":
print(f" [LAZY] Loading model from {self._path}...")
self._model = {"weights": [0.1, 0.2, 0.3]} # simulate loading
return self._model
raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}")
@property
def is_loaded(self) -> bool:
return self._model is not None
m = LazyModel("/models/bert.pt")
print(m.is_loaded) # False -- model not loaded yet
print(m.model) # [LAZY] Loading model... -> {'weights': [...]}
print(m.is_loaded) # TrueMatrix2x2This brings together every protocol from this lesson into one cohesive class:
import math
from functools import total_ordering
from typing import Iterator
@total_ordering
class Matrix2x2:
"""A 2x2 matrix supporting arithmetic, indexing, comparison, iteration, and calling."""
def __init__(self, a: float, b: float, c: float, d: float) -> None:
"""[[a, b], [c, d]]"""
self._data = [float(a), float(b), float(c), float(d)]
# --- String representations ---
def __repr__(self) -> str:
return f"Matrix2x2({self._data[0]}, {self._data[1]}, {self._data[2]}, {self._data[3]})"
def __str__(self) -> str:
return (f"[[{self._data[0]:.2f}, {self._data[1]:.2f}]\n"
f" [{self._data[2]:.2f}, {self._data[3]:.2f}]]")
# --- Container protocol ---
def __len__(self) -> int:
return 2 # two rows
def __getitem__(self, row: int) -> list[float]:
if row not in (0, 1):
raise IndexError(f"Row index {row} out of range for 2x2 matrix")
return self._data[row * 2 : row * 2 + 2]
def __iter__(self) -> Iterator[list[float]]:
yield self._data[:2]
yield self._data[2:]
# --- Comparison ---
def __eq__(self, other: object) -> bool:
if not isinstance(other, Matrix2x2):
return NotImplemented
return self._data == other._data
def __lt__(self, other: "Matrix2x2") -> bool:
"""Order by determinant (useful for ranking transformations)."""
if not isinstance(other, Matrix2x2):
return NotImplemented
return self.det() < other.det()
def __hash__(self) -> int:
return hash(tuple(self._data))
# --- Arithmetic ---
def __add__(self, other: "Matrix2x2") -> "Matrix2x2":
if not isinstance(other, Matrix2x2):
return NotImplemented
return Matrix2x2(*(a + b for a, b in zip(self._data, other._data)))
def __sub__(self, other: "Matrix2x2") -> "Matrix2x2":
if not isinstance(other, Matrix2x2):
return NotImplemented
return Matrix2x2(*(a - b for a, b in zip(self._data, other._data)))
def __mul__(self, other):
"""Matrix * scalar or Matrix @ Matrix (matrix product)."""
if isinstance(other, (int, float)):
return Matrix2x2(*(v * other for v in self._data))
if isinstance(other, Matrix2x2):
a, b, c, d = self._data
e, f, g, h = other._data
return Matrix2x2(a*e + b*g, a*f + b*h, c*e + d*g, c*f + d*h)
return NotImplemented
def __rmul__(self, scalar: float) -> "Matrix2x2":
return self.__mul__(scalar)
# --- Callable: apply matrix to a 2D vector ---
def __call__(self, vec: list[float]) -> list[float]:
"""Apply this matrix as a linear transformation to a 2D vector."""
if len(vec) != 2:
raise ValueError("Matrix2x2 can only transform 2D vectors")
a, b, c, d = self._data
return [a * vec[0] + b * vec[1], c * vec[0] + d * vec[1]]
# --- Helper methods ---
def det(self) -> float:
"""Compute the determinant: ad - bc."""
a, b, c, d = self._data
return a * d - b * c
def trace(self) -> float:
"""Sum of diagonal elements."""
return self._data[0] + self._data[3]
# -- Demo --
I = Matrix2x2(1, 0, 0, 1) # identity
R90 = Matrix2x2(0, -1, 1, 0) # 90° rotation
S2 = Matrix2x2(2, 0, 0, 2) # scale by 2
print("Identity matrix:")
print(I)
print()
print(f"Rotation matrix det: {R90.det():.1f}") # 1.0 (rotation preserves area)
print(f"Scale matrix det: {S2.det():.1f}") # 4.0 (area scales by 4)
# Arithmetic
print("\nI + S2:")
print(I + S2)
print("\n3 * R90:")
print(3 * R90)
# Apply as a linear transformation (callable!)
point = [1.0, 0.0]
rotated = R90(point)
print(f"\nRotate {point} by 90°: {rotated}") # [0.0, 1.0]
# Indexing and iteration
print(f"\nFirst row: {R90[0]}") # [0, -1]
print("All rows:")
for row in R90:
print(f" {row}")
# Comparison (by determinant)
matrices = [S2, I, R90, Matrix2x2(3, 1, 1, 3)]
print(f"\nSorted by determinant: {sorted(matrices)}")Explore how Python maps operators to dunder methods, see the protocol dispatch in action, and visualize Method Resolution Order:
Tests · Implement all 6 challenges to build a complete 3D vector with every major protocol!
Interactive Lab
See how Python's object model works — method resolution order, inheritance chains, and dunder methods
__repr__; optionally define __str__ -- __repr__ is the universal fallback used in REPLs, logs, and containers. Make it return valid Python that recreates the object__eq__, you must also define __hash__ -- Python sets __hash__ = None automatically, making objects unhashable unless you define it. Hash based on the same fields used in __eq____radd__ enables 5 + obj -- when the left operand does not know how to add, Python tries the right operand's reflected method. Always return NotImplemented (not None) when you cannot handle the operation__call__ makes instances behave like functions -- this is how PyTorch models work: calls which runs the forward pass with gradient hooksWhat does Python call when you write len(my_obj)?
__getitem__str(x) → "give me a string version of yourself" via __str__with x: → "prepare yourself" via __enter__, then "clean up" via __exit__a + b |
a.__add__(b) |
a * b | a.__mul__(b) |
a == b | a.__eq__(b) |
a < b | a.__lt__(b) |
str(x) | x.__str__() |
repr(x) | x.__repr__() |
for item in x: | x.__iter__() then __next__() |
x(arg) | x.__call__(arg) |
with x as v: | x.__enter__() / x.__exit__(...) |
abs(x) | x.__abs__() |
hash(x) | x.__hash__() |
>= | __ge__ |
model(x)model.__call__(x)__enter__/__exit__ guarantee cleanup -- return False from __exit__ to propagate exceptions; return True to suppress them