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
Disassembly of the bytecode for ~ 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();
}
Implementation of the UNARY_INVERT opcode

The 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);
}
Implementation of PyNumber_Invert()

What this code does is:

  1. 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)
  2. If the method is there then call it and return its value
  3. 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", "~")
Generalization of the creation of unary arithmetic operator functions

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:

  1. inv
  2. __inv__
  3. invert
  4. __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.