Python descriptor 的 docstring 为何未被 help() 显示
结论
__doc__ 确实存在,Python 的属性查找也正常。文档没有显示,是因为 help() 使用的 pydoc 会过滤从类型继承而来的实例文档。
在会出现这一现象的 CPython 实现中,pydoc 大致会做如下判断:
doc = object.__getattribute__(obj, "__doc__")
typedoc = type(obj).__doc__
if isinstance(typedoc, str) and typedoc == doc:
return None
如果实例的 __doc__ 与其类型的 __doc__ 内容相同,pydoc 就会把它当作类型文档,不再作为实例自己的文档显示。判断依据是字符串值是否相等,至于该属性保存在实例字典中,还是从类型继承而来,并不影响结果。
第三种写法确实完成了赋值:
self.__doc__ = noop.__doc__
由于两个字符串相同,它仍会触发 pydoc 的过滤规则。
为什么直接读取 __doc__ 没有问题
普通属性访问与 help() 的处理过程不同。
执行以下代码时:
print(test.attr.__doc__)
Python 会按照常规的属性查找规则处理:
- 先查找 descriptor 实例自身的属性。
- 找不到时,再到
noop类及其基类中查找。 - 返回找到的字符串。
因此,第一个示例可以从 noop.__doc__ 取得文档,第三个示例也能读到显式保存在实例上的 __doc__。
help(test.attr) 还会让 pydoc 判断这段文档是否属于对象自身。如果文档内容与 type(test.attr).__doc__ 相同,pydoc 就会将其过滤。
这是 pydoc 的启发式判断,与 descriptor 协议或 __doc__ 属性本身无关。它无法分辨下面两种情况:
# 从类继承而来
instance.__doc__ == type(instance).__doc__
# 显式写入实例,但内容刚好相同
instance.__doc__ = type(instance).__doc__
两种情况得到的字符串值相同,所以 pydoc 会采用相同的处理方式。
为什么添加空格后能够显示
下面的代码使两个原始字符串不再相等:
self.__doc__ = noop.__doc__ + " "
这样,pydoc 就不会过滤实例文档。后续的文档格式化通常会清理末尾空白,因此最终显示的内容看起来没有变化。
添加空格只是针对 pydoc 实现细节的变通方法,并非设置 __doc__ 的正式要求。不同 Python 版本或其他 Python 实现的内部行为未必完全一致。
推荐做法:区分类型文档和属性文档
descriptor 类与具体 descriptor 实例的文档通常用途不同:
noop.__doc__用于说明 descriptor 类型的工作方式。test.attr.__doc__用于说明test.attr这个具体属性的含义。
为两者提供不同的文本,也更符合 help() 的处理方式:
class noop:
"""A descriptor that accepts writes and returns None on instance access."""
def __init__(self, doc=None):
self.__doc__ = doc
def __get__(self, obj, owner=None):
return self if obj is None else None
def __set__(self, obj, value):
pass
def __delete__(self, obj):
pass
class test:
attr = noop("XXX documentation.")
调用:
help(noop)
help(test.attr)
help(test)
help(test())
help(noop) 会显示 descriptor 类型的说明,其余调用可以显示 attr 的说明。
如果类中有多个属性,可以分别提供文档:
class test:
attr = noop("Documentation for attr.")
other = noop("Documentation for other.")
如果必须显示完全相同的文本
如果业务要求四种调用显示相同的内容,可以使用尾随空白这一变通方法:
class noop:
"""XXX documentation."""
def __init__(self):
self.__doc__ = type(self).__doc__ + " "
def __get__(self, obj, owner=None):
return self if obj is None else None
def __set__(self, obj, value):
pass
def __delete__(self, obj):
pass
另一种做法是让实际的 descriptor 实例属于一个类文档不同的子类,从而绕过相等性判断:
class noop:
"""XXX documentation."""
def __get__(self, obj, owner=None):
return self if obj is None else None
def __set__(self, obj, value):
pass
def __delete__(self, obj):
pass
class _noop_attribute(noop):
"""Internal descriptor instance type."""
def __init__(self):
self.__doc__ = noop.__doc__
class test:
attr = _noop_attribute()
此时:
test.attr.__doc__ == noop.__doc__
结果仍为 True。不过,type(test.attr).__doc__ 是 _noop_attribute 的文档,与实例文档并不相同,因此不会触发原来的过滤规则。
这种结构比添加尾随空格更清楚,但如果只是为了配合 help() 而增加一个类型,通常有些复杂。实际项目中,最好分别编写 descriptor 类型和具体属性的文档,使两者含义明确、内容不同。
需要注意的几点
- 严格来说,只有类体第一条语句中的字符串才是 docstring。给实例的
__doc__赋值,只是在设置一个可供文档工具读取的属性。 help()的输出由pydoc生成,与直接打印obj.__doc__并不是一回事。- 即使能正常读取
__doc__,help()也不一定会显示它,因为pydoc还会进行对象分类、继承判断和文档过滤。 help(test())通常会生成该实例所属类型的文档,因此能够列出test类中的 descriptor 属性。- 尾随空格可以绕过当前的过滤逻辑,但依赖具体实现。需要长期维护时,最好为 descriptor 类型和 descriptor 实例提供含义明确且内容不同的文档。
备注:内容仅供参考。
