Unravelling assertions

In this post, as part of my series on Python's syntactic sugar, I'm going to cover assert statements. Now, the actual unravelling of the syntax for assert a, b is already given to us by the language reference:

if __debug__:
    if not a:
        raise AssertionError(b)
Implementation of assert a, b

Since there isn't much to it, I'm going to spend this post mostly explaining what's going on with this unravelled code since there's a couple of details that you might not know.

To begin, __debug__ represents whether CPython was run with -O or -OO. These flags control the optimization level of CPython (hence the use of the letter "O" for them; saying it twice basically represents "more optimizations"). Without either flag specified, __debug__ == True. But if you use either -O or -OO then __debug__ == False (-OO also strips out docstrings to save a tiny bit of memory).

The next thing to be aware of is that the error message argument is only executed if the assertion fails. This is why you can't just define an assert_() function for the unravelling; like with and and or, you have to make sure not to execute b unless not a is true (and since raise is a statement, we can't use our conditional operator trick like we did with and and or).

The unravelling is actually a bit simplistic because technically the lookup of __debug__ and AssertionError is done directly from the builtins module, not from the scope of the statement. So if you want to be really accurate in the unravelling, it's:

import builtins

if builtins.__debug__:
    if not a:
        raise builtins.AssertionError(b)
A more accurate implementation of assert a, b

Finally, you might be wondering why I didn't write this as a single if statement: if __debug__ and not a: raise AssertionError(b). Semantically they are equivalent, but in the bytecode that Python produces they are vastly different. Look at the bytecode for the unravelling proposed at the start:

4           0 LOAD_CONST               0 (None)
            2 RETURN_VALUE

Compare that to the bytecode for if not a and __debug__: raise AssertionError(b) (and I chose that order for the and to prove a point which you will see in a moment):

  2           0 LOAD_GLOBAL              0 (a)
              2 POP_JUMP_IF_TRUE        16
              4 LOAD_CONST               1 (False)
              6 POP_JUMP_IF_FALSE       16

  3           8 LOAD_GLOBAL              1 (AssertionError)
             10 LOAD_GLOBAL              2 (b)
             12 CALL_FUNCTION            1
             14 RAISE_VARARGS            1
        >>   16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

One is a bit shorter than the other. 😉 Python's peephole optimizer notices if __debug__: ... and will completely drop the statement if you run Python with -O or higher (and just note that assert statements are dropped by the peephole optimizer as well). Otherwise the peephole optimizer will replace __debug__ with the appropriate literal. That means there's a bit of wasted effort with if not a and __debug__: ... since not a will be evaluated before __debug__. And even with the reverse order to and you still have to deal with the test and jump for the if conditional guard.

And that's it! And I would like to leave you with a piece of advice when it comes to assert statements: never put actual logic that you couldn't stand to have not run in an assert! Some of you might be 🙄, but I know of a multi-billion dollar company with a lot of Python which couldn't use -O because it broke their code due to the removal of assert statements (I don't know if they ever fixed that issue).