详解 exec 背后发生了什么

昨天在写 python 调用 java 的通用函数时遇到一个问题,java 不支持用 * 号表示不确定数量的参数的用法,在执行语句时必须将参数全部写出并用逗号隔开。因此解决方案是先用 * 号解包提供的参数,然后用字符串格式化把参数组装成字符串,再利用 exec 执行调用 java 的语句,最后用 locals() 字典获取执行得到的结果。

其中有很多细节问题,下面来详细说说 exec 这个有意思的东西。

我们先去 Python 官方文档看看:
详解 exec 背后发生了什么

基本上有以下要点:

  1. exec 函数的返回值是None
  2. 可以省略 globals 和 locals 两个可选项
  3. 如果 globals 字典中不包含 __builtins__ 键值对,就会自动加入;但是如果已经包含了就不会再自动加入了。因此可以通过自己添加 __builtins__ 来控制可以使用哪些内置代码
  4. 不要改变默认的 locals 字典
  5. exec 函数会对 locals 产生一些变动

用例子证明上述 1-3 点:

def foo():
    a = {'x':1}
    b = {}
    print(exec("x+=1", b, a))  # None
    print(b)  # {'__builtins__': {'__name__':......}}

foo()

def foo2():
    a = {'x':1}
    b = {'__builtins__': None}
    exec("x+=1", b, a)
    print(b)  # {'__builtins__': None}

foo2()

其他的就有点云里雾里,为什么不要试图改变默认的 locals 字典,exec 函数会造成哪些变动?

要知道这些,只有自己写代码来验证。我们先看一个小例子:

def test():
    x = 0
    loc = locals()
    print(loc)  # {'x': 0}
    exec("x += 1")
    print(loc)  # {'x': 1, 'loc': {...}}
    locals()
    print(loc)  # {'x': 0, 'loc': {...}}

通过最后三行我们看到,在运行了 locals() 之后,之前的 x=1 变成了 x=0,我们猜测执行 locals() 函数会获取当前局部变量(直接忽略 exec 执行的结果)的值并且保存到字典中

第二个例子:

def test2(x):
    x = 1
    loc = locals()
    print(loc)  # {'x': 1}
    exec("y = 2")
    print(loc)  # {'x': 1, 'loc': {...}, 'y': 2}
    print(locals())  # {'x': 1, 'loc': {...}, 'y': 2}
	
test2(4)

由最后一行我们知道,执行 locals() 函数会获取当前局部变量的值,但并不是直接把字典变成获取后的结果,而是更新字典,因为最后的字典中还是有 y=2,而 y=2 是在 exec 中得到的,如果不是更新字典而是直接生成字典的话,应该没有 y 才对。

下面得到我们的第一个结论:执行locals()函数会收集最近的局部变量的值(不包含执行exec后更新的结果)并将结果更新到原来的局部变量字典中

第三个例子:

def test3(x):
    x = 1
    loc = locals()
    print(loc)  # {'x': 1}
    exec("x += 1")
    print(loc)  # {'x': 2, 'loc': {...}}
    exec("x += 1")
    print(loc)  # {'x': 2, 'loc': {...}}
    print(locals())  # {'x': 1, 'loc': {...}}
	
foo(4)

明明用 exec 执行了两次 x += 1,结果得到的 x 都是 2,而且中间我们用的是 loc,避免了直接调用 locals() 函数来把第一次得到的 x=2 给覆盖掉,为什么还是像被覆盖掉了一样呢。这边就要引出我们的第二个结论:exec如果不提供局部命名空间,默认使用locals()作为局部命名空间,在这个过程中会先执行locals()函数并将得到的结果作为exec的默认参数

根据这个结论,在我们第二次运行 exec 时,会先执行 locals(),根据第一个结论,执行 locals() 之后 x 从 2 更新变成了函数最开始的 1,然后 exec 又将 x 增加 1 变成了 2。这边很明显 exec 运行的结果改变了局部命名空间,那么 exec 是否只会改变局部命名空间呢,我们继续往下看。

x = 0

def test4():
    loc = locals()
    exec('x+=1', globals())
    print(loc)
    print(globals())
	
test4()
globals()
print(x)
{}
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__':<_frozen_importlib_external.SourceFileLoader object at 0x00000176F7160488>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'c:/Users/yuryq/Desktop/Untitled-1.py', '__cached__': None, 'x': 1, 'test4': <function test4 at 0x00000176F7231F78>}
1

这次执行 exec 并没有更新局部命名空间,而是更新了全局命名空间。虽然没有提供局部命名空间时会默认把 locals() 作为局部命名空间,但是在其中并没有找到 x,而是在全局命名空间中找到了 x,那么就会把改变更新到全局命名空间中。于是我们猜测:执行 exec 会去命名空间中搜寻变量,优先查找局部命名空间,然后是全局命名空间,在哪个命名空间中找到了就会将改变更新到那个命名空间中。下面再看一个例子:

x = 0

def test5():
    loc = locals()
    print(loc)  # {}
    exec("x += 1")
    print(loc)  # {'loc': {...}, 'x': 1}
    print(globals())  # {..., 'x': 0, 'test5': <function test5 at 0x000001A2D1FD1F78>}

test5()

这边局部命名空间肯定找不到我们需要的 x,而是在全局命名空间中找到的,但是最后却将改变更新到了局部命名空间,也就是说,上面的结论应该重新修改一下:执行exec会去命名空间中搜寻变量,如果未提供参数或者提供了两个命名空间,则优先查找局部命名空间,然后是全局命名空间。如果只提供了一个命名空间,则只会从那个命名空间进行查找。如果未提供默认命名空间,则会优先将改变更新到局部命名空间中,否则更新到提供的命名空间中

另外我们注意到一个小细节,在最后打印 x 的值之前先运行了一下 globals(),如果类似 locals() 的作用,最后的 x 应该会被更新成最开始的 0,但是最后打印出来的是 1,也就是说,globals()会呈现任何对全局命名空间的改动,而不是像locals()那样会忽略exec的作用

下面用最后一个例子收尾:

def test6():
    x = 1
    loc = locals()
    print(loc)
    exec(" x += 1")
    print(loc)
    exec("x += 1", globals(), loc)
    print(loc)
    print(locals())
	
test6()

试着用上面得到的结论去预测一下打印出来的结果吧,然后运行看看实际情况跟你预测的是否一致。 😆