Magic/Dunder Methods, Abstract Base Classes & Dataclasses
Magic / Dunder Methods
Section titled “Magic / Dunder Methods”Every Python built-in operation, print(), len(), +, [], with, is secretly a method call. When you write
len(playlist), Python calls playlist.__len__(). When you write a + b, Python calls a.__add__(b).
These are dunder methods, named for their double underscore prefix and suffix. By implementing them on your own classes you make your objects speak Python’s native language. They stop being custom objects that need special handling and start behaving exactly like built-ins.
The name “magic methods” comes from the fact that Python calls them automatically, you never invoke __len__ directly,
you just call len() and Python handles the rest.
String Representation
Section titled “String Representation”The first dunders worth knowing are the ones that control how your object appears as a string. Python calls __str__
when you print() an object and __repr__ when you inspect it in the REPL or debugger.
The convention is simple: __str__ is for humans, readable and friendly while __repr__ is for developers, unambiguous
and ideally valid Python that could recreate the object.
class Book: def __init__(self, title, author, pages): self.title = title self.author = author self.pages = pages
def __str__(self): # For end users → print(book) return f"'{self.title}' by {self.author}"
def __repr__(self): # For developers → repr(book), debugging return f"Book('{self.title}', '{self.author}', {self.pages})"
b = Book("1984", "Orwell", 328)print(b) # '1984' by Orwell ← __str__print(repr(b)) # Book('1984', 'Orwell', 328) ← __repr__Comparison Methods
Section titled “Comparison Methods”By default, Python has no idea how to compare two instances of your class. t1 > t2 will raise a TypeError. Implement
the comparison dunders and your objects become fully comparable, sortable, and usable in conditions just like
numbers or strings.
Each dunder maps directly to an operator:
| Dunder | Operator |
|---|---|
__eq__ | == |
__lt__ | < |
__le__ | <= |
__gt__ | > |
__ge__ | >= |
:::note[@functools.total_ordering] You don’t need to implement all comparison methods. You define the two that matter
— __eq__ (equality) and __lt__ (less than) — and Python derives the rest mathematically:
a <= b→a < b or a == ba > b→not (a < b) and not (a == b)a >= b→not (a < b)
from functools import total_ordering
@total_orderingclass Temperature: def __eq__(self, other): return self.celsius == other.celsius # your logic
def __lt__(self, other): return self.celsius < other.celsius # your logic
# Python generates __le__, __gt__, __ge__ from the two above:::
Here is the full manual implementation — all four comparison methods defined explicitly. Notice the last line: once you
implement these dunders, sorted() works on your objects for free — no extra code needed.
class Temperature: def __init__(self, celsius): self.celsius = celsius
def __eq__(self, other): # == return self.celsius == other.celsius
def __lt__(self, other): # return self.celsius < other.celsius
def __le__(self, other): # <= return self.celsius <= other.celsius
def __gt__(self, other): # > return self.celsius > other.celsius
def __str__(self): return f"{self.celsius}°C"
t1 = Temperature(100)t2 = Temperature(50)t3 = Temperature(100)
print(t1 == t3) # Trueprint(t1 > t2) # Trueprint(t2 < t1) # True
# Now you can even sort a list of Temperature objects!temps = [Temperature(30), Temperature(10), Temperature(20)]print(sorted(temps)) # [10°C, 20°C, 30°C]:::note[@functools.total_ordering] The implementation above defines all four methods manually. In practice you only
need two: __eq__ and __lt__. Let @total_ordering generate the rest. :::
Arithmetic Methods
Section titled “Arithmetic Methods”Arithmetic dunders let your objects support mathematical operators. Each operator maps to a method: + calls __add__,
- calls __sub__, * calls __mul__.
Vector is the classic example: a 2D point in space with x and y coordinates. Adding two vectors adds their
components, subtracting does the opposite, and multiplying by a scalar scales both dimensions.
class Vector: def __init__(self, x, y): self.x = x self.y = y
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): # v1 * 3 return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar): # 3 * v1 (reversed) return self.__mul__(scalar)
def __neg__(self): # -v1 return Vector(-self.x, -self.y)
def __abs__(self): # abs(v1) → magnitude return (self.x**2 + self.y**2) ** 0.5
def __repr__(self): return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)v2 = Vector(1, 4)
print(v1 + v2) # Vector(3, 7)print(v1 - v2) # Vector(1, -1)print(v1 * 3) # Vector(6, 9)print(3 * v1) # Vector(6, 9) ← uses __rmul__print(abs(v1)) # 3.605...:::caution[Reflected version] One subtlety worth knowing: v1 * 3 calls v1.__mul__(3), that’s straightforward. But
3 * v1 is different, Python calls (3).__mul__(v1) first, which fails because integers don’t know how to multiply
with a Vector. Python then tries the reflected version: v1.__rmul__(3). Without __rmul__, 3 * v1 raises a
TypeError. :::
Container Methods
Section titled “Container Methods”Container dunders make your objects behave like Python’s built-in collections: lists, dicts, sets. Implement them and your class supports:
len()- indexing with
[] inchecksforloops without inheriting from anything.
Implement them and your class becomes a first-class citizen alongside Python’s own container types. Any code that
works with lists, sorted(), enumerate(), list comprehensions, for loops will work with your object too, without
inheriting from anything.
Playlist will support built-in collections operations out of the box.
class Playlist: def __init__(self, name): self.name = name self._songs = []
def add(self, song): self._songs.append(song)
def __len__(self): # len(playlist) return len(self._songs)
def __getitem__(self, index): # playlist[0] return self._songs[index]
def __setitem__(self, index, value): # playlist[0] = "new song" self._songs[index] = value
def __delitem__(self, index): # del playlist[0] del self._songs[index]
def __contains__(self, song): # "song" in playlist return song in self._songs
def __iter__(self): # for song in playlist return iter(self._songs)
def __repr__(self): return f"Playlist('{self.name}', {self._songs})"
p = Playlist("Chill Mix")p.add("Song A")p.add("Song B")p.add("Song C")
print(len(p)) # 3print(p[0]) # Song Aprint("Song B" in p) # True
for song in p: # Iteration works! print(song)Context Manager Methods
Section titled “Context Manager Methods”The with statement is Python’s way of saying: “set something up, do some work, then tear it down, no matter what
happens”. File handles, database connections, network sockets, locks, anything that needs guaranteed cleanup is a good
candidate for a context manager. For instance, a file is guaranteed to close even if an exception is raised inside the
block, no explicit try/finally needed:
with open("file.txt") as f: data = f.read()Any object can support this protocol by implementing two dunders:
__enter__runs when execution enters thewithblock and returns the object bound toas__exit__runs when execution leaves, whether normally or because an exception was raised.
class FileManager: def __init__(self, filename, mode): self.filename = filename self.mode = mode self.file = None
def __enter__(self): # Called when entering `with` block self.file = open(self.filename, self.mode) return self.file
def __exit__(self, exc_type, exc_val, exc_tb): # Called on exit if self.file: self.file.close() return False # False = don't suppress exceptions
with FileManager("test.txt", "w") as f: f.write("Hello!")# File is automatically closed here, even if an error occursCallable Objects
Section titled “Callable Objects”In Python, functions are objects. But the reverse is also possible: objects can behave like
functions. Implement __call__ and your object becomes callable: you can invoke it with () just like a
function.
:::note[When to use it] This is useful when you need a callable that also carries state, something a plain function
can’t do. A Multiplier that remembers its factor, a rate limiter that tracks call history, a validator that holds its
rules. All are cleaner as callable objects than as functions with global state or closures. :::
class Multiplier: def __init__(self, factor): self.factor = factor
def __call__(self, value): # Makes the object callable like a function return value * self.factor
double = Multiplier(2)triple = Multiplier(3)
print(double(5)) # 10print(triple(5)) # 15print(callable(double)) # TrueAbstract Base Classes (ABCs)
Section titled “Abstract Base Classes (ABCs)”Inheritance lets subclasses reuse code. But what if you want to enforce a contract, guarantee that every subclass implements specific methods, rather than just hoping they do?
That’s what Abstract Base Classes are for. An ABC defines the interface: the methods every subclass must implement
without providing the implementation itself. If a subclass forgets to implement a required method, Python raises a
TypeError the moment you try to instantiate it, not later when you call the missing method.
This catches bugs early and makes your intent explicit: Shape is not meant to be used directly, it’s a blueprint that
Circle, Rectangle and any future shape must follow.
Shape (ABC — blueprint, cannot be instantiated)├── Circle must implement area() and perimeter()└── Rectangle must implement area() and perimeter()from abc import ABC, abstractmethod
class Shape(ABC): # Inherit from ABC to make it abstract
@abstractmethod def area(self): # Subclasses MUST implement this pass
@abstractmethod def perimeter(self): # Subclasses MUST implement this pass
def describe(self): # Concrete method — shared by all shapes return f"I am a shape with area {self.area():.2f}"
class Circle(Shape): def __init__(self, radius): self.radius = radius
def area(self): import math return math.pi * self.radius ** 2
def perimeter(self): import math return 2 * math.pi * self.radius
class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height
def area(self): return self.width * self.height
def perimeter(self): return 2 * (self.width + self.height)
# shape = Shape() # ❌ TypeError: Can't instantiate abstract classc = Circle(5)r = Rectangle(4, 6)
print(c.area()) # 78.53print(r.perimeter()) # 20print(c.describe()) # I am a shape with area 78.54print(r.describe()) # I am a shape with area 24.00
# Polymorphism — treat all shapes uniformlyshapes = [Circle(3), Rectangle(2, 5), Circle(7)]for shape in shapes: print(f"Area: {shape.area():.2f}"):::note[Why use ABCs?] They prevent incomplete implementations. If a subclass forgets to implement area(), Python
raises a TypeError immediately, catching bugs early. :::
Dataclasses
Section titled “Dataclasses”Dataclasses eliminate boilerplate for classes that primarily store data.
Every data class you write without @dataclass follows the same tedious pattern: write __init__ to store the
attributes, write __repr__ so it prints nicely, write __eq__ so equality comparison works. The logic is always the
same, only the field names change.
@dataclass is Python’s answer to this boilerplate. You declare the fields with type hints and Python generates
__init__, __repr__, and __eq__ automatically. The class stays focused on what it is, not on the ceremony of
setting it up.
Then use tabs for the before/after:
class Point: def __init__(self, x, y, z): self.x = x self.y = y self.z = z
def __repr__(self): return f"Point(x={self.x}, y={self.y}, z={self.z})"
def __eq__(self, other): return self.x == other.x and self.y == other.y and self.z == other.z
p1 = Point(1.0, 2.0)p2 = Point(1.0, 2.0)p3 = Point(1.0, 2.0, 3.0)
print(p1) # Point(x=1.0, y=2.0, z=0.0) ← __repr__ auto-generatedprint(p1 == p2) # True ← __eq__ auto-generatedprint(p1 == p3) # Falsefrom dataclasses import dataclass
@dataclassclass Point: x: float y: float z: float = 0.0Same result, a third of the code.
Advanced Dataclass Features
Section titled “Advanced Dataclass Features”The basic @dataclass covers most cases, but three advanced features are worth knowing:
field(default_factory=list): mutable defaults like lists and dicts must usefield(default_factory=...)instead of a direct assignment. If you writegrades: list = [], all instances share the same list, a classic Python gotcha.field(init=False, repr=False): excludes a field from__init__and__repr__. Useful for auto-generated values like IDs that the caller should never set directly.__post_init__: runs automatically after__init__. Use it for validation or any setup that depends on the fields already being set.
from dataclasses import dataclass, fieldfrom typing import List
@dataclassclass Student: name: str age: int grades: List[float] = field(default_factory=list) # Mutable default _id: int = field(init=False, repr=False) # Not in __init__ or repr
def __post_init__(self): # Runs after __init__ self._id = id(self) # Auto-generate ID if self.age < 0: raise ValueError("Age cannot be negative")
def average(self): return sum(self.grades) / len(self.grades) if self.grades else 0.0
s = Student("Alice", 20, [85.0, 92.0, 78.0])print(s) # Student(name='Alice', age=20, grades=[85.0, 92.0, 78.0])print(s.average()) # 85.0Under the hood: field(default_factory=list)
In Python, default values in a class definition are created once when the class is defined, not each time you create an instance. So if you write:
@dataclassclass Student: grades: list = [] # ❌ WRONGEvery Student shares the exact same list object in memory. Add a grade to one student and it appears
on all of them:
s1 = Student()s2 = Student()
s1.grades.append(90)print(s2.grades) # [90] ← s2 is contaminated!field(default_factory=list) fixes this by calling list() fresh for each new instance:
@dataclassclass Student: grades: list = field(default_factory=list) # ✅ correctNow every student gets their own independent list:
s1 = Student()s2 = Student()
s1.grades.append(90)print(s2.grades) # [] ← untouchedThe same applies to any mutable default: dicts, sets, or custom objects. Immutable defaults like int, str, float,
and bool are safe to use directly because they can’t be modified in place.
:::note[Mutable vs. immutable] A mutable object is one that can be changed after creation. A immutable object cannot.
| Mutable | Immutable |
|---|---|
list | int |
dict | str |
set | float |
| custom objects | bool |
tuple |
The problem with mutable defaults is precisely that they can be changed — so when two instances share the same default object, a change made through one instance is visible through the other.
Immutable defaults are safe because there’s no way to modify them in place — str and int can’t be mutated, so
sharing them between instances is harmless.
@dataclassclass Example: count: int = 0 # ✅ safe — int is immutable name: str = "Alice" # ✅ safe — str is immutable tags: list = field(default_factory=list) # ✅ safe — fresh list each time tags: list = [] # ❌ danger — shared list:::
Frozen Dataclasses (Immutable)
Section titled “Frozen Dataclasses (Immutable)”By default, dataclass instances are mutable, anyone can change p.x = 999 after creation. Add
frozen=True and Python makes the instance immutable: any attempt to modify a field after creation raises a
FrozenInstanceError.
Frozen dataclasses gain one important property as a side effect, they become hashable. Regular mutable objects can’t be used as dict keys or added to sets because their hash could change if their data changes. A frozen dataclass has a fixed hash, so it works anywhere a hashable object is expected.
@dataclass(frozen=True) # Makes instances immutableclass Coordinate: lat: float lon: float
c = Coordinate(40.7128, -74.0060)print(c) # Coordinate(lat=40.7128, lon=-74.006)# c.lat = 0 # ❌ FrozenInstanceError — can't modify!
# Frozen dataclasses are hashable, so they can be used as dict keys or in setslocations = {Coordinate(40.7, -74.0): "New York"}Quick Reference
Section titled “Quick Reference”A summary of all the dunders covered in this chapter — use this as a cheat sheet when you need a quick reminder of which method to implement.
| Dunder | Triggered by |
|---|---|
__str__ | print(obj), str(obj) |
__repr__ | repr(obj), debugging |
__len__ | len(obj) |
__getitem__ | obj[key] |
__contains__ | x in obj |
__iter__ | for x in obj |
__call__ | obj() |
__add__ | obj + other |
__eq__ | obj == other |
__lt__ | obj < other |
__enter__/__exit__ | with obj: |
Exercises
Section titled “Exercises”Exercise 1 — Magic methods Intermediate
Section titled “Exercise 1 — Magic methods Intermediate”Create a Vector2D class that supports:
__add__,__sub__,__mul__(scalar),__rmul____neg__,__abs__(magnitude)__eq__and__lt__(compare by magnitude)__iter__(so you can unpack:x, y = vector)__repr__and__str__- A
normalize()method returning a unit vector
v1 = Vector2D(3, 4)v2 = Vector2D(1, 2)
print(v1 + v2) # Vector2D(4, 6)print(v1 - v2) # Vector2D(2, 2)print(v1 * 3) # Vector2D(9, 12)print(3 * v1) # Vector2D(9, 12)print(abs(v1)) # 5.0print(v1 > v2) # Truex, y = v1 # Unpackingprint(x, y) # 3 4print(v1.normalize()) # Vector2D(0.6, 0.8)Solution
Exercise 2 — Abstract Base Classes Intermediate
Section titled “Exercise 2 — Abstract Base Classes Intermediate”Design a payment system. Create:
- Abstract base class
PaymentMethodwith abstract methodspay(amount)andrefund(amount), and abstract propertyname CreditCard(PaymentMethod)— tracks spending limit and current balancePayPal(PaymentMethod)— tracks email and balanceCryptoCurrency(PaymentMethod)— tracks coin type, amount, and exchange rate to USD- A
checkout(cart_total, payment)function that uses any payment method
card = CreditCard("Alice", limit=1000, balance=800)paypal = PayPal("alice@example.com", balance=500)crypto = CryptoCurrency("BTC", amount=0.01, rate=45000)
checkout(200, card) # Paid $200 via Credit Card. Remaining limit: $600checkout(150, paypal) # Paid $150 via PayPal. Remaining balance: $350checkout(100, crypto) # Paid $100 via BTC (0.00222 BTC). Remaining: 0.00778 BTCSolution
Exercise 3 — Context manager
Section titled “Exercise 3 — Context manager”Build a DatabaseConnection class that acts as a context manager:
__init__takes a connection string__enter__simulates connecting and returns itself__exit__simulates disconnecting, and rolls back if an exception occurred- Has
execute(query)method that logs all queries - Has
commit()method - Raises
RuntimeErrorif you callexecute()outside awithblock - Tracks total queries executed as a class attribute
with DatabaseConnection("postgresql://localhost/mydb") as db: db.execute("SELECT * FROM users") db.execute("INSERT INTO users VALUES ('Alice')") db.commit()# Connecting to postgresql://localhost/mydb# Query executed: SELECT * FROM users# Query executed: INSERT INTO users VALUES ('Alice')# Committed 2 queries# Disconnecting cleanly
# On exception → rolls back instead of committingwith DatabaseConnection("postgresql://localhost/mydb") as db: db.execute("DELETE FROM users") raise RuntimeError("Something went wrong")# Rolling back 1 queries