bigywater.tech
Python · 81

ast:Python 源码如何变成抽象语法树

Python 在执行代码之前先把源码变成一棵树。这篇用 ast 模块把这棵树摊开来看:括号去哪了、注释去哪了、优先级怎么变成形状、改树之后再执行会怎样——每一项都是真跑出来的。

1759 个节点
json/decoder.py 这一个文件(364 行)的 AST 节点数
39
把 10*2+5 里所有整数常量 +1 之后重新编译,执行出的结果(11*3+6)
LOAD_SMALL_INT 7
2*3+1 在编译时就折叠成了 7

验证一:一行赋值的语法树

源码 x = a + b * c
实测 · 本机执行输出
ast.dump(ast.parse(src), indent=2)
Module(
  body=[
    Assign(
      targets=[
        Name(id='x', ctx=Store())],
      value=BinOp(
        left=Name(id='a', ctx=Load()),
        op=Add(),
        right=BinOp(
          left=Name(id='b', ctx=Load()),
          op=Mult(),
          right=Name(id='c', ctx=Load()))))])

根是 Module,里面一条 Assign,赋值号右边是一个 BinOp。注意加号是根、乘号被挂在加号的右子树里——+ 的优先级低于 *,所以乘法在树里更深,更早求值。Name 上的 ctx=Load()/Store() 区分「读」和「写」这个名字。

验证二:括号不在树里

优先级和括号只用形状来表达
实测 · 本机执行输出
  a + b * c      BinOp(left=Name(id='a', ctx=Load()), op=Add(), right=BinOp(left=Name(id='b', ctx=Load()), op=Mult(), right=Name(id='c', ctx=Load())))
  (a + b) * c    BinOp(left=BinOp(left=Name(id='a', ctx=Load()), op=Add(), right=Name(id='b', ctx=Load())), op=Mult(), right=Name(id='c', ctx=Load()))
  a + (b * c)    BinOp(left=Name(id='a', ctx=Load()), op=Add(), right=BinOp(left=Name(id='b', ctx=Load()), op=Mult(), right=Name(id='c', ctx=Load())))
  'a + b * c' 与 'a + (b * c)' 的树相同? True

三个写法里,(a + b) * c 的树形状不同(加号在乘号下面);而 a + b * c 和 a + (b * c) 的树完全相同——那对多余的括号从解析结果里消失了。

验证三:注释和空白也不在树里

实测 · 本机执行输出
  带注释和多余空白的源码与干净源码, ast.dump 相同? True
  ast.unparse(ast.parse(a1)) = 'x = 1' (注释丢了, 格式统一了)
  ast.unparse(ast.parse('(a + b) * c')) = '(a + b) * c' (括号是按需重新生成的)

带注释和一堆空白的源码,与干净源码的 ast.dump 完全一样。所以「用 ast 解析后再 unparse 回写」会丢注释、统一格式(括号按需重新生成)——需要保留注释和格式的重构工具不能只靠 ast。位置信息则保留在每个节点上:lineno、col_offset、end_lineno、end_col_offset,可以精确指回源码。

实测 · 本机执行输出
  BinOp: lineno=1 col_offset=9 end_lineno=2 end_col_offset=7; Assign 的 _fields = ('targets', 'value', 'type_comment')

验证四:拿真实文件数一数

实测 · 本机执行输出
  文件 364 行, 共 1759 个节点, 48 种类型; 出现最多的 8 种:
    Name           436
    Load           424
    Constant       148
    Store          116
    Assign         90
    Call           75
    Attribute      61
    BinOp          38
  函数定义: ['_decode_uXXXX', 'py_scanstring', 'JSONObject', 'JSONArray', '__init__', '__reduce__']

标准库的 json/decoder.py 364 行,树里有 1759 个节点、48 种类型。最多的是 Name 和 Load——绝大多数节点都是「读一个名字」。ast.walk 可以遍历整棵树,写 lint 工具、找函数定义、统计调用,都是这类遍历。

验证五:改树,再让 Python 执行

实测 · 本机执行输出
  改写后的源码: "result = 11 * 3 + 6\nprint('原代码里写的是 10*2+5, 计算结果:', result)"
原代码里写的是 10*2+5, 计算结果: 39
  (11 * 3 + 6 = 39)

用 NodeTransformer 把所有整数常量加 1,unparse 出来的是 11 * 3 + 6,compile 后执行得到 39;而 print 里的字符串常量没有被动(我只改了 int)。整个流程就是:解析成树 → 改树 → 重新编译 → 执行,不需要碰源码文本。

验证六:literal_eval 和 eval 的区别

实测 · 本机执行输出
  literal_eval("{'a': [1, 2, (3, 4)]}") = {'a': [1, 2, (3, 4)]}
  literal_eval("__import__('os').getcwd()") → ValueError: malformed node or string on line 1: Call(func=Attribute(value=Call(func=Name(...), args=[Constant(...)], keywords=[]), attr='getcwd', ctx=Load()), args=[], keywords=[])
  eval("__import__('os').getcwd()") → 直接执行了, 返回 <当前目录>

literal_eval 只接受字面量(字典、列表、元组、数字、字符串)组成的树,遇到函数调用这样的节点直接 ValueError;eval 则毫不犹豫地执行了 __import__('os').getcwd()。解析来自外部的配置字符串,请用 literal_eval——但要记住它也不是万能沙箱,它只保证「不执行代码」,不保证「输入合理」。

验证七:编译器在树上做的事

常量折叠 + 后序遍历
实测 · 本机执行输出
  x = 2 * 3 + 1    → [('LOAD_SMALL_INT', 7), ('LOAD_CONST', 'None')]
  x = a * b + 1    → [('LOAD_NAME', 'a'), ('LOAD_NAME', 'b'), ('BINARY_OP', '*'), ('LOAD_SMALL_INT', 1), ('BINARY_OP', '+'), ('LOAD_CONST', 'None')]
  a + b * c 的字节码执行顺序: [('LOAD_NAME', 'a'), ('LOAD_NAME', 'b'), ('LOAD_NAME', 'c'), ('BINARY_OP', '*'), ('BINARY_OP', '+')]

2 * 3 + 1 编译后只有一条 LOAD_SMALL_INT 7——编译器在树上就把常量算掉了,运行时没有任何乘法。而 a * b + 1 里有变量,保留了完整的运算:先 LOAD a、LOAD b、*,再 + 1。a + b * c 的执行顺序是 a、b、c、乘、加,正是这棵树的后序遍历。

分步演示:一棵表达式树的生长与求值

树的形状、字节码顺序都来自上面的真实输出
a + b * c 的语法树
当前节点 / 当前字节码

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 ast、dis
    • 没有验证:词法分析(tokenize)阶段的细节(见第 83 篇)、类型注释、不同 Python 版本 AST 节点的差异、更复杂语句(函数、类、推导式)的树形状
    京ICP备2026067502号-1京公网安备11010502063487号