Leverage the power of Python’s data model in your classes

Published on Oct. 24, 2022 by Sebastian Paulo in
Programming

Special Methods in Python

Python’s intuitive syntax explains much of its popularity as a programming language. Under the hood, this ease of use is powered by Python’s data model that provides a flexible interface for using objects. „Special methods“ are an essential part of this model. They are recognizable by their leading and trailing double underscores (object.__method_name__) and are often referred to as „dunder“ methods (for double underscore). Special methods work without being explicitly called by their dunder name as they are implicitly invoked by the interpreter.

That is why, for instance, we can simply iterate over the elements of built-in objects like lists by writing for element in my_list thanks to the presence of the __iter__ method. Similarly, the __contains__ method makes it possible to find out if an element is part of a collection with the plain expression element in my_collection. Arithmetic operators are another example of the versatility of special methods. The __add__ method can take on different meanings such as 1 + 1 (addition of numbers) or list_1 + list_2 (list concatenation). Similarly, comparison operators like __lt__ (less than) can be adapted to whatever the meaning of less or greater is in the context of a given object.

Special methods do not only animate Python’s built-in objects. They can also be used to give custom classes the same look and feel. This has the advantage that people who use your classes can rely on their expectations about how objects in Python usually work.

Defining a custom Polynomial class

Let’s illustrate the power of special methods by defining a Polynomial class. From linear algebra we know that polynomials like x2 + 4x + 1 and 5x4 + 2x2 can be added with each other and multiplied by a scalar. Libraries like NumPy have implemented such a class and I don’t intend to compete with them. But it is a good exercise to demonstrate how special methods work.

First, we define a class Polynomial. New instances of this class will be instantiated with a sequence of coefficients. The index position of each coefficient in the sequence indicates the power to which the variable x following the coefficient is raised. This means that the length of the sequence corresponds to the largest power (i.e. the degree of the polynomial) plus 1 because indexing starts at 0 and coefficients with the value of 0 must also take their place at the correct index position in the sequence. To underline the importance of index positions and immutability in the order of the coefficients the example class internally represents coefficients as a tuple of floats.

class Polynomial:

    def __init__(self, coefficients: Sequence[float]) -> None:
        self.coefficients = tuple(float(coef) for coef in coefficients)

# Example
p = Polynomial((2.0, 4.0, -1.0))

String representation with __repr__ and __str__

The first thing we notice when using just the class definition from above is that the string representation is very uninformative. We only get something like <Polynomial object at 0x101240880> when printing an instance of Polynomial. But Python's special methods come to the rescue. Have a look at the implementation of the __repr__ and __str__ methods.

class Polynomial:

    ...

    def __repr__(self) -> str:
        coeffs = ", ".join(str(c) for c in self.coefficients)
        return f"Polynomial(({coeffs}))"

    def __str__(self) -> str:
        coefs_as_str = []
        for i in range(len(self.coefficients) - 1, -1, -1):
            if self.coefficients[i] == 0:
                continue
            elif i == 0:
                coefs_as_str.append(f"{self.coefficients[i]:g}")
            elif self.coefficients[i] == 1.0:
                coefs_as_str.append(f"x^{i}")
            elif self.coefficients[i] == -1.0:
                coefs_as_str.append(f"-x^{i}")
            else:
                coefs_as_str.append(f"{self.coefficients[i]:g}x^{i}")
        poly_str = " + ".join(coefs_as_str)
        poly_str = poly_str.replace("x^1", "x").replace(" + -", " - ")
        return poly_str

Usually, it is recommended that classes have at least a decent __repr__ method. Their purpose is to provide a clear description of the object, in a form that could be copied and used in code. In contrast, the __str__ method, triggered by the print() function, should provide a representation of the object that is readable for the human user. In this case, we want the standard representation of polynomials, starting with the highest power from left to right. We get something like this:

>>> p
Polynomial((2.0, 4.0, -1.0))
>>> print(p)
-x^2 + 4x + 2

Adding polynomials with __add__

Moving on to mathematical operations, we implement the __add__ method as follows:

class Polynomial:

    ...

    def __add__(self, other: Polynomial) -> Polynomial:
        max_len = max(len(self.coefficients), len(other.coefficients))
        p_1 = self.coefficients + (0,) * (max_len - len(self.coefficients))
        p_2 = other.coefficients + (0,) * (max_len - len(other.coefficients))
        poly_sum = tuple(sum(i) for i in zip(p_1, p_2))
        return Polynomial(poly_sum)

    ...

The add method takes another Polynomial and returns a new instance of the Polynomial class. The two polynomials to be added have to be of equal length. That is why we pad the shorter one with 0s up to the length of the longer one. Then we add the tuples element-wise to get a tuple of sums that will be used as the argument of the returned new polynomial.

>>> p = Polynomial((2.0, 4.0, -1.0))
>>> q = Polynomial((4.0, -1.0, -3.0, 0.0, 5.0))
>>> w = p + q
>>> w
Polynomial((6.0, 3.0, -4.0, 0.0, 5.0))
>>> print(w)
5x^4 - 4x^2 + 3x + 6
>>> w + p + q
Polynomial((12.0, 6.0, -8.0, 0.0, 10.0))

As you can see, addition works right away with more than two polynomials (w + p + q).

Multiplying polynomials by scalars with __mul__ and __rmul__

Finally, the __mul__ method takes a scalar (a real number) and returns a new instance of Polynomial that is the result of multiplying each coefficient with the scalar.

class Polynomial:
    
    ...
    def __mul__(self, scalar: float) -> Polynomial:
        return Polynomial(tuple(scalar * coef for coef in self.coefficients))

    def __rmul__(self, scalar: float) -> Polynomial:
        return Polynomial(tuple(scalar * coef for coef in self.coefficients))
    ...
>>> p = Polynomial((2.0, 4.0, -1.0))
>>> m = p * 2
>>> m
Polynomial((4.0, 8.0, -2.0))
>>> print(m)
-2x^2 + 8x + 4

Note that we need the __rmul__ method (that looks exactly like our implementation of __mul__) to be able to multiply the scalar from the right side as well, otherwise we'll get a TypeError.

Conclusion

We could talk about many more special methods, but let's leave it at that. Beyond standard mathematical objects like polynomials, it's worth reflecting what concrete meaning special methods can take on in the specific contexts of classes we create as Python programmers. A clever mapping of these methods to the functionality of classes can make objects more readable and useable.

A complete version of the code example used in this post can be accessed on the blog's GitHub repo here.

Find more posts related to:

Python OOP