PAYATHON 2026

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 会按照常规的属性查找规则处理:

  1. 先查找 descriptor 实例自身的属性。
  2. 找不到时,再到 noop 类及其基类中查找。
  3. 返回找到的字符串。

因此,第一个示例可以从 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 实例提供含义明确且内容不同的文档。

备注:内容仅供参考。