Python Dunder Methods

Master Python's Magic Methods - The Power Behind Custom Classes

Dunder methods (double underscore methods) allow you to define how objects of your classes behave with built-in Python operations like addition, comparison, iteration, and more.

What Are Dunder Methods?

Cheatsheet

Definition

Dunder methods (short for "double underscore") are special methods in Python that have double underscores at the beginning and end of their names. They're also called "magic methods" or "special methods."

Why "Dunder"? It's a contraction of "Double UNDERscore" - __init__ is pronounced "dunder init".

Key Characteristics

  • Automatically invoked by Python in specific situations
  • Enable operator overloading (+, -, *, /, etc.)
  • Make custom classes behave like built-in types
  • Implement context managers, iteration, and more

Basic Dunder Method Example

class Student:
    def __init__(self, name, age):
        # __init__ is called when creating a new instance
        self.name = name
        self.age = age
    
    def __str__(self):
        # __str__ provides a readable string representation
        return f"Student: {self.name}, Age: {self.age}"
    
    def __repr__(self):
        # __repr__ provides an official string representation
        return f"Student('{self.name}', {self.age})"

# Usage
student = Student("Alice", 21)
print(student)          # Calls __str__: "Student: Alice, Age: 21"
print(repr(student))   # Calls __repr__: "Student('Alice', 21)"

Dunder Method Categories

Initialization & Construction

Methods for object creation and initialization

  • __init__
  • __new__
  • __del__

String Representation

Methods for converting objects to strings

  • __str__
  • __repr__
  • __format__

Arithmetic Operations

Methods for mathematical operations

  • __add__
  • __sub__
  • __mul__

Comparison Operations

Methods for comparing objects

  • __eq__
  • __lt__
  • __gt__

Common Dunder Methods with W3Schools References

Category Method Purpose W3Schools Reference
Initialization __init__ Object constructor, initializes new instances View
Initialization __new__ Creates and returns a new instance (called before __init__) View
String __str__ Returns readable string representation for users View
String __repr__ Returns official string representation for developers View
Arithmetic __add__ Implements addition (+) operator View
Arithmetic __sub__ Implements subtraction (-) operator View
Comparison __eq__ Implements equality (==) operator View
Comparison __lt__ Implements less than (<) operator View
__init__

Constructor Method

Purpose

The __init__ method is the constructor of a class. It's automatically called when a new instance of the class is created. This method initializes the object's attributes.

When Called

  • When creating a new object: obj = MyClass()
  • Before any other method is called on the new instance
  • Only once per object creation

Example Code

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
        self.current_page = 1
    
    def read(self):
        print(f"Reading {self.title} by {self.author}")

# __init__ is called automatically here
book1 = Book("Python Basics", "John Doe", 300)
book2 = Book("Advanced Python", "Jane Smith", 500)

print(book1.title)  # Output: Python Basics
print(book2.pages)  # Output: 500

Use Cases

  • Setting initial values for object attributes
  • Validating input parameters
  • Establishing connections (database, network)
  • Loading configuration or initial data
__str__

String Representation Methods

__repr__

__str__ vs __repr__

__str__ (For Users)

Should return a readable, nicely formatted string intended for end users. Called by str() and print() functions.

__repr__ (For Developers)

Should return an unambiguous, official string representation that could be used to recreate the object. Called by repr() function.

Golden Rule

__repr__ is for developers, __str__ is for users. If you only implement one, implement __repr__ (__str__ will fall back to __repr__).

Example Code

class Product:
    def __init__(self, name, price, category):
        self.name = name
        self.price = price
        self.category = category
    
    def __str__(self):
        # User-friendly representation
        return f"{self.name} - ${self.price} ({self.category})"
    
    def __repr__(self):
        # Developer representation (can recreate object)
        return f"Product('{self.name}', {self.price}, '{self.category}')"

# Create product instance
product = Product("Laptop", 999.99, "Electronics")

# Different outputs
print(str(product))    # Output: Laptop - $999.99 (Electronics)
print(repr(product))   # Output: Product('Laptop', 999.99, 'Electronics')
print(product)        # Calls __str__: Laptop - $999.99 (Electronics)

Practical Tips

  • Always implement __repr__ for debugging purposes
  • __str__ should be readable and user-friendly
  • __repr__ should ideally allow object recreation with eval()
  • In interactive Python shell, objects show their __repr__
__add__

Addition Operator Overloading

Purpose

The __add__ method allows you to define what the + operator does when used with instances of your class. This is called operator overloading.

How It Works

When Python encounters obj1 + obj2, it calls obj1.__add__(obj2). The method should return a new object representing the result of the addition.

Related Methods

__sub__ __mul__ __truediv__ __floordiv__ __mod__

Example Code

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        # Define vector addition
        if isinstance(other, Vector):
            # Add corresponding components
            return Vector(self.x + other.x, self.y + other.y)
        elif isinstance(other, (int, float)):
            # Scalar addition (add to both components)
            return Vector(self.x + other, self.y + other)
        else:
            raise TypeError("Can only add Vector or number to Vector")
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"

# Using the + operator with Vector objects
v1 = Vector(2, 3)
v2 = Vector(4, 5)

# Vector addition
v3 = v1 + v2
print(v3)  # Output: Vector(6, 8)

# Scalar addition
v4 = v1 + 10
print(v4)  # Output: Vector(12, 13)

# This would raise TypeError
# v5 = v1 + "hello"

Common Use Cases

  • Mathematical objects (vectors, matrices, complex numbers)
  • Money/currency objects (adding amounts)
  • Collection objects (combining lists, sets)
  • Time/duration objects
  • Custom data structures

Comprehensive Example: Bank Account Class

Real-World Implementation

A BankAccount class using multiple dunder methods

class BankAccount:
    def __init__(self, account_holder, balance=0):
        # Initialize account with holder name and balance
        self.account_holder = account_holder
        self.balance = balance
        self.transaction_history = []
    
    def __str__(self):
        # User-friendly string representation
        return f"Bank Account: {self.account_holder}, Balance: ${self.balance:.2f}"
    
    def __repr__(self):
        # Developer representation
        return f"BankAccount('{self.account_holder}', {self.balance})"
    
    def __add__(self, other):
        # Combine two accounts (e.g., joint account)
        if isinstance(other, BankAccount):
            new_account = BankAccount(
                f"{self.account_holder} & {other.account_holder}",
                self.balance + other.balance
            )
            return new_account
        elif isinstance(other, (int, float)):
            # Deposit money
            return BankAccount(self.account_holder, self.balance + other)
        else:
            raise TypeError("Can only add BankAccount or number")
    
    def __eq__(self, other):
        # Compare accounts by balance
        if isinstance(other, BankAccount):
            return self.balance == other.balance
        return False
    
    def __lt__(self, other):
        # Compare if balance is less than another account
        if isinstance(other, BankAccount):
            return self.balance < other.balance
        return NotImplemented
    
    def __len__(self):
        # Return number of transactions
        return len(self.transaction_history)
    
    def __contains__(self, transaction):
        # Check if transaction is in history
        return transaction in self.transaction_history
    
    def deposit(self, amount):
        self.balance += amount
        self.transaction_history.append(f"Deposit: +${amount}")
        return f"Deposited ${amount}"
    
    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance -= amount
            self.transaction_history.append(f"Withdrawal: -${amount}")
            return f"Withdrew ${amount}"
        else:
            return "Insufficient funds"

# Demonstration of all dunder methods
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 2000)

print(str(account1))        # Calls __str__
print(repr(account2))       # Calls __repr__

# Using operators
joint_account = account1 + account2   # Calls __add__
print(str(joint_account))   # Bank Account: Alice & Bob, Balance: $3000.00

print(account1 == account2)           # Calls __eq__, False
print(account1 < account2)            # Calls __lt__, True

# Using other dunder methods
account1.deposit(500)
account1.withdraw(200)
print(len(account1))           # Calls __len__, 2
print("Deposit: +$500" in account1)  # Calls __contains__, True

Initialization

__init__ sets up account holder and initial balance

String Representation

__str__ and __repr__ provide different string formats

Operator Overloading

__add__, __eq__, __lt__ enable intuitive operations