Magic or dunder(double underscore) methods are special pre-defined methods that allow us to use operators, builtin functions and other features in a special way on our custom objects.

One defining features of these methods is that they start and end with two underscores. The most obvious one and which you have most likely interacted with is the __init__() method which is called when objects are being instantiated.

By implementing dunder methods, we can make our custom objects behave similar to the built-in types. 

Note: The dunder methods are not just normal methods, they are called magic methods for a reason. Python  recognizes them and knows exactly what special task each one of them performs . 

In these article we will look at various dunder methods,  their tasks and how we can incorporate them in our custom classes.

Dunder methods are everywhere

Have you ever wondered what happens when you use some standard operators or call some bulitin functions such as len(), str(), print(), etc?. In reality Python actually calls a corresponding dunder method. This allows Python to provide a consistent and predictable interface no matter what type of object is being worked with.

Consider the following examples:

#create a list
l = [1, 2, 3, 4, 5]

#print l 
print(l)
print(l.__str__())

#print the length of list l
print(len(l))
print(l.__len__())

What is going on in the above example is that when you call the print() function with an object, python actually calls the __str__()  method of that object. In the case with len(), python calls the __len__() method.

various dunder methods

The following table summarizes some common dunder methods and their usage.

method description
__init__ Initializes class objects.
__str__ Returns a string representation of an object. It is called when we are call the builtin str function as well as the print() function in some cases.
__eq__ Checks whether two objects are equal. It is called when we use the equality(==) operator.
__ne__ Checks whether two objects are not equal. It is called when the not equal (!=) operator is called.
__add__ Adds/concatenates two objects together. It is called when we call the addition(+) operator.
__del__ Deletes an object from memory. It is called when we use the del statement. 
__call__ Enables on object to be called like a function. It is called when an objects is being called i.e using parenthesis.
__repr__ Returns the 'official' string representation of an object.
__bool__ Returns the boolean value of an object. It is called when we call the bool() function on an object.

Note: The above list is far from exhaustive, there are many more dunder methods not included.

Dunder method with custom objects

The dunder methods allows custom objects to define how the object will behave with standard operators, builtin  functions etc.

Let us now see the magic with custom objects.

Basic Example

The  __repr__() method is called by the print() function. The following example shows how we can define this method on our custom object.

#define the class to demonstrate the __repr__ dunder method. This method is called by the print function.
class Example:
   #define the __repr__ dunder method
   def __repr__(self):
       return "I am an example object"

e = Example()
print(e)
A stack data structure

In this part we will create a class for a stack data structure to demonstrate the various magic methods.  In a stack data structure objects are stored in a specific order so that the most recently added object is the first one to be removed. It is also known as a Last In First Out (LIFO) data structure. It is an analogous to a  stack of plates where the last plate added is the first one to be removed.

The methods relevant to a stack are:

Push Adds elements onto the top of the stack.
Pop Removes the top element from the stack.
Top Returns the top element from the stack without removing it.
isEmpty Checks if the stack is empty or not
clear Removes all items from the stack
#define the Stack class
class Stack:
 
   #define some dunder methods
   def __init__(self):
       #define some private attribute
       self._data = []
       self._size = 0
   
   def __len__(self):
       return self._size

   def __str__(self):
       return("Stack(%s)"%self._data)

   def __repr__(self):
       return("Stack(%s)"%self._data)
   
   def __bool__(self):
        return bool(self.is_empty())
    
   #Define the methods relevant to a stack data type
   def push(self, v):
      self._size += 1
      self._data.append(v)
   
   def pop(self):
       if self.is_empty():
           raise(Exception("Stack Is Empty!"))
       self._size -= 1
       return self._data.pop()
 
   def top(self):
       return self._data[-1]

   def is_empty(self):
       return self._size == 0

   def clear(self):
       self._data.clear()
       self.size = 0


#Create a stack object
S = Stack()

#push items to the stack
S.push(10)
S.push(20)
S.push(30)

#print the stack
print(S)

#Add more data to the stack
S.push(100)
S.push(200)
S.push(300)
S.push(400)
print(S)
print(len(S))

S.pop()
S.pop()
print(S)

print(len(S))


#clear the stack
S.clear()
print(S)
print(len(S))
print(bool(S))
Make the stack support  relevant standard operators

We can make the stack that we defined above support Python's standard operators such as + to concatenate two stack and == to tell whether to stacks are equal.

#define the Stack class
class Stack:
 
   #define some dunder methods
   def __init__(self):
       #define some private attribute
       self._data = []
       self._size = 0
   
   def __len__(self):
       return self._size

   def __str__(self):
       return("Stack(%s)"%self._data)

   def __repr__(self):
       return("Stack(%s)"%self._data)
   
   def __bool__(self):
        return bool(self.is_empty())
   
   #Add the corresponding dunder methods for the relevant operators
   def __eq__(self, s):
       """This returns true if the current stack is equal to stack s, else False"""
       return self._data == s._data
   
   def __ne__(self, s):
        """This returns true if the current stack is NOT EQUAL to stack s, else False"""
        return self._data != s._data

   def __add__(self, s):
       """"Returns a new stack with items of the current stack combined with those of stack s"""
       new_stack = Stack()
       for i in self._data:
           new_stack.push(i)

       for i in s._data:
          new_stack.push(i)

       return new_stack

   #Define the methods relevant to a stack data type
   def push(self, v):
      self._size += 1
      self._data.append(v)
   
   def pop(self):
       if self.is_empty():
           raise(Exception("Stack Is Empty!"))
       self._size -= 1
       return self._data.pop()
 
   def top(self):
       return self._data[-1]

   def is_empty(self):
       return self._size == 0

   def clear(self):
       self._data.clear()
       self.size = 0


S1 = Stack()
S2 = Stack()
S3 = Stack()

S1.push(10)
S1.push(20)

S2.push(100)
S2.push(200)

S3.push(10)
S3.push(20)

print(S1 == S2) #Check whether S1 is equal to S2
print(S1 == S3)  #Check whether S1 is equal to S3

#add S1 and S2
S4 = S1 + S2
print(S4)
print(len(S4))