Unravelling binary arithmetic operations in Python
[This post has been updated multiple times since it's initial posting; see the Corrections section at the end for what was changed.]
The reaction to my blog post on unravelling attribute access was positive enough that I'm inspired to do another post on how much of Python's syntax is actually just syntactic sugar. In this post I want to tackle binary arithmetic operations.
Specifically, I want to unravel how subtraction works:
a - b. Now I am purposefully choosing subtraction as it is non-commutative. This helps make sure that the order of operations matters compared to doing addition where you could make a mistake and flip
b in the implementation and still end up with the same result.
Finding the C code
As in my last post, we are going to start by seeing what bytecode the CPython interpreter compiles for the syntax.
So it looks like the
BINARY_SUBTRACT opcode is what we want to dive into. Looking that up in
Python/ceval.c shows you the C code to implement that opcode is as follows:
The key bit of code to see here is that
PyNumber_Subtract() implements the actual semantics for subtraction. Now untangling that function through some macros gets you to the
binary_op1() function. What this provides is a generic way to manage binary operations. Instead of using it as our reference for implementation, though, we are going to work from Python's data model as I think the documentation is nice and clear on what the semantics should be for subtraction.
Learning from the data model
Reading through the data model, you will discover that two methods play a part in implementation subtraction:
a - b, the
__sub__() method is searched from the type of
a and then
b is passed as an argument (much like with
__getattribute__() in my blog post on attribute access, special/magic methods are resolved on the type of an object, not the object itself for performance purposes; I use
_mro_getattr() to represent this in the example code below). So if it's defined,
type(a).__sub__(a, b) will be used to do subtraction.
That means subtraction, at its simplest form, is just a method call! You can generalize this today using the
operator.sub() function. We will model our own implementation after that function. I will be using the names
rhs to represent the left-hand side and right-hand side, respectively, of
a - b to make the example code easier to follow.
Letting the right-hand side participate via
But what if
a doesn't implement
__sub__()? Then we try calling
b are different types (the "r" in
__rsub__ stands for "right", as in right-hand side). This makes sure that both sides of the operation get a chance to try and make the expression work when they differ; when they are the same the assumption is
__sub__() would have been able to handle the situation (plus there's a reason related to how things are implemented at the C level, discussed later). Even when the implementation is the same, though, you still want to call
__rsub__() in case it matters when one of the objects is a different (sub)class.
Letting a type take a pass
Now both sides of the expression get to participate! But what if the type of an object doesn't support subtraction for some reason (e.g.
4 - "stuff" doesn't work)? What
__rsub__ can do in that case is return
NotImplemented. That's a signal to Python that it should move on and try the next option in making the operation work. For our code that means we need to check what the methods return before we can assume it worked.
Letting subclasses boss around their parents
If you take a look at the docs for
__rsub__(), you will notice there is a note. What it says is that if the right-hand side of a subtraction expression is a subclass of the left-hand side (and a true subclass; being the same class does not count) and the
__rsub__() method is different between the two objects, then
__rsub__() is called before
__sub__(). In other words, you reverse the order you try the methods if
b is a subclass of
This might seem like a rather odd special-case, but there's logic behind it. When you subclass something it means you are injecting new logic into how a class should operate compared to its superclass. This logic is not necessarily exposed to the superclass, which means that if a superclass operates on a subclass it could very easily overlook how the subclass wants to be treated.
To put it concretely, imagine a class named
Spam, that when you do
Spam() - Spam() you get an instance of
LessSpam. Now imagine you created a subclass of
Bacon, such that when you subtract
Spam you get
VeggieSpam. Without the rule above,
Spam() - Bacon() would lead to
Spam doesn't know that removing
Bacon should lead to
VeggieSpam. But with the rule above, the expected outcome of
VeggieSpam will occur since
Bacon.__rsub__() gets first dibs on creating the value of the expression (had it been
Bacon() - Spam() then the proper outcome would still work out since
Bacon.__sub__() would be called first, hence why the rule says the classes have to differ with different methods and not just be a subclass as defined by
As for why the implementation of
__rsub__ must be different even when the "proper" subclass is met, that stems from how operator overloading was originally implemented at at the C level in CPython. Guido and I talked back and forth about it, but the key insight is that at the C level there's only a "subtraction" method, not
__rsub__. And so for code that calls extension modules the way that this left-side/right-side things occur is calling the "subtraction" method with different argument order (i.e.
A.subtract(a, b) for
A.subtract(b, a) for
A.__rsub__). Without that rule you would then call the exact same method twice which is redundant and could lead to subtle bugs; with this specific rule you avoid those issues (same goes for why
__rsub__ is not called when both sides of the operation are the same type; you would just be calling the same code, just with reversed arguments).
And that's it! This gives us a complete implementation of subtraction.
Generalizing to other binary operations
With subtraction out of the way, what about the other binary operations? Well, it turns out they all operate the same, they just happen to have different special/magic method names that they use. So if we can generalize this approach, then we will have implemented the semantics for 13 operations:
And thanks to closures and Python's flexibility in how objects know details about themselves, we can generalize the creation of the
With this code, you can define the operation for subtraction as
_create_binary_op("sub", "-") and then repeat as necessary for the other operations.
You can find more posts by me unravelling Python's syntax by checking out the "syntactic sugar" tag of this blog. The source code can be found at https://github.com/brettcannon/desugar.
- 2020-08-19: Fixed the rules for when
__rsub__()is called before
- 2020-08-22: Fixed
__rsub__not being called when the types are the same; also stripped out transitional code in favour of just opening code and final code to make my life simple.
- 2020-08-23: Added back in most of the examples.
- 2020-09-29: Expanded on why the
__r*__method is skipped when it's the same on both sides of the expression and each object is a "proper" subclass of each other