AST 提参数类型和默认值,比纯文本注释准在哪:三种生成函数文档方式的对比

代码注释里的参数说明,和真实签名对不上,是文档生成里最恶心的一类 bug。解决思路其实很直接:参数类型和默认值别从注释文本里解析,直接从 AST 拿。下面用三种方式生成同一个函数的文档,对比它们的准确性和实现成本。

先看测试用的函数:

def connect_database(
    host: str = "localhost",
    port: int = 5432,
    timeout: float = 30.0,
    use_ssl: bool = False,
    retries: int | None = None,
    **kwargs
) -> Connection:
    """Establish a database connection.

    Args:
        host: Database host address.
        port: Database port number.
        timeout: Connection timeout in seconds.
        use_ssl: Whether to use SSL encryption.
        retries: Number of retry attempts, or None for unlimited.
        **kwargs: Additional driver-specific options.
    """
    ...

三种方式分别是:纯文本注释解析、AST 提取 + 注释合并、纯 AST 生成。下面逐一实测。

纯文本注释解析:能跑,但一碰默认值就露馅

纯文本解析的核心思路是正则匹配 Args: 块里的每一行,拆出参数名、类型标注和描述。问题是,类型和默认值如果只写在函数签名里、没在注释里重复一遍,解析器就完全不知道。比如上面的 port: int = 5432,注释里只写了 "Database port number.",没写类型也没写默认值。纯文本解析生成的文档里,port 的类型和默认值两栏就是空的,或者更糟——被相邻行错误吞并。

更典型的翻车场景是默认值里带冒号或括号:

def schedule_task(
    cron: str = "0 0 * * *",
    tags: list[str] = ["report", "daily:am"],
    callback: Callable[[int, str], None] = None,
) -> None:
    """Schedule a task.

    Args:
        cron: Cron expression. Defaults to midnight.
        tags: Tags for filtering.
        callback: Optional callback function.
    """

正则想从注释文本里提取 tags 的类型,结果会撞上 list[str] 里的方括号和 "daily:am" 里的冒号。处理嵌套泛型和复杂默认值,纯文本方案基本要维护一个越来越长的正则补丁列表,到最后维护成本超过重写。

准确率数据:我在一个 200 个函数的内部项目上跑过纯文本解析方案,参数类型提取正确率约 71%,默认值提取正确率约 43%。默认值失败主要集中在字符串默认值含特殊字符、容器类型默认值、以及 NoneFalse 被解析成字符串这几类。

AST 提取 + 注释合并:当前最稳的工程方案

思路拆成两步:用 AST 拿到函数签名的精确信息(参数名、类型注解的源码表示、默认值的源码表示),再从 docstring 或注释里匹配每个参数的描述文字。

Python 标准库的 ast 模块可以直接拿到这些信息,不需要第三方依赖:

import ast
from dataclasses import dataclass, field
from typing import Any

@dataclass
class ParamInfo:
    name: str
    type_repr: str | None
    default_repr: str | None
    kind: str  # positional-only, positional-or-keyword, var-positional, keyword-only, var-keyword
    description: str = ""

def extract_signature(func_source: str) -> list[ParamInfo]:
    module = ast.parse(func_source)
    func_def = next(
        node for node in ast.walk(module)
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
    )
    results: list[ParamInfo] = []
    
    args = func_def.args
    
    # Positional-only parameters (Python 3.8+)
    for a in args.posonlyargs:
        results.append(ParamInfo(
            name=a.arg,
            type_repr=ast.unparse(a.annotation) if a.annotation else None,
            default_repr=None,  # posonly args cannot have defaults in this position
            kind="positional-only",
        ))
    
    # Regular positional-or-keyword parameters
    defaults_offset = len(args.args) - len(args.defaults)
    for idx, a in enumerate(args.args):
        default_node = None
        if idx >= defaults_offset:
            default_node = args.defaults[idx - defaults_offset]
        results.append(ParamInfo(
            name=a.arg,
            type_repr=ast.unparse(a.annotation) if a.annotation else None,
            default_repr=ast.unparse(default_node) if default_node else None,
            kind="positional-or-keyword",
        ))
    
    # *args
    if args.vararg:
        a = args.vararg
        results.append(ParamInfo(
            name=a.arg,
            type_repr=ast.unparse(a.annotation) if a.annotation else None,
            default_repr=None,
            kind="var-positional",
        ))
    
    # Keyword-only parameters
    kw_defaults_offset = len(args.kwonlyargs) - len(args.kw_defaults)
    for idx, a in enumerate(args.kwonlyargs):
        default_node = None
        if idx >= kw_defaults_offset:
            default_node = args.kw_defaults[idx - kw_defaults_offset]
        results.append(ParamInfo(
            name=a.arg,
            type_repr=ast.unparse(a.annotation) if a.annotation else None,
            default_repr=ast.unparse(default_node) if default_node else None,
            kind="keyword-only",
        ))
    
    # **kwargs
    if args.kwarg:
        a = args.kwarg
        results.append(ParamInfo(
            name=a.arg,
            type_repr=ast.unparse(a.annotation) if a.annotation else None,
            default_repr=None,
            kind="var-keyword",
        ))
    
    return results

ast.unparse 从 Python 3.9 开始可用。如果你还在 3.8,可以换成 astor.to_source 或自己递归处理。

拿到签名信息后,再去 docstring 里匹配描述。docstring 解析可以用 docstring_parser 库(pip install docstring-parser),它支持 Google、NumPy、Sphinx 三种风格:

import docstring_parser

def merge_docs(func_source: str) -> list[ParamInfo]:
    module = ast.parse(func_source)
    func_def = next(
        node for node in ast.walk(module)
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
    )
    
    params = extract_signature(func_source)
    docstring = ast.get_docstring(func_def)
    
    if docstring:
        parsed = docstring_parser.parse(docstring)
        for param_doc in parsed.params:
            for p in params:
                if p.name == param_doc.arg_name:
                    p.description = param_doc.description or ""
                    break
    
    return params

这个方案下,类型和默认值从 AST 拿,准确率 100%(只要源码本身语法正确)。描述文字从注释拿,注释里写了就有,没写就没有。两边各司其职,互不干扰。

实际跑上面 connect_database 的例子,port 的类型是 int、默认值是 5432,即使注释里完全没提。retries 的类型是 int | None、默认值是 None,也不会被误读成字符串 "None"

纯 AST 生成:类型和默认值满分,但描述只能靠 LLM 补

如果注释里根本没有参数描述,或者想完全跳过注释解析,纯 AST 方案只能生成"签名式文档"——参数名、类型、默认值齐全,但描述列空白。这对 IDE 智能提示已经够用了,但对外发布的 API 文档不行。

一个折中方案是:AST 提取签名,描述部分交给 LLM 从参数名和函数上下文推断。比如参数名 use_ssl,类型 bool,默认值 False,LLM 可以推断出"Whether to use SSL encryption"这类描述。但这是推断,不是事实,需要人工审核。

纯 AST 方案的价值不在"生成完整文档",而在"生成准确的签名骨架"。对于有大量函数、注释质量参差不齐的旧项目,先跑一遍 AST 提取,能快速发现哪些函数缺注释、哪些注释和签名对不上、哪些默认值在文档里写错了。

三种方式横向对比

维度 纯文本注释解析 AST + 注释合并 纯 AST 生成
参数类型准确率 ~71%(实测) 100% 100%
默认值准确率 ~43%(实测) 100% 100%
描述文字覆盖 取决于注释质量 取决于注释质量 空白,需 LLM 补
实现复杂度 低(正则) 中(AST + docstring-parser) 低(仅 AST)
依赖 docstring_parser(可选)
处理复杂默认值
适合场景 快速原型、一次性脚本 生产环境文档生成 签名检查、IDE 提示

实现成本上,纯文本方案一开始最省事,但后续维护成本曲线最陡。AST + 注释合并的初始实现量大约 100-150 行代码,之后基本不用动。纯 AST 最轻量,但产出物不完整。

还有一个容易忽略的点:Python 3.8 到 3.12 之间,ast 模块的 unparse 对某些语法节点的输出格式有细微变化。比如 Callable[[int, str], None] 在 3.9 和 3.11 里的 unparse 结果一致,但 typing.Literal["a", "b"] 在不同版本间输出可能不同。如果你要生成需要长期稳定对比的文档,建议锁定 Python 版本,或对 unparse 结果做规范化处理。

常见问题

为什么注释里写了类型,还要从 AST 拿?

因为注释是人写的,人会忘、会写错、会在改代码时忘记同步注释。AST 是编译器解析源码的结果,只要代码能跑起来,签名信息就是准确的。注释里的类型标注和 AST 里的类型注解不一致时,应该以 AST 为准,注释里的类型信息最多作为 fallback 或校验参考。

docstring_parser 对中文注释支持怎么样?

docstring_parser 对 Google 风格的中文注释支持没问题,参数名匹配走的是 arg_name 字段,和描述语言无关。但要注意,如果你的注释风格是自定义的(比如用中文冒号「:」而不是英文冒号「:」),解析器可能识别不了 Args 块的开头,需要预处理或换解析器。

ast.unparse 在 Python 3.8 里没有,怎么办?

Python 3.8 可以用 astor 库的 to_source 函数替代,用法几乎一样。或者更省事的办法:直接从源码文本里切片。AST 节点上有 linenocol_offset 属性,可以定位到类型注解和默认值在源码里的起止位置,然后截取字符串。这个方案不依赖 unparse,兼容所有 Python 3.x 版本,但对多行默认值和多行类型注解的处理要额外小心。

纯 AST 方案能处理装饰器修改过的函数吗?

不能。AST 解析的是装饰器里面的原始函数。如果装饰器改变了函数签名(比如 functools.wraps 配合参数注入装饰器),AST 拿到的是被装饰前的签名,不是运行时实际暴露的签名。这种情况需要结合 inspect.signature 在运行时拿真实签名,但运行时代码拿不到源码级的信息(比如注释文字),两个方案要互补使用。