一、property 为什么能拦截赋值?
每个写过 Python 的人都见过这个报错:
class C: @property def x(self): return 10c = C()c.x = 20# AttributeError: property 'x' of 'C' object has no setter
问题很简单:为什么 c.x = 20 会被拒绝?你在类里写的是 @property def x,不是 def set_x 也不是什么拦截器。Python 怎么知道要在赋值时抛异常?
如果你只是记住了 “@property 让方法像属性一样用”,那你掌握的是用法,不是原理。真正的问题是:property 背后到底是什么机制,让它能拦截属性的读取、赋值和删除?
这个机制就是描述符(Descriptor)。property 本身只是一个内置的、用 C 实现的描述符。理解描述符,你不仅能看懂 property 的底牌,还能理解 staticmethod、classmethod、以及为什么 self 会自动出现在方法调用的第一个参数位置。
二、描述符是什么?
描述符不是某种需要导入的特殊类型,也不是装饰器或元类。它是 Python 的一种协议(protocol)——就像迭代器协议(__iter__ + __next__)一样。只要你的对象"遵守这个协议",Python 就会按协议规则来调用它。
协议的内容只有三个方法:
| | |
|---|
__get__ | descr.__get__(self, obj, type=None) | 读取 |
__set__ | descr.__set__(self, obj, value) | 写入 |
__delete__ | descr.__delete__(self, obj) | 删除 |
官方文档说得很精确:
“一般而言,描述器是具有描述器协议中的方法之一的属性值。这些方法是 __get__()、__set__() 和 __delete__()。如果为某个属性定义了这些方法中的任何一个,它就被称为 descriptor。”
注意三个关键词:
- “之一”:定义任意一个方法,你就是描述符。不需要三个都写。
- “属性值”:描述符不是独立存在的,它是作为另一个类的属性被使用的。
- “descriptor”:这是 Python 内部机制的名字,不是某个类的名字。
还有一个可选方法,在 Python 3.6+ 引入:
def __set_name__(self, owner, name): ...
当描述符被作为类变量定义时,Python 会自动调用它,告诉描述符"你被分配到了哪个类?你在这个类里叫什么名字?"。
三、从最简单的例子开始
我们先写世界上最简单的描述符——一个无论什么时候被访问,都返回 10 的对象。
class Ten: def __get__(self, obj, objtype=None): return 10
Ten 本身是一个普通的类。它的特殊之处只在于定义了 __get__。根据协议,Python 会把它当作描述符对待。
要使用描述符,必须把它作为类变量定义在另一个类里:
class A: x = 5 # 普通类属性 y = Ten() # 描述符实例a = A()print(a.x) # 5 —— 普通属性查找,直接从类字典拿值print(a.y) # 10 —— 描述符查找,触发 __get__(),返回 10
注意:10 这个值并不存在于类字典或实例字典中,它是在你访问的那一刻实时计算出来的。这就是描述符的第一个特征:按需计算。
官方文档对此的解释非常清晰:
“在 a.x 属性查找中,点运算符会找到存储在类字典中的 'x': 5。在 a.y 查找中,点运算符会根据描述器实例的 __get__ 方法将其识别出来,调用该方法并返回 10。”
“请注意,值 10 既不存储在类字典中也不存储在实例字典中。相反,值 10 是在调用时才取到的。”
当然,只返回常数的描述符没什么实战价值。但它完成了一件重要的事:证明了只要定义 __get__,就能接管另一个类中属性的访问行为。
四、动态查找:描述符的真正用途
只返回常数不如直接写 y = 10。描述符的真正价值在于根据访问时的上下文动态计算值。例如,一个实时返回目录文件数的属性:
import osclass 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')print(s.size) # 20print(g.size) # 3os.remove('games/chess')print(g.size) # 2 —— 自动更新
__get__ 的三个参数此时有了实际意义:
self:描述符实例本身(DirectorySize() 这个对象)。obj:发起访问的实例(s 或 g)。通过它可以获取实例上的其他数据。objtype
这个例子同时说明了一个关键规则:描述符必须是类变量。如果你把它放进实例的 __dict__ 里,Python 只会把它当成普通值原样返回,不会触发 __get__。
五、数据描述符 vs 非数据描述符
这是描述符中最核心、也最容易让人混淆的概念。官方文档的定义非常明确:
“如果一个对象定义了 __set__() 或 __delete__(),它将被视为数据描述器。仅定义了 __get__() 的描述器称为非数据描述器。”
这个分类决定了 Python 属性查找时的优先级。官方文档给出了精确规则:
“数据和非数据描述器的不同之处在于,如何计算实例字典中条目的替代值。如果实例的字典具有与数据描述器同名的条目,则数据描述器优先。如果实例的字典具有与非数据描述器同名的条目,则该字典条目优先。”
我们用三组实验来建立直觉。
实验一:数据描述符 vs 实例字典
class DataDescriptor: def __get__(self, obj, objtype=None): return 'from data descriptor' def __set__(self, obj, value): # 定义了 __set__,所以是数据描述符 passclass TestData: attr = DataDescriptor()td = TestData()td.__dict__['attr'] = 'from instance dict'print(td.attr) # → 'from data descriptor'
实例字典里有 attr,但数据描述符优先级更高,所以 Python 忽略了实例字典,调用了描述符的 __get__。
实验二:非数据描述符 vs 实例字典
class NonDataDescriptor: def __get__(self, obj, objtype=None): return 'from non-data descriptor' # 没有定义 __set__,所以是非数据描述符class TestNonData: attr = NonDataDescriptor()tnd = TestNonData()tnd.__dict__['attr'] = 'from instance dict'print(tnd.attr) # → 'from instance dict'
这次结果反过来了:实例字典覆盖了非数据描述符。
实验三:为什么方法可以被同名属性覆盖?
class D: def f(self): return selfd = D()d.f = 'not a method anymore'print(d.f) # 'not a method anymore'
原因是:函数在类中是以非数据描述符的形式存在的。因为是非数据描述符,实例字典里的同名字符串优先级更高,直接覆盖了它。但 D.f 仍然返回原始函数,因为类访问不查实例字典。
这三组实验总结为一张优先级图:
这张图把五条规则压缩成一个金字塔:数据描述符永远在最顶端,实例字典次之,非数据描述符再次之。记住这个层级,你就掌握了 Python 属性查找的精髓。
六、让描述符只读
既然数据描述符优先级高于实例字典,我们可以利用这个特性做出只读属性。但这里有一个反直觉的技巧:只读的秘密不是"不写 __set__",而是**“写了 __set__ 但让它抛异常”**。
class ReadOnly: def __get__(self, obj, objtype=None): return 'readonly' def __set__(self, obj, value): raise AttributeError('read-only')class RO: x = ReadOnly()r = RO()print(r.x) # 'readonly'r.x = 1 # AttributeError: read-only
为什么必须写 __set__?因为如果你不写,你就是非数据描述符,实例字典可以覆盖你。只有你写了 __set__,才升级为数据描述符,实例字典才无法撼动你。property 不写 @setter 时为什么报错 property 'x' of 'C' object has no setter?正是这个机制——内置的 property 类定义了一个抛异常的 __set__。
七、总结:三个核心直觉
读完上篇,你应该已经建立了三个最核心的直觉:
- 描述符是一种协议:定义了
__get__/__set__/__delete__ 中任意一个,Python 就会用描述符的规则来调用你。不是类型,不是类,是协议。 - 描述符必须是类变量:只有放在类命名空间里的对象,Python 才会检查它是不是描述符。实例字典里的东西只是普通值。
- 数据描述符压制实例字典,非数据描述符被实例字典压制:
property 的只读、方法的可覆盖,全源于此。
property 能拦截属性访问,不是因为它有魔法,而是因为它遵守了描述符协议,并且在 __set__ 里抛了异常。你的自定义描述符和 property 一样,完全遵守同一套规则。
中篇我们会把这些直觉变成工具:从记录日志到搭建一个完整的属性验证器框架。你会发现,描述符一旦用好了,能让你的代码干净得像在写声明式配置,而不是 imperative 的胶水代码。