我花了三天手写解析器,同事告诉我 Python 自带了一个——ast 模块
有一次我接到一个需求:把一个旧项目里所有 print() 调用改成 logger.info()。
文件有一百多个,手改是不可能的。我想写个脚本用正则替换,大概写了两个小时,越写越崩溃——print 出现在字符串里的怎么办?出现在注释里的怎么办?多行参数的怎么办?
最后我把半成品脚本删掉了,打算手改。
第二天同事来了,看了一眼问题,说:你用 ast 不就行了?
我当时完全不知道这个模块是什么。
ast 是什么
ast 是 Python 标准库里的抽象语法树(Abstract Syntax Tree)模块。
每一段 Python 代码,Python 解释器在运行之前都会把它解析成一棵树。ast 模块让你能够直接操作这棵树——读它、遍历它、修改它、再把它转回代码。
先感受一下:
import ast
code = """
x = 1
print(x + 2)
"""
tree = ast.parse(code)
print(ast.dump(tree, indent=2))
运行结果(部分):
Module(
body=[
Assign(
targets=[Name(id='x', ctx=Store())],
value=Constant(value=1)
),
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
BinOp(
left=Name(id='x', ctx=Load()),
op=Add(),
right=Constant(value=2)
)
],
keywords=[]
)
)
]
)
每一行代码都被拆成了结构化的节点。print(x + 2) 是一个 Call,函数名是 Name(id='print'),参数是 BinOp(二元运算)。
再也不用猜正则会不会误匹配了。
遍历 ast:找出所有函数调用
ast.NodeVisitor 是一个遍历器基类,重写 visit_ 方法就能处理对应类型的节点:
import ast
code = """
def process():
print("start")
result = calculate(x, y)
print(f"result: {result}")
logger.info("done")
"""
class CallFinder(ast.NodeVisitor):
def visit_Call(self, node):
if isinstance(node.func, ast.Name):
print(f"第 {node.lineno} 行: 调用了 {node.func.id}()")
elif isinstance(node.func, ast.Attribute):
print(f"第 {node.lineno} 行: 调用了 {node.func.attr}()")
self.generic_visit(node) # 继续遍历子节点
tree = ast.parse(code)
finder = CallFinder()
finder.visit(tree)
运行结果:
第 3 行: 调用了 print()
第 4 行: 调用了 calculate()
第 5 行: 调用了 print()
第 6 行: 调用了 info()
这个输出精准定位了每一次函数调用,行号准确,不会被注释或字符串干扰。
修改 ast:把 print 换成 logger.info
ast.NodeTransformer 是一个转换器基类,visit_ 返回新节点就完成替换:
import ast
class PrintToLogger(ast.NodeTransformer):
def visit_Call(self, node):
self.generic_visit(node) # 先处理子节点
# 判断是不是 print() 调用
if isinstance(node.func, ast.Name) and node.func.id == 'print':
# 构造 logger.info(...)
new_func = ast.Attribute(
value=ast.Name(id='logger', ctx=ast.Load()),
attr='info',
ctx=ast.Load()
)
node.func = new_func
return node
code = """
print("start processing")
x = calculate(data)
print(f"result: {x}")
"""
tree = ast.parse(code)
new_tree = PrintToLogger().visit(tree)
ast.fix_missing_locations(new_tree)
# 转回代码字符串
import ast
print(ast.unparse(new_tree))
运行结果:
logger.info('start processing')
x = calculate(data)
logger.info(f'result: {x}')
精准替换,只动了 print,其他代码一字未改。
处理一百个文件的脚本我下午就写完了,两个小时都不到。
静态分析:找出代码里所有没用的变量
这是一个稍微进阶一点的用法——找出函数里赋值了但从没读过的变量:
import ast
class UnusedVarFinder(ast.NodeVisitor):
def __init__(self):
self.assigned = {} # 变量名 -> 赋值节点行号
self.used = set() # 被读过的变量名
def visit_Assign(self, node):
for target in node.targets:
if isinstance(target, ast.Name):
self.assigned[target.id] = node.lineno
self.generic_visit(node)
def visit_Name(self, node):
if isinstance(node.ctx, ast.Load):
self.used.add(node.id)
self.generic_visit(node)
def report(self):
unused = {name: line for name, line in self.assigned.items()
if name not in self.used}
for name, line in unused.items():
print(f"第 {line} 行: 变量 '{name}' 赋值后从未使用")
code = """
def process(data):
result = transform(data)
temp = result * 2 # temp 赋值但没用
output = format(result)
return output
"""
tree = ast.parse(code)
finder = UnusedVarFinder()
finder.visit(tree)
finder.report()
运行结果:
第 4 行: 变量 'temp' 赋值后从未使用
pylint、flake8 背后的原理就是这个,只是做得更全面更精确。
什么时候值得用 ast
不是所有字符串处理都要上 ast,但有几类场景用了之后会后悔没早用:
- 批量修改代码风格(函数调用替换、导入语句整理)
- 代码合规检查(禁止用某些函数、找出潜在危险操作)
- 自动生成代码(从类定义自动生成序列化代码)
- 分析项目依赖(找出哪些模块被哪些文件 import)
以前我碰到这类需求,第一反应是正则。现在第一反应是 ast。
正则处理的是字符串,ast 处理的是语义。这是两个不同维度的东西,不是同一个工具在不同场景的选择,是完全不同的抽象层次。
那次 print 替换之后,我在项目里又用 ast 写了一个代码审查工具,专门检查有没有直接用 open() 而不用 with 的写法,跑一遍全扫出来,省了不少 review 时间。
学 Python 这几年,每隔一段时间都会发现标准库里有个自己从来没用过但绝了的模块。ast 是让我最意外的一个。
你用过 ast 或者类似的代码分析工具吗?欢迎评论区聊聊,说不定我也没想到的用法。
最后,别忘了关注「有为大青年」,我们下期见~