eval、exec、compile 三个函数常被混着用,其实是同一条流水线的不同环节:字符串 → code 对象 → 在某个字典里执行。这篇把每个环节的输入输出、命名空间、以及「空 builtins 就安全」的误解都实测了一遍。
compile('1 + 2' , mode='eval' ) → 成功, co_names=(), co_consts=(1,)
compile('x = 1' , mode='eval' ) → SyntaxError: invalid syntax
compile('x = 1\ny = x + 1', mode='exec' ) → 成功, co_names=('x', 'y'), co_consts=(1, None)
compile('1 + 2' , mode='single') → 成功, co_names=(), co_consts=(1, None)
compile('x = 5' , mode='single') → 成功, co_names=('x',), co_consts=(5, None)
eval('1 + 2') 返回: 3 | exec('1 + 2') 返回: None
mode='single' 执行表达式语句会像交互式解释器那样打印:
3mode='eval' 只接受一个表达式,x = 1 这种语句直接 SyntaxError;'exec' 接受任意语句,co_names 里列出了会用到的名字;'single' 是交互式解释器用的模式,表达式语句的值会被打印出来(输出里的孤零零的 3)。返回值上:eval 返回表达式的值,exec 永远返回 None。
exec 之后 ns 里的键: ['__builtins__', 'x', 'f']
ns['__builtins__'] 的类型: dict (exec 自动塞进去的)
ns['f']() = 20
在函数里 exec('z = 99') 然后读 z → NameError: name 'z' is not defined
给 exec 传一个显式 locals 字典 → {'z': 99}exec 把定义的名字都写进你传的字典,并且会自动往里加一个 __builtins__ 键(值是一个字典)。在函数里直接 exec("z = 99"),然后读 z,得到 NameError——exec 改的是一份局部变量快照,不是函数真实的局部变量。想拿到结果,就显式传一个字典进去读,像最后一行那样。
exec('print(...)', {'__builtins__': {}}) → NameError: name 'print' is not defined
仍然没有任何内置名字, 却通过对象的类关系拿回了 __import__ 并调用了 os.getpid() → 73740 | 与当前进程 pid 相同? True
().__class__.__base__.__subclasses__() 里有 280 个类可以被脚本摸到__builtins__ 设成空字典,直接写 print 确实会 NameError,很多人因此以为「安全了」。但 Python 的对象之间有大量相互引用:从一个元组 () 出发沿着 __class__ → __base__ → __subclasses__() 能摸到 280 个类,其中某个类的实例里又带着 __builtins__,于是 __import__ 被找回来,os.getpid() 被调用,返回值和当前进程一致。不要用 eval/exec 加受限 builtins 去执行不可信的输入。解析不可信的字面量用 ast.literal_eval(见上一篇);要执行不可信代码,需要的是进程级隔离,而不是替换命名空间。
co_argcount = 2 | co_varnames = ('a', 'b', 'c') | co_consts = (2,) | co_names = () | co_filename = e82.py | co_firstlineno = 37
co_code 前 8 字节: 800057012c000000 → 字节码是 bytes
exec(code) → 42函数的 __code__ 和 compile 的返回值是同一种东西:参数个数、局部变量名、常量表、用到的全局名、定义所在的文件与行号,以及字节码本身(co_code,就是 bytes)。exec 一个 code 对象和 exec 一个字符串效果一样,只是跳过了编译。
File "<用户配置第3行>", line 2, in <module> ZeroDivisionError: division by zero SyntaxError 里的文件名/行号: <用户配置> 1
compile 的第二个参数就是回溯里显示的「文件名」。给用户配置、模板、DSL 起一个有意义的名字(比如 <用户配置第3行>),出错时一眼就知道是哪一段。SyntaxError 里也带着 filename 和 lineno。
optimize=0: AssertionError: boom optimize=1: assert 之后的代码执行了
$ python3 -c "assert 1 == 2, \"boom\"; print(\"assert 之后的代码执行了\")" AssertionError: boom $ python3 -O -c "assert 1 == 2, \"boom\"; print(\"assert 之后的代码执行了\")" assert 之后的代码执行了
optimize=1 时 assert 语句根本没有被编译进去——命令行的 -O 开关也是同样的效果(见上面加了 -O 的那条命令),所以用 assert 做的校验会整个消失。绝不要用 assert 做输入校验或权限检查。
eval(字符串) 6623 ns/次 | eval(预编译 code) 205 ns/次 | 直接写成 Python 表达式 124 ns/次
同一个表达式,eval(字符串) 每次 6623 ns;先 compile 一次,再反复 eval(code) 只要 205 ns,快了约 32 倍;而直接写成 Python 表达式是 124 ns。如果要在循环里反复求值同一段表达式(比如用户配置的规则),一定要预编译。
compile、eval、exec,标准库 dis、tracebacklocals() 的具体语义在不同版本的差异、flags/dont_inherit 的其他取值、更严格的沙箱手段(进程隔离等)