Faster (to code) if statements are much faster to read, debug, and modify than naively written if statements. What makes them so great?
explicit conditions
the if tree is “lean” and not “bushy”
A defensive else clause
Refactoring a slow if statement
Consider this (slow to code) if statement:
if foo: if bar < 4: if baz in ["a", "b", "k", "r"]: if qux: x = 0 else: x = 1 else: x = 2 else: x = 3 else: x = 4
When is x = 2
? Uh, well definitely when foo is true. Maybe when bar < 4? Not sure. So, um, if we look up from x = 2
, it looks like “if baz in…
” is lined up with it. So it’s when that’s not true. And before if baz…
, both if foo
and if bar < 4
are true, so… maybe x = 2
when
foo and bar < 4 and baz not in [“a”, “b”, “k”, “r”]
We’re right. But. Ew.
Avoid Bushy Decision Trees
Kernighan and Plauger agree. In The Elements of Programming Style, they have a rule: “avoid bushy decision trees” where bushy refers to short, fat, nested if decision trees. Like ours. Okay, let’s try it:
if foo and bar < 4 and baz in ["a", "b", "k", "r"] and qux: x = 0 elif foo and bar < 4 and baz in ["a", "b", "k", "r"] and not qux: x = 1 elif foo and bar < 4 and not baz in ["a", "b", "k", "r"]: x = 2 elif foo and bar >= 4: x = 3 else: x = 4
Be explicit
Much clearer. This is a lean tree with explicit conditions. When is x = 2
? Hm, what’s the line above it say? foo and bar < 4 and not baz in ["a", "b", "k", "r"].
Nice, much easier than before when we had to look up 9 lines, match indentations, and combine logic in our head.
Except, when x = 4
, we just see “else
”. Well, if we look at all of the other conditions, we can see else is called when not foo
. Let’s make it explicit:
if foo and bar < 4 and baz in ["a", "b", "k", "r"] and qux: x = 0 elif foo and bar < 4 and baz in ["a", "b", "k", "r"] and not qux: x = 1 elif foo and bar < 4 and not baz in ["a", "b", "k", "r"]: x = 2 elif foo and bar >= 4: x = 3 elif not foo: x = 4
The Defensive Else
Great! Just one problem. What if we made a mistake?
if foo and bar < 4 and baz in ["a", "b", "k", "r"] and qux: x = 0 elif foo and bar < 4 and baz in ["a", "b", "k", "r"] and not qux: x = 1 elif foo and bar < 4 and not baz in ["a", "b", "k", "r"]: x = 2 elif foo and bar > 4: x = 3 elif not foo: x = 4
When (foo, bar, baz, qux) == (true, 4, “c”, False), what’s x?
Depends on what x was before, none of these if statements run. When we run this code and (foo, bar, baz, qux) == (true, 4, “c”, False), the code may fail later. Or it won’t, the customer will just get the wrong data. But, if we’re lucky and the code does fail, and we figure out x is set incorrectly, then, okay, so x should’ve been set to 3. When is x set to 3? Let’s look for x =…okay there’s one time x = y + z. Well y and z are both at least 10, so it’s not there…next it’s set to 3 in else. Okay when is this else triggered…
Too slow. Too much thinking.
We want our code to fail fast and explicitly. The computer should tell me when it failed. What do Kernighan and Plauger suggest?
Leaving [else] in with an error message may help catch “impossible” conditions
Great! Let’s add it.
class IncompleteIfTreeException(BaseException): pass if foo and bar < 4 and baz in ["a", "b", "k", "r"] and qux: x = 0 elif foo and bar < 4 and baz in ["a", "b", "k", "r"] and not qux: x = 1 elif foo and bar < 4 and not baz in ["a", "b", "k", "r"]: x = 2 elif foo and bar > 4: x = 3 elif not foo: x = 4 else: v = {foo: foo, bar: bar, baz: baz, qux: qux} raise IncompleteIfTreeException("No condition was true for the following values: {}", v)
This is the defensive else.
Debugging it all together
If we run this code, we’ll get the nice following error message:
File "./fast-if-exception", line 20, in <module> raise IncompleteIfTreeException("No condition was true for the following values: {}", v) __main__.IncompleteIfTreeException: ('No condition was true for the following values: {}', {'foo': True, 'bar': 4, 'baz': 'c', 'qux': False})
Line 20, IncompleteIfTreeException. Okay.. so one of the if statements before line 20 should’ve run but didn’t. What values should I know about?
{‘foo’: True, ‘bar’: 4, ‘baz’: ‘c’, ‘qux’: False}
Hm, okay, in this case, x should be set to 3…so the issue’s here
elif foo and bar > 4:
foo
is True
, okay. bar
is 4
…Oh, it should be bar >
=
4
. Done.
The computer tells us what the issue is, where the issue is, and the data caused the issue. All that’s left is figuring out what code we should’ve written in this case. Which is pretty fast.
If-Else statements
We’ve been dealing with complicated if-elif-elif…-else statements, but what about a simple if-else?
if foo: x() else: y()
Surely using elif instead of else and using a defensive else is overkill?
Yes, it is overkill, if it’s this simple. But if the if has a large, 50 line consequent before the else:
if foo: ... 50 lines later else: y()
Then, “else” is not so clear because understanding it requires reading code 50 lines away. First we’d try refactoring out the lines into a method, to shorten it
if foo: x() else: y()
Which is a simple if else, great.
But, for argument’s sake, let’s say we can’t refactor away the 50 lines. What can we do? Maybe adding a comment will help?
if foo: ... 50 lines later # foo is false else: y()
Kernighan and Plauger aren’t so happy.
Don’t comment bad code, — rewrite it
Okay, okay.
if foo: ... 50 lines later elif not foo: y()
Explicit, but...where’s the defensive else? Is it an oversight? Let’s look up 50 lines to see what other cases are present. Hm, if foo, if not foo, okay fine, this code is correct. But, this code made us think. Thinking is slow. What made us think? The lack of defensive else. So, let’s write the defensive else.
if foo: ... 50 lines later elif not foo: y() else: v = {"foo": foo} raise IncompleteIfTreeException("No condition was true for the following values: {}", v)
Everything’s explicit and there’s no thinking required.
More code is faster to code?
It’s always counter-intuitive that writing defensive code lets us code faster. Defensive code doesn’t run when the program works. So, why not skip it and we’ll code faster. But thinking about code and debugging code is slow. Adhoc log grokking is slow. Simulating the program in your head is slow. Narrowing down the issue is slow. Running test after test to determine problematic data is slow. Compared to all of this, writing explicit, readable defensive code is fast. So if we write a little bit more code and drastically reduce the time spent thinking and debugging, we’ll code faster.