Bamboozled by some Python
June 19, 2018
Last weekend, I was bamboozled for a while when working through a Python problem, which seems to be worth sharing.
Before going into that, let’s talk about short-circuit operators for some background. Most Pythonistas would know that the boolean operators, and
and or
are short-circuit operators.
def do_something():
print("I love Python!")
return True
>>> True and do_something()
I Love Python!
True
>>> False and do_something()
False
# "I love Python!" is not printed above
When the result of a boolean operator can be derived from the left side argument, Python cleverly avoids evaluating the other argument.
In this toy example, we will create a change_pointer
function. Using a one element list as a pointer, change_pointer(pointer)
will set the element to True
, and return True
.
def change_pointer(pointer):
print("Changing pointer!")
pointer[0] = True
return True
def case0():
result_pointer = [False]
return result_pointer[0] and change_pointer(result_pointer)
Take a moment to walk through the code and deduce the output of case0()
before going to the next section.
>>> case0()
False
If you got it correctly, great! Why is this so?
Let’s look at the return statement:
return result_pointer[0] and change_pointer(result_pointer)
Referring to the left and right sides of the boolean operator and
as a
and b
, a
is bound to the value False
, the first element of result_pointer
even before the change_pointer()
bound to b
executes. Because of short-circuit evaluation, change_pointer
does not even execute! (Notice that “Changing pointer!” is not printed as well)
How can we get this function to return True
instead then?
def case2():
result_pointer = [False]
a = result_pointer[0]
b = change_pointer(result_pointer)
return a and b
In case2()
, we will evaluate a
and b
before passing them to the boolean and
, so that change_pointer()
will be forced to be evaluated.
>>> case2()
Changing pointer!
False
However, the return value of case2()
is still False
, because a
has already been bound to False
value even before change_pointer()
executes; Changing the elements of result_pointer
will have no effect.
def case3():
result_pointer = [False]
b = change_pointer(result_pointer)
a = result_pointer[0]
return a and b
If we want to use the elements of result_pointer
only after change_pointer()
has been evaluated, a
should only be bound after the execution of change_pointer()
. We can also clean up the code to take advantage of short-circuit evaluation should it be desired:
def case4():
result_pointer = [False]
# run change_pointer() first
return change_pointer(result_pointer) and result_pointer[0]
The results are:
>>> case3()
Changing pointer!
True
>>> case4()
Changing pointer!
True
Since I was playing around with the disassembler module the other day, I thought it would be interesting to add in a section to see what goes on behind the scenes, specifically for case1()
and case4()
.
Running dis(func)
shows us the instructions that will be executed in the course of running func
. Here’s what each column represents:
- The line number, for the first instruction of each line
- The current instruction, indicated as
-->
(Not shown in our example above)- A labelled instruction, indicated with
>>
- The address of the instruction
- The operation code name (Reference List)
- Operation parameters
- Interpretation of the parameters in parentheses. Source
Let’s try to understand what happens here, for the return statement.
case1()
>>> dis(case1)
17 0 LOAD_CONST 1 (False)
2 BUILD_LIST 1
4 STORE_FAST 0 (result_pointer)
19 6 LOAD_FAST 0 (result_pointer)
8 LOAD_CONST 2 (0)
10 BINARY_SUBSCR
12 JUMP_IF_FALSE_OR_POP 20
14 LOAD_GLOBAL 0 (change_pointer)
16 LOAD_FAST 0 (result_pointer)
18 CALL_FUNCTION 1
>> 20 RETURN_VALUE
LOAD_FAST
pushes the reference for result_pointer
onto the stack. Current Stack: [result_pointer]
LOAD_CONST
pushes 0
, the index accessor onto the stack. Current Stack: [result_pointer, 0]
BINARY_SUBSCR
takes the top of the stack and uses it to access values from the second element on the stack. Current Stack: [result_pointer, 0, result_pointer[0]]
JUMP_IF_FALSE_OR_POP
checks the top of the stack (result_pointer[0]
). If it is False
, it causes a jump to instruction 20
(which is the return value).14
, 16
and 18
call the change_pointer()
function with its parameters, which leaves the result on top of the stack. Current Stack: [result_pointer, 0, result_pointer[0], change_pointer(result_pointer)]
RETURN_VALUE
returns the boolean value on top of the stackWe can see here a few things that reflect the analysis earlier:
result_pointer[0]
is bound to the parameters of the boolean operator; whether change_pointer()
is called does not affect it’s value on the stackJUMP_IF_FALSE_OR_POP
will skip execution of change_pointer()
should the value on top of the stack be False
— short-circuit evaluationcase4()
>>> dis(case4)
54 0 LOAD_CONST 1 (False)
2 BUILD_LIST 1
4 STORE_FAST 0 (result_pointer)
56 6 LOAD_GLOBAL 0 (change_pointer)
8 LOAD_FAST 0 (result_pointer)
10 CALL_FUNCTION 1
12 JUMP_IF_FALSE_OR_POP 20
14 LOAD_FAST 0 (result_pointer)
16 LOAD_CONST 2 (0)
18 BINARY_SUBSCR
>> 20 RETURN_VALUE
6
, 8
and 10
call the change_pointer()
function with its parameters, which leaves the result on top of the stack. Current Stack: [change_pointer(result_pointer)]
JUMP_IF_FALSE_OR_POP
checks the top of the stack (change_pointer(result_pointer)
). If it is False
, it causes a jump to instruction 20
(which is the return value)case0
, instructions 14
, 16
, and 18
pushes the value of result_pointer[0]
onto the stack. Current Stack: [change_pointer(result_pointer), result_pointer, 0, result_pointer[0]]
RETURN_VALUE
returns the boolean value on top of the stackHere, we see that change_pointer()
is called before any elements from result_pointer
are pushed onto the stack, so their values can be mutated before the final result is returned.
and
and &
On the side, it’s also worth noting that there is a difference between using the logical and
operator and the bitwise &
operators.
a() and b() # b() will not be called if a() is True
a() & b() # b() will be called irregardless of the result of a()
result = result and b()
# vs
result &= b() # No short circuit evaluation
I came across this problem when I was trying to recursively iterate across a tree while extracting out values into a global variable. It might be obvious in simple examples, but these errors are easy to overlook in larger pieces of code.
This is also why using pure functions are much safer and can help us avoid such pitfalls. It’s pretty stupid to do so for the previous example, but it can be written in this contrived manner:
def pure_change_pointer(pointer):
print("Changing pointer! (Not really)")
return True, True
def case5():
result_pointer = [False]
return_val, new_result = pure_change_pointer(result_pointer)
result_pointer[0] = new_result
return result_pointer[0] and return_val
Hopefully you’ll learn something from this post, and avoid making the same mistakes as me. Let me know if you have any feedback at @jiahaog!
I used Python 3.6.5
for the examples in this article, but the concepts here should apply for Python 2 as well.
I’m Jia Hao, and I write software in San Francisco. Follow me on Twitter!