Skip to main content

Chapter 11: Descriptor Protocol

属性访问看起来像一次普通的名字读取:obj.name。在对象系统内部,这次读取会先进入 attribute lookup,再根据类字典里保存的对象形态决定返回普通值、实例值、绑定方法,或由某个对象接管读取、写入、删除行为。descriptor protocol 处理的主问题就是:类属性本身如何参与属性访问的结果生成。

读完本章后,你应能追踪一次 obj.attr 从实例、类、MRO 到 descriptor 的完整判断顺序;能区分 data descriptor、non-data descriptor 和普通实例属性的覆盖关系;能解释普通函数为什么访问后变成 bound method;能判断 propertyclassmethodstaticmethod 各自改变了 attribute access 的哪一步。

本章以一个短的字段 descriptor 贯穿。它把 LineItem.quantity 这种类属性变成一个受控字段:读取时从实例私有存储取值,写入时做校验,删除时清理对应状态。这个例子足够覆盖 descriptor 的核心路径,也能连接到常见的 property、方法绑定和 CPython 的属性缓存边界。

本章默认讨论 CPython 的实现模型,但 descriptor 的语义规则来自 Python data model。Python 3.14 的 Descriptor GuideImplementing Descriptors 给出了协议定义;CPython 源码中的 Objects/object.cObjects/typeobject.cObjects/funcobject.cObjects/descrobject.c 体现了对象访问、类型访问、函数绑定和内置 descriptor 的实现形状。

11.1 Descriptor protocol contract

descriptor 的工作定义是:一个对象作为 owner class 的类属性出现,并在自身类型上提供 __get____set____delete__ 中至少一个方法。属性访问命中这个类属性后,解释器会按 descriptor 规则调用这些方法,让这个类属性对象参与结果生成。

协议的三个核心入口分别对应读取、写入和删除。__get__(self, instance, owner) 处理读取;__set__(self, instance, value) 处理写入;__delete__(self, instance) 处理删除。这里的 self 是 descriptor 对象本身,instance 是发起访问的实例,owner 是拥有该 descriptor 的类。通过类访问属性时,instance 传入 None,这使 descriptor 能区分类访问和实例访问。

下面的 PositiveField 是贯穿本章的材料。它被放在 LineItem 的类字典中,作为 quantityprice 两个属性的控制对象。

class PositiveField:
def __set_name__(self, owner, name):
self.public_name = name
self.storage_name = f"_{name}"

def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self.storage_name]

def __set__(self, instance, value):
if value <= 0:
raise ValueError(f"{self.public_name} must be positive")
instance.__dict__[self.storage_name] = value

def __delete__(self, instance):
del instance.__dict__[self.storage_name]


class LineItem:
quantity = PositiveField()
price = PositiveField()

def __init__(self, quantity, price):
self.quantity = quantity
self.price = price

def subtotal(self):
return self.quantity * self.price

这段代码有两个对象层级。LineItem.quantity 指向一个 PositiveField 实例;LineItem(3, 20).__dict__ 保存的是 {"_quantity": 3, "_price": 20}。公开属性名存在于类字典中,真实数据存在于实例字典中。descriptor 的作用就是把这两层连接起来。

__set_name__ 不是 descriptor 判定条件,但它常和 descriptor 一起出现。类创建完成时,owner class 会把类和属性名通知给 descriptor;这个回调让同一个 descriptor 类可以服务多个字段。没有它时,descriptor 往往要把 _quantity_price 这类私有名硬编码在构造参数中。

读取 item.quantity 时,解释器查到 LineItem.__dict__["quantity"]PositiveField 对象,再调用它的 __get__。写入 item.quantity = 5 时,解释器调用 __set__。删除 del item.quantity 时,解释器调用 __delete__。这些调用发生在 attribute lookup 和 attribute assignment 的内部路径中,调用方看到的仍是普通属性语法。

类访问需要单独处理。LineItem.quantity 会让 instanceNone,示例中选择返回 descriptor 对象本身。这样调试者可以通过类拿到 descriptor 的配置状态,也能用 vars(LineItem)["quantity"] 直接取出类字典里的原对象。直接访问类字典不会触发 descriptor 调用,因为这条路径跳过了点号属性访问。

descriptor 的位置边界也要固定:它要出现在 owner class 或其父类的类字典中。把一个实现了 __get__ 的对象塞进某个实例的 __dict__,普通 obj.attr 不会把它当作 descriptor 调用。descriptor 是类属性参与实例访问的协议,不是任意对象在任意容器中的自动拦截器。

11.2 Data descriptor and non-data descriptor precedence

descriptor 的优先级由它是否提供写入或删除入口决定。提供 __set____delete__ 的 descriptor 是 data descriptor;只提供 __get__ 的 descriptor 是 non-data descriptor。这个分类影响实例字典和类字典同名属性相遇时的返回结果。

实例读取 obj.name 时,可以用下面这条顺序定位结果:先查类及 MRO 中是否有同名 data descriptor;再查实例字典;再查类及 MRO 中是否有同名 non-data descriptor;再返回普通类属性;最后才进入 __getattr__ 兜底。Python 官方 descriptor guide 对 instance lookup 的等价伪代码也按这个顺序展开。

这个流程适合画成分支图。图中只覆盖实例读取路径,类读取和 super() 读取有各自的调用参数。

这个顺序解释了 PositiveField 为什么能拦截写入并控制读取。它定义了 __set____delete__,因此属于 data descriptor。即使有人手动写入 item.__dict__["quantity"] = 999item.quantity 仍会先命中 LineItem.quantity 这个 data descriptor,再由 __get__ 返回 item.__dict__["_quantity"]

item = LineItem(3, 20)
item.__dict__["quantity"] = 999

print(item.quantity) # 3
print(item.__dict__["quantity"]) # 999
print(item.__dict__["_quantity"]) # 3

这段代码证明了一个判断:实例字典中的同名键能存在,但读取优先级低于 data descriptor。descriptor 选择 _quantity 作为真实存储名,也让公开属性名 quantity 保持受控。工程代码中应把这种映射关系写清楚,否则调试时会看到两个相近的键,难以判断哪一个参与真实读取。

non-data descriptor 的覆盖关系相反。下面的 ComputedLabel 只实现 __get__,实例字典中出现同名键时,实例值会覆盖 descriptor 返回值。

class ComputedLabel:
def __get__(self, instance, owner=None):
if instance is None:
return self
return f"item:{instance.quantity}"


class LabeledItem(LineItem):
label = ComputedLabel()


labeled = LabeledItem(3, 20)
print(labeled.label) # item:3

labeled.__dict__["label"] = "manual"
print(labeled.label) # manual

这段代码展示了 non-data descriptor 的设计用途:它提供默认计算行为,同时允许单个实例通过实例字典覆盖。普通函数方法也属于这个类别,所以实例上同名赋值可以覆盖方法读取结果。这个能力很灵活,也会制造调试成本,因为同一个表达式 obj.name 的返回值可能来自类 descriptor,也可能来自实例字典。

只读 data descriptor 需要一个细节。若一个 descriptor 希望只允许读取,仍然可以定义 __set__ 并在其中抛出 AttributeError。只要 __set__ 出现在 descriptor 类型上,它就按 data descriptor 排序。property 没有 setter 时依然能拦截实例同名覆盖,原因就在这里。

class ReadOnlyToken:
def __get__(self, instance, owner=None):
return "fixed-token"

def __set__(self, instance, value):
raise AttributeError("token is read-only")

判断 descriptor 覆盖关系时,稳定顺序是:先看 owner class 的 MRO 中有没有同名对象;再看这个对象的类型上有没有 __set____delete__;然后才看实例字典是否有同名键。这个顺序比“实例属性覆盖类属性”更精确,因为 data descriptor 是类属性中拥有高优先级的一类。

11.3 Function descriptor and method binding

普通函数放进类字典后,会变成 non-data descriptor。类体执行时,def subtotal(self): ... 创建一个 function object,并把它绑定到 LineItem.__dict__["subtotal"]。实例读取 item.subtotal 时,function object 的 __get__ 被调用,返回一个 bound method。

bound method 保存两部分状态:__func__ 指向原始函数,__self__ 指向绑定的实例。随后调用这个 method 时,实例会作为第一个实参传给原始函数。self 的来源就在这一步,调用语法本身没有额外创造一个特殊变量。

item = LineItem(3, 20)
method = item.subtotal

print(method.__func__ is LineItem.__dict__["subtotal"]) # True
print(method.__self__ is item) # True
print(method()) # 60

这段代码把方法调用拆成了两个阶段。第一阶段是属性读取:item.subtotal 从类字典找到 function descriptor,并生成绑定了 item 的 method object。第二阶段是调用:method() 最终执行原始函数,并把 item 放到第一个参数位置。

类访问函数时,绑定对象不同。LineItem.subtotal 通过类读取,function descriptor 收到的 instanceNone,返回原始函数本身。调用 LineItem.subtotal(item) 时,调用者显式传入实例;调用 item.subtotal() 时,bound method 已经保存实例。两种调用进入的是同一个函数对象,差别在实参准备阶段。

item = LineItem(3, 20)

print(LineItem.subtotal(item)) # 60
print(item.subtotal()) # 60

函数是 non-data descriptor,所以实例字典可以覆盖同名方法读取。这个边界在调试 monkey patch、测试替身和动态对象时经常出现。

item = LineItem(3, 20)
item.subtotal = lambda: 0

print(item.subtotal()) # 0
print(LineItem.subtotal(item)) # 60
print(LineItem.__dict__["subtotal"]) # 原始函数对象

这里 item.subtotal 返回实例字典中的 lambda,descriptor binding 没有发生。LineItem.subtotal(item) 仍然使用类中的原始函数。排查“方法突然不走类定义”的问题时,应先检查 vars(obj) 中是否出现了同名键,再检查类字典中的函数 descriptor。

CPython 中,函数绑定由 Objects/funcobject.c 里的函数 descriptor 逻辑支撑;官方 descriptor guide 也用等价 Python 代码说明:函数的 __get__ 在实例访问时返回 MethodType(function, instance)。这解释了 Python 的对象方法为何属于属性系统的一部分,而非独立于 attribute lookup 的语法特例。

11.4 property, classmethod, and staticmethod

propertyclassmethodstaticmethod 都是内置 descriptor,它们改变的是属性读取后的返回值形态。它们的差异可以放在同一组维度下看:descriptor 保存什么对象、实例访问传入什么、类访问传入什么、最终返回什么。

property 用函数组织托管属性。读取时调用 fget(instance);写入时调用 fset(instance, value);删除时调用 fdel(instance)。它适合把已经暴露为属性的访问点升级为受控计算、校验或兼容层。Python 官方 descriptor guide 给出的 pure Python equivalent 也显示了 property 如何通过 __get____set____delete__ 包装这些函数。

class Product:
def __init__(self, quantity, price):
self.quantity = quantity
self.price = price

@property
def total(self):
return self.quantity * self.price


product = Product(3, 20)
print(product.total) # 60

total 在类字典里保存的是 property object。product.total 读取时调用 property 的 __get__,再调用 total 原函数并传入 product。调用方看到的是属性读取,内部执行的是函数调用。这个设计让公开 API 保持属性形态,同时把计算逻辑收束到类内部。

没有 setter 的 property 仍是 data descriptor。写入 product.total = 10 会进入 property 的写入入口并抛出 AttributeError。它的效果是公开属性只读,实例字典中的同名键也无法通过普通属性读取覆盖它。

classmethod 改变函数绑定的第一个参数。普通方法从实例访问时绑定实例;classmethod 从实例或类访问时都绑定 owner class。它适合替代构造器、类型级注册、按子类分派的工厂方法。返回的 bound method 中,__self__ 是类对象。

class Product:
tax_rate = 0.1

def __init__(self, quantity, price):
self.quantity = quantity
self.price = price

@classmethod
def with_total(cls, total, quantity):
price = total / quantity
return cls(quantity, price)


created = Product.with_total(60, 3)
print(created.price) # 20.0

这段代码的关键点是 cls 来自 descriptor binding。若子类继承 with_total,通过子类调用时 cls 指向子类,返回对象也会按子类构造路径生成。classmethod 的价值在于把“当前访问的类”作为 runtime 参数注入函数。

staticmethod 保留函数对象的调用形态。通过类访问或实例访问时,它都返回底层函数,不向参数列表注入实例或类。它适合放置与类语义相关、但不读取实例状态和类状态的函数。

class PriceMath:
@staticmethod
def discount(price, rate):
return price * (1 - rate)


print(PriceMath.discount(100, 0.2)) # 80.0
print(PriceMath().discount(100, 0.2)) # 80.0

这三个内置 descriptor 可以用一张表压缩成判断模型。

形式类字典中保存的对象实例访问结果类访问结果主要用途
普通函数function object绑定实例的 method原始函数实例行为
propertyproperty objectfget(instance) 的结果property 对象本身托管属性
classmethodclassmethod object绑定类的 method绑定类的 method类型级构造或分派
staticmethodstaticmethod object原始函数原始函数命名空间归属

表里的“类字典中保存的对象”和“访问结果”要分开看。vars(Product)["with_total"] 返回 classmethod object;Product.with_total 返回绑定类的 method。很多 introspection、装饰器和框架错误都来自混淆这两层。

11.5 Descriptor cache and engineering boundaries

descriptor 的语义顺序由 data model 决定,CPython 的 cache 只优化这条顺序的执行成本。也就是说,cache 可以减少 MRO 查找、字典查找和 method loading 的开销,但它要保持 data descriptor、实例字典、non-data descriptor、普通类属性的可见结果一致。

Python 3.11 引入的 specializing adaptive interpreter 会把热路径上的 LOAD_ATTRLOAD_METHOD 等指令替换为更具体的变体,并在字节码旁边维护 inline cache。Python 3.11 的 What’s New 说明 attribute loading 可以缓存对象或类命名空间中的索引;PEP 659 说明 LOAD_ATTR specialization 会根据属性种类选择快路径,并在输入形态变化时退回通用路径。

对 descriptor 来说,cache 的工程含义有三条。第一,稳定的类型和稳定的类字典形状更容易走快路径。第二,运行期修改类属性、替换 descriptor、给实例动态塞入同名属性,会让缓存守卫失效或回到通用查找。第三,cache 不授予 descriptor 新语义;代码判断仍按 attribute lookup precedence 进行。

这也给 descriptor 设计划定了边界。descriptor 适合封装字段校验、延迟计算、兼容属性、ORM 字段、配置映射、__slots__ 风格的受控存储。它的入口位于属性读取和写入路径上,所以 __get__ 应保持可预测,__set__ 应把校验和存储边界写清楚,异常类型应能让调用方定位失败原因。

PositiveField 体现了一个稳定写法:descriptor 对象保存字段元数据,实例对象保存业务值。descriptor 自身一般放配置,例如公开名、私有存储名、校验参数;实例字典保存每个实例不同的数据。若 descriptor 把业务值存在 descriptor 自身上,所有实例会共享同一个状态,字段就会串扰。

class BrokenField:
def __get__(self, instance, owner=None):
return self.value

def __set__(self, instance, value):
self.value = value


class BrokenItem:
quantity = BrokenField()


first = BrokenItem()
second = BrokenItem()
first.quantity = 3
second.quantity = 8

print(first.quantity) # 8
print(second.quantity) # 8

这段代码的失败点在存储位置。BrokenItem.quantity 只有一个 descriptor 对象,两个实例写入都改同一个 self.value。正确做法是把每个实例的值写进 instance.__dict__ 或 slot 存储;descriptor 自身只保存字段规则。

排查 descriptor 相关问题时,按下面的顺序看证据:先用 vars(type(obj)) 或沿 MRO 查类字典,确认同名类属性是否存在;再看这个类属性的类型上是否定义 __set____delete__;再看 vars(obj) 是否有同名键;随后判断 __get__ 收到的 instance 是对象还是 None;最后再考虑 CPython 版本下的 LOAD_ATTR specialization 和缓存失效。这个顺序能把语义问题和性能问题拆开。

descriptor 的可维护性边界也来自这个排查顺序。公开属性读写一旦进入 descriptor,读者需要同时理解类字典、实例字典和 descriptor 类型。简单数据字段使用普通属性;需要校验和兼容层时使用 property;多个字段共享同一套规则时抽出自定义 descriptor;需要改类创建、继承和命名空间准备时,再进入 metaclass 或 class construction 相关工具。

最小自检任务

阅读下面代码,判断每一行输出,并说明每个结果来自实例字典、data descriptor、non-data descriptor、普通函数绑定,还是类访问 descriptor。

class DataSlot:
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__["_name"]

def __set__(self, instance, value):
instance.__dict__["_name"] = value.upper()


class NonDataLabel:
def __get__(self, instance, owner=None):
if instance is None:
return self
return f"label:{instance.name}"


class User:
name = DataSlot()
label = NonDataLabel()

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

def show(self):
return self.name


user = User("alice")
user.__dict__["name"] = "raw"
user.__dict__["label"] = "manual"
user.show = lambda: "patched"

print(user.name)
print(user.label)
print(user.show())
print(User.show(user))
print(User.name is User.__dict__["name"])

答案要点

print(user.name) 输出 ALICEUser.name 是 data descriptor,因为 DataSlot 定义了 __set__。读取顺序先调用 DataSlot.__get__,所以实例字典里的同名键 "name": "raw" 不参与最终结果。

print(user.label) 输出 manualUser.label 是 non-data descriptor,只定义 __get__。实例字典中有同名键 "label" 时,实例字典优先,所以 descriptor 的 label:{instance.name} 计算路径没有执行。

print(user.show()) 输出 patched。普通函数是 non-data descriptor,实例字典里的 show 覆盖了类中的函数 descriptor。这里调用的是实例字典中的 lambda。

print(User.show(user)) 输出 ALICE。通过类访问普通函数返回原始函数,调用者显式传入 user。函数体里的 self.name 再进入 data descriptor 读取路径,得到 _name 中保存的大写值。

print(User.name is User.__dict__["name"]) 输出 True。通过类访问 DataSlot.__get__ 时,instanceNone,示例选择返回 descriptor 对象本身;直接访问类字典也返回同一个 descriptor 对象。

本章知识点总结

  • 协议入口:descriptor 通过 __get____set____delete__ 接管类属性命中后的读取、写入和删除行为。
  • 类字典位置:descriptor 要作为 owner class 或父类的类属性出现,实例字典中的 descriptor 对象不会自动触发协议。
  • 实例参数:实例访问 descriptor 时传入实例和 owner class,类访问 descriptor 时传入 None 和 owner class。
  • 命名通知__set_name__ 让 descriptor 在类创建阶段获得 owner class 和属性名,适合生成私有存储名。
  • Data 优先级:data descriptor 提供 __set____delete__,读取时优先于实例字典同名键。
  • Non-data 覆盖:non-data descriptor 只提供 __get__,实例字典同名键会覆盖它的读取结果。
  • 只读属性:只读 data descriptor 可以定义抛出 AttributeError__set__,从而保持 data descriptor 排序。
  • 方法绑定:普通函数在类字典中是 non-data descriptor,实例读取会生成保存 __func____self__ 的 bound method。
  • 实例遮蔽方法:实例字典同名键可以覆盖普通方法读取,因为函数 descriptor 属于 non-data descriptor。
  • Property 语义property 是托管属性 descriptor,读取、写入和删除会分派到 fgetfsetfdel
  • 类方法绑定classmethod 把 owner class 绑定为第一个参数,使实例访问和类访问都获得类级 method。
  • 静态方法返回staticmethod 返回底层函数,不向参数列表注入实例或类。
  • 缓存边界:CPython 的 LOAD_ATTR specialization 优化查找成本,但不改变 descriptor precedence。
  • 存储边界:descriptor 自身适合保存字段规则,实例相关业务值应存放在实例字典、slot 或外部受控存储中。
  • 排查顺序:先查类字典和 MRO,再判定 descriptor 类型,再查实例字典,最后分析绑定结果和缓存表现。