Operator Overloading in Python

Apr 27, 2021

As a C++ developer, I was familiar with operator overloading and happy to know that Python offered the same feature. Luckily, I hadn't been broken down by some of the poor examples of overuse that plagued the C++ world of operator overloading, like some of the folks who I've encountered online. For me, I was excited that the Python language offered the feature to (for example) add two user- defined matrices classes together with the + operator, instead of having to use an overly-verbose .add() method, like would be seen in Java.

Python, of course, added this language feature because it was practical and increased the readability of the code. As in, it made it clear what the writer of the code wanted to accomplish. However, the Python language implemented operator overloading in a way that could not be as easily abused as C++.

The way Python did this was to implement operators as dunder methods (AKA special methods with double underscores before and after). For example, when executing the code 5 + 4, Python is really executing:

int.__add__(5, 4)

To overload an operator in Python, a user-defined class needs to override the dunder method which is associated with the operator. The table is shown below. Don't worry if you don't know all of them, or understand what 'Reverse Operator' or 'In-Place Operator' is for now. Only the ones which are applicable to your class needs to be overridden (that's the benefit of Python). Reverse and In- Place operators will be covered below, as well as unary methods, which get their own table further below.

Mathematical Operators:

Operator Standard Dunder Method Reverse Operator In-Place Operator Description
+ __add__ __radd__ __iadd__ Addition
- __sub__ __rsub__ __isub__ Subtraction
* __mul__ __rmul__ __imul__ Multiplication
/ __truediv__ __rtruediv__ __itruediv__ Division
// __floordiv__ __rfloordiv__ __ifloordiv__ Floor Division
% __mod__ __rmod__ __imod__ Modulo
** __pow__ __rpow__ __ipow__ Power
@ __matmul__ __rmatmul__ __imatmul__ Matrix Multiplication

Bitwise Logical Operators:

Operator Standard Dunder Method Reverse Operator In-Place Operator Description
& __and__ __rand__ __iand__ And
| __or__ __ror__ __ior__ Or
^ __xor__ __rxor__ __ixor__ Exclusive Or
<< __lshift__ __rlshift__ __ilshift__ Left Bitshift
>> __rshift__ __rrshift__ __irshift__ Right Bitshift

Boolean Operators:

Operator Standard Dunder Method Reverse Operator In-Place Operator Description
== __eq__ __eq__ N/A Equality
!= __ne__ __ne__ N/A Inverse Equality
> __gt__ __lt__ N/A Greater-Than
< __lt__ __gt__ N/A Less-Than
>= __ge__ __le__ N/A Less-Than-Or-Equal
<= __le__ __ge__ N/A Greater-Or-Equal

Let's focus on just the 'Standard Dunder Method' column for now. The process to override any of these methods are pretty much the same. Add a method to the user-defined class which takes self and one additional argument. The method should return the result of the operation and not change the class instance or either of the input parameters, as a best practice. This is true for the methods in the 'Reverse Operator' column as well, but may not be true for the methods in the 'In-Place Operator' column, depending on the implementation.

An example for adding two angles might look like this:

>>> class Angle:
...     def __init__(self, degrees):
...         self._degrees = degrees

...     def __repr__(self):
...         return f'Angle({self._degrees})'

...     def __add__(self, angle_to_add):
...         degrees = self._degrees + angle_to_add._degrees
...         if degrees > 360:
...             degrees -= 360
...         if degrees < 0:
...             degrees += 360
...         return Angle(degrees)
... 
>>> angle1 = Angle(270)
>>> angle2 = Angle(180)
>>> angle3 = angle1 + angle2
>>> angle3
Angle(90)

The Angle class' addition method is really simple, but it adds a step to standard addition. Instead of just adding two numbers, it also checks that the answer is between 0 and 360 degrees. If not, it wraps the answer by adding or subtracting 360 degrees.

The last line shows that this angle wrap works as expected. Instead of 270 degrees and 180 degrees becoming 450 degrees, the angle is calculated as 90 degrees.

Let's examine in detail how this worked. The __add__ dunder method takes self and angle_to_add. The self argument is actually what falls to the left of the +. The angle_to_add is what falls to the right of the + sign. You can see the newly calculated value is then determined by adding the _degrees attribute of the objects on both sides of the +. After checking that the newly calculated number of degrees is within bounds, a new object is created and returned, meeting the best practice for the __add__() method.

What if you would like to add an integer to an angle object without having to explicitly convert to an Angle? While this may not be the best practice (Python seeks to be explicit rather than implicit), it might be the right choice for your application. It also demonstrates a 'gotcha' while implementing __add__ between object types. You might think this will work:

>>> class Angle:
...     ''' Other implementation details removed for brevity'''
...     def __add__(self, int_to_add):
...         degrees = self._degrees + int_to_add
...         if degrees > 360:
...             degrees -= 360
...         if degrees < 0:
...             degrees += 360
...         return Angle(degrees)
... 
>>> angle = Angle(10)
>>> angle + 10
Angle(20)

It certainly does when the Angle is on the left side of the operand. However, placing the Angle on the right side of the operand creates a TypeError:

>>> 10 + angle
Traceback (most recent call last):
  File '', line 1, in 
TypeError: unsupported operand type(s) for +: 'int' and 'Angle'

If your application needs objects which require commutative operators (the Angle type sure does), you will need to implement the 'Reverse Operator', shown above in the operator table. For addition, this would be the rad __radd__ method. The implementation can be super easy in a lot of cases:

>>> class Angle:
...     ''' Other implementation details removed for brevity'''
...     def __radd__(self, int_to_add):
...         return self + int_to_add

The 'Reverse Operator' is invoked by Python when a user-defined class is on the right side of an operator. The arguments are a little confusing here (they certainly confused me when making this example). The left side of the operator is actually the second argument. The right side of the operator (the user defined class) is self or the first argument. The arguments are reversed from the standard operator, but make sense if thinking of it from a purely method- signature standpoint.

The __radd__ method can just delegate to the 'forward' operator __add__() in most cases.

The last operator is the 'In-Place' operator types. Many classes will not need to implement these operators. Python will automatically use the associated 'forward' operator and then assign to a new object. For example, the int class does not implement __iadd__ but += is a valid operator.

The side effect of not implementing 'In-Place' operators is that Python will create a new object after completing the new calculation. This isn't always desirable (for example, if objects are mutable or expensive to create). In those cases, it is best to implement the 'In-Place' operators so that the same objects can be modified.

Like the 'forward' and 'reverse' operators, 'In-Place' operators must take two parameters: self and the object to perform the operation with. Unlike 'forward' and 'reverse' operators, they must return self to work correctly.

An example of the 'In-Place' operator += for our Angle class:

>>> class Angle:
...    ''' Other implementation details removed for brevity'''
...     def __iadd__(self, angle_to_add):
...         self._degrees += angle_to_add._degrees
...         if self._degrees > 360:
...             self._degrees -= 360
...         if self._degrees < 0:
...             self._degrees += 360
...         return self
... 
>>> angle = Angle(10)
>>> angle += Angle(40)
>>> angle
Angle(50)

The equality operators are defined exactly the same as the math-y operators like addition, subtraction, etc. However, instead of returning an instance of the class, they return a True or False value. Like the forward and reverse operators, it is not expected that the class itself or either input parameter is changed by a equality operator.

Python also provides unary operators that didn't quite fit into the table that I placed above. There are only three:

- (__neg__): Used to negate an object
+ (__pos__): Used to reference the positive value of an object
~ (__invert__): Used to find bitwise inverse

Of course, this is where my Angle class example falls apart...

To demonstrate __neg__(), Angle will have to be satisfied with a negative _degrees value, something it wasn't satisfied with in the addition examples above. That's fine I guess...

Unary operators are fairly simple to implement from a syntax perspective. They just take a single argument self and return the representation of the object after it has been operated on. Most of the time, this will be a new object of the same type. Note that like the forward and reverse operators above, the unary operators are expected to return a new object and not modify the one being operated on.

Here's an example of __neg__() for Angle:

>>> class Angle:
...    ''' Other implementation details removed for brevity'''
...     def __neg__(self):
...         return Angle(-self._degrees)
... 
>>> angle = Angle(10)
>>> -angle
Angle(-10)

The other two unary operators can be defined similarly for your user-defined class, as applicable.

I've covered quite a bit here, and skipped over some of the more interesting operators like __pow__ and __matmul__ (introduced in Python 3.5), but I hope this served as a good primer. If I find myself using __pow__ and __matmul__ frequently in my code and come to understand and love them, I'm sure I'll write a more detailed article about them in the future.

For now, remember to only implement what is practical and what will help you clearly convey what you are trying to accomplish. That's what moved me to Python, and I hope it stays that way.