拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python docstring全解:从语法到工程实践的完整指南

1. 先说一个我观察了很久的现象docstring是被低估得最厉害的语言特性写Python几年的人很多都有个通病——代码写了一堆docstring基本靠#注释顶上或者干脆啥也不写。项目刚开始跑得飞快等三个月后再回来看自己都认不出那个函数是干嘛的。这时候才想起来补文档结果发现最难受的不是写不写而是怎么写得让半年后的自己一眼看懂。这篇文章我就把关于docstring值得注意的东西一次性说透不搞虚的。很多人以为docstring就是给函数加个说明文字这个理解不能说错但远远不够。docstring在Python里是一个真实的运行时对象它被存放在__doc__属性中可以被help()读取、被IDE解析、被Sphinx之类的工具转成项目文档、被doctest当成测试用例来执行。换句话说docstring不只是给人看的备注它同时还在跟工具链打交道。你要是把它当成普通注释来写后期会吃不少暗亏。这篇内容适合谁看如果你刚学Python不久看完能少走很多弯路如果你已经写了两三年里面讲到的边界情况和踩坑经验大概率是你平时没注意过的。我尽量把语法规范、团队实践、工具链配合这几个层面都覆盖到读完可以直接用到自己的项目里。2. 先把docstring和注释的关系掰扯清楚2.1 注释是给代码的随身便签docstring是给使用者的产品说明书我在代码评审的时候经常看到有人这么写# 这个函数用来计算两个数的和 def add(a, b): return a b然后就没有然后了。这种注释写了等于没写因为它只解释了一个正常人看代码两秒就能理解的事实。真正的注释应该回答为什么存在这个分支为什么这里要加一这个参数为什么可以是None这类代码本身看不出来的问题。而docstring对应的是另一个层面的信息这个函数接收什么、返回什么、可能抛什么异常、在什么场景下调用它才合理。它服务的对象是调用方——包括三个月后的你自己、你的同事、以及使用你发布的库的外部开发者。所以我的建议很简单代码内部逻辑复杂、有历史包袱时用#注释解释为什么任何对外暴露的函数、类、模块都写docstring解释是什么、怎么用、注意什么。这两者不是一回事也不能互相替代。#注释会被解释器忽略docstring则会被保留下来成为函数对象的一部分。这带来一个实际差异你在交互式环境里输入help(add)只能看到docstring看不到任何#注释。2.2 docstring在语法层面上的硬性要求不止是加个引号很多教材里说docstring就是在函数开头写一段字符串这话没错但它有更严格的语法位置要求——docstring必须是模块、函数、类或方法定义体中的第一条语句。注意第一条这三个字前面连空字符串都不能有更别说表达式或代码了。举个例子下面这种写法会让docstring失效def add(a, b): x 1 # 任何一条可执行语句先于字符串出现docstring就变成普通字符串了 这是一个没有被识别为docstring的字符串 return a b这种代码的运行结果就是add.__doc__为Nonehelp(add)显示没有文档字符串。而且你要注意这个字符串没有任何赋值操作解释器会直接丢弃它不报错也不警告属于比较隐蔽的坑。另外要注意docstring这个字符串必须是字面量字符串不能用变量来代替doc 我的文档 def foo(): doc # 不行这只是一个表达式语句如果你在某处看到有人写了这种代码基本可以断定他对docstring的机制理解有偏差。对于decorator和docstring的组合也有一个顺序问题def log(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper log def add(a, b): 返回a和b的和 return a b这种情况下add.__doc__其实是None因为装饰器返回的是wrapper而不是原始add函数。这是新手特别容易踩的坑解决办法是用functools.wrapsimport functools def log(func): functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapperfunctools.wraps会把原始函数的__doc__、__name__等属性复制到wrapper上docstring才得以保留。2.3 单行docstring不是随便写一句那么简单单行docstring是截文档里最常见的形式比如def square(x): 计算并返回x的平方。 return x * x这里有几个细节值得注意。第一句号。PEP 257建议docstring无论长短都应该以句号结尾。这样做不是为了证明你语文好而是因为很多文档生成工具会把第一行自动截取为摘要句号能保证摘要的完整性。第二单行docstring应该写在函数体的第一行上下都不要留空行。第三即使函数体只有一行docstring和代码之间也要有换行不能写成计算x的平方。 return x * x这种。类属性的docstring容易被人遗漏class Circle: 圆类。 pi 3.14159 # 这不是docstring而特殊属性__slots__里的描述其实是标准库允许通过docstring定义的class Point: 二维坐标点。 __slots__ { x: 横坐标, y: 纵坐标, }这个写法比较冷门我不建议在团队代码里推广但如果你在维护一个小型工具库用这种方式给__slots__属性加说明还是挺香的。3. 多行docstring的布局大多数人都写错了3.1 标准结构一句话摘要 空行 详细描述PEP 257给多行docstring定的规矩是第一行是简明摘要相当于标题空一行后是详细说明。摘要部分不要重复函数名也不要啰嗦就以做什么为核心。比如def connect(host, port, timeout30): 建立与目标主机的TCP连接。 该函数会尝试建立连接并返回一个socket对象。 连接失败时抛出ConnectionError异常。 如果是写自动化脚本建议在调用前先检查host格式。 我为什么强调摘要要短因为Sphinx、pydoc等工具会把第一行提取出来生成目录和索引。你第一行写一长串生成的文档目录就会非常难看。实践中我一般控制在50个字符以内保证在文档侧边栏不换行。3.2 缩进一个容易让生成文档错乱的细节多行docstring的缩进比较特殊——它允许第二行开始的内容比第一行多缩进但不要随意居中。比如def foo(): 第一行摘要。 详细描述从这一行开始。 花括号式的写法反而不合适def foo(): 第一行摘要。 这一行开始产生了多余的缩进。 如果你用上面的写法Sphinx解析时可能会把第二行内容当成代码块处理或者折叠进摘要。同样的不要在docstring末尾的前多打空格。这些都是小问题但生成文档后页面上会非常显眼排查起来又很费时间。模块级别的docstring也值得单独说。模块docstring放在所有import之前本模块提供XX工具函数适用于XX场景。 依赖requests、pandas。 注意不要在生产环境的worker中直接导入该模块。 import os import sys有人习惯把模块docstring写在import之后这在语法上不会报错但__main__执行时模块docstring仍然可以被识别只不过很多静态检查工具会认为它不符合PEP 8。我个人建议严格按docstring在最顶部来。3.3 __init__方法的docstring写还是不写这是个一直有争议的话题。PEP 257本身没有明确要求__init__必须写docstring但从使用者的角度来说实例化对象时最想看到的初始化参数说明就在构造函数的docstring里。我的实践做法是如果__init__的参数比较多、逻辑不是一眼能看明白就写docstring如果__init__只是简单赋值几个属性那docstring通常可以省略类级别的docstring已经足够说明该类的用途。还有一个容易被忽略的地方继承场景下子类如果重写了__init__但没有写docstring那么调用help(子类)时显示的文档会直接缺失不会自动继承父类的。这时候对于通用组件类型的类我建议在子类__init__里写一行指向父类的docstring比如参见父类。至少告诉调用方这里不是漏写了。4. 三种主流docstring风格选型背后是有逻辑的4.1 Google风格、Numpy风格、Sphinx风格到底差在哪现在Python项目里主流有三种docstring风格Google风格、Numpy风格、Sphinx风格reStructuredText风格。它们都能被Sphinx配合插件解析只是语法不同。Google风格示例def fetch_data(url, timeout10): 获取指定URL的数据。 Args: url (str): 请求地址。 timeout (int): 超时时间单位秒默认10。 Returns: dict: 解析后的JSON数据。 Raises: ValueError: 当url为空或格式非法时抛出。 ...Numpy风格示例def fetch_data(url, timeout10): 获取指定URL的数据。 Parameters ---------- url : str 请求地址。 timeout : int, optional 超时时间单位秒默认10。 Returns ------- dict 解析后的JSON数据。 Raises ------ ValueError 当url为空或格式非法时抛出。 ...Sphinx风格示例def fetch_data(url, timeout10): 获取指定URL的数据。 :param url: 请求地址。 :type url: str :param timeout: 超时时间单位秒默认10。 :type timeout: int :returns: 解析后的JSON数据。 :rtype: dict :raises ValueError: 当url为空或格式非法时抛出。 ...三种风格都能用关键是团队统一。我见过最头疼的就是一个项目里三种风格混着来Sphinx一跑各种解析警告约等于没有文档。4.2 我为什么推荐中小团队用Google风格没有特殊要求的情况下我倾向于给中小团队推荐Google风格理由有三第一可读性最好。Args:、Returns:这种标记非常接近自然语言新成员看一遍就会写不需要查reference。Numpy风格在科学计算社区很流行因为它的分节标题更醒目适合参数特别多的场景但在Web项目里看起来有点重。第二跟Sphinx配合没有任何障碍。只要装了sphinx.ext.napoleon扩展Google和Numpy风格都能被正确解析生成的文档效果几乎一致。第三写起来快。Google风格的缩进和分组逻辑符合直觉不容易出错。Sphinx的:param:标签写起来容易漏而且一旦参数改名docstring里的标签忘记同步文档就会悄悄过期。但有个例外如果你在维护一个科学计算库目标用户是研究机构或者学术圈子那Numpy风格是事实标准很多该领域的老牌项目都在用强行改成Google风格反而不利于项目融入社区。4.3 类型注解时代docstring里的参数类型还要不要写这是近年来很热门的话题。Python 3.5之后有了类型注解很多代码直接写def add(a: int, b: int) - int: 返回两个整数的和。 return a b那么docstring里还要不要写Args: a (int)我的看法是如果项目启用了严格类型检查比如用mypydocstring里可以不写类型只要写清楚这个参数是什么含义就够了。类型信息交给注解含义描述交给docstring避免信息重复导致维护负担翻倍。如果项目没有启用类型检查或者对外开放的API受众很杂那我建议仍然在docstring里保留类型信息。因为很多用户不会主动去读类型注解但他们会看文档页面上的Args部分。5. docstring在真实项目里的隐形作用力5.1 help()和IDE提示背后的机制知道这个你就能玩出花来help(函数名)读取的就是__doc__属性。IDE的智能提示也依赖解析docstring。这意味着你在调试代码时临时想查一个第三方库的用法不需要翻文档网站直接help(module.function)就行。我还经常用这种方式快速给自己写的模块生成说明书python -c import my_module; help(my_module)这比打开源码翻注释效率高多了。如果团队成员都遵守docstring规范这个习惯能让团队协作非常顺畅——接手别人代码时先对核心模块跑一遍help基本能建立起大致认知框架。5.2 doctest把docstring变成可以执行的测试用例doctest是Python标准库中的一个模块它允许你把示例代码写在docstring里然后用它来做回归测试。来看个例子def add(a, b): 返回a和b的和。 add(1, 2) 3 add(-1, 1) 0 return a b运行python -m doctest -v my_module.pydoctest会执行docstring里开头的语句然后把输出和下一行期望值比对。这个用法在标准库和一些教学性质的库里非常常见。它的好处是让示例和测试合一文档里写的例子一定是可运行的坏处是如果代码逻辑变化你需要同步更新docstring里的期望输出不然测试就挂了。我不建议把doctest当成主要测试手段因为它的表达能力不如pytest但对于纯函数、算法片段、命令行工具这类场景doctest是一个很棒的补充。我自己的做法是核心函数用pytest写完整覆盖辅助函数或示例类函数用doctest做快速验证。5.3 Sphinx自动生成文档时docstring会被二次解析Sphinx是Python生态最常用的文档生成工具。它会把docstring里的reStructuredText语法转换成HTML。如果你用了Google风格而不装sphinx.ext.napoleon扩展Sphinx就会把Args:这些部分当作普通段落显示效果会很糟糕。另外还有一个小坑docstring里如果用了反引号包裹的codeSphinx会解析成行内代码。如果你代码里写了单反引号但忘了闭合Sphinx构建时会告警。这种问题在docstring很长、内容很多的项目里经常出现建议在CI里加一步文档构建的检查一旦有docstring解析错误就直接失败别等发版前手动构建才发现。5.4 用inspect模块读取docstring实现自动化校验既然docstring是对象属性那就可以通过程序去读取和校验它。我参与过一个内部组件库的维护当时团队要求所有对外暴露的函数必须有docstring于是我在CI里写了一个简单的检查脚本import inspect import my_module missing [] for name, obj in inspect.getmembers(my_module): if inspect.isfunction(obj) and obj.__module__ my_module.__name__: if obj.__doc__ is None or len(obj.__doc__.strip()) 0: missing.append(name) if missing: raise SystemExit(f以下函数缺少docstring: {missing})这个脚本很粗糙但已经能堵住大部分忘了写文档的问题。你还可以进一步校验docstring里是否包含Args:、Returns:等段落强制规范落地。6. 几年项目里踩过的docstring的坑6.1 最大的坑docstring和代码不同步这是我在项目中最常遇到的问题。有次线上报错我定位到一个处理订单状态的函数它docstring里写着仅处理待付款状态且会发送通知邮件我当时就相信了直接拿着它当依据去排查结果查了半天没发现问题。后来才发现docstring描述的是旧逻辑代码已经改成支付成功后由外部回调通知。从那以后我给自己定了个规矩改代码的时候如果行为变更了docstring必须同步改否则宁可不写。因为一份过时的文档比没有文档更危险它会误导后来者。具体操作上我会在code review的checklist里加一项docstring是否与本次改动一致。不要小看这个动作坚持半年之后团队里因为文档欺骗导致的认知成本会大幅下降。6.2 拷贝粘贴导致的docstring错配有个典型场景你新写了一个connect_udp函数顺手复制了connect_tcp的docstring结果里面全是建立TCP连接的描述。这种错误在CI阶段根本不会暴露因为docstring解析不会检查语义。要避免这个问题除了写的时候多看一眼还可以借助IntelliJ IDEA或VSCode的AI提示。不过我个人的经验是最有效的还是代码评审时多问一句这个docstring真的是描述当前函数吗。尤其是工具函数、配置类函数这种同名不同语义的情况屡见不鲜。6.3 别把docstring当成代码注释的垃圾桶我见过有人把debug日志、TODO清单、甚至排查问题的临时代码写在docstring里。这会让docstring变得非常臃肿而且Sphinx生成文档时会把这些不经意的内容全部发布出去造成信息泄漏不说文档质量也很低。一个合格的多行docstring应该包含这个函数/类的作用参数和返回值的语义使用注意点尤其是边界行为可能抛出的异常如果是异步函数最好说明它的行为和调用方式。优先级从高到低。如果内容太多精简掉哪些我觉得优先级最低的是示例代码除非你的函数使用起来确实容易踩坑否则可以不放。其次是一些链路说明比如内部会调用XXX这些内容更适合放在普通注释里而不是docstring里。6.4 特殊符号的坑反斜杠、Unicode、多行字符串docstring如果包含路径比如Windows风格的路径C:\Users\adminPython会把\U解析成Unicode转义。你把这种docstring直接写在模块里可能会有SyntaxWarning。解决方式是用原生字符串r...或者在路径里把反斜杠换成正斜杠。另外docstring内部如果出现三个连续双引号会导致语法错误。虽然这种情况不常见但如果你要写的文档里引用了JSON片段还是很容易碰到。解决办法是把json里的字符串用单引号包裹拼接或者改用作为docstring的定界符。这里我多说一句Python官方风格建议docstring用但如果你某个docstring里真的需要包含用做定界符是完全合法的风格上也不会有工具报警。PEP 8对docstring使用哪种引号没有强制规定。6.5 __doc__属性可以被改写动态注入的玩法与风险__doc__是普通属性所以可以在运行时给它赋值。这在一些代码生成、框架开发场景下很有用。比如你写了一个类装饰器可以根据函数名字自动生成一段docstringdef auto_doc(func): if func.__doc__ is None: func.__doc__ f{func.__name__} 的自动生成文档。 return func auto_doc def foo(): pass print(foo.__doc__) # 输出foo 的自动生成文档。这个能力在动态生成API、插件系统、自动化框架里很有价值。但要注意滥用它会破坏docstring的可维护性——你在源码里看到的docstring和运行时实际的值不一致排查问题时会很困惑。我在业务代码里一般不用这种手段只有做脚手架或框架时才会考虑。7. 我给团队定的一套docstring规约你可以直接拿去用如果你团队里还没有关于docstring的统一约定可以参考下面这套我在项目里实践了两年效果还不错。7.1 覆盖范围所有模块文件必须有模块级docstring一行摘要即可复杂模块补充详细描述。所有公开类必须有类级docstring。所有公开函数和公开方法必须有docstring包括内部类的公开方法。私有函数可以省略但如果是复杂算法逻辑我建议还是简单写一句。7.2 风格使用Google风格配合Sphinx的napoleon扩展。摘要行不超过60字符。Args:中每个参数一行参数名加粗或用反引号包裹类型写在冒号后。Returns:写在返回类型后如果是生成器则明确说明yield的元素类型。Raises:列出可能抛出的异常和触发条件。7.3 CI检查至少保证每个公开函数有docstring。建议启用pydocstyle检查工具包名字叫pydocstyle以前叫pep257但最好先配置好忽略规则默认的规则级别太严格容易导致团队抵制。我一般只开启D100、D103、D200、D400这几项核心规则。文档构建作为CI的一个独立job失败即阻断合并。7.4 常见的反面例子我列个表给你写法问题把docstring当注释堆积大量内部实现细节文档噪音过多使用者在API层看到一堆无用信息docstring中使用缩写词不解释Sphinx生成的术语表没法自动识别读者看不懂在docstring里写注意代码还不太完善之类的话一旦发布文档会展示给所有人不利于项目形象每个函数都写该函数用于...啰嗦摘要行应该直接说计算...、连接...在docstring里描述最近改了啥变更历史应该用Git记录不是文档的职责7.5 关于docstring和README的分工模块docstring用来描述这个模块是做什么的、怎么用、注意什么而README描述的是整个项目怎么搭建、怎么部署、有哪些脚本命令。两者不要重复。如果你在模块docstring里写了一大段安装教程那大概率是放错地方了。我在实际项目中看到过一个“豪华版”docstring把一个函数的来源背景、团队讨论链接、代码评审记录全写进去了。这种信息用Git提交信息来沉淀效率更高放在docstring里只会让使用者感到困惑。8. 一些你可能没见过的进阶操作8.1 用docstring给命令行工具生成帮助信息如果你用argparse写命令行工具add_parser的帮助文本可以直接从函数的docstring里提取import argparse def main(): 命令行入口。 parser argparse.ArgumentParser(descriptionmain.__doc__) ...更进一步如果你定义了一个命令函数字典可以自动遍历函数生成子命令的帮助信息。我做过一个小工具把一组函数转成命令行子命令帮助文本全部来自它们的docstring省掉了手写help文本的重复劳动。8.2 利用docstring做配置项说明在开发配置驱动类的项目时我会把配置项的说明放在配置类的docstring里然后用脚本读取并生成Markdown文档。这样配置新增一个字段只要在类属性或docstring里补上说明文档就能自动更新。这种方式比在Wiki里手写配置表要好得多不会出现配置项已经改名、文档还写着旧名字的问题。8.3 给自动生成的代码补docstring如果你在用dataclasses或写代码生成器生成的类没有docstring体验很糟糕。这时候可以给生成器加一段逻辑把字段信息拼成docstring赋给生成的类。尤其在做ORM模型或DTO定义时这个玩法很实用。from dataclasses import dataclass, fields dataclass class User: 用户信息。 id: int name: str email: str # 运行时给类动态补充字段说明 def add_field_docs(cls): parts [] for f in fields(cls): parts.append(f- {f.name}: 待补充说明) cls.__doc__ cls.__doc__ \n.join(parts) if cls.__doc__ else \n.join(parts) add_field_docs(User) print(User.__doc__)这种动态修改__doc__的方式不推荐常规使用但用于代码生成场景确实能节约大量手工时间。9. 最后分享几个我一直在用的习惯我在实际写代码时的日常习惯分享给你参考。先在函数体的第一行把docstring占住再写逻辑。不要等函数写完了再补docstring那样大概率会漏掉或写得很敷衍。哪怕一开始只有一行摘要先把框架立起来后面再逐步完善细节。写docstring的摘要行时我会强迫自己用动词开头。比如计算解析发送校验如果发现这个函数需要用一个名词加动词的复杂结构才能表达清楚那说明函数本身可能设计得太复杂了拆分成多个单一职责的函数会更好。在修改函数行为时强制自己打开对应的docstring看一遍如果语义变了立刻更新。我在很多项目里发现超过30%的docstring已经处于虚假状态和代码行为不符。这30%的坑会在未来某一天变成排查bug的时间黑洞。如果你维护的是一个被外部依赖的库docstring的变更也要走变更记录流程。用户信任你的文档如果你悄悄改了一个参数的含义而没有更新docstring那会有用户因为你错误的文档写出有问题的调用代码。用docstring驱动测试。对复杂函数我会先在docstring里写一两个示例然后跑doctest等示例通过后再去补正式的pytest用例。这种做法的好处是示例本身就是你期望的行为描述写测试时思路会清晰很多。对docstring做一次全文检索自检。在项目根目录执行grep -r TODO --include*.py看看docstring里有没有藏TODO这些内容一旦发布出去用户看到会质疑项目的成熟度。同理像FIXME、XXX这些标记也不要出现在docstring里。关于docstring我能想到的注意事项大概就这么多。这个东西看起来简单但真正用得好的项目其实不多。写代码的人和用代码的人之间docstring是离得最近的那座桥值得你花点心思去维护它。如果你有自己独到的写法或者踩过哪些我上面没提到的坑欢迎交流。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门