ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python中的类与对象之描述符详解

Python中的类与对象之描述符详解 前言描述符descriptor是 Python 对象模型里被误解最多的一环。很多资料把它讲成「一种高级技巧」其实它就在你每天写的代码里函数、property、classmethod、staticmethod全都是描述符。obj.method之所以能自动带上self靠的就是函数这个非数据描述符的__get__。第一个要澄清的误解是「描述符和property是两回事」。实际上property就是标准库提供的一个数据描述符property只是它的装饰器门面。第二个误解是「描述符定义在哪个类里都行」。官方规则很硬描述符必须出现在拥有者类的类字典里或它的某个父类的类字典里把它存进实例字典是没用的。本文讲四件事描述符协议本身、数据描述符与非数据描述符的查找优先级、函数与property为什么属于不同类别、以及用它实现字段校验的完整例子。所有行为以 Python 官方数据模型章节为准本机无解释器输出为人工推演。一、协议与一次属性查找一个对象只要定义了__get__/__set__/__delete__中的任意一个它就算描述符。三个方法的签名是__get__(self, instance, ownerNone) # 读instance 是访问它的对象类访问时为 None__set__(self, instance, value) # 写给实例上的这个属性赋值__delete__(self, instance) # 删del 实例上的这个属性最简例子# 适用于 Python 3.0class Descriptor:def __get__(self, instance, ownerNone):return 从描述符读取def __set__(self, instance, value):print(收到赋值:, value)class C:x Descriptor() # 关键放在类字典里c C()print(c.x) # 从描述符读取c.x 10 # 收到赋值: 10print(C.x) # 从描述符读取 ← 类访问时 instance 为 None官方把描述符参与的查找过程写得很明确读a.x时相当于调用type(a).__dict__[x].__get__(a, type(a))读类属性A.x时相当于调用A.__dict__[x].__get__(None, A)如果是super(A, a).x则从a的 MRO 里A之后找基类B调用B.__dict__[x].__get__(a, A)。所以__get__的owner参数是「查找起点所在的类」不一定等于type(instance)。二、查找优先级数据描述符 vs 非数据描述符这是描述符最容易答错的部分。按官方定义定义了__set__或__delete__的叫数据描述符data descriptor只定义__get__的叫非数据描述符non-data descriptor。实例属性查找的优先级从高到低是优先级来源说明1数据描述符在类及父类的字典里永远压过实例字典2实例的__dict__普通属性就存在这里3非数据描述符在类及父类的字典里会被实例字典覆盖4类属性普通值用两段代码看清差别# 适用于 Python 3.0class NonData:def __get__(self, instance, ownerNone):return 来自非数据描述符class Data:def __get__(self, instance, ownerNone):return 来自数据描述符def __set__(self, instance, value):instance.__dict__[_hidden] valueclass A:d NonData()class B:d Data()a A()a.__dict__[d] 来自实例字典print(a.d) # 来自实例字典 ← 非数据描述符被实例字典覆盖b B()b.__dict__[d] 来自实例字典print(b.d) # 来自数据描述符 ← 数据描述符赢一句话记忆数据描述符有「写」的能力所以它有资格压住实例字典非数据描述符只有「读」实例字典一出现就把它盖住。三、函数、property、classmethod 分别属于哪一类官方明确说明函数包括被staticmethod、classmethod装饰的都是非数据描述符。这解释了为什么实例可以「覆盖」方法# 适用于 Python 3.0class C:def m(self):return 方法c C()print(c.m()) # 方法print(type(C.__dict__[m])) # class functionc.__dict__[m] 被覆盖print(c.m) # 被覆盖 ← 非数据描述符被实例字典压住c.m之所以是绑定方法是因为函数这个非数据描述符的__get__返回了一个「绑定方法对象」把c和函数绑在一起。同理classmethod的__get__绑定的是类staticmethod的__get__原样返回函数。这三种行为差异全部来自各自__get__的实现不同。而property是数据描述符所以它反过来能压住实例字典# 适用于 Python 3.0class C:propertydef p(self):return 42c C()c.__dict__[p] 99 # 直接塞进实例字典print(c.p) # 42 ← property 是数据描述符赢这也解释了官方那句「实例无法覆盖 property 的行为却可以覆盖普通方法」——差别就在数据描述符与非数据描述符。还有一个边界条款值得记住如果描述符只定义了__set__却没有__get__那么读这个属性时会返回描述符对象本身除非实例字典里正好有值。只写不读的描述符几乎没人这么用但规则确实如此。四、实战带类型校验的字段把__set_name__和描述符结合可以做出「自己知道名字、还会校验类型」的字段。这是不少 ORM 和表单库的基础# 适用于 Python 3.6class Typed:def __init__(self, expected_type):self.expected_type expected_typeself.name Nonedef __set_name__(self, owner, name):self.name name # 类创建时自动告知名字def __get__(self, instance, ownerNone):if instance is None: # 类访问返回描述符本身return selfreturn instance.__dict__.get(self.name)def __set__(self, instance, value):if not isinstance(value, self.expected_type):raise TypeError(f{self.name} 需要 {self.expected_type.__name__}f收到 {type(value).__name__})instance.__dict__[self.name] valuedef __delete__(self, instance):del instance.__dict__[self.name]class Person:name Typed(str)age Typed(int)def __init__(self, name, age):self.name name # 走 Typed.__set__self.age agep Person(Alice, 30)print(p.name, p.age) # Alice 30p.age 不是整数 # TypeError: age 需要 int收到 str三个要点__set_name__Python 3.6由type.__new__在建类时自动调用参数是所属类和属性名于是字段不必自己写名字。官方提醒类建好之后再给类属性赋值钩子不会自动触发需要时得手动调__set_name__。真实数据存在instance.__dict__[self.name]所以读的时候必须走__get__不能指望实例字典被自动查找到——因为它是数据描述符优先级最高的那一档。instance is None时返回self这样Person.name能拿到描述符对象本身便于做内省。少了这个分支类访问就会因为对None取属性而报错。另外描述符对象还可以带上__objclass__属性inspect模块会把它当作「这个对象定义在哪个类里」的提示写通用工具时用得上。常见坑点把描述符实例存进实例而不是类❌c.d Descriptor()→ 它只是实例属性协议完全不生效c.d直接返回那个对象。 ✅ 描述符必须写在类体里class C: d Descriptor()。以为非数据描述符能压住实例属性❌ 只定义__get__的自定义描述符却指望实例字典里的同名值被忽略。 ✅ 记住非数据描述符排在实例字典之后要压住就得同时定义__set__。__get__没处理instance is None❌def __get__(self, instance, ownerNone): return instance.attr→ 类访问C.x时instance是None直接AttributeError。 ✅ 先判断if instance is None: return self。在__get__里用getattr(instance, self.name)取值❌def __get__(self, instance, ownerNone): return getattr(instance, self.name)→ 读的正是同一个名字无限递归。 ✅ 走实例字典return instance.__dict__.get(self.name)。依赖__set_name__在类建好后仍会自动调用❌class A: pass之后A.x Typed(int)→ 钩子不会再触发self.name还是None。 ✅ 手动补一句A.x.__set_name__(A, x)或把字段写在类体里。__set__里直接写self.xxx期望存到实例上❌def __set__(self, instance, value): self.value value→ 值存到了描述符对象自己的属性上被所有实例共享。 ✅ 存进实例字典instance.__dict__[self.name] value。只写__set__不写__get__❌ 以为读的时候会返回存进去的值 → 实际返回描述符对象本身实例字典里恰好有值才例外。 ✅ 读写都要就两个都定义。描述符只影响实例不影响类字典本身❌C.x 1之后指望实例的c.x还是走__get__→ 类字典里的描述符已被值替换协议不再生效。 ✅ 需要动态改行为就改描述符内部状态而不是把类属性整个换掉。另注Python 2 里旧式类class C:不继承object对描述符支持不完整Python 2.7 已于 2020 年 1 月 1 日停止维护Python 3 里所有类都是新式类上述规则一致适用。总结概念判定与实例字典比数据描述符定义了__set__或__delete__描述符赢非数据描述符只定义__get__实例字典赢函数 /classmethod/staticmethod非数据描述符实例可覆盖property数据描述符实例不可覆盖查找起点描述符必须在类或父类字典里实例字典里的描述符无效描述符的全部秘密就是「属性查找按优先级选出一个对象再按它的协议决定返回什么」。记住数据描述符与非数据描述符这条分界线obj.method的绑定、property的只读、classmethod与staticmethod的差别就都能用同一套规则推出来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进