Difficulty: Intermediate
Magic methods (also called dunder methods, short for double underscore) are special methods with names surrounded by double underscores. They let you hook into Python's built-in behavior and make your custom objects work with operators, built-in functions, and language constructs like for-loops and with-statements. They are never called directly by name; instead, Python calls them automatically when you use the corresponding syntax.
__str__ and __repr__ control how your objects are converted to strings. __str__ is called by print() and str() and should return a human-friendly description. __repr__ is called by the interactive interpreter and repr(), and should ideally return a string that could recreate the object. If only one is defined, Python falls back from __str__ to __repr__ (but not vice versa), so if you define only one, make it __repr__.
__eq__ and __lt__ are comparison methods. __eq__ defines behavior for == and should return True or False. Without __eq__, Python defaults to identity comparison (same as 'is'). __lt__ defines behavior for < and, when combined with the functools.total_ordering decorator, can auto-generate <=, >, and >= as well. These methods are crucial for sorting and using your objects in sets or as dictionary keys.
__len__ and __getitem__ make your object behave like a collection. __len__ is called by the len() function. __getitem__ is called when you use square bracket notation obj[key]. Implementing __getitem__ also makes your object iterable (you can use it in a for-loop) because Python will call __getitem__ with increasing integer indices until an IndexError is raised. For more control over iteration, implement __iter__ and __next__ instead.
__add__, __sub__, __mul__, and other arithmetic dunder methods define how operators work with your objects. __add__ handles the + operator, __sub__ handles -, and so on. There are also reflected versions like __radd__ that are called when your object appears on the right side of the operator and the left object does not know how to handle the operation. These methods should return new objects rather than modifying the originals, consistent with how built-in types behave.
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __str__(self):
return f'{self.title} by {self.author}'
def __repr__(self):
return f"Book('{self.title}', '{self.author}', {self.pages})"
b = Book('1984', 'Orwell', 328)
print(str(b))
print(repr(b))
print(b) # print calls __str__
__str__ returns a readable description for end users. __repr__ returns a detailed string that could recreate the object. print() calls __str__ by default.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def __eq__(self, other):
return self.grade == other.grade
def __lt__(self, other):
return self.grade < other.grade
def __repr__(self):
return f'{self.name}:{self.grade}'
students = [Student('Alice', 88), Student('Bob', 95), Student('Charlie', 72)]
students.sort()
print(students)
print(Student('X', 88) == Student('Y', 88))
print(Student('X', 70) < Student('Y', 90))
sort() uses __lt__ to order the students by grade. __eq__ compares grades regardless of name, so two students with the same grade are considered equal. __repr__ controls how they appear in the list printout.
class Deck:
def __init__(self):
suits = ['Hearts', 'Diamonds', 'Clubs', 'Spades']
ranks = ['A', '2', '3']
self.cards = [f'{r} of {s}' for s in suits for r in ranks]
def __len__(self):
return len(self.cards)
def __getitem__(self, index):
return self.cards[index]
deck = Deck()
print(len(deck))
print(deck[0])
print(deck[-1])
print(deck[2:5])
__len__ lets len() work on the deck. __getitem__ supports integer indexing, negative indexing, and slicing because Python's slice objects are passed directly to __getitem__ and the internal list handles them.
class Matrix:
def __init__(self, rows):
self.rows = rows
def __add__(self, other):
result = []
for r1, r2 in zip(self.rows, other.rows):
result.append([a + b for a, b in zip(r1, r2)])
return Matrix(result)
def __mul__(self, scalar):
result = [[val * scalar for val in row] for row in self.rows]
return Matrix(result)
def __repr__(self):
return '\n'.join(str(row) for row in self.rows)
m1 = Matrix([[1, 2], [3, 4]])
m2 = Matrix([[5, 6], [7, 8]])
print(m1 + m2)
print('---')
print(m1 * 3)
__add__ performs element-wise addition of two matrices. __mul__ multiplies every element by a scalar. Both return new Matrix objects. __repr__ formats the matrix row by row.
__str__, __repr__, __len__, __eq__, __lt__, __add__, __getitem__, dunder methods