Into the depths of a Python exec scope-handling bug

May 1, 2012

I can rarely resist a Python mystery, so when I saw The exec Statement and A Python Mystery (via Hacker News) my eyes lit right up. Here is the code in slightly shortened form (from here, via HN again):

def exec_and_return(codeobj, x):
  exec codeobj
  return x

co1 = compile("x = x + 1", '<s>', 'exec')
co2 = compile("del x", '<s>', 'exec')

print exec_and_return(co1, 1)
print exec_and_return(co2, 1)

I'll save you running this in the interpreter and tell you the answer; this prints 2 (what you'd expect) and then 1 (not what you'd expect, you'd expect an error since x is being referenced after having been deleted).

The deep answer to what is going on here is that CPython's exec imperfectly handles running code in a function scope, or specifically in matching up the scope between function scope and the compiled code's scope. The difficulties are ultimately caused by how CPython implements function scope (and accesses a function's local variables) at a low level.

Used together this way, compile() and exec are specified to take code, compile it generically (without you having to specify what sort of scope it will run in), and then have exec run that generically compiled code in the current scope, whatever that is. exec can be called on to run the same compiled code object at the module ('global') level, in a class being created, or inside a function or a closure, and it must make all of them work. In most language implementations this would be straightforward because all of these different sorts of scopes would be implemented the same way (with the same internal data structures and the same way of accessing variables). With a uniform implementation of scopes, exec and the compiled code wouldn't really care just what sort of scope it was running in; everything would work the same way regardless. Unfortunately for us, CPython doesn't work this way.

In CPython, function local variables are stored and accessed in a completely different and much faster way than variables are in all other scopes. Everything except functions holds variables in a dictionary (more or less) and accesses them by name; functions store local variables and function arguments in an indexed array and accesses them by their index number. This difference is reflected in the compiled code, where code in a function uses completely different bytecode operations to access variables than all other code does. This means that code compiled for a non-function context can't directly use a function's scope and vice versa (and in fact code compiled for one function can't use another functions's scope, because even if they have variable names in common the array offsets are probably going to be different).

(Closures can use yet another set of bytecodes with somewhat peculiar effects.)

Because it basically has no choice, compile() creates code objects that are natively designed to run in a non-function scope; they access and manipulate variables through the name lookup bytecodes (you can see this by doing 'import dis; dis.dis(co1.co_code)'). This means that when exec runs a compiled code object in a function context, it must somehow fix up the difference between the array-based function scope that actually exists and the name-based scope that the compiled code expects. So what exec does is that it materializes a fake name-based local scope (which is very similar to what you get from locals()), feeds it to the compiled code object as the local scope, and when the compiled code object finishes it copies all of the changes from the fake local scope object back to the real function scope.

Except, of course, exec has a bug; it doesn't copy all of the changes, as we've seen here. exec will copy back changes in variable values (well, their bindings) but it doesn't notice when variables have been deleted. So co1 changes the value of its local x to 2 and this change is copied back to the function scope, making the function return 2, but when co2 deletes x this deletion is not copied back; the function scope x is left untouched at 1 and is then returned.

(This bug is not intrinsic to how CPython allows function local variables to be manipulated, but fixing it would probably be complex.)

Sidebar: why compile() has no choice

The short answer is that using bytecode that's designed to run in a non-function scope is the only way that compile() can create code objects that can at least sometimes run without fixups. Even if compile() used the bytecodes for accessing a function's local variables, it doesn't know the proper array offsets for the various names; in fact, the proper array offsets will vary between functions. Consider:

def exec2(a, b, co):
  x = a + b
  exec co
  return x

In our original function, x is in the second local variable slot. In exec2, x is in the fourth local variable slot. Executing co would thus need some sort of indirect mapping table for the slot offsets in order for it to manipulate the right variable. And of course exec would have to make up a fake local scope and array if co was run in a module context.

Worse, the fixups needed in a module context would be quite challenging because the compiled code run by exec can itself make calls to outside functions. When the code called out to any outside function, exec would have to quickly copy any changes from the fake local array back to the modules's scope, call the outside function, then copy changes from the module's scope back into the fake local scope array. Let's just say no to that idea.

Written on 01 May 2012.
« Notes to myself about progressive JavaScript
Python scopes and the CPython bytecode opcodes that they use »

Page tools: View Source, Add Comment.
Login: Password:
Atom Syndication: Recent Comments.

Last modified: Tue May 1 23:58:20 2012
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.