注释即文档:从 docstring 到一致性检查的工程化方法
注释即文档从 docstring 到一致性检查的工程化方法一、注释与文档的割裂双份维护的腐烂陷阱代码注释和外部文档常常是两份独立的东西。docstring 写在函数里API 文档单独维护在 wiki。改了代码逻辑docstring 忘改。改了函数签名外部文档没人动。时间一长两边都对不上代码。结果是文档失去信任没人看也没人改。新人 onboarding 只能去读源码文档成了摆设。根子在注释和文档被当成两件事。实际上注释是离代码最近的文档。它和代码同生共死版本一致天然成立。把注释当文档的唯一源头外部文档由它生成腐烂才能被压住。本文讨论注释的分层、契约化以及如何用工具检查一致性。二、注释的分层与契约why/contract/TODO 的信息架构注释不是越多越好。废话注释稀释信噪比比没注释更糟。有用的注释按信息类型分层各司其职。第一层是 why 注释。解释设计决策与取舍回答为什么这么做。代码能说清做什么说不清为什么不做另一种。第二层是 contract 注释。描述函数的契约前置条件、后置条件、参数约束、异常语义。docstring 是它的载体也是文档生成的源。第三层是 TODO 与风险标记。记录待办、已知缺陷、临时方案。必须带责任人或日期否则永远挂着腐烂。分层之后还要让注释与代码变更联动。改了函数签名docstring 的参数段必须同步。这是工具要检查的核心。检查流程如下flowchart TD A[源码 AST] -- B[提取函数/方法节点] B -- C{有 docstring?} C --|否| D[报告: 缺失 docstring] C --|是| E[解析参数段] E -- F{签名参数与 docstring 一致?} F --|否| G[报告: 参数不一致] F --|是| H[扫描 TODO 标记] H -- I{TODO 带日期且未过期?} I --|否| J[报告: TODO 腐烂] I --|是| K[通过] D -- L[汇总报告] G -- L J -- L style D fill:#ffebee style G fill:#ffebee style J fill:#ffebee style K fill:#e8f5e9契约注释的关键是可机器校验。参数名对不上就报警TODO 过期就报警。让注释从自由文本变成可验证契约。三、基于 AST 的注释一致性检查器下面用 Python 的 ast 模块实现一个检查器。它扫描模块校验 docstring 存在性、参数一致性、TODO 标记的腐烂。集成到 CI 里能在合并前挡住不一致的提交。import ast import re import datetime from dataclasses import dataclass, field from pathlib import Path from typing import Optional dataclass class Issue: 单条检查问题带定位便于 CI 输出。 为什么用 dataclass结构清晰便于序列化为 JSON 报告。 file: str line: int func: str kind: str # missing_doc / param_mismatch / todo_rot detail: str dataclass class CommentChecker: 注释一致性检查器docstring/参数/TODO 三类校验。 为什么基于 AST 而非正则正则无法处理嵌套与字符串内的伪代码。 AST 给出准确的函数边界与参数列表。 today: datetime.date field( default_factorydatetime.date.today ) issues: list[Issue] field(default_factorylist) # TODO 格式TODO(name|2025-12-31): 内容 # 强制带责任人或日期否则一律视为腐烂 TODO_RE re.compile( rTODO(?:\(([^)|])\|(\d{4}-\d{2}-\d{2})\))?\s*:(.*) ) def check_file(self, path: Path) - None: 检查单个 Python 文件。 为什么用 tokenize 而非 ast 取注释ast 不保留注释节点。 但函数粒度的 TODO 仍需结合 ast 定位到所属函数。 try: source path.read_text(encodingutf-8) except UnicodeDecodeError as e: # 非 UTF-8 文件直接跳过避免误报淹没真实问题 self.issues.append(Issue( str(path), 0, , encoding, f无法解码: {e}, )) return try: tree ast.parse(source, filenamestr(path)) except SyntaxError as e: # 语法错误优先报编译问题注释检查无意义 self.issues.append(Issue( str(path), e.lineno or 0, , syntax, f语法错误: {e.msg}, )) return # 收集函数节点公共方法与私有都查 for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): self._check_function(path, node, source) def _check_function(self, path: Path, node: ast.FunctionDef, source: str) - None: 校验单个函数的 docstring 与参数一致性。 doc ast.get_docstring(node) func_name node.name # 公开函数必须有 docstring下划线前缀的内部函数放宽 if not doc and not func_name.startswith(_): self.issues.append(Issue( str(path), node.lineno, func_name, missing_doc, 公开函数缺少 docstring, )) return if not doc: return # 提取真实参数名跳过 self/cls 与 *args/**kwargs 的语法噪音 params [ a.arg for a in node.args.args if a.arg not in (self, cls) ] # docstring 中 Args 段提取的参数名 doc_params self._extract_doc_params(doc) # 不一致只报签名有但 doc 没提的避免过度严格 missing set(params) - set(doc_params) if missing and Args: in doc: # 只有声明了 Args 段才校验完整性否则放过 self.issues.append(Issue( str(path), node.lineno, func_name, param_mismatch, fdocstring 未提及参数: {sorted(missing)}, )) # 检查函数体内行注释的 TODO 腐烂 for line_no, line in enumerate( source.splitlines()[node.lineno - 1:node.end_lineno], startnode.lineno, ): self._check_todo(path, line_no, func_name, line) def _extract_doc_params(self, doc: str) - list[str]: 从 docstring 的 Args 段抽取参数名。 为什么不用固定模板Google/NumPy/Sphinx 风格各异。 这里用宽松的缩进 标识符:模式兼容主流风格。 params: list[str] [] in_args False for line in doc.splitlines(): stripped line.strip() # 进入参数段 if re.match(r(Args|Arguments|Parameters)\s*:, stripped): in_args True continue # 遇到下一个段标题则结束 if in_args and re.match(r(Returns|Raises|Yields|Note)\s*:, stripped): break if in_args: m re.match(r(\w)\s*:, stripped) if m: params.append(m.group(1)) return params def _check_todo(self, path: Path, line: int, func: str, text: str) - None: 校验 TODO 标记是否带有效日期且未过期。 m self.TODO_RE.search(text) if not m: return owner, date_str, _ m.groups() if not date_str: # 无日期的 TODO 视为高腐烂风险强制要求补 self.issues.append(Issue( str(path), line, func, todo_rot, TODO 未标注截止日期无法追踪腐烂, )) return try: due datetime.date.fromisoformat(date_str) except ValueError: self.issues.append(Issue( str(path), line, func, todo_rot, fTODO 日期格式非法: {date_str}, )) return if due self.today: self.issues.append(Issue( str(path), line, func, todo_rot, fTODO 已过期: {date_str}, )) if __name__ __main__: checker CommentChecker() checker.check_file(Path(example.py)) for issue in checker.issues: print(f{issue.file}:{issue.line} {issue.kind} {issue.detail})生产系统会把它接进 pre-commit 与 CI 流水线。报告按严重度分级missing_doc 阻断合并todo_rot 只警告。并用 Sphinx 从通过校验的 docstring 生成 API 文档。四、注释约束的代价维护负担与过度文档化注释约束提升一致性但也会反噬。过度文档化。强制每个函数有 docstring会导致废话注释。def add(a, b): 相加这种零信息文档纯粹凑指标。约束要分级公开 API 严内部辅助宽。模板形式主义。docstring 模板套用参数段写得整齐但空洞。param a: 参数 a 这种自循环描述比没注释更费眼。检查器只能查结构查不出语义空话。AST 的局限。动态生成的函数、装饰器包装的签名AST 看不准。运行时才确定的参数静态检查会误报或漏报。动态语言天然吃亏需配合类型标注补强。CI 延迟。全量扫描大仓库有秒级开销。应做增量检查只扫变更文件配合 pre-commit 缓存。适用边界。注释约束适合长期维护、多协作者的库。一次性脚本、原型代码强加约束反而拖慢验证速度。一个常被忽视的点是注释覆盖率不是目的。把 docstring 数量当 KPI会催生大量低质注释反而拉低信噪比。应追踪的是不一致修复率和TODO 过期率而非原始数量。另一个现实问题是机器生成注释的审阅AI 批量补的 docstring 结构齐全但可能描述错误必须经人 review 后才能合并不能直接信任。最后对遗留代码库应走渐进策略只对新增与修改的函数启用强制校验存量分批偿还避免一次性卡死所有合并。五、总结注释是离代码最近的文档把它当唯一源头外部文档由它生成。机制上分 why、contract、TODO 三层各管一类信息。工程上用 AST 校验一致性让注释从自由文本变为可验证契约。落地路线先约定 docstring 风格与 TODO 格式再写检查器做缺失与参数校验接 CI 增量扫描阻断不一致合并最后用 docstring 生成 API 文档废弃独立维护的文档副本。注释能信文档才不会烂。