Python Intermediate: Dunder methods

"Dunder" is short for "double underscore," and it is commonly used in the context of Python programming.

Python Intermediate: Dunder methods

For more posts about Python, follow the link below.👇👇👇

Exploring Python
Python is frequently utilized in creating websites and software, as well as for automating tasks, analyzing data, and visualizing information.

In the previous post, we learned about objects and classes that are rooted in the object-oriented programming paradigm. 👇👇👇

Python Intermediate: Classes, objects, and inheritance
Building upon these foundational concepts, classes, and objects allow us to structure our code in a more organized and modular manner. They promote code reusability and help model real-world entities in a way that’s intuitive and maintainable.

In Python, these classes have magic "Dunder" methods. "Dunder" is a colloquial term used in programming to refer to "double underscore". These methods have names that start and end with double underscores, like __init__ what we already know. They are also known as "special methods" because they have special meaning in the Python language. Dunder methods allow us to define how objects of a class behave in various contexts and interact with built-in Python functions or operators.

💡
Dunder is short for double underscore and refers to magic methods in Python (like __init__ method).

__init__ method

The __init__ method is a special method in Python, often referred to as a constructor. The primary purpose of the method is to set up the initial state of an object. When we create a new instance of a class, Python automatically calls __init__ with the newly created object as the first argument (self) along with any additional arguments that we provide.

self is a convention (not a keyword) used to refer to the instance of the object being created. It allows us to access and modify the object's attributes within the __init__ method. We can name it differently, but it's a widely accepted convention to use self for clarity.

Inside the __init__ method, we typically define and initialize instance attributes (variables) that store the object's data. These attributes can have different data types, including numbers, strings, lists, or even other objects. These attributes define the object's properties.

The __init__ method can accept arguments other than self. These arguments allow us to pass initial values or configuration parameters to the object when we create it. We can use these arguments to customize the object's initial state.

Let’s take a look at the example of __init__ method from the previous post.

class Car:
    def __init__(self, make: str, speed: int):
        self.__make: str = make
        self.__speed: int = speed

    def drive(self):
        print(f'Driving {self.__make} at {self.__speed} km/h')


if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    car2 = Car(f'Audi', 140)

    car1.drive()
    car2.drive()

The __init__ method in the provided implementation of the Car class is responsible for initializing the attributes of each Car object when it's created. Let's break down the __init__ method and how it works:

def __init__(self, make: str, speed: int):
    self.__make: str = make
    self.__speed: int = speed

__init__(self, make: str, speed: int): This is the constructor method, denoted by __init__. It takes three parameters:

    • self: It represents the instance of the class, which is automatically passed when a new object is created. It's used to access and manipulate the object's attributes.
    • make: This parameter is a string that represents the make or brand of the car.
    • speed: This parameter is an integer that represents the speed of the car in kilometers per hour (km/h).

Inside the __init__ method, self.__make and self.__speed are instance attributes. The __ prefix indicates that these attributes are private and should not be accessed directly from outside the class. They are set to the values provided as arguments during object creation. These attributes store the make and speed of each car object.

Now, let's see how this __init__ method is used in the __main__ block:

if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    car2 = Car(f'Audi', 140)

In the __main__ block, two instances of the Car class are created:

  • car1 represents a Fiat car with a speed of 50 km/h.
  • car2 represents an Audi car with a speed of 140 km/h.

These objects are created by calling the Car constructor (__init__) with the specified make and speed values.

Finally, the drive method is called on each car object to print out a message about driving.

car1.drive()
car2.drive()

The drive method prints a message indicating the make and speed of each car. For example, if we call car1.drive(), it will print ”Driving Fiat at 50 km/h”, and if we call car2.drive(), it will print ”Driving Audi at 140 km/h”.

__str__ method

The __str__ method is a special method used to define a human-readable string representation of an object. It is part of Python's data model and allows us to provide a custom string representation for instances of our classes. This method is automatically called when we use the str() function on an object or when we try to print the object using print().

The __str__ method should have the following signature.

def __str__(self):
    # Return the string representation here

Inside the __str__ method, we construct and return a string that represents the object. This string can contain any relevant information about the object's state, attributes, or purpose. The __str__ method is often used for custom classes where we want to improve the clarity of object representations. Common use cases include:

  • providing a descriptive summary of an object's attributes,
  • creating formatted strings that include key information,
  • or hiding complex or internal details from users.

Let’s see how we can use the __str__ method in Car class.

class Car:
    def __init__(self, make: str, speed: int):
        self.__make: str = make
        self.__speed: int = speed

    def drive(self):
        print(f'Driving {self.__make} at {self.__speed} km/h')

    def __str__(self) -> str:
        return f'Make: {self.__make}, speed: {self.__speed} km/h'


if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    car2 = Car(f'Audi', 140)

    print(car1)
    print(car2)

In the above example, the __str__ method of the Car class returns a string that includes the car's make (or brand) and speed, providing a clear and informative representation of the object. Let’s print it!

❯ python3 main.py
Make: Fiat, speed: 50 km/h
Make: Audi, speed: 140 km/h

If we don't define a __str__ method in our custom class, Python falls back to using the default __str__ implementation, which typically returns a string containing the object's memory address. Here we can see an example.

❯ python3 main.py
<__main__.Car object at 0x10fa9ace0>
<__main__.Car object at 0x10fa9ac80>

__repr__ method

The __repr__ method is used to define an unambiguous and formal string representation of an object. It is intended to be a representation that, when passed to the eval() function, would recreate an object with the same state. The primary purpose of __repr__ is to aid in debugging and development, providing a detailed and precise way to represent an object as a string.

The __repr__ method should have the following signature.

def __repr__(self):
    # Return the formal string representation here

Inside the __repr__ method, we construct and return a string that represents the object. This string should ideally be a valid Python expression that, when executed using eval(), would create an object with the same state as the original. The __repr__ method is often used for custom classes to formally represent objects. Common use cases include:

  • displaying detailed information about an object's attributes and state,
  • representing complex or custom objects in a way that can be recreated programmatically,
  • or ensuring that the string representation is unambiguous and unique for each object.

Let’s see how we can use the __repr__ method in Car class.

class Car:
    def __init__(self, make: str, speed: int):
        self.__make: str = make
        self.__speed: int = speed

    def drive(self):
        print(f'Driving {self.__make} at {self.__speed} km/h')

    def __str__(self) -> str:
        return f'Make: {self.__make}, speed: {self.__speed} km/h'

    def __repr__(self) -> str:
        return f'Car(make="{self.__make}", speed={self.__speed})'

In the above example, the __repr__ method of the Car class returns a string that includes the Car class name along with make and speed properties, providing a formal and unambiguous string representation of the Car object.

if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    car2 = Car(f'Audi', 140)

    print(car1)
    print(car2)
    car3 = eval(repr(car2))
    print(car3)

Our example is capable of recreating a Car object using eval() and repr() functions.

❯ python3 main.py
Make: Fiat, speed: 50 km/h
Make: Audi, speed: 140 km/h
Make: Audi, speed: 140 km/h

If we don't define a __repr__ method in our custom class, Python falls back to using the default __repr__ implementation, which typically returns a string containing the object's memory address similar to the default __str__ implementation.

__len__ method

The __len__ method used to define the length of an object. It allows us to customize how the built-in len() function behaves when called on instances of our custom classes. By implementing __len__, we can make our objects iterable and take advantage of sequence-like behaviors.

The __len__ method should have the following signature.

def __len__(self):
    # Return the length of the object here

Inside the __len__ method, we calculate and return an integer that represents the object's length. This integer can be zero or a positive number, depending on the object's content and how you define its length. Common use cases are:

  • sequences: Implementing __len__ is common for classes that represent sequences, such as lists, tuples, and custom collection classes. It allows us to use the object in for loops and functions that expect sequences,
  • or custom objects: We can use __len__ in custom classes to define the length of objects based on specific criteria. For example, if we have a class representing a database table, we might define the length as the number of rows.

Having Car classes in place, let's define the ParkingLot class for them. We can assume that our parking space will have a limited number of spots and we want to know how many.

class ParkingLot:
  def __init__(self, spots: int):
    self.__spots: list[Car] = list()
    for _ in range(spots):
      self.__spots.append(None)
  
    def __len__(self) -> int:
    return len(self.__spots)

The primary purpose of the __len__ method is to provide a custom implementation for determining the length or size of a ParkingLot object. When you use the len() function on an object, it returns the number of parking spots in the parking lot. Inside the __len__ method, the code returns the length of the self.__spots list, which represents the number of parking spots in the parking lot.

Let's create a parking lot with 10 spots.

if __name__ == '__main__':
    parkingLot = ParkingLot(10)
    print(len(parkingLot))

Printing len() gives the following output.

❯ python3 main.py
10

If we don't define a __len__ method in our custom class, instances of the class will not have a defined length, and attempting to use the len() function on them will raise a TypeError.

Let's define the __str__ method to print parking occupancy.

class ParkingLot:
    def __init__(self, spots: int):
        self.__spots: list[Car] = list()
        for _ in range(spots):
            self.__spots.append(None)

    def __len__(self) -> int:
        return len(self.__spots)  
        
    def __str__(self) -> str:
        occupancy = f'Space occupancy: \n'
        for index, spot in enumerate(self.__spots):
            occupancy += f'Spot {index} is '
            if spot:
                occupancy += f'BUSY, parked car: {spot}\n'
            else:
                occupancy += f'FREE\n'
        return occupancy

if __name__ == '__main__':
    parkingLot = ParkingLot(10)
    print(parkingLot)

Now we can print and see which spots are busy and which are free.

❯ python3 main.py
Space occupancy: 
Spot 0 is FREE
Spot 1 is FREE
Spot 2 is FREE
Spot 3 is FREE
Spot 4 is FREE
Spot 5 is FREE
Spot 6 is FREE
Spot 7 is FREE
Spot 8 is FREE
Spot 9 is FREE

__setitem__ method

The __setitem__ method is a special method used to define how objects of a class should respond when elements or values are assigned to specific positions using square brackets ([]). It allows us to create objects that support item assignment, making them mutable and subscriptable. By implementing the __setitem__ method, we can customize how our objects are modified using square brackets.

The __setitem__ method should have the following signature.

def __setitem__(self, key, value):
    # Define how to assign a value to the item based on the key

The self parameter is the instance of the object and is automatically passed to the method. The key parameter represents the index or key where the assignment is taking place. It can be of any data type, depending on how we want to use it. The value parameter represents the value that is being assigned to the specified position or key. Inside the method, we define the logic to assign the value to the item specified by the key. This logic can involve updating the object’s internal state, changing attributes, or any other custom behavior. Common use cases are:

  • custom mutable sequences: Implementing __setitem__ is common for classes that aim to mimic the behavior of mutable sequences like lists or arrays. It allows us to define how elements can be modified or replaced at specific indices,
  • or custom mutable mappings: We can use __setitem__ to create custom mapping classes, similar to dictionaries, where elements can be assigned or updated using keys.

Now we can go back to our ParkingLot class and implement the __setitem__ method to gain the ability to assign cars to spots.

class ParkingLot:
    def __init__(self, spots: int):
        self.__spots: list[Car] = list()
        for _ in range(spots):
            self.__spots.append(None)
  
    def __len__(self) -> int:
        return len(self.__spots)
        
    def __str__(self) -> str:
        occupancy = f'Space occupancy: \n'
        for index, spot in enumerate(self.__spots):
            occupancy += f'Spot {index} is '
            if spot:
                occupancy += f'BUSY, parked car: {spot}\n'
            else:
                occupancy += f'FREE\n'
        return occupancy
    
    def __setitem__(self, index: int, car: Car):
        self.__spots[index] = car

The __setitem__ method is defined within the ParkingLot class, and it takes three parameters. The self parameter represents the instance of the ParkingLot object and is automatically passed to the method. The index parameter represents the position or spot in the parking lot where the car should be parked. The car parameter represents the Car object that is being parked in the specified spot. Inside the __setitem__ method, the code assigns the car object to the self.__spots list at the specified index. This effectively parks the car in the parking spot specified by the index. The parking spot is updated with the car object, replacing any previous value that might have been in that spot.

Now let's park a car.

if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    print(car1)
    parkingLot = ParkingLot(10)
    parkingLot[3] = car1
    print(parkingLot)

When executing the above code, we get the following output.

❯ python3 main.py
Make: Fiat, speed: 50 km/h
Space occupancy: 
Spot 0 is FREE
Spot 1 is FREE
Spot 2 is FREE
Spot 3 is BUSY, parked car: Make: Fiat, speed: 50 km/h
Spot 4 is FREE
Spot 5 is FREE
Spot 6 is FREE
Spot 7 is FREE
Spot 8 is FREE
Spot 9 is FREE

So we have parked a car!

__delitem__ method

The __delitem__ method in Python is a special method used to define how objects of a class should respond when elements or values are deleted from specific positions using the del statement and square brackets ([]). It allows us to create objects that support item deletion, making them mutable and subscriptable. By implementing __delitem__, we can customize how our objects are modified or cleaned up when specific items are removed.

The __delitem__ method should have the following signature.

def __delitem__(self, key):
    # Define how to delete the item based on the key

The self parameter is the instance of the object and is automatically passed to the method. The key parameter represents the index or key from which the item is being deleted. It can be of any data type, depending on how we want to use it. Inside the __delitem__ method, we define the logic to remove the item specified by the key. This logic can involve updating the object's internal state, removing attributes, or any other custom behavior required for item deletion. Common use cases are:

  • custom mutable sequences: Implementing __delitem__ is common for classes that aim to mimic the behavior of mutable sequences like lists or arrays. It allows us to define how elements can be deleted at specific indices,
  • or custom mutable mappings: We can use __delitem__ to create custom mapping classes, similar to dictionaries, where elements can be deleted using keys.

Let's go back to our ParkingLot class one more time and implement the __delitem__ method to gain the ability to free parking spots.

class ParkingLot:
    def __init__(self, spots: int):
        self.__spots: list[Car] = list()
        for _ in range(spots):
            self.__spots.append(None)
  
    def __len__(self) -> int:
        return len(self.__spots)
        
    def __str__(self) -> str:
        occupancy = f'Space occupancy: \n'
        for index, spot in enumerate(self.__spots):
            occupancy += f'Spot {index} is '
            if spot:
                occupancy += f'BUSY, parked car: {spot}\n'
            else:
                occupancy += f'FREE\n'
        return occupancy
    
    def __setitem__(self, index: int, car: Car):
        self.__spots[index] = car

    def __delitem__(self, index: int):
        self.__spots[index] = None

The __delitem__ method defined within the ParkingLot class takes two parameters. The self parameter represents the instance of the ParkingLot object and is automatically passed to the method. The index parameter represents the position or spot in the parking lot where the car should be removed. Inside the __delitem__ method, the code sets the parking spot at the specified index to None. This action effectively removes or "unparks" any car that might have been parked in that spot. By setting the parking spot to None, it indicates that the spot is now empty and available for parking another car.

Now let's free some parking spots.

if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    print(car1)
    parkingLot = ParkingLot(10)
    parkingLot[3] = car1
    print(parkingLot)
    del parkingLot[3]
    print(parkingLot)

Let's run the script and see the output.

❯ python3 main.py
Make: Fiat, speed: 50 km/h
Space occupancy: 
Spot 0 is FREE
Spot 1 is FREE
Spot 2 is FREE
Spot 3 is BUSY, parked car: Make: Fiat, speed: 50 km/h
Spot 4 is FREE
Spot 5 is FREE
Spot 6 is FREE
Spot 7 is FREE
Spot 8 is FREE
Spot 9 is FREE

Space occupancy: 
Spot 0 is FREE
Spot 1 is FREE
Spot 2 is FREE
Spot 3 is FREE
Spot 4 is FREE
Spot 5 is FREE
Spot 6 is FREE
Spot 7 is FREE
Spot 8 is FREE
Spot 9 is FREE

The parking lot is fully free again!

__getitem__ method

The __getitem__ method in Python is a special method used to define how objects of a class should respond when they are accessed using square brackets ([]). It allows us to create objects that behave like sequences or mappings, making them iterable and subscriptable. By implementing __getitem__, we can customize how our objects are indexed and accessed.

The __getitem__ method should have the following signature.

def __getitem__(self, key): 
    # Define how to retrieve the item based on the key

The self parameter is the instance of the object and is automatically passed to the method. The key parameter represents the index or key used to access an element within the object. It can be of any data type, depending on how you want to use it. Inside the __getitem__ method, we define the logic to retrieve and return the item corresponding to the given key. The return value can be any Python object, such as an integer, string, list, or even another custom object. Common use cases are:

  • custom sequences: Implementing __getitem__ is common for classes that aim to mimic the behavior of sequences like lists or tuples. It allows us to define how elements are accessed based on their positions or indices,
  • or custom mappings: We can use __getitem__ to create custom mapping classes, similar to dictionaries, where elements are accessed using keys.

Now we can go back to our ParkingLot class and implement the __getitem__ method to gain the ability to retrieve a car from a parking spot.

class ParkingLot:
    def __init__(self, spots: int):
        self.__spots: list[Car] = list()
        for _ in range(spots):
            self.__spots.append(None)
  
    def __len__(self) -> int:
        return len(self.__spots)
        
    def __str__(self) -> str:
        occupancy = f'Space occupancy: \n'
        for index, spot in enumerate(self.__spots):
            occupancy += f'Spot {index} is '
            if spot:
                occupancy += f'BUSY, parked car: {spot}\n'
            else:
                occupancy += f'FREE\n'
        return occupancy
    
    def __setitem__(self, index: int, car: Car):
        self.__spots[index] = car

    def __delitem__(self, index: int):
        self.__spots[index] = None

    def __getitem__(self, index: int) -> Car:
        return self.__spots[index]

The __getitem__ method defined within the ParkingLot class takes two parameters. The self parameter represents the instance of the ParkingLot object and is automatically passed to the method. The index parameter represents the position or spot in the parking lot from which a car should be retrieved. Inside the __getitem__ method, the code retrieves the car object from the parking spot at the specified index and returns it. If the parking spot is empty (i.e., no car is parked in that spot), None is returned.

if __name__ == '__main__':
    car1 = Car(f'Fiat', 50)
    print(car1)
    parkingLot = ParkingLot(10)
    parkingLot[3] = car1
    print(parkingLot)
    print(parkingLot[2])
    print(parkingLot[3])

Now let's see the output.

❯ python3 main.py
Make: Fiat, speed: 50 km/h
Space occupancy: 
Spot 0 is FREE
Spot 1 is FREE
Spot 2 is FREE
Spot 3 is BUSY, parked car: Make: Fiat, speed: 50 km/h
Spot 4 is FREE
Spot 5 is FREE
Spot 6 is FREE
Spot 7 is FREE
Spot 8 is FREE
Spot 9 is FREE

None
Make: Fiat, speed: 50 km/h

Summary

Described methods play essential roles in defining the behavior and representation of custom classes. The __init__ method initializes object attributes upon creation. __str__ and __repr__ methods customize string representations for users and developers, respectively. The __len__ method allows objects to have a length, making them iterable. __getitem__, __setitem__, and __delitem__ enable subscriptable and mutable behavior, defining how elements are accessed, modified, and deleted within custom objects, such as sequences and mappings. These methods collectively empower developers to tailor the functionality and presentation of their classes to meet specific programming needs.

What’s next?

Subscribe to receive more posts like this directly to your email!

Python Intermediate: Iterators, list comprehensions, and generator expressions
Mastering iterators, list comprehensions, and generator expressions is essential. We will take on a journey through these fundamental Python constructs, revealing how they simplify code, boost data processing, and enhance code readability.

Ideas💡, questions❔❓ ? Feel free to start the discussion 🗣️🎙️!

Ready for challenges? 👇👇👇

Python Intermediate: Brain teasers
In the world of programming, the road to expertise is paved with challenges. Don’t shy away from solving programming problems; instead, embrace them as opportunities for growth.