我用Python标准库ast模块写了个代码坏味道扫描器

2026-08-16 0 585

给同事做代码评审的时候,我总是盯着那些超过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写得更勤快了。

我用Python标准库ast模块写了个代码坏味道扫描器
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python 我用Python标准库ast模块写了个代码坏味道扫描器 https://www.taomawang.com/server/python/2551.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务