Previous | Tutorial index | Next

📘 TUTORIAL 8: DUNDER (MAGIC) METHODS

Learning Objective

Learn the most important special methods to customize class behavior.

8.1 What Are Dunder Methods?

Dunder methods (short for double underscore methods) are special methods that have two leading and two trailing underscores, like __init__, __str__, and __add__. They are also called magic methods because they enable your classes to integrate seamlessly with Python’s built-in operations and syntax.

How They Work

Why They Matter

The Most Important Dunder Methods

We will cover the ones you will use most frequently:

8.2 __str__ and __repr__: Representing Your Objects

These two methods control how your objects are converted to strings.

__str__

__repr__

Example

class Person: def __init__(self, name, age): self.name = name self.age = age def __repr__(self): return f"Person('{self.name}', {self.age})" def __str__(self): return f"{self.name} (age {self.age})" p = Person("Alice", 30) print(p) # Alice (age 30) -> __str__ print(repr(p)) # Person('Alice', 30) -> __repr__

In the REPL, typing p would show Person('Alice', 30).

When to define both: If the __repr__ is already clear and user-friendly, you can omit __str__. Many library classes define __repr__ only.

8.3 __len__: Supporting the len() Function

To make your object work with len(), define __len__ to return an integer.

Example: A Custom List-like Class

class ShoppingCart: def __init__(self): self.items = [] def add(self, item): self.items.append(item) def __len__(self): return len(self.items) cart = ShoppingCart() cart.add("apple") cart.add("banana") print(len(cart)) # 2

8.4 __eq__, __lt__, etc.: Comparison Operators

If you define __eq__ and __lt__, Python can sometimes infer others (but it's safer to define them explicitly or use functools.total_ordering).

Example: A Book Class with Equality and Ordering

class Book: def __init__(self, title, author, pages): self.title = title self.author = author self.pages = pages def __eq__(self, other): if not isinstance(other, Book): return NotImplemented return self.pages == other.pages def __lt__(self, other): if not isinstance(other, Book): return NotImplemented return self.pages < other.pages def __repr__(self): return f"Book('{self.title}', '{self.author}', {self.pages})" b1 = Book("1984", "Orwell", 328) b2 = Book("Brave New World", "Huxley", 268) print(b1 == b2) # False print(b1 < b2) # False (328 < 268 is False) print(b2 < b1) # True

Important: If you do not handle the case where other is not the same type, return NotImplemented so Python can try swapping operands.

8.5 __add__ and Friends: Arithmetic Operators

Example: A Vector Class Supporting Addition

class Vector: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): if not isinstance(other, Vector): return NotImplemented return Vector(self.x + other.x, self.y + other.y) def __repr__(self): return f"Vector({self.x}, {self.y})" v1 = Vector(2, 3) v2 = Vector(4, 5) v3 = v1 + v2 print(v3) # Vector(6, 8)

Reverse Operators (e.g., __radd__)

If the left operand does not support the operation, Python tries __radd__ on the right operand. This is less common but useful for mixing types.

8.6 __call__: Making Objects Callable

When you define __call__, your object can be used like a function.

Example: A Counter Function

class Counter: def __init__(self): self.count = 0 def __call__(self): self.count += 1 return self.count c = Counter() print(c()) # 1 print(c()) # 2 print(c()) # 3

This is useful for creating function-like objects that maintain state (e.g., closures, decorators, or stateful callbacks).

8.7 Other Useful Dunder Methods

Dunder Method Purpose Example Use
__contains__ Called by in operator if item in my_container:
__getitem__ Indexing (obj[key]) value = my_list[0]
__setitem__ Assignment by index my_list[0] = 5
__delitem__ Deletion by index del my_list[0]
__iter__ Returns an iterator for for loops for item in my_obj:
__next__ Gets the next item in an iterator Used with __iter__
__hash__ Makes object hashable (for sets, dict keys) hash(obj)
__bool__ Called by bool() and if statements if obj:

We will focus on the most common ones, but you should know these exist.

8.8 The NotImplemented Singleton

When implementing binary operators (like __add__), if the operation is not supported with the given operand, you should return the special value NotImplemented (not raise NotImplementedError). This allows Python to try the reflected operation on the other operand.

Example

class Vector: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): if isinstance(other, Vector): return Vector(self.x + other.x, self.y + other.y) return NotImplemented # Try other's __radd__ def __radd__(self, other): # If other is something that can be added to Vector # For example, if other is a number, we might add it to both components if isinstance(other, (int, float)): return Vector(self.x + other, self.y + other) return NotImplemented v = Vector(1, 2) result = v + 3 # uses __radd__ (since 3 does not have __add__ for Vector) print(result) # Vector(4, 5)

8.9 Best Practices and Common Pitfalls

Best Practice Explanation
Always define __repr__ It helps with debugging and provides a clear representation.
Define __str__ only if different from __repr__ If the unambiguous repr is user-friendly, skip __str__.
Use NotImplemented for unsupported operands Do not raise an error; let Python try the other side.
Keep dunder methods focused Each dunder should do one thing and do it well.
Be consistent with operator semantics If you define __eq__, also consider defining __ne__ (or Python will infer it from __eq__ in many contexts, but explicit is sometimes better).
Avoid side effects in __repr__ It should just return a string, not modify the object.
Overload only when it makes sense Not every class should support + or <; only when the operation is intuitively meaningful.

Common Pitfalls

8.10 Full Walkthrough Example: A Fraction Class

Let's build a simple Fraction class that demonstrates many dunder methods.

import math class Fraction: def __init__(self, numerator, denominator): if denominator == 0: raise ValueError("Denominator cannot be zero") self.numer = numerator self.denom = denominator self._reduce() def _reduce(self): """Simplify the fraction using GCD.""" g = math.gcd(self.numer, self.denom) self.numer //= g self.denom //= g # String representations def __repr__(self): return f"Fraction({self.numer}, {self.denom})" def __str__(self): return f"{self.numer}/{self.denom}" # Arithmetic def __add__(self, other): if not isinstance(other, Fraction): return NotImplemented new_numer = self.numer * other.denom + other.numer * self.denom new_denom = self.denom * other.denom return Fraction(new_numer, new_denom) def __sub__(self, other): if not isinstance(other, Fraction): return NotImplemented new_numer = self.numer * other.denom - other.numer * self.denom new_denom = self.denom * other.denom return Fraction(new_numer, new_denom) def __mul__(self, other): if not isinstance(other, Fraction): return NotImplemented return Fraction(self.numer * other.numer, self.denom * other.denom) def __truediv__(self, other): if not isinstance(other, Fraction): return NotImplemented return Fraction(self.numer * other.denom, self.denom * other.numer) # Comparison def __eq__(self, other): if not isinstance(other, Fraction): return NotImplemented return self.numer == other.numer and self.denom == other.denom def __lt__(self, other): if not isinstance(other, Fraction): return NotImplemented return self.numer * other.denom < other.numer * self.denom def __le__(self, other): if not isinstance(other, Fraction): return NotImplemented return self < other or self == other # Length? Not meaningful, but we could define __len__ as number of digits? # We'll skip. # Callable: convert to float def __call__(self): return self.numer / self.denom # For use in set/dict (hashable if immutable) def __hash__(self): return hash((self.numer, self.denom)) # --- Using the class --- f1 = Fraction(1, 2) f2 = Fraction(2, 3) print(f1) # 1/2 print(repr(f1)) # Fraction(1, 2) print(f1 + f2) # 7/6 print(f1 * f2) # 1/3 print(f1 == f2) # False print(f1 < f2) # True (1/2 < 2/3) print(f1()) # 0.5 (as float)

Observations:

📝 Quiz 8: Dunder Methods

Answer the following questions to check your understanding.

1. Which dunder method is called by print(obj)?

Answer(B) `__str__`

2. What is the best practice regarding __repr__ and __str__?

Answer(C) Always define `__repr__`; define `__str__` if a different user‑friendly string is needed.

3. If __str__ is not defined, what does print(obj) use?

Answer(A) `__repr__`

4. Which dunder method is called when you write obj + other?

Answer(B) `__add__`

5. What should a binary operator (like __add__) return if the operation is not supported for the given operand type?

Answer(C) `NotImplemented`

6. Consider the following code:

class Point: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): return Point(self.x + other.x, self.y + other.y)

What will happen if you try p1 + 5 where p1 is a Point?

Answer(B) Raises AttributeError because the code does not check the type of `other`. It should return `NotImplemented` to let Python try `__radd__`.

7. (True/False) The __call__ method allows an object to be used as a function.

Answer(A) True

8. Which dunder method defines the behavior of the len() function?

Answer(A) `__len__`

9. If you define __eq__ but not __ne__, what does obj1 != obj2 do?

Answer(B) It returns `not (obj1 == obj2)` (Python inverts `__eq__`).

10. Which of the following is a valid reason to define __call__ on a class?

Answer(D) All of the above.

đŸ§Ș Exercise 8: Implementing a Custom Class with Dunder Methods

(Exercise text unchanged)

Part C: Reflection
In comments, explain why you chose to implement __repr__ and __str__ differently, and why you defined __lt__ and __eq__.

Sample Answer `__repr__` provides an unambiguous, developer‑friendly representation that can be used to recreate the object, so I used `Time(h, m, s)`. `__str__` gives a human‑readable time format (HH:MM:SS) for end‑users. I defined `__eq__` and `__lt__` to allow meaningful comparisons and sorting of times; these are natural operations for time objects and enable Python built‑ins like `sorted()` to work with our class.

🏠 Homework 8: Building a Polynomial Class

(Homework text unchanged)

Part 3: Reflection Questions
Answer these in a comment block at the top of your script:

  1. Why is it important to trim trailing zeros in the coefficients list? What issues would arise if we didn't?
  2. In the __str__ method, how did you handle the signs and formatting? What were the trickiest cases?
  3. How does the __len__ method relate to the degree of the polynomial? Why did we choose to return 0 for the zero polynomial?
  4. What would happen if you tried to add a Polynomial to an integer? How could you support that (e.g., p + 5)? (Hint: look up __add__ with type checks and __radd__.)
  5. How does implementing these dunder methods make the Polynomial class more Pythonic and easier to use compared to having methods like add(), sub(), etc.?
Sample Answers 1. Trimming trailing zeros ensures that the representation is canonical (e.g., `Polynomial(1, 2, 0)` becomes `Polynomial(1, 2)`). Without trimming, equality checks could fail because `[1,2]` and `[1,2,0]` are not equal, and the degree would be incorrectly reported. 2. The tricky parts were handling the first term (no leading `+`), dealing with coefficients of `1` and `-1` (omit the `1`), and correctly formatting the sign between terms. I built the string from the highest degree down, adding `" + "` or `" - "` as needed. 3. `__len__` returns the degree of the polynomial (the highest exponent with non‑zero coefficient). For the zero polynomial, the degree is undefined, but we return 0 for simplicity, which is consistent with the length of the trimmed list. 4. If `other` is an integer, the current `__add__` would return `NotImplemented` because the type check fails. To support `p + 5`, we can define `__radd__` so that `5 + p` also works, and treat the integer as a constant polynomial. 5. By implementing dunder methods, we can use natural operators (`+`, `-`, `*`, `==`, `len()`, etc.) instead of verbose method calls. This makes the class intuitive and its usage consistent with Python's numeric types, improving readability and reducing the learning curve for users.

Bonus Challenge (Optional):
Implement __sub__ and __neg__ such that -p works. Also implement __radd__ and __rmul__ so that 5 + p and 2 * p work. Test them.

📚 Additional Resources for Self-Study

  1. Real Python: Python's Magic Methods – Excellent overview.
  2. CodeGym: The 12 Magic Methods You'll Actually Use in Python – Practical list.
  3. Python Official Docs: 3. Data model – Special method names – The definitive reference.
  4. LearnPython: Day 22 - Dunder Methods – Interactive.

✅ Summary Checklist for Tutorial 8

Before moving to Tutorial 9 (Class as Decorator), ensure you can confidently say YES to the following:

Ready for the next tutorial? In Tutorial 9, you will learn how to use a class as a decorator – a powerful technique for creating reusable decorators with state.

Previous | Tutorial index | Next