Unravelling unary arithmetic operators
In this entire blog series on Python's syntactic sugar, this might end up being the most boring post. ๐ We will cover the unary arithmetic operators: -
, +
, and ~
(inversion if you don't happen to be familiar with that last operator). Due to the fact that there is only a single object being involved, it's probably the most straightforward syntax to explain in Python.
The example we are going to use in this post is ~ a
.
What the data model says
If you look at the data model, you will see the documentation says that for inversion the method name is __invert__
(and the other operators) and has following details:
Called to implement the unary arithmetic operations (-
,+
,abs()
and~
).
That's it. That is literally all of the documentation for unary arithmetic operators in Python's data model. Now is that an over-simplification, or is it actually as simple as it sounds?
Looking at the bytecode
Let's look at what CPython executes for ~ a
:
>>> def spam(): ~ a
...
>>> import dis; dis.dis(spam)
1 0 LOAD_GLOBAL 0 (a)
2 UNARY_INVERT
4 POP_TOP
6 LOAD_CONST 0 (None)
8 RETURN_VALUE
~ a
It looks like UNARY_INVERT
is the opcode we care about here.
Looking at the C code
The opcode
Diving into Python/ceval.c
, you can see how UNARY_INVERT
is implemented:
case TARGET(UNARY_INVERT): {
PyObject *value = TOP();
PyObject *res = PyNumber_Invert(value);
Py_DECREF(value);
SET_TOP(res);
if (res == NULL)
goto error;
DISPATCH();
}
UNARY_INVERT
opcodeThe C API
The opcode implementaiton seems simple and PyNumber_Invert()
is the key function here.
PyObject *
PyNumber_Invert(PyObject *o)
{
PyNumberMethods *m;
if (o == NULL) {
return null_error();
}
m = o->ob_type->tp_as_number;
if (m && m->nb_invert)
return (*m->nb_invert)(o);
return type_error("bad operand type for unary ~: '%.200s'", o);
}
PyNumber_Invert()
What this code does is:
- Gets the
__invert__
method off ย of the object's type (which is typical for Python's data model; this is for performance as it bypasses descriptors and other dynamic attribute mechanisms) - If the method is there then call it and return its value
- Otherwise raise a
TypeError
Implementing in Python
In Python this all looks like:
import desugar.builtins as debuiltins
def __invert__(object_: Any, /) -> Any:
"""Implement the unary operator `~`."""
type_ = type(object_)
try:
unary_method = debuiltins._mro_getattr(type_, "__invert__")
except AttributeError:
raise TypeError(f"bad operand type for unary ~: {type_!r}")
else:
return unary_method(object_)
This can be generalized to create a closure for an arbitrary unary operation like so:
import desugar.builtins as debuiltins
def _create_unary_op(name: str, operator: str) -> Callable[[Any], Any]:
"""Create a unary arithmetic operation function."""
method_name = f"__{name}__"
def unary_op(object_: Any, /) -> Any:
"""A closure implementing a unary arithmetic operation."""
type_ = type(object_)
try:
unary_method = debuiltins._mro_getattr(type_, method_name)
except AttributeError:
raise TypeError(f"bad operand type for unary {operator}: {type_!r}")
else:
return unary_method(object_)
unary_op.__name__ = unary_op.__qualname__ = method_name
unary_op.__doc__ = f"Implement the unary operation `{operator} a`."
return unary_op
neg = __neg__ = _create_unary_op("neg", "-")
pos = __pos__ = _create_unary_op("pos", "+")
inv = __inv__ = invert = __invert__ = _create_unary_op("invert", "~")
And that's it! As always, the code in this post can be found in my desugar project.
Aside: a bit of method name history
You may have noticed that for inversion there are four function names in the operator module:
inv
__inv__
invert
__invert__
It turns out that in Python 2.0, the special/magic method for inversion was renamed from the first two to the last two (I assume to be more self-explanatory). For backwards-compatibility the older names were left in the operator module.