Skip to main content

Polymorphism Through Dunder Methods

Introduction: Making Objects Behave Like Built-In Types

In the last lesson, you learned that polymorphism doesn't require inheritance. Objects can work together if they implement the same interface—this is duck typing. But how do you design interfaces that Python understands?

The answer lies in dunder methods (double-underscore methods, also called "magic methods"). These special methods are Python's protocol definitions. When you implement __str__(), Python knows your object supports print(). When you implement __eq__(), Python knows your object supports == comparisons. When you implement __lt__(), Python knows your object can be sorted.

In this lesson, you'll see how dunder methods enable polymorphism across completely unrelated classes. A Task in a todo app, a Case in a legal system, and an Appointment in a calendar might have nothing in common structurally—but if they all implement __lt__(), they can all be sorted by the same sorted() function. That's polymorphism through protocol.

Why this matters for AI systems: When building multi-agent systems, you often need agents, tasks, messages, and logs to work with Python's built-in operations (len(), sorting, string conversion, comparisons). Dunder methods make this possible without forcing inheritance hierarchies.


The Task Entity: Our Primary Example

Throughout this lesson, we'll use a concrete domain object—the Task class from todo applications. This is the canonical Task definition used across Part 5 lessons.

Loading Python environment...

This simple class will demonstrate how dunder methods enable polymorphic behavior—making Task objects work naturally with Python's syntax and operations.


String Representation: Making Objects Printable

When you print() an object or view it in the Python shell, Python needs to convert it to a string. Two dunder methods control this.

str: User-Friendly Display

__str__() returns a string optimized for end users. Python calls it when you use print() or str().

Loading Python environment...

The user sees a clean, readable format. No implementation details, no <Task object at 0x...> nonsense.

repr: Developer-Friendly Display

__repr__() returns a string for developers debugging code. Python calls it in the interactive shell or when you call repr().

Loading Python environment...

Convention: repr() output should ideally be valid Python code that could recreate the object. This helps debugging—you can copy the repr output and paste it into code.

Comparing Tasks in Different Contexts

Notice how __str__() and __repr__() serve different purposes:

Loading Python environment...


Comparison Methods: Making Objects Orderable

What happens when you try to sort tasks by priority? Without implementing comparison methods, Python raises an error. The __lt__() method (and related comparison dunder methods) teach Python how to compare your objects.

eq and lt: Equality and Ordering

Loading Python environment...

Key insight: By implementing just __lt__() and __eq__(), Python can derive all other comparisons (&lt;=, >, >=) through the @functools.total_ordering decorator if needed.

hash: Making Objects Usable in Sets and Dicts

When you implement __eq__(), you should also implement __hash__(). This allows Task objects to be used as dictionary keys or members of sets.

Loading Python environment...

Critical rule: Objects that compare equal (via __eq__()) must have the same hash (via __hash__()). Breaking this rule causes mysterious bugs in sets and dicts.


Container Protocols: Making Objects Behave Like Collections

Dunder methods also let objects behave like containers. A TodoList might contain tasks. Using __len__() and __contains__(), we make TodoList work with Python's built-in len() function and the in operator.

Loading Python environment...


Polymorphism Across Different Domain Objects

Here's where dunder methods create powerful polymorphism without inheritance. Different domain objects—Task, Case, Appointment—can work with the same functions if they implement the same dunder methods.

Loading Python environment...

This is polymorphism without inheritance. Task, Case, and Appointment have no common parent class. Yet they all work with sorted() because they all implement __lt__(). This is the power of protocol-based polymorphism.


Practical Example: Task Management Operations

Let's see Task dunder methods in action with realistic operations:

Loading Python environment...

Output:

Tasks by priority:
[○] Fix bug
[○] Review PR
[○] Refactor code
[○] Update docs

Unique tasks: 2

Task status:
Task(title='Fix bug', priority=1, done=False) -> pending
Task(title='Review PR', priority=2, done=False) -> pending
Task(title='Refactor code', priority=3, done=False) -> pending
Task(title='Update docs', priority=5, done=False) -> pending

Common Patterns and Best Practices

Pattern 1: Type Checking in Dunder Methods

Always check types before operating on other:

Loading Python environment...

Return NotImplemented (not False) when you don't know how to handle the operation. This lets Python try the reverse operation on the other object.

Pattern 2: Hash Consistency

If you implement __eq__(), implement __hash__() to match:

Loading Python environment...

Objects that compare equal must have the same hash. Breaking this rule causes cryptic bugs with sets and dictionaries.

Pattern 3: repr Should Be Valid Python

Ideally, repr() output can be evaluated to recreate the object:

Loading Python environment...


Try With AI

Setup: Create a Task class and explore how dunder methods enable polymorphic behavior.

Prompt 1: Discovering String Representation

"Create a Task class with init(title, priority). Implement both str() and repr(). Show me:

  1. What print(task) returns (should call str)
  2. What task in the shell returns (should call repr)
  3. When would a user see str output vs repr output?
  4. Why does repr show all the details but str shows a clean format?"

What you're learning: The difference between user-facing and developer-facing output, and when each is appropriate.

Prompt 2: Making Tasks Sortable

"Implement __eq__() and __lt__() for Task based on priority. Show me:

  1. How tasks = [Task('Fix bug', 3), Task('Update docs', 5)]; sorted(tasks) works
  2. What Python does internally when it calls sorted() - trace through lt
  3. How eq() determines if two tasks are the same
  4. Design choice: should eq compare by title or priority? Why?"

What you're learning: How comparison dunder methods enable sorting without inheritance, and the design choices involved.

Prompt 3: Using Tasks in Collections

"Implement __hash__() for Task and show:

  1. How task_set = {Task('Fix bug'), Task('Fix bug')} removes duplicates
  2. Why __hash__ must match __eq__ (use the same field)
  3. How tasks work as dictionary keys: task_deadlines = {task: '2025-01-15'}
  4. What breaks if you implement eq but forget hash?"

What you're learning: The contract between __hash__ and __eq__, and why breaking it causes mysterious bugs.

Prompt 4: Polymorphism Across Domains

"Create three unrelated classes (Task, Case, Appointment) - NO inheritance. Each has different attributes but all implement __lt__() using different priority fields:

  1. Task sorts by priority (1 is high, 10 is low)
  2. Case sorts by urgency (same scale)
  3. Appointment sorts by importance (same scale)

Now show me one sorted() call that works on a mixed list of all three types. Explain why this works WITHOUT a common parent class. This is polymorphism through protocol."

What you're learning: The core insight—dunder methods create implicit contracts that multiple unrelated classes can follow, enabling polymorphism without inheritance.


Summary

Dunder methods are Python's protocol definitions. By implementing __str__(), __repr__(), __eq__(), __lt__(), and __hash__(), you teach Python how to work with your objects using built-in operations and functions.

Key takeaways:

  • __str__(): User-friendly output for print()
  • __repr__(): Developer-friendly output for debugging
  • __eq__(): Define equality for == comparisons
  • __lt__(): Define ordering for sorted()
  • __hash__(): Enable use in sets and as dictionary keys

Most importantly: Dunder methods enable polymorphism across unrelated classes. Task, Case, and Appointment have nothing in common—no inheritance, no ABC—yet they all work with sorted() because they all implement __lt__(). This is the power of protocol-based design.

In the next lesson, you'll explore even more dunder methods (__add__, __len__, __getitem__, __iter__, __call__) that enable operators, container behavior, and callable objects.