给同事做代码评审的时候,我总是盯着那些超过10行还没有docstring的函数,心里默默吐槽。直到有一天下班,我实在不想再看重复的问题了,于是花了一个周末,用Python的ast模块写了一个小工具,专门从纯代码层面把常见的“坏味道”挑出来。
今天把思路和完整实现分享一下,你不需要安装任何第三方库,Python 3.10+直接就能跑。
为什么选ast而不是正则
最开始有人问,你干嘛不直接用正则匹配“def”和“return”?但我需要理解代码的逻辑结构。比如一个函数里到底有多少个if,只有转换成语法树才能准确地数。正则只适合处理一行字符串,处理不了嵌套块。ast模块能把代码变成一棵节点树,我只需要遍历这棵树,找出我定义的“坏味道”。
先从基础开始:把文件变成AST
核心就是ast.parse(),它能读取Python源码文件的字符串,返回一个Module节点。之后我遍历每个节点,找到所有函数定义、类定义,检查它们的子节点。
import ast
with open("example.py", "r", encoding="utf-8") as f:
code = f.read()
tree = ast.parse(code)
# tree 是一个 Module 节点
print(type(tree)) # <class 'ast.Module'>
接下来我要遍历这棵树。官方推荐使用ast.NodeVisitor,可以针对特定类型的节点写处理方法。比如:
class FuncLinter(ast.NodeVisitor):
def visit_FunctionDef(self, node):
# 这里检查每一个函数定义
self.check_docstring(node)
self.check_param_count(node)
self.generic_visit(node) # 继续访问子节点
注意,visit_FunctionDef负责所有普通函数和类中的方法。还有一个AsyncFunctionDef,也得处理。
规则一:函数必须有docstring
我知道文档字符串不是强制的,但真的能提升代码可读性。检查方法很简单:函数体的第一个语句是ast.Expr,而且值是一个字符串常量。
def check_docstring(self, node):
docstring = ast.get_docstring(node)
if docstring is None:
line = node.lineno
print(f"防静电: 第{line}行的函数 {node.name} 没有文档字符串")
这里ast.get_docstring()是ast模块自带的工具,直接返回函数的docstring内容,没有就返回None。非常方便。
规则二:形参数量超过5个
一个函数形参太多,说明它多半做了太多事。我检查所有位置参数、关键字参数,但不包括self和cls,因为这两个是常规。判断数量是否超过5个。
def check_param_count(self, node):
# node.args.args 是位置参数
# node.args.kwonlyargs 是关键字参数
# node.args.vararg和kwarg 是 *args, **kwargs,不计入常规参数
positional = [a for a in node.args.args if a.arg not in ("self", "cls")]
kwonly = [a for a in node.args.kwonlyargs]
total = len(positional) + len(kwonly)
if total > 5:
print(f"乱参数: 第{node.lineno}行函数 {node.name} 有 {total} 个参数")
规则三:函数中出现了过深的嵌套(简化循环复杂度)
真正的McCabe复杂度要算每个分支、循环、and/or等。我这里简化成统计嵌套控制块的层数,如果超过4层就提示。核心是在遍历所有子节点时,记录当前深度。
def check_condition_depth(self, node):
# 先拿到函数身体
body = node.body
depth = 0
def walk(statements, current_depth):
for stmt in statements:
if isinstance(stmt, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.With, ast.AsyncWith)):
new_depth = current_depth + 1
if new_depth > 4:
print(f"迷宫: 第{node.lineno}行函数 {node.name} 控制流嵌套过深({new_depth})")
# 里面还可能嵌套子语句
walk(stmt.body, new_depth)
if hasattr(stmt, 'orelse'):
walk(stmt.orelse, current_depth)
walk(body, depth)
这个逻辑不算严格,但对于日常审查来说足够了。真正想写复杂代码的人可能故意嵌套六层,这个就帮他们按下了暂停键。
规则四:变量名是单个字母
尤其是除了“i”、“j”、“k”以外的单字母变量,很容易让人看不懂。这个要针对赋值语句的左边名字。
def visit_Assign(self, node):
# 一个赋值可能同时给多个变量赋值: a = b = 0
for target in node.targets:
if isinstance(target, ast.Name):
name = target.id
if len(name) == 1 and name not in "ijkxy":
print(f"模糊: 第{node.lineno}行, 变量 '{name}' 建议使用更见名知意的名字")
self.generic_visit(node)
注意,这里可能把“x,y = func()”拆成两个Name,所以“x”也会报。但我觉得没关系,反正“x”也不是好名字。
组合成一个完整的扫描器
把所有规则放在同一个类里,然后写一个主要的入口函数。这里我直接把所有方法集成在CodeSmellChecker里。
class CodeSmellChecker(ast.NodeVisitor):
def __init__(self):
self.smells = []
def report(self, kind, node, message):
self.smells.append({
"kind": kind,
"line": node.lineno,
"message": message
})
def visit_FunctionDef(self, node):
self.check_docstring(node)
self.check_param_count(node)
self.check_condition_depth(node)
self.generic_visit(node)
visit_AsyncFunctionDef = visit_FunctionDef
def visit_Assign(self, node):
self.check_variable_names(node)
self.generic_visit(node)
# 具体检查方法同上面,只是把print改成report调用
# ... 省略
这样代码更加整洁,之后要添加新规则只需要在visit_方法里加一句调用。
完整代码示例
下面的代码是完整可运行的版本,我把它放在一个叫作code_scan.py的文件里。
import ast
import sys
class CodeSmellChecker(ast.NodeVisitor):
def __init__(self):
self.smells = []
def report(self, kind, node, msg):
self.smells.append({
"kind": kind,
"line": getattr(node, "lineno", 0),
"msg": msg
})
def visit_FunctionDef(self, node):
self.check_docstring(node)
self.check_param_count(node)
self.check_nested_depth(node)
self.generic_visit(node)
visit_AsyncFunctionDef = visit_FunctionDef
def check_docstring(self, node):
if ast.get_docstring(node) is None:
self.report("NO_DOC", node, f"函数 '{node.name}' 缺少文档字符串")
def check_param_count(self, node):
positional = [a for a in node.args.args if a.arg not in ("self", "cls")]
kwonly = [a for a in node.args.kwonlyargs]
total = len(positional) + len(kwonly)
if total > 5:
self.report("TOO_MANY_ARGS", node, f"函数 '{node.name}' 有 {total} 个参数")
def check_nested_depth(self, node):
body = node.body
def walk(statements, depth):
for stmt in statements:
if isinstance(stmt, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.With, ast.AsyncWith)):
nd = depth + 1
if nd > 4:
self.report("DEEP_NEST", stmt, f"函数 '{node.name}' 在第{stmt.lineno}行嵌套过深({nd}层)")
walk(stmt.body, nd)
walk(stmt.orelse, depth)
walk(body, 0)
def visit_Assign(self, node):
for target in node.targets:
if isinstance(target, ast.Name):
name = target.id
if len(name) == 1 and name not in ("i", "j", "k", "x", "y"):
self.report("BAD_NAME", target, f"变量 '{name}' 名字太短,请换一个更有意义的")
self.generic_visit(node)
def scan_file(filename):
with open(filename, "r", encoding="utf-8") as f:
code = f.read()
tree = ast.parse(code)
checker = CodeSmellChecker()
checker.visit(tree)
return checker.smells
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python code_scan.py 目标文件.py")
sys.exit(1)
smells = scan_file(sys.argv[1])
if not smells:
print("一切干净,没有闻到坏味道。")
else:
for s in smells:
print(f"第{s['line']}行 [{s['kind']}] {s['msg']}")
给这段代码跑一跑
我拿一个典型的有坏味道的Python文件做实验,内容如下:
def calc(a,b,c,d,e,f):
# 没docstring,6个参数
result = 0
for i in range(a):
for j in range(b):
for k in range(c):
for l in range(d):
for m in range(e):
result += f
return result
x = 1
y = 2
tmp = x + y
运行命令 python code_scan.py demo.py,得到输出:
第1行 [NO_DOC] 函数 'calc' 缺少文档字符串
第1行 [TOO_MANY_ARGS] 函数 'calc' 有 6 个参数
第5行 [DEEP_NEST] 嵌套过深(6层)
第12行 [BAD_NAME] 变量 'x' 名字太短,请换一个更有意义的
第13行 [BAD_NAME] 变量 'y' 名字太短,请换一个更有意义的
这样就把代码里的坏味道都揪出来了。虽然“tmp”不是单字母,但它其实也不是好名字,但我懒得扩展规则了。你完全可以加一条“变量名包含tmp前缀”的建议。
还能怎么扩展?
这只是个开始。ast能做的事情很多,比如寻找重复的函数定义、检测没有使用的函数参数、检查全局变量修改,甚至还可以自动重构。你可以让你的扫描器理解“策略模式”,发现包含多个elif的巨型函数,提示它拆分成字典映射。
如果深入一点,可以结合symtable模块来分析变量作用域,甚至计算一段代码的圈复杂度。不同团队可以定制不同的规则,我的只是一些通识。
最后说点体会
通过写这个工具,我第一次觉得Python的语法树其实离我们不远。静态分析工具就像“编译器的小助手”,不需要真正运行程序就能看到结构。虽然不能完全代替代码评审,但至少能帮我在人肉扫描之前,先把一些低级问题过滤掉。
那之后,我顺手把它当成一个pre-commit钩子,每次提交代码前自动扫描一遍。效果还不错,至少同事的docstring写得更勤快了。

