bigywater.tech
Python · 82

compile、eval 和 exec:Python 如何动态执行代码

eval、exec、compile 三个函数常被混着用,其实是同一条流水线的不同环节:字符串 → code 对象 → 在某个字典里执行。这篇把每个环节的输入输出、命名空间、以及「空 builtins 就安全」的误解都实测了一遍。

3 种 mode
eval 只接表达式、exec 接语句、single 像交互式解释器
6623 → 205 ns
eval(字符串) 与 eval(预编译 code) 单次耗时
280 个类
空 __builtins__ 下仍能通过 () 摸到的类,足以找回 __import__

验证一:三种 mode 的区别

实测 · 本机执行输出
  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' 执行表达式语句会像交互式解释器那样打印:
3

mode='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 改的是一份局部变量快照,不是函数真实的局部变量。想拿到结果,就显式传一个字典进去读,像最后一行那样。

验证三:空 __builtins__ 不是沙箱

实测 · 本机执行输出
  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(见上一篇);要执行不可信代码,需要的是进程级隔离,而不是替换命名空间。

验证四:code 对象里有什么

实测 · 本机执行输出
  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 一个字符串效果一样,只是跳过了编译。

验证五:文件名会出现在回溯里

所以别总用默认的 "<string>"
实测 · 本机执行输出
  File "<用户配置第3行>", line 2, in <module>
  ZeroDivisionError: division by zero
  SyntaxError 里的文件名/行号: <用户配置> 1

compile 的第二个参数就是回溯里显示的「文件名」。给用户配置、模板、DSL 起一个有意义的名字(比如 <用户配置第3行>),出错时一眼就知道是哪一段。SyntaxError 里也带着 filename 和 lineno。

验证六:optimize 会去掉 assert

实测 · 本机执行输出
  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 字符串都要重新编译

实测 · 本机执行输出
  eval(字符串)     6623 ns/次 | eval(预编译 code)      205 ns/次 | 直接写成 Python 表达式      124 ns/次

同一个表达式,eval(字符串) 每次 6623 ns;先 compile 一次,再反复 eval(code) 只要 205 ns,快了约 32 倍;而直接写成 Python 表达式是 124 ns。如果要在循环里反复求值同一段表达式(比如用户配置的规则),一定要预编译。

分步演示:字符串怎样变成命名空间里的名字

每一步的字典都是上面实验的实际内容
exec / eval 与命名空间
此刻新写入的名字

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,内置 compile、eval、exec,标准库 dis、traceback
    • 没有验证:locals() 的具体语义在不同版本的差异、flags/dont_inherit 的其他取值、更严格的沙箱手段(进程隔离等)
    京ICP备2026067502号-1京公网安备11010502063487号