4.5. Magic Methods (Dunder Methods) in Python (Optional Section)#

Magic methods, formally known as dunder methods (a contraction of “double underscore” methods), hold a special place in Python’s object-oriented programming paradigm. These methods are characterized by their names being enclosed in double underscores both at the beginning and end of the method name. They play a crucial role in defining how instances of a class behave when subjected to various operations or functions. By leveraging magic methods, you can finely tailor the behavior of built-in functions, operators, and actions for user-defined objects. This customization empowers you to create objects that are not only powerful but also intuitive and user-friendly [Sweigart, 2020].

Let’s delve into an exploration of the key roles of magic methods, accompanied by illustrative examples:

4.5.1. Initialization Methods#

The __init__ magic method takes center stage when an object is instantiated. It is automatically invoked and serves as the constructor, responsible for initializing the object’s attributes and defining its initial state. In contrast, the __new__ method precedes __init__ and is responsible for constructing the instance itself.

class MyClass:
    def __new__(cls, *args, **kwargs):
        # The __new__ method is responsible for creating a new instance of the class.
        instance = super().__new__(cls)
        # Custom instance creation logic can be implemented here.
        return instance
    
    def __init__(self, value):
        # The __init__ method initializes the created instance with a 'value' attribute.
        self.value = value
        # Other initialization code can be placed here.

obj = MyClass(101)  # Creating an instance of MyClass with the value 101.

In this example, __new__ allocates memory for the instance, and __init__ initializes its attributes. This two-step process ensures proper object creation.

4.5.2. String Representations#

The __str__ method assumes the role of furnishing a human-friendly string representation of an object, intended for consumption by end-users. On the other hand, the __repr__ method undertakes the task of yielding an unequivocal and comprehensive string depiction of the object, primarily employed for debugging and developmental endeavors.

4.5.2.1. Human-Friendly Representation#

The __str__ method in Python is used to define a human-friendly representation of an object. It returns a string that represents the object in a way that is easy for humans to understand. This method is often implemented in classes to provide a textual representation of an object when it is printed or converted to a string using functions like str().

Here are a few reasons why __str__ is considered a human-friendly representation [Pankaj and Anderson, 2023]:

  1. Readability: The primary purpose of __str__ is to create a string that is easy to read and understand for humans. It should provide relevant information about the object’s state or characteristics.

  2. Debugging: When debugging code, it is helpful to have a clear and informative string representation of objects. This can assist developers in quickly identifying issues or understanding the state of objects in the program.

  3. User-Friendly Output: In applications that interact with users, a human-friendly representation makes it easier to display information to users in a format they can comprehend. This is especially important for user interfaces and error messages.

  4. Logging: When logging events or data, a human-readable string representation of objects can make log files more useful and easier to analyze.

Here’s a simple example of how the __str__ method can be implemented in a class:

class MyClass:
    def __str__(self):
        # The __str__ method defines a human-friendly string representation for the class.
        return "Hello Calgary!"  # Define a human-friendly string representation

obj = MyClass()  # Create an instance of MyClass
print(obj)  # Output: Hello Calgary!  # Display the string representation of obj
Hello Calgary!

In this code, the __str__ method is implemented in the MyClass class to provide a human-friendly string representation, and then an instance of MyClass is created and printed to display the string representation, which is “Hello Calgary!”.

4.5.2.2. Unambiguous Debugging Aid#

The __repr__ method in Python is used to define an unambiguous debugging aid. It returns a string that represents an object in a way that is primarily intended for developers and debugging purposes. Unlike __str__, which provides a human-friendly representation, __repr__ is meant to produce a representation that is as unambiguous as possible, making it useful for debugging and understanding the state of objects in a program. Here are some reasons why __repr__ is considered an unambiguous debugging aid [Pankaj and Anderson, 2023, Python Software Foundation, 2023]:

  1. Debugging Assistance: When you’re debugging code, you often need detailed information about objects, including their internal state. The __repr__ method is designed to provide this information in a clear and unambiguous format.

  2. Uniqueness: The __repr__ method should ideally return a string that, when passed to the Python interpreter, would create an object with the same state. This means the representation is unambiguous and unique to the object, aiding in debugging by allowing you to recreate objects easily.

  3. Development and Testing: During development and testing phases, developers can use the __repr__ representation to quickly inspect the state of objects and verify that they are behaving as expected.

  4. Logging and Error Messages: __repr__ is often used to provide detailed information in log messages or error messages, helping developers pinpoint issues more effectively.

Here’s a simple example of how the __repr__ method can be implemented in a class:

class Point:
    def __init__(self, x, y):
        # Initialize a Point object with x and y coordinates.
        self.x = x
        self.y = y
    
    def __repr__(self):
        # Define a clear and unambiguous representation of a Point object.
        return f"Point(x={self.x}, y={self.y})"

p = Point(3, 5)  # Create a Point object with coordinates (3, 5).
print(repr(p))  # Output: Point(x=3, y=5)  # Display the __repr__ representation of the Point object.
Point(x=3, y=5)

In this code, the Point class is defined to represent points in a two-dimensional space. The __init__ method initializes a Point object with given x and y coordinates, and the __repr__ method provides an unambiguous representation of the object’s state, including its x and y values. Finally, an instance of Point is created with coordinates (3, 5), and its __repr__ representation is printed.

4.5.3. Controlling Length Evaluation#

The __len__ magic method empowers you to dictate the behavior of the built-in len() function when it’s invoked on an object. By implementing this method within a class, you can customize how the length of that object is determined. This becomes particularly useful when dealing with user-defined collection classes, as it enables you to specify how many items the collection contains [Mayer, 2023].

Consider a scenario where you’ve designed a custom collection class:

class MyCollection:
    def __init__(self, items):
        # Initialize a MyCollection object with a list of items.
        self.items = items
    
    def __len__(self):
        # Define the custom behavior for len() when applied to a MyCollection object.
        return len(self.items)

my_collection = MyCollection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])  # Create a MyCollection with a list of 10 items.
length = len(my_collection)  # Calls my_collection.__len__() to determine the length.
print(length)  # Output: 10  # Display the length of the MyCollection object.
10

In this code, the MyCollection class is defined to represent a collection of items. The __init__ method initializes a MyCollection object with a list of items, and the __len__ method is implemented to customize the behavior of the len() function when applied to a MyCollection object. Finally, an instance of MyCollection is created with a list of 10 items, and its length is determined using len(), which returns 10.

4.5.4. Custom Indexing and Assignment#

The dynamic duo of __getitem__ and __setitem__ bestow upon you the power to redefine how an object reacts when accessed using square brackets, as in obj[key]. This dynamic duo allows you to craft bespoke indexing and item assignment behaviors for your objects, propelling your code’s flexibility and elegance [Martelli, 2003, Mertz, 2003].

4.5.4.1. Creating Custom Indexing#

The __getitem__ magic method stands as your canvas to create a personalized indexing mechanism for your objects. By implementing this method, you enable your objects to respond in a custom fashion when accessed with square brackets and an index [Martelli, 2003, Mertz, 2003].

class CustomList:
    def __init__(self, items):
        self.items = items  # Initialize with the provided items
    
    def __getitem__(self, index):
        return self.items[index]  # Retrieve and return item at the specified index

my_list = CustomList([101, 102, 1030, 104, 105])  # Create an instance of CustomList
print(my_list[2])  # Output: 1030  # Display the item at index 2
1030

In this example, the CustomList class features the __getitem__ method, which returns the item at the specified index. This enables instances of CustomList to be indexed and accessed just like regular lists.

4.5.4.2. Creating Custom Item Assignment#

The __setitem__ magic method complements __getitem__ by enabling custom handling of item assignment using square brackets [Martelli, 2003, Mertz, 2003].

class CustomDict:
    def __init__(self):
        self.data = {}
    
    def __setitem__(self, key, value):
        self.data[key] = value

my_dict = CustomDict()
my_dict['name'] = 'Alice'
print(my_dict.data)  # Output: {'name': 'Alice'}
{'name': 'Alice'}

In this example, the CustomDict class employs __setitem__ to allow assignment of key-value pairs. When an item is assigned using square brackets, the __setitem__ method takes charge and inserts the key-value pair into the object’s data dictionary.

4.5.5. Iteration Support#

The dynamic duo of __iter__ and __next__ play a pivotal role in shaping the iteration behavior of an object. Together, they empower you to craft objects that seamlessly participate in Python’s iteration constructs, such as for loops, making your code more elegant and expressive [Martelli, 2003].

4.5.5.1. Creating an Iterator#

The __iter__ magic method is your gateway to creating an iterator for an object. When this method is defined within a class, it returns an iterator object, allowing the class to become iterable. The returned iterator is expected to have a __next__ method [Martelli, 2003].

class MyRange:
    def __init__(self, start, end):
        # Initialize a MyRange object with a start and end value.
        self.start = start
        self.end = end
    
    def __iter__(self):
        # Define the custom iterator by returning the instance itself.
        return self
    
    def __next__(self):
        # Define the behavior for generating the next value in the range.
        if self.start >= self.end:
            raise StopIteration  # Raise StopIteration to signal the end of iteration
        result = self.start
        self.start += 1
        return result

my_range = MyRange(1, 5)  # Create an instance of MyRange with a start of 1 and an end of 5.
for num in my_range:  # Iterate over the instance using the custom iterator.
    print(num)  # Output: 1 2 3
1
2
3
4

In this code, the MyRange class is defined to represent a custom range-like object. The __init__ method initializes a MyRange object with a start and end value. The __iter__ method is implemented to return the instance itself as the iterator, and the __next__ method defines the behavior for generating the next value in the range. When the range is iterated over, it produces values from the start to the end (exclusive) using the custom iterator, resulting in the output “1 2 3”.

4.5.5.2. Advancing Iteration#

The __next__ method, part of the iterator object, defines how the next item is retrieved during each iteration cycle. It should either return the next item or raise a StopIteration exception to signal the end of the iteration.

By combining __iter__ and __next__, you enable objects of your class to seamlessly participate in iteration, enhancing code readability and reusability.

class Countdown:
    def __init__(self, start):
        self.start = start
    
    def __iter__(self):
        return self  # Return the instance itself as the iterator
    
    def __next__(self):
        if self.start < 0:
            raise StopIteration  # Raise exception to signal end of iteration
        result = self.start
        self.start -= 1
        return result

countdown_iterator = Countdown(5)  # Create an instance of Countdown
for number in countdown_iterator:  # Iterate over the instance using custom iterator
    print(number)  # Output: 5 4 3 2 1 0
5
4
3
2
1
0

In this example, the Countdown class represents an iterable that counts down from a given starting number. The __next__ method decreases the start value in each iteration, and when it becomes negative, a StopIteration exception is raised to signal the end of the iteration.

By combining the __iter__ and __next__ methods, the countdown_iterator object becomes iterable, allowing it to be used in a for loop. The loop iterates over the countdown, printing the numbers 5 through 0, and then “Blastoff!” is printed after the iteration completes.

4.5.6. Membership Testing#

The __contains__ magic method wields the power to ascertain whether a particular value is part of an object. This method comes into play when the in keyword is employed to examine membership within the object [Mayer, 2023].

class CustomContainer:
    def __init__(self, items):
        self.items = items  # Initialize the container with the provided items
    
    def __contains__(self, value):
        return value in self.items  # Check if the value is in the items collection

my_container = CustomContainer([1, 3, 5, 7])  # Create an instance of CustomContainer
print(3 in my_container)  # Output: True  # Check if 3 is in the container
print(6 in my_container)  # Output: False  # Check if 6 is in the container
True
False

In this depiction, the CustomContainer class is endowed with the __contains__ method. When the in keyword is employed to inspect membership within an instance of this class, the __contains__ method is engaged to determine whether the specified value exists in the items collection.

Through the judicious application of the __contains__ method, you elevate your objects to support membership testing with the in keyword.

4.5.7. Arithmetic Magic Methods#

The realm of arithmetic magic methods, encompassing __add__, __sub__, __mul__, and their kin, offers you a realm of control over the behavior of arithmetic operators such as +, -, *, and more when they’re applied to objects. By shaping these methods, you can bestow upon your custom classes the ability to partake in arithmetic operations in a manner that aligns with your vision [Martelli, 2003].

4.5.7.1. Creating Addition Logic#

The __add__ magic method grants you the capacity to engineer the logic for addition when instances of your class are combined using the + operator.

class CustomNumber:
    def __init__(self, value):
        self.value = value  # Initialize the instance with the provided value
    
    def __add__(self, other):
        if isinstance(other, CustomNumber):  # Check if 'other' is also a CustomNumber instance
            return CustomNumber(self.value + other.value)  # Perform addition and create a new instance
        raise TypeError("Unsupported operand type for +")  # Raise error if unsupported operand is used

num1 = CustomNumber(3)  # Create an instance of CustomNumber
num2 = CustomNumber(6)  # Create another instance of CustomNumber
result = num1 + num2  # Perform addition using the __add__ method
print(result.value)  # Output: 9  # Display the result of the addition
9

In this representation, the CustomNumber class exhibits the __add__ method, permitting instances to harmonize in addition. This enables you to perform addition operations using the + operator while also accommodating custom behavior.

4.5.7.2. __sub__, __mul__, and Beyond#

The concepts of __sub__, __mul__, and analogous magic methods seamlessly extend from the realm of __add__. These methods, when thoughtfully crafted, grant your objects the potential to engage in subtraction, multiplication, and other arithmetic operations.

By mastering these arithmetic magic methods, you endow your custom classes with the capacity to participate in arithmetic harmonies, elevating code functionality and coherence.

class CustomNumber:
    def __init__(self, value):
        self.value = value  # Initialize the instance with the provided value
    
    def __add__(self, other):
        if isinstance(other, CustomNumber):  # Check if 'other' is also a CustomNumber instance
            return CustomNumber(self.value + other.value)  # Perform addition and create a new instance
        raise TypeError("Unsupported operand type for +")  # Raise error if unsupported operand is used
    
    def __sub__(self, other):
        if isinstance(other, CustomNumber):  # Check if 'other' is also a CustomNumber instance
            return CustomNumber(self.value - other.value)  # Perform subtraction and create a new instance
        raise TypeError("Unsupported operand type for -")  # Raise error if unsupported operand is used
    
    def __mul__(self, other):
        if isinstance(other, CustomNumber):  # Check if 'other' is also a CustomNumber instance
            return CustomNumber(self.value * other.value)  # Perform multiplication and create a new instance
        raise TypeError("Unsupported operand type for *")  # Raise error if unsupported operand is used

num1 = CustomNumber(10)  # Create an instance of CustomNumber
num2 = CustomNumber(5)   # Create another instance of CustomNumber

# Addition
result_add = num1 + num2  # Perform addition using the __add__ method
print(result_add.value)   # Output: 15  # Display the result of the addition

# Subtraction
result_sub = num1 - num2  # Perform subtraction using the __sub__ method
print(result_sub.value)   # Output: 5   # Display the result of the subtraction

# Multiplication
result_mul = num1 * num2  # Perform multiplication using the __mul__ method
print(result_mul.value)   # Output: 50  # Display the result of the multiplication
15
5
50

4.5.8. Comparison Magic Methods#

The world of comparison magic methods, encompassing __eq__, __ne__, __lt__, __gt__, and their ilk, bestows upon you the authority to redefine how objects interact during comparisons. These methods come into play when you employ comparison operators like ==, !=, <, >, and more. By crafting these methods, you lay the foundation for nuanced and custom comparison behaviors that align with your intentions.

4.5.8.1. Creating Equality Checks#

The __eq__ magic method allows you to shape the logic for checking equality between instances of your class. It is triggered when the == operator is used to compare objects.

class Person:
    def __init__(self, name, age):
        self.name = name  # Initialize instance with provided name
        self.age = age    # Initialize instance with provided age
    
    def __eq__(self, other):
        if isinstance(other, Person):  # Check if 'other' is also a Person instance
            return self.name == other.name and self.age == other.age  # Compare name and age attributes
        return False  # Return False if 'other' is not a Person instance

person1 = Person("Alice", 25)  # Create an instance of Person
person2 = Person("Alice", 25)  # Create another instance of Person
person3 = Person("Bob", 30)    # Create yet another instance of Person

print(person1 == person2)  # Output: True   # Compare person1 and person2 for equality
print(person1 == person3)  # Output: False  # Compare person1 and person3 for equality
True
False

In this example, the Person class is equipped with the __eq__ method to enable instances to be compared for equality using the == operator. The method evaluates the equality of both the name and age attributes.

4.5.8.2. Creating Non-Equality Check#

The landscape of comparison magic methods extends beyond __eq__, encompassing __ne__ (not equal), __lt__ (less than), __gt__ (greater than), and more. With thoughtful implementation, these methods grant your objects the ability to engage in diverse and custom comparison scenarios [van Hattem, 2022].

class Temperature:
    def __init__(self, value):
        self.value = value  # Initialize the instance with the provided value
    
    def __eq__(self, other):
        if isinstance(other, Temperature):  # Check if 'other' is also a Temperature instance
            return self.value == other.value  # Compare values for equality
        return False  # Return False if 'other' is not a Temperature instance
    
    def __ne__(self, other):
        if isinstance(other, Temperature):  # Check if 'other' is also a Temperature instance
            return self.value != other.value  # Compare values for inequality
        return True  # Return True if 'other' is not a Temperature instance
    
    def __lt__(self, other):
        if isinstance(other, Temperature):  # Check if 'other' is also a Temperature instance
            return self.value < other.value  # Compare values for less than
        raise TypeError("Unsupported operand type for <")  # Raise error for unsupported operand types
    
    def __gt__(self, other):
        if isinstance(other, Temperature):  # Check if 'other' is also a Temperature instance
            return self.value > other.value  # Compare values for greater than
        raise TypeError("Unsupported operand type for >")  # Raise error for unsupported operand types

temp1 = Temperature(25)  # Create an instance of Temperature
temp2 = Temperature(30)  # Create another instance of Temperature

# Equality
print(temp1 == temp2)  # Output: False  # Compare temp1 and temp2 for equality

# Inequality
print(temp1 != temp2)  # Output: True   # Compare temp1 and temp2 for inequality

# Less than
print(temp1 < temp2)   # Output: True   # Compare temp1 and temp2 for less than

# Greater than
print(temp1 > temp2)   # Output: False  # Compare temp1 and temp2 for greater than
False
True
True
False

4.5.9. Object Invocation#

The illustrious __call__ magic method extends to you the power to bestow upon your objects the ability to be invoked as if they were functions. This method takes the lead when your object is followed by parentheses, simulating a function call and enabling you to craft customized behavior for this scenario [Ramos, 2023].

class CallableCounter:
    def __init__(self):
        self.count = 0  # Initialize count to 0
    
    def __call__(self):
        self.count += 1  # Increment count with each call
        return self.count  # Return the updated count

counter = CallableCounter()  # Create an instance of CallableCounter
print(counter())  # Output: 1  # Invoke the instance as a function
print(counter())  # Output: 2  # Invoke the instance as a function again
1
2

In this example, the CallableCounter class is equipped with the __call__ method. This method orchestrates the behavior of the object when it’s invoked as a function. With each invocation, the counter increments, demonstrating how you can wield the __call__ method to provide function-like behavior to your objects.

4.5.10. Context Management#

In Python, __enter__ and __exit__ methods are essential components of context managers. They enable you to specify setup and teardown actions when an object is utilized within a with statement [Ramalho, 2022].

import time

class Timer:
    def __enter__(self):
        # Enter the context: Record the start time when entering the 'with' block.
        self.start_time = time.time()
        return self
    
    def __exit__(self, exc_type, exc_value, traceback):
        # Exit the context: Calculate and print the elapsed time when exiting the 'with' block.
        self.end_time = time.time()
        elapsed_time = self.end_time - self.start_time
        print(f"Elapsed time: {elapsed_time:.4f} seconds")

# Using Timer as a context manager
with Timer() as timer:
    time.sleep(2)  # Simulate some time-consuming operation
Elapsed time: 2.0002 seconds

In this code, the Timer class is designed to be used as a context manager. The __enter__ method records the start time when entering the with block, and the __exit__ method calculates and prints the elapsed time when exiting the with block. When the with block is entered, the timer starts, and when it exits, the elapsed time is displayed, providing a convenient way to measure the duration of operations within the with context.

4.5.11. Attribute Access#

In Python, __getattr__ and __setattr__ methods empower you to customize how attributes are accessed and assigned within an object. They offer fine-grained control over attribute access and modification [Fehily, 2002].

class ProtectedAttributes:
    def __init__(self):
        # Initialize a dictionary to store protected attributes.
        self._data = {}
    
    def __getattr__(self, name):
        # Define behavior when accessing an attribute.
        if name in self._data:
            return self._data[name]
        else:
            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
    
    def __setattr__(self, name, value):
        # Define behavior when assigning an attribute.
        if name == "_data":
            # Allow direct assignment to the _data attribute.
            super().__setattr__(name, value)
        else:
            self._data[name] = value

# Using the ProtectedAttributes class
obj = ProtectedAttributes()

# Assigning and accessing attributes
obj.name = "Alice"
obj.age = 30

print(obj.name)  # Output: Alice
print(obj.age)   # Output: 30

# Accessing a non-existent attribute
# print(obj.city)  # Raises AttributeError
Alice
30

In this code, the ProtectedAttributes class allows you to protect certain attributes from direct access and modification by encapsulating them within the _data dictionary. The __getattr__ method is used to define the behavior when accessing an attribute, raising an AttributeError if the attribute doesn’t exist. The __setattr__ method customizes the behavior when assigning an attribute, allowing direct assignment to _data but storing other attributes within it.

4.5.12. Object Deletion#

In Python, the __del__ method enables you to specify custom behavior that occurs when an object is explicitly deleted using the del statement or when its reference count drops to zero and it’s about to be destroyed by the garbage collector. This method allows you to perform cleanup or resource release actions associated with the object’s deletion [Kalb, 2022].

class ManagedObject:
    def __init__(self, name):
        # Initialize a ManagedObject with a name attribute.
        self.name = name
    
    def __del__(self):
        # Define behavior when the object is deleted.
        print(f"{self.name} is being deleted")

# Creating instances of ManagedObject
obj1 = ManagedObject("Object 1")
obj2 = ManagedObject("Object 2")

# Deleting the instances using the del statement
del obj1
del obj2
Object 1 is being deleted
Object 2 is being deleted

In this code, the ManagedObject class represents objects with a name attribute. The __del__ method is implemented to specify behavior when an object is deleted. When instances obj1 and obj2 are explicitly deleted using the del statement, the __del__ method is called for each instance, and a message indicating that the object is being deleted is printed to the console.

Magic Method

Purpose

Example

__init__ (__new__)

Initialize object attributes and state when created.

obj = MyClass(42)

__str__ (__repr__)

Provide human-readable and unambiguous string representations.

str(obj) / repr(obj)

__len__

Define behavior of len() function for object.

len(my_list)

__getitem__ and __setitem__

Customize indexing and item assignment behavior.

obj[key] / obj[key] = value

__iter__ and __next__

Define iteration behavior for loops.

for item in obj:

__contains__

Determine if specified value is present.

value in obj

__add__, __sub__, __mul__, etc.

Define behavior of arithmetic operators.

obj1 + obj2

__eq__, __ne__, __lt__, __gt__, etc.

Define custom comparison behavior.

obj1 == obj2

__call__

Allow object to be called like a function.

obj()

__enter__ and __exit__

Define context manager setup and teardown behavior.

with obj:

__getattr__ and __setattr__

Control attribute access and assignment.

obj.attribute / obj.attribute = value

__del__

Define behavior when object is deleted using del.

del obj