Python 描述符协议:从一个点号到语言核心机制
一、引言:藏在点号背后的魔法
写过 Python 的人都用过obj.attr这样的语法。它看起来再普通不过——访问一个属性嘛。但你有没有想过这样几个问题:
- 为什么
func是一个函数,而obj.func自动就成了"绑定方法",第一个参数self不用你传? - 为什么
@property可以让一个方法看起来像一个属性,而且支持赋值校验? - 为什么
@classmethod、@staticmethod加上一行装饰器就改变了函数的调用方式? - 为什么定义了
__slots__之后,类的实例会变得更小、更快、还能阻止你乱写属性?
这些看似毫不相关的特性,其实都源自同一套底层机制——描述符协议(Descriptor Protocol)。可以说,描述符是 Python 面向对象系统的"基础粒子"。掌握它,意味着你从"语法糖的使用者"变成了"理解 Python 内部运行机制的人"。
本文基于 Python 3.14 官方文档(Raymond Hettinger 撰写的 Descriptor Guide),从直觉到机制、从基础到内核,带你彻底理解这个协议。
二、定义:什么是描述符?
官方给出的定义非常简洁:任何实现了__get__()、__set__()或__delete__()这三个方法中至少一个的对象,就是一个描述符。
完整的协议签名是这样的:
descr.__get__(self,obj,type=None)# 读取属性时被调用descr.__set__(self,obj,value)# 赋值时被调用descr.__delete__(self,obj)# 删除时被调用还有一个可选的钩子:
descr.__set_name__(self,owner,name)# 类创建时被自动调用,告知描述符自己的"名字"注意一个至关重要的前提:描述符只有作为类变量时才生效。如果你把它放在实例的__dict__里,它就只是一个普通对象,所有协议方法都不会被触发。这一点经常被初学者忽略。
三、最小例子:从一个常数开始
让我们从最简单的描述符开始建立直觉:
classTen:def__get__(self,obj,objtype=None):return10classA:x=5# 普通类属性y=Ten()# 描述符实例运行一下:
>>>a=A()>>>a.x# 普通查找:55>>>a.y# 描述符查找:__get__ 被调用10a.x在类字典里找到5,直接返回。a.y找到的是一个Ten实例,Python 发现它有__get__方法,于是调用Ten.__get__(y, a, A),把结果返回。
值得注意:返回值10既不在类字典里,也不在实例字典里,它是被"现算"出来的。这就是描述符最朴素的能力——把"取值"这个动作变成了"调用一段代码"。
四、有用一点的例子:动态计算与受管理属性
把上面的例子推广一下,描述符就能做点真正有用的事了:
importosclassDirectorySize:def__get__(self,obj,objtype=None):returnlen(os.listdir(obj.dirname))classDirectory:size=DirectorySize()def__init__(self,dirname):self.dirname=dirname>>>d=Directory('songs')>>>d.size# 每次调用都重新数文件20这里出现了__get__的三个参数,它们的含义是描述符理解的关键:
self:描述符实例本身(DirectorySize对象);obj:访问描述符的那个实例(这里是Directory实例d);objtype:访问描述符的那个类(这里是Directory)。
正是obj这个参数让描述符能"看到"宿主对象的内部状态,从而实现一切个性化逻辑——记录日志、校验数据、懒加载、ORM 字段映射等等。
五、最关键的一刀:数据描述符 vs 非数据描述符
这是描述符协议中最容易混淆但又最重要的一对概念。规则是这样的:
| 类型 | 实现的方法 | 与实例字典的优先级 |
|---|---|---|
| 数据描述符 | 实现了__set__或__delete__(通常也实现__get__) | 优先于实例__dict__ |
| 非数据描述符 | 只实现__get__ | 低于实例__dict__ |
为什么这个区别如此关键?因为它决定了 Python 属性查找的全部行为。看一个能击穿直觉的例子:
classDataDesc:def__get__(self,obj,objtype=None):return"from data descriptor"def__set__(self,obj,value):raiseAttributeError("read only")classNonDataDesc:def__get__(self,obj,objtype=None):return"from non-data descriptor"classC:a=DataDesc()b=NonDataDesc()c=C()c.__dict__['a']="from instance dict"c.__dict__['b']="from instance dict"print(c.a)# "from data descriptor" —— 数据描述符赢print(c.b)# "from instance dict" —— 实例字典赢同一行c.__dict__['x'] = ...,对a没用,对b却覆盖了。这不是 bug,是设计:
- 数据描述符通常承担**“必须经过的拦截器”**角色(如
property),所以优先级最高,实例无法绕过; - 非数据描述符常常是**“默认行为,可被覆盖”**(如方法,允许被实例属性遮蔽,方便 monkey-patching)。
只读数据描述符的标准写法也来自这个规则:定义__set__但让它抛AttributeError,这样它仍然是数据描述符(优先级最高),但任何赋值都会失败。
六、属性查找的完整链路:一图看穿
理解了上面的区别,我们就可以画出 Python 解析obj.x时真正完整的查找顺序了。这个顺序由object.__getattribute__()实现:
1. 在 type(obj) 的 MRO 中查找 x: └── 如果找到的是【数据描述符】 → 调用 __get__(),返回 ✅ 2. 在 obj.__dict__ 中查找 x: └── 如果存在 → 返回 ✅ 3. 回到第 1 步在类层级中找到的那个 x: └── 如果是【非数据描述符】→ 调用 __get__(),返回 ✅ └── 如果是普通类变量 → 直接返回 ✅ 4. 调用 __getattr__(obj, 'x')(如果定义了) ✅ 5. 抛出 AttributeError ❌下面是官方文档给出的 Python 等价实现,把这个过程一行一行展开:
defobject_getattribute(obj,name):"Emulate PyObject_GenericGetAttr() in Objects/object.c"null=object()objtype=type(obj)cls_var=find_name_in_mro(objtype,name,null)descr_get=getattr(type(cls_var),'__get__',null)ifdescr_getisnotnull:if(hasattr(type(cls_var),'__set__')orhasattr(type(cls_var),'__delete__')):returndescr_get(cls_var,obj,objtype)# 数据描述符ifhasattr(obj,'__dict__')andnameinvars(obj):returnvars(obj)[name]# 实例变量ifdescr_getisnotnull:returndescr_get(cls_var,obj,objtype)# 非数据描述符ifcls_varisnotnull:returncls_var# 类变量raiseAttributeError(name)这段代码是理解一切 Python 属性行为的唯一权威——背下来你就再也不会困惑了。需要特别留意的是:__getattr__并不在这段代码中,它是由点号操作符和getattr()函数在收到AttributeError后才作为兜底调用的。
七、__set_name__:解决"描述符不知道自己叫什么"的问题
回到一个实际问题。如果你想做一个"日志属性",你需要把数据存到某个地方——但描述符不知道自己绑定到了哪个属性名。怎么办?
Python 3.6 之后引入的__set_name__方法解决了这个问题。当一个类被创建时,元类type会扫描类字典里的所有描述符,对每一个有__set_name__的描述符自动调用它,告知(owner_class, attr_name):
classLoggedAccess:def__set_name__(self,owner,name):self.public_name=name self.private_name='_'+namedef__get__(self,obj,objtype=None):value=getattr(obj,self.private_name)print(f'读取{self.public_name}={value!r}')returnvaluedef__set__(self,obj,value):print(f'写入{self.public_name}={value!r}')setattr(obj,self.private_name,value)classPerson:name=LoggedAccess()# 自动得知 public_name='name', private_name='_name'age=LoggedAccess()# 自动得知 public_name='age', private_name='_age'def__init__(self,name,age):self.name=name self.age=age注意一个细节:__set_name__只在类被创建时调用。如果你之后动态地把描述符塞到一个已存在的类上,需要手动调用它。
还有一个常见但优雅的设计:把数据存到宿主实例的__dict__里,而不是描述符自己身上。如果你把数据存在self.value这种描述符实例属性里,那么所有共享这个描述符的实例都会互相覆盖——因为描述符是类级共享的。
八、实战范式:一个可复用的校验器框架
把上面所有概念串起来,我们就能构造一个非常实用的"声明式数据校验器"。这是描述符最经典的工业级用法之一:
fromabcimportABC,abstractmethodclassValidator(ABC):def__set_name__(self,owner,name):self.private_name='_'+namedef__get__(self,obj,objtype=None):returngetattr(obj,self.private_name)def__set__(self,obj,value):self.validate(value)setattr(obj,self.private_name,value)@abstractmethoddefvalidate(self,value):...classNumber(Validator):def__init__(self,minvalue=None,maxvalue=None):self.minvalue,self.maxvalue=minvalue,maxvaluedefvalidate(self,value):ifnotisinstance(value,(int,float)):raiseTypeError(f'{value!r}必须是数字')ifself.minvalueisnotNoneandvalue<self.minvalue:raiseValueError(f'{value!r}不能小于{self.minvalue}')classOneOf(Validator):def__init__(self,*options):self.options=set(options)defvalidate(self,value):ifvaluenotinself.options:raiseValueError(f'{value!r}必须是{self.options}之一')使用起来——注意——是完全声明式的,业务代码不写一行校验:
classComponent:kind=OneOf('wood','metal','plastic')quantity=Number(minvalue=0)def__init__(self,kind,quantity):self.kind=kind# 自动校验self.quantity=quantity# 自动校验Component('metle',5)# ValueError: 'metle' 必须是 {'wood', 'metal', 'plastic'} 之一Component('metal',-1)# ValueError: -1 不能小于 0这是一个非常强大的范式——你看到的所有"声明式 ORM 字段"(Django Model、SQLAlchemy ORM、Pydantic、attrs、dataclasses 的某些扩展)背后,本质上都是这套机制。
九、揭秘 Python 内核:原来你天天在用描述符
理解了描述符之后,回头看 Python 的几个核心特性,会有一种"恍然大悟"的感觉。
9.1@property就是一个数据描述符工厂
property本质上就是把三个函数(getter、setter、deleter)封装成一个数据描述符。它的纯 Python 等价实现核心就这几行:
classProperty:def__init__(self,fget=None,fset=None,fdel=None,doc=None):self.fget,self.fset,self.fdel=fget,fset,fdel self.__doc__=docdef__get__(self,obj,objtype=None):ifobjisNone:returnselfifself.fgetisNone:raiseAttributeErrorreturnself.fget(obj)def__set__(self,obj,value):ifself.fsetisNone:raiseAttributeError self.fset(obj,value)def__delete__(self,obj):ifself.fdelisNone:raiseAttributeError self.fdel(obj)短短二十行就实现了@property,而且"为什么没有 setter 的 property 是只读的"也不再神秘——因为__set__直接抛AttributeError了。
9.2 方法绑定:函数其实是非数据描述符
这是描述符里最深也最优雅的一招。Python 中的函数对象本身实现了__get__:
classFunction:def__get__(self,obj,objtype=None):ifobjisNone:returnself# 从类访问,返回函数本身returnMethodType(self,obj)# 从实例访问,返回绑定方法所以当你写d.f()时:
- 点号触发属性查找;
- 在类字典里找到
f(一个函数对象,非数据描述符); - 调用
f.__get__(d, D),返回一个bound method,里面打包了(d, f); - 调用这个 bound method,它内部会把
d作为第一个参数传给f。
这就是self的来源!Python 中根本没有"方法"这个独立的运行时类型,方法只是"函数 + 描述符协议"涌现出的行为。
9.3staticmethod和classmethod:方法绑定的两种变体
理解了"函数即非数据描述符"之后,这两个装饰器就毫无神秘感了:
| 装饰器 | __get__返回 | 从实例调用 | 从类调用 |
|---|---|---|---|
| 普通函数 | bound method | f(obj, *args) | f(*args) |
staticmethod | 原函数 | f(*args) | f(*args) |
classmethod | 绑定到类的方法 | f(type(obj), *args) | f(cls, *args) |
staticmethod的实现简单得不可思议——就是__get__直接return self.f。classmethod则是把类对象(而不是实例)绑成第一个参数。
9.4__slots__:用描述符替代实例字典
__slots__同样是描述符的产物。当你写__slots__ = ('x', 'y')时,元类会为每个 slot 名字自动创建一个member_descriptor(一种数据描述符),它读写一个 C 层面的固定大小数组,而不是__dict__。
带来的好处是:
- 检测拼写错误:因为没有
__dict__来"宽容"未声明的属性,写错名字立即报错; - 节省内存:官方测试显示,两个属性的实例从 152 字节降到 48 字节;
- 加快访问:实例变量读取速度提升约 35%(Python 3.10 / Apple M1 测试);
- 配合 property 实现不可变对象:把数据塞进
_x、_y这种私有 slot,再用只读 property 暴露。
代价是失去了__dict__,所以像functools.cached_property这种依赖__dict__缓存的工具会失效。
十、几个容易踩的坑
走到这里,你已经对描述符有了系统的理解。最后整理一些常见陷阱:
陷阱一:在__init__里给实例赋值描述符
classC:def__init__(self):self.x=MyDescriptor()# ❌ 没用,描述符必须是类变量描述符协议只对类字典中的对象生效,放到实例字典里就是普通对象。
陷阱二:在描述符上保存"每个实例的状态"
classBad:def__set__(self,obj,value):self.value=value# ❌ 所有实例共享 self!描述符是类级别共享的。要么用obj.__dict__,要么用一个以id(obj)为 key 的WeakKeyDictionary。
陷阱三:覆盖__getattribute__时忘了调用父类
整套描述符机制都嵌在object.__getattribute__里。你要是自己重写它而没调用super().__getattribute__,所有描述符都会失效。
陷阱四:弄混__getattr__和__getattribute__
__getattribute__拦截所有属性访问;__getattr__只在前者抛AttributeError后兜底调用。描述符工作在__getattribute__这一层。
十一、总结:描述符是什么
回到最开始的问题。描述符是 Python 面向对象系统中的一个反转点:
传统语义中,"调用方"决定属性查找如何进行;描述符让"被查找的数据"反过来掌控这个过程。
它的协议只有四个方法(__get__、__set__、__delete__、__set_name__),但 Python 中几乎所有"看起来很神奇"的特性都是它的产物:方法绑定、property、classmethod、staticmethod、super()、__slots__、ORM 字段、数据校验框架……
学习描述符的真正价值不在于"以后我要写一个描述符"——大多数应用代码确实不需要直接定义描述符。而在于:当你理解了描述符,你就理解了 Python 的属性查找模型,理解了为什么self不需要传,理解了 Python 是如何用极少的核心机制构建出极丰富的语言特性。
这才是从"会用 Python"到"懂 Python"的分水岭。
