首页
/ CPython 描述符机制详解:从 __get__ 协议到 property、classmethod 与 __slots__ 的底层实现

CPython 描述符机制详解:从 __get__ 协议到 property、classmethod 与 __slots__ 的底层实现

2026-09-06 16:53:35作者:昌雅子Ethen

本文基于 CPython 官方文档 Descriptor GuideDoc/howto/descriptor.rst)展开,系统讲解 Python 描述符(descriptor)协议:先用渐进式示例从"返回常量的描述符"一路推导到"带 __set_name__ 的可定制描述符",再给出一个可直接用于数据校验的实战工具;随后深入技术细节,用纯 Python 复刻 object.__getattribute__ 的查找逻辑,解释实例查找、类查找与 super() 查找中描述符的调用规则;最后拆解 property、函数到绑定方法的转换、staticmethodclassmethod 以及 __slots__ 的纯 Python 等价实现,并对照 CPython C 源码(Objects/typeobject.cObjects/funcobject.cObjects/descrobject.c)验证这些机制在解释器中的真实落点。读完后,你能够独立编写数据校验、访问日志、ORM 字段等描述符,并理解 self/cls 参数绑定的底层原理。

什么是描述符

描述符是任何定义了 __get____set____delete__ 方法的对象。它让对象可以自定义属性的查找(lookup)、存储(storage)与删除(deletion)行为。描述符必须作为类变量存放在另一个类中才会生效;若放在实例里则不起作用。

理解描述符不仅扩展你的工具集,还能加深对 Python 运行机制的理解:属性、方法、静态方法、类方法、property 以及 super() 都是构建在描述符协议之上的。CPython 的 type_new_set_names()(见 Objects/typeobject.c)在类创建时扫描类字典,对每个定义了 __set_name__ 的条目自动回调——这就是官方文档所述"自动名称通知"的实现位置。

渐进式入门:从常量描述符到可定制名称

官方文档的入门部分从最简单的示例出发,逐个增加功能。

示例一:返回常量的描述符

最朴素的描述符只实现 __get__

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

class A:
    x = 5           # 普通类属性
    y = Ten()       # 描述符实例

交互式会话中两者的区别一目了然:

>>> a = A()
>>> a.x   # 普通属性查找:在类字典里找到 'x': 5
5
>>> a.y   # 描述符查找:点号运算符发现 __get__,调用后返回 10
10

a.x 的查找过程是:点号运算符在类字典中找到 x: 5 并直接返回。a.y 的查找则是:点号运算符发现一个描述符实例(通过它的 __get__ 方法识别),调用该方法得到 10

关键细节:值 10 既不存放在类字典里,也不存放在实例字典里,而是按需计算出来的。这个例子演示了机制但不实用——取常量用普通属性查找就够了。

示例二:动态查找

真正有意思的描述符通常执行计算而非返回常量:

import os

class DirectorySize:

    def __get__(self, obj, objtype=None):
        return len(os.listdir(obj.dirname))

class Directory:

    size = DirectorySize()      # 描述符实例

    def __init__(self, dirname):
        self.dirname = dirname   # 普通实例属性
>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size                            # songs 目录有 20 个文件
20
>>> g.size                              # games 目录有 3 个文件
3
>>> os.remove('games/chess')           # 删除一个游戏
>>> g.size                              # 文件数自动更新
2

这个例子同时揭示了 __get__ 各参数的用途:

  • selfDirectorySize 的实例(即 size);
  • obj 是宿主类 Directory 的实例(sg)——描述符正是靠 obj 得知目标目录的;
  • objtype 是宿主类 Directory 本身。

示例三:受管属性(日志访问)

描述符最常见的用途之一是管理对实例数据的访问:描述符作为公有属性挂在类字典中,真正的数据以私有属性形式存放在实例字典中;访问公有属性时触发 __get____set__

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAgeAccess:

    def __get__(self, obj, objtype=None):
        value = obj._age
        logging.info('Accessing %r giving %r', 'age', value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', 'age', value)
        obj._age = value

class Person:

    age = LoggedAgeAccess()             # 描述符实例

    def __init__(self, name, age):
        self.name = name                # 普通实例属性
        self.age = age                  # 触发 __set__()

    def birthday(self):
        self.age += 1                   # 同时触发 __get__() 和 __set__()
>>> mary = Person('Mary M', 30)   # 初始的年龄更新会被记录
INFO:root:Updating 'age' to 30

>>> vars(mary)                    # 真实数据存放在私有属性里
{'name': 'Mary M', '_age': 30}

>>> mary.age                      # 访问数据并记录查找日志
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday()                # 更新同样被记录
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31

>>> dave.name                      # 普通属性查找不被记录
'David D'

这个例子的主要缺陷是:私有名 _age 被硬编码进了 LoggedAgeAccess 类——每个实例只能有一个被记录的属性,且名字不可更改。

示例四:借助 __set_name__ 自定义名称

当类使用描述符时,可以告知每个描述符它被赋给了哪个变量名。定义新类时,元类 type 会扫描类字典,对每个定义了 __set_name__ 的条目发起回调:

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAccess:

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        logging.info('Accessing %r giving %r', self.public_name, value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', self.public_name, value)
        setattr(obj, self.private_name, value)

class Person:

    name = LoggedAccess()                # 第一个描述符实例
    age = LoggedAccess()                 # 第二个描述符实例

    def __init__(self, name, age):
        self.name = name                 # 调用第一个描述符
        self.age = age                   # 调用第二个描述符

    def birthday(self):
        self.age += 1
>>> vars(vars(Person)['name'])
{'public_name': 'name', 'private_name': '_name'}
>>> vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}

>>> pete = Person('Peter P', 10)
INFO:root:Updating 'name' to 'Peter P'
INFO:root:Updating 'age' to 10

>>> vars(pete)
{'_name': 'Peter P', '_age': 10}

注意这里用 vars(vars(Person)['name']) 直接查看描述符实例本身而不触发它——这正是"描述符只有在点号查找时才会被调用"的体现。

对应到 CPython 源码,type_new_set_names()Objects/typeobject.c)遍历新类型字典的每个条目,通过 _PyObject_LookupSpecial(value, &_Py_ID(__set_name__)) 检查是否定义了该特殊方法,若有则以 (type, key) 两个参数调用它。文档同时提醒:该通知逻辑位于 type.__new__ 中,只在类创建时执行一次;事后向类中添加描述符需要手动调用 __set_name__

入门小结

  • 描述符 = 定义了 __get____set____delete__ 的对象;可选地还可以有 __set_name__(该方法即使类本身不是描述符也会被调用);
  • 描述符由点号运算符在属性查找过程中触发;用 vars(some_class)[descriptor_name] 间接访问只会拿到描述符实例本身,不会触发它;
  • 描述符只在作为类变量时使用才生效,放在实例中无效;
  • 核心动机:提供一种钩子,让存放在类变量中的对象能够控制属性查找时发生什么。传统上由"被调用的类"主导查找行为,描述符反转了这一关系,让被查找的数据本身拥有话语权;
  • 描述符贯穿整个语言:函数变成绑定方法靠的是它,classmethodstaticmethodpropertyfunctools.cached_property 等常用工具全部以描述符实现。

完整实战:数据校验描述符

官方文档给出了一个定位"难以发现的数据损坏 bug"的实战工具——校验器(validator):在存储任何数据之前先验证新值是否满足类型与取值范围约束,不满足就抛异常,从源头阻止数据损坏。

Validator 基类

Validator 既是一个抽象基类(ABC),又是一个受管属性描述符:

from abc import ABC, abstractmethod

class Validator(ABC):

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.private_name, value)

    @abstractmethod
    def validate(self, value):
        pass

自定义校验器继承 Validator 并提供 validate 方法即可。

三个实用校验器

  1. OneOf:值必须属于某个受限选项集合;
  2. Number:值必须是 intfloat,可选地检查上下界;
  3. String:值必须是 str,可选地检查最小/最大长度,并支持用户自定义谓词(predicate):
class OneOf(Validator):

    def __init__(self, *options):
        self.options = set(options)

    def validate(self, value):
        if value not in self.options:
            raise ValueError(
                f'Expected {value!r} to be one of {self.options!r}'
            )

class Number(Validator):

    def __init__(self, minvalue=None, maxvalue=None):
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(
                f'Expected {value!r} to be at least {self.minvalue!r}'
            )
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(
                f'Expected {value!r} to be no more than {self.maxvalue!r}'
            )

class String(Validator):

    def __init__(self, minsize=None, maxsize=None, predicate=None):
        self.minsize = minsize
        self.maxsize = maxsize
        self.predicate = predicate

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be a str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(
                f'Expected {value!r} to be no smaller than {self.minsize!r}'
            )
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.maxsize!r}'
            )
        if self.predicate is not None and not self.predicate(value):
            raise ValueError(
                f'Expected {self.predicate} to be true for {value!r}'
            )

实际应用

class Component:

    name = String(minsize=3, maxsize=10, predicate=str.isupper)
    kind = OneOf('wood', 'metal', 'plastic')
    quantity = Number(minvalue=0)

    def __init__(self, name, kind, quantity):
        self.name = name
        self.kind = kind
        self.quantity = quantity

描述符阻止了非法实例的创建:

>>> Component('Widget', 'metal', 5)   # 被拦截:'Widget' 不是全大写
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'

>>> Component('WIDGET', 'metle', 5)   # 被拦截:'metle' 拼写错误
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}

>>> Component('WIDGET', 'metal', -5)  # 被拦截:-5 是负数
ValueError: Expected -5 to be at least 0

>>> Component('WIDGET', 'metal', 'V') # 被拦截:'V' 不是数字
TypeError: Expected 'V' to be an int or float

>>> c = Component('WIDGET', 'metal', 5)  # 放行:输入全部合法

这套模式可以直接迁移到真实项目:字段约束、访问审计、延迟加载都能用同样的 __set_name__ + __get__/__set__ 骨架实现。

技术原理:描述符协议与调用机制

描述符协议的定义

descr.__get__(self, obj, type=None)
descr.__set__(self, obj, value)
descr.__delete__(self, obj)

仅此而已。定义了其中任何一个方法,对象就视为描述符,能在被作为属性查找时覆盖默认行为。默认行为是把属性从对象的字典中取/存/删;例如 a.x 的查找链是:先查 a.__dict__['x'],再查 type(a).__dict__['x'],然后沿着 type(a) 的方法解析顺序(MRO)继续。如果查到的值定义了描述符方法之一,Python 可能覆盖默认行为转而调用描述符方法——具体在优先链中的哪个位置,取决于定义了哪些描述符方法。

按方法组合分为两类:

  • 数据描述符(data descriptor):定义了 __set____delete__。若实例字典中有同名条目,数据描述符优先
  • 非数据描述符(non-data descriptor):只定义 __get__(方法就是典型用法)。若实例字典中有同名条目,字典条目优先

想造一个只读数据描述符,只需让 __set__ 抛出 AttributeError——仅定义一个抛异常的 __set__ 占位方法,就足以使其成为数据描述符。

实例查找的完整优先级

obj.xobj 的命名空间链中查找属性;若搜索在实例 __dict__ 之外找到描述符,则按以下优先级规则调用其 __get__

数据描述符 > 实例变量 > 非数据描述符 > 类变量 > __getattr__(若定义)

点号查找的逻辑位于 object.__getattribute__ 中。官方文档给出了一段纯 Python 等价实现:

def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = sentinel('null')
    objtype = type(obj)
    cls_var = find_name_in_mro(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)   # 数据描述符
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                         # 实例变量
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)        # 非数据描述符
    if cls_var is not null:
        return cls_var                                  # 类变量
    raise AttributeError(name)

官方文档用 DualOperator / DualOperatorWithSlots 等测试类(点号访问 vs 括号访问结果一致)验证了这段模拟与真实 object.__getattribute__ 的保真度,覆盖场景包括:类变量 x、实例变量 zpropertyp2/p3,其中 p3 被实例字典条目覆盖)、普通方法(m5/m7)、__getattr__ 钩子、__slots__ 类以及继承自基类的描述符(U2 继承 U1D1 描述符)。

从源码结构看,文档注释中提到的 _PyType_Lookup() 在当前 CPython 树中确实存在(Objects/typeobject.c),它通过 MRO 逐级查类字典(tp_dict),是 find_name_in_mro 的 C 实现原型;通用属性设置的 C 实现则位于 Objects/object.c_PyObject_GenericSetAttrWithDict() 等函数中。

一个容易踩坑的细节:__getattribute__ 的代码里没有 __getattr__ 钩子——所以直接调用 __getattribute__(或 super().__getattribute__)会完全绕过 __getattr__。真正负责在 __getattribute__ 抛出 AttributeError 时调用 __getattr__ 的是点号运算符和 getattr 函数,其逻辑可概括为:

def getattr_hook(obj, name):
    "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)   # __getattr__
>>> class ClassWithGetAttr:
...     x = 123
...     def __getattr__(self, attr):
...         return attr.upper()
...
>>> cw = ClassWithGetAttr()
>>> cw.y = 456
>>> getattr_hook(cw, 'x')
123
>>> getattr_hook(cw, 'y')
456
>>> getattr_hook(cw, 'z')
'Z'

从类查找与从 super 查找

  • 从类查找(A.x:逻辑位于 type.__getattribute__,步骤与 object.__getattribute__ 类似,只是把"实例字典查找"换成"沿 MRO 搜索"。找到描述符时以 desc.__get__(None, A) 调用(实例参数为 None)。C 实现在 Objects/typeobject.c_Py_type_getattro(当前版本的 tp_getattro 入口,见 Objects/typeobject.c#L6414-L6429)与 _PyType_Lookup 中。
  • 从 super 查找(super(A, obj).m:逻辑位于 super() 返回对象的 __getattribute__ 方法。查找 obj.__class__.__mro__ 中紧跟在 A 之后的基类 B,然后返回 B.__dict__['m'].__get__(obj, A);若 m 不是描述符则原样返回。C 实现是 super_getattroObjects/typeobject.c#L12624)。

调用逻辑总结

描述符机制嵌入在 objecttypesuper 三者的 __getattribute__ 方法中。要记住的要点:

  • 描述符由 __getattribute__ 方法调用;
  • 类从 objecttypesuper 继承这套机制;
  • 重写 __getattribute__ 会导致自动描述符调用失效,因为全部描述符逻辑都集中在那个方法里;
  • object.__getattribute__type.__getattribute____get__ 的调用方式不同:前者带上实例(可能带类),后者实例位置传 None 且总是带类;
  • 数据描述符永远覆盖实例字典;非数据描述符可能被实例字典覆盖。

自动名称通知

有时描述符需要知道自己被赋给了哪个类变量名。创建新类时,元类 type 扫描新类字典;若某个条目是定义了 __set_name__ 的描述符,就以两个参数调用它:owner 是描述符所在的类,name 是被赋值的类变量名。

实现细节位于 Objects/typeobject.ctype_new() 构建完类型后调用 type_new_set_names(),后者复制类字典并逐项检查 __set_name__。由于更新逻辑在 type.__new__ 中,通知只在类创建时发生——之后动态添加的描述符需要手动调用 __set_name__

ORM 示例:描述符实现对象关系映射

官方文档用一个简化骨架展示如何用数据描述符实现 ORM。核心思想:数据存放在外部数据库,Python 实例只持有数据库表的键;描述符负责查找与更新:

class Field:

    def __set_name__(self, owner, name):
        self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
        self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'

    def __get__(self, obj, objtype=None):
        return conn.execute(self.fetch, [obj.key]).fetchone()[0]

    def __set__(self, obj, value):
        conn.execute(self.store, [value, obj.key])
        conn.commit()

Field 定义描述各表 schema 的模型:

class Movie:
    table = 'Movies'                    # 表名
    key = 'title'                       # 主键
    director = Field()
    year = Field()

    def __init__(self, key):
        self.key = key

class Song:
    table = 'Music'
    key = 'title'
    artist = Field()
    year = Field()
    genre = Field()

    def __init__(self, key):
        self.key = key

连接数据库(文档演示用 sqlite3.connect('entertainment.db'),官方测试 setup 中用的是内存库 :memory: 并预置了 MusicMovies 两张表及索引):

>>> Movie('Star Wars').director
'George Lucas'
>>> jaws = Movie('Jaws')
>>> f'Released in {jaws.year} by {jaws.director}'
'Released in 1975 by Steven Spielberg'

>>> Song('Country Roads').artist
'John Denver'

>>> Movie('Star Wars').director = 'J.J. Abrams'
>>> Movie('Star Wars').director
'J.J. Abrams'

注意 __set_name__ 在这里的关键作用:owner.tableowner.key 必须在类创建时(此时 table/key 类变量已就绪)就能访问,因此 SQL 语句的组装放在 __set_name__ 而非 __init__ 里——这也是"描述符需要知道宿主类时"的标准解法。

纯 Python 等价实现:拆解内置描述符

描述符协议简单却蕴含丰富的可能性。有若干常见用法被打包成内置工具:property、绑定方法、静态方法、类方法、__slots__ 都基于描述符协议。

property:访问即调用的数据描述符

调用 property 是一种简洁的构建数据描述符的方式——访问属性时触发函数调用。其签名为:

property(fget=None, fset=None, fdel=None, doc=None) -> property

典型用法(官方文档示例)定义受管属性 x

class C:
    def getx(self): return self.__x
    def setx(self, value): self.__x = value
    def delx(self): del self.__x
    x = property(getx, setx, delx, "I'm the 'x' property.")

>>> C.x.__doc__
"I'm the 'x' property."
>>> c.x = 2.71828
>>> c.x
2.71828
>>> del c.x
>>> c.x
AttributeError: 'C' object has no attribute '_C__x'

property 的 C 类型定义是 Objects/descrobject.c 中的 PyProperty_Type(见 Objects/descrobject.c#L2062),其 getter/setter/deleter 辅助方法也是描述符方法之外的普通方法。纯 Python 等价实现(覆盖大部分核心功能):

class Property:
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __set_name__(self, owner, name):
        self.__name__ = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

注意几个行为细节:

  • __get__obj is None 时返回 self——这就是为什么 C.x 拿到的是 property 对象本身;
  • fget/fset/fdel 缺省为 None 时,对应操作抛 AttributeError(这正是"只读 property"的原理,错误信息形如 property 'dept' of 'Immutable' object has no setter);
  • doc 未显式给出时自动取自 fget.__doc__
  • 装饰器风格(@x.setter / @x.deleter)等价于函数式写法,官方测试对两种风格验证了完全一致的行为(设置/读取/删除、hasattr 语义、缺失 getter/setter/deleter 时抛 AttributeError)。

官方文档给出的动机场景:当接口已经授予了属性访问权(例如电子表格类的 Cell('b10').value),后续演进又要求每次访问时做额外工作(如重新计算单元格),但不想破坏既有客户端代码——把该属性包进 property 数据描述符即可平滑升级:

class Cell:
    ...

    @property
    def value(self):
        "Recalculate the cell before returning value"
        self.recalc()
        return self._value

内置 property 或上面的 Property 等价类在这个场景中都适用。

函数与绑定方法:self 从哪里来

Python 的面向对象特性构建在函数式环境之上,两者通过非数据描述符无缝融合:存放在类字典中的函数在被访问时变成方法;方法与普通函数的唯一区别是对象实例会被前置到参数列表(惯例上叫 self)。

types.MethodType 的等价物:

class MethodType:
    "Emulate PyMethod_Type in Objects/classobject.c"

    def __init__(self, func, obj):
        self.__func__ = func
        self.__self__ = obj

    def __call__(self, *args, **kwargs):
        func = self.__func__
        obj = self.__self__
        return func(obj, *args, **kwargs)

    def __getattribute__(self, name):
        "Emulate method_getset() in Objects/classobject.c"
        if name == '__doc__':
            return self.__func__.__doc__
        return object.__getattribute__(self, name)

    def __getattr__(self, name):
        "Emulate method_getattro() in Objects/classobject.c"
        return getattr(self.__func__, name)

    def __get__(self, obj, objtype=None):
        "Emulate method_descr_get() in Objects/classobject.c"
        return self

为支持方法的自动创建,函数本身带有 __get__ 方法以便在属性访问时绑定方法——因此函数是返回绑定方法的非数据描述符。其行为可模拟为:

class Function:
    ...

    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        if obj is None:
            return self
        return MethodType(self, obj)

在 CPython 中,这个描述符协议方法的 C 实现就是 Objects/funcobject.c 里的 func_descr_get()Objects/funcobject.c#L1267),它作为 tp_descr_get 槽(见 Objects/funcobject.c#L1310)注册在函数类型上。三种访问路径的行为:

>>> D.f.__qualname__
'D.f'

# 通过类字典访问不会触发 __get__,直接返回底层函数对象
>>> D.__dict__['f']
<function D.f at 0x00C45070>

# 从类点号访问调用 __get__,原样返回底层函数
>>> D.f
<function D.f at 0x00C45070>

# 从实例点号访问时 __get__ 返回绑定方法对象
>>> d = D()
>>> d.f
<bound method D.f of <__main__.D object at 0x00B18C90>>

# 绑定方法内部保存了底层函数与绑定的实例
>>> d.f.__func__
<function D.f at 0x00C45070>
>>> d.f.__self__
<__main__.D object at 0x00B18C90>>

"如果你曾好奇普通方法里的 self、类方法里的 cls 从何而来——就是这里!"官方文档还验证了手工绑定的等价性:

>>> d2.f = d.f.__get__(d2, D2)
>>> d2.f() is d
True

三种方法的绑定对照

非数据描述符提供了对"函数绑定为方法"这一模式的简单变体机制。函数有 __get__,可以把 obj.f(*args) 调用转换成 f(obj, *args);而 cls.f(*args) 保持 f(*args)。官方文档的对照表:

变换 从对象调用 从类调用
function f(obj, *args) f(*args)
staticmethod f(*args) f(*args)
classmethod f(type(obj), *args) f(cls, *args)

staticmethod

静态方法原样返回底层函数,c.fC.f 等价于直接查找,函数从对象和类以完全相同的方式可访问。适合不引用 self 的方法——例如统计包里与特定数据集无关但概念相关的工具函数 erf(x),可以从对象或类调用。

class E:
    @staticmethod
    def f(x):
        return x * 10

>>> E.f(3)
30
>>> E().f(3)
30

纯 Python 版本的 staticmethod

import functools

class StaticMethod:
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, objtype=None):
        return self.f

    def __call__(self, *args, **kwds):
        return self.f(*args, **kwds)

    @property
    def __annotations__(self):
        return self.f.__annotations__

functools.update_wrapper() 的作用值得强调:它添加了指向底层函数的 __wrapped__ 属性,并携带 __name____qualname____doc____module__ 等属性,使包装器"看起来就是"被包装的函数。官方测试验证了 StaticMethodstaticmethod 的等价性:

>>> sm = vars(E_sim)['f']    # 类字典里是 StaticMethod 实例
>>> type(sm).__name__
'StaticMethod'
>>> f = E_sim.f              # 点号访问后是普通函数
>>> type(f).__name__
'function'
>>> sm.__name__ == f.__name__
True
>>> sm.__qualname__ == f.__qualname__
True
>>> sm(3) == f(3)
True

classmethod

与静态方法不同,类方法在调用前把类引用前置到参数列表,无论调用方是对象还是类,形式都相同:

class F:
    @classmethod
    def f(cls, x):
        return cls.__name__, x

>>> F.f(3)
('F', 3)
>>> F().f(3)
('F', 3)

当方法只需要类引用而不依赖特定实例数据时就有用,典型用途是替代类构造器——例如 dict.fromkeys 从键列表创建新字典:

class Dict(dict):
    @classmethod
    def fromkeys(cls, iterable, value=None):
        "Emulate dict_fromkeys() in Objects/dictobject.c"
        d = cls()
        for key in iterable:
            d[key] = value
        return d

>>> d = Dict.fromkeys('abracadabra')
>>> type(d) is Dict
True
>>> d
{'a': None, 'b': None, 'r': None, 'c': None, 'd': None}

纯 Python 版本的 classmethod

import functools

class ClassMethod:
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        return MethodType(self.f, cls)

关键差异在于绑定目标:function.__get__ 绑定的是实例 objClassMethod.__get__ 绑定的却是cls(实例调用时 clsNone,则取 type(obj))。官方测试确认了从类和从实例两种调用方式都得到相同结果,且 update_wrapper() 正确复制了 __name____qualname____doc____annotations____wrapped__ 可直接用于调用底层函数:

>>> T.cm(11, 22)
('T', 11, 22)
>>> t = T()
>>> t.cm(11, 22)
('T', 11, 22)
>>> f = vars(T)['cm'].__wrapped__
>>> f(T, 11, 22)
('T', 11, 22)

成员对象与 slots

当类定义 __slots__ 时,实例字典被替换为一个定长的槽值数组。对用户而言有五个影响:

  1. 立即发现拼写错误的属性赋值 bug——只允许 __slots__ 中列出的属性名:
class Vehicle:
    __slots__ = ('id_number', 'make', 'model')

>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'
  1. 便于创建不可变对象,由描述符管理对槽中私有属性的访问:
class Immutable:

    __slots__ = ('_dept', '_name')   # 替换实例字典

    def __init__(self, dept, name):
        self._dept = dept            # 存到私有属性
        self._name = name

    @property                       # 只读描述符
    def dept(self):
        return self._dept

    @property
    def name(self):                 # 只读描述符
        return self._name

>>> mark = Immutable('Botany', 'Mark Watney')
>>> mark.dept
'Botany'
>>> mark.dept = 'Space Pirate'
AttributeError: property 'dept' of 'Immutable' object has no setter
>>> mark.location = 'Mars'
AttributeError: 'Immutable' object has no attribute 'location'
  1. 节省内存。按官方文档的实测数据(64 位 Linux 构建),含两个属性的实例用 __slots__ 占 48 字节,不用则占 152 字节。这种"享元(flyweight)"设计只有在创建大量实例时才真正重要。
  2. 提升速度。按官方文档的测量(Python 3.10,Apple M1 处理器),读取实例变量快 35%。
  3. 阻断需要实例字典的工具,例如 functools.cached_property
from functools import cached_property

class CP:
    __slots__ = ()                   # 消除实例字典

    @cached_property                 # 需要实例字典
    def pi(self):
        return 4 * sum((-1.0)**n / (2.0*n + 1.0)
                       for n in reversed(range(100_000)))

>>> CP().pi
TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.

由于 __slots__ 需要直接访问 C 结构并控制对象内存分配,无法做出精确的纯 Python 替代;但官方文档构建了一个基本忠实的模拟:真实的 C 槽结构由私有列表 _slotvalues 模拟,对该结构的读写由**成员描述符(member descriptor)**管理:

null = sentinel('null')

class Member:

    def __init__(self, name, clsname, offset):
        'Emulate PyMemberDef in Include/descrobject.h'
        # 另见 descr_new() in Objects/descrobject.c
        self.name = name
        self.clsname = clsname
        self.offset = offset

    def __get__(self, obj, objtype=None):
        'Emulate member_get() in Objects/descrobject.c'
        # 另见 PyMember_GetOne() in Python/structmember.c
        if obj is None:
            return self
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        return value

    def __set__(self, obj, value):
        'Emulate member_set() in Objects/descrobject.c'
        obj._slotvalues[self.offset] = value

    def __delete__(self, obj):
        'Emulate member_delete() in Objects/descrobject.c'
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        obj._slotvalues[self.offset] = null

    def __repr__(self):
        'Emulate member_repr() in Objects/descrobject.c'
        return f'<Member {self.name!r} of {self.clsname!r}>'

type.__new__ 负责把成员对象挂到类变量上(模拟元类):

class Type(type):
    'Simulate how the type metaclass adds member objects for slots'

    def __new__(mcls, clsname, bases, mapping, **kwargs):
        'Emulate type_new() in Objects/typeobject.c'
        # type_new() 调用 PyTypeReady(),后者调用 add_methods()
        slot_names = mapping.get('slot_names', [])
        for offset, name in enumerate(slot_names):
            mapping[name] = Member(name, clsname, offset)
        return type.__new__(mcls, clsname, bases, mapping, **kwargs)

object.__new__ 负责创建带槽而非实例字典的实例(粗略模拟):

class Object:
    'Simulate how object.__new__() allocates memory for __slots__'

    def __new__(cls, *args, **kwargs):
        'Emulate object_new() in Objects/typeobject.c'
        inst = super().__new__(cls)
        if hasattr(cls, 'slot_names'):
            empty_slots = [null] * len(cls.slot_names)
            object.__setattr__(inst, '_slotvalues', empty_slots)
        return inst

    def __setattr__(self, name, value):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__setattr__(name, value)

    def __delattr__(self, name):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__delattr__(name)

使用时继承 Object 并指定元类 Type

class H(Object, metaclass=Type):
    'Instance variables stored in slots'

    slot_names = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

此时元类已为 xy 装载了成员对象(vars(H) 中可见 'x': <Member 'x' of 'H'>)。创建实例后,属性存于 slot_values 列表:

>>> h = H(10, 20)
>>> vars(h)
{'_slotvalues': [10, 20]}
>>> h.x = 55
>>> vars(h)
{'_slotvalues': [55, 20]}

# 拼写错误或未赋值的属性会抛异常
>>> h.xz
AttributeError: 'H' object has no attribute 'xz'

官方测试还覆盖了两类边界情况:del h.x 之后 hasattr(h, 'x')False;未初始化槽的实例对 hasattr(hu, 'x') 也返回 False——模拟的 Member.__get__ 通过把 null 哨兵视为"未赋值"来复现真实语义。

源码级佐证:描述符机制在 CPython 中的落点

将上述纯 Python 模拟与 CPython 当前源码(本仓库版本 3.16.0a0,见 Include/patchlevel.h#L30)对照,可以确认文档所述各机制的真实实现位置:

文档所述机制 源码位置
MRO 名称查找(find_name_in_mro 模拟的 _PyType_Lookup Objects/typeobject.c#L6268-L6276
类的属性查找入口 _Py_type_getattrotp_getattro Objects/typeobject.c#L6414-L6429,槽注册见 Objects/typeobject.c#L7191
super() 的属性查找 super_getattro Objects/typeobject.c#L12624
__set_name__ 自动通知 type_new_set_names Objects/typeobject.c#L12211-L12253
函数的描述符协议方法 func_descr_getself 绑定点) Objects/funcobject.c#L1267
property 的 C 类型 PyProperty_Type Objects/descrobject.c#L2062
通用属性设置 _PyObject_GenericSetAttrWithDict Objects/object.c

值得注意的实现细节:

  • 描述符分派的"双重检查"与文档模拟一致_Py_type_getattro(类属性查找)内部先做 MRO 查找,再检查查到的对象是否为描述符——与 object_getattribute 模拟中"先 find_name_in_mro、再取 type(cls_var).__get__"的次序相同。
  • __set_name__ 回调发生在 type_new 成功路径上。从源码结构看,type_new() 在构造完类型后调用 type_new_set_names(type)Objects/typeobject.c#L4943),逐条目以 PyObject_CallFunctionObjArgs(set_name, type, key, NULL) 调用——这解释了为什么事后动态添加的描述符收不到通知。
  • func_descr_get 就是"函数 → 绑定方法"的唯一枢纽tp_descr_get 槽在 Objects/funcobject.c#L1310 指向它:obj is None 时返回函数本身,否则构造绑定方法。文档中 d.f.__func__/d.f.__self__ 的展示,对应的正是绑定方法保存这两个引用的事实。

这些位置印证了官方文档的核心论断:描述符机制"嵌入在 objecttypesuper__getattribute__ 中",且各查找入口对 __get__ 的传参规则(实例 vs None)由具体实现决定。

总结

回到 Descriptor Guide 的知识脉络:

  1. 最小实现:只要定义 __get__,对象即成为描述符;必须作为类变量使用,值按需计算而非存储;
  2. 实用模式:动态查找(DirectorySize)、访问日志/受管属性(LoggedAgeAccess)、配合 __set_name__ 消除硬编码名称(LoggedAccess);
  3. 生产级应用:校验器(Validator + OneOf/Number/String)在数据源头阻止损坏;ORM Field 用数据描述符桥接外部存储;
  4. 协议语义:数据描述符压过实例字典,非数据描述符让位于实例字典;__getattribute__ 是唯一集中了全部描述符逻辑的方法,重写它会使自动描述符调用失效;__getattr__ 是点号/getattr 层面的后备钩子;
  5. 内置工具拆解property 是四参数据描述符;函数是返回绑定方法的非数据描述符(func_descr_getself 的诞生地);staticmethod 原样透传,classmethod 把绑定目标换成类;__slots__ 用成员描述符管理定长槽数组,换来拼写即时报错、不可变对象、48 vs 152 字节的内存差与更快的读取,代价是无法使用 cached_property 等依赖实例字典的特性。

掌握这套机制后,阅读 CPython 源码(Objects/typeobject.cObjects/funcobject.cObjects/descrobject.c)中属性查找相关函数时,可以把 C 实现逐行对回本文的纯 Python 模拟——这正是官方文档推荐的"从行为到实现"的学习路径。

登录后查看全文
热门项目推荐
相关项目推荐