Python 描述符与元类:属性访问与「类的类」
更新时间:2026-09-02。本文回答:
@property到底是怎么生效的?为什么obj.method自动带上了self?Django/ORM 里models.IntegerField()为什么能拦得住赋值?「元类」听着玄,它到底在什么时候运行? 这些都不是魔法,而是 CPython 里两条确定的协议:描述符协议(决定「属性访问时发生什么」)和元类机制(决定「类这个对象是谁造出来的」)。本文 6 个 demo 全部在服务器(CentOS 7,Python 3.6.8)实测,代码见文末「代码位置」。
一、属性访问不是查字典那么简单
直觉上 obj.x 像是去 obj.__dict__["x"] 里取。但 CPython 的真实规则要复杂一层:当一个属性被定义在类上、且它是个「描述符」时,访问会被这个描述符对象拦截。
描述符就是实现了 __get__ / __set__ / __delete__ 中任意一个的对象。最关键的一条优先级规则(object.__getattribute__ 的语义):
- 先在类及其 MRO 上找同名属性;若找到的是数据描述符(同时定义了
__get__和__set__),调用它的__get__,优先于实例字典; - 否则看实例的
__dict__,有就直接返回; - 否则类上若是非数据描述符(只定义
__get__)或普通属性,返回它(非数据描述符会被调用__get__做绑定); - 都没有,触发
__getattr__或抛AttributeError。
这张图浓缩了后面所有现象:数据描述符压过实例字典,实例字典压过非数据描述符。property 是数据描述符(所以它永远拦得住),普通函数是非数据描述符(所以实例字典能盖住方法)。
为什么规则要这么设计?这背后有一条很朴素的工程逻辑:「类想强制的策略」应当压过「实例的随手赋值」,但「实例自己的状态」应当压过「类给的默认行为」。数据描述符(property、ORM 字段)表达的是类层面的强制约束——校验、计算、代理,这种约束绝不能因为实例上碰巧有个同名键就被绕过去,所以它排在最前;而非数据描述符(普通方法、cached_property)表达的更像「类提供的一份默认实现」,实例完全可以用自己的属性覆盖它(猴子补丁、缓存回填都靠这个),所以它排在实例字典之后。理解了这条优先级,就同时理解了「为什么 property 拦得住赋值」和「为什么方法能被实例属性盖掉」这两个看似矛盾的现象。
还要区分两个常被混淆的钩子:描述符协议(__get__/__set__)是类级别的拦截器,定义在类上、对所有实例生效;而 __getattr__/__setattr__ 是实例级别的钩子,定义在对象自己身上。查找顺序是:__getattribute__(内含上面那张描述符三级查找)彻底找不到时,才最后调用 __getattr__ 兜底;而对 __setattr__,只要类上有同名数据描述符,赋值就会走描述符的 __set__,否则才写进实例字典或交给对象的 __setattr__。一句话:描述符是「这个属性在类上被特殊定义了」的机制,__getattr__ 是「什么都没找到」时的最后兜底,二者处于查找链的两端。
二、实测 demo1:描述符如何工作
一个描述符对象定义在类上、被所有实例共享,真正的数据却存在每个实例自己的 __dict__ 里(d1_descriptor_basic/desc.py):
== create two Orders (each __init__ assignment fires __set__) ==
[__set__] price = 30
[__set__] price = 5
== read o1.price (fires __get__, value from o1.__dict__) ==
[__get__] price on <Order object> -> 30
o1.price = 30
== class-level access returns the descriptor itself ==
Order.price = <__main__.Logged object at 0x...>
== proof: the data lives on each instance, descriptor lives on class ==
'price' in Order.__dict__ ? True
o1.__dict__ = {'name': 'book', '_price': 30}
o2.__dict__ = {'name': 'pen', '_price': 5}三个要点:赋值 self.price = price 触发的是类上那个共享描述符的 __set__;读取时 __get__ 收到当前实例 obj,从而能做到「同一个描述符、不同实例取到不同值」;而通过类访问 Order.price 时 obj is None,描述符返回它自己——这正是「类属性 vs 实例属性」的分叉点。
三、实测 demo2:数据描述符 vs 非数据描述符
优先级规则用一个对照实验看得最清楚(d2_data_nondata/nondata.py):往实例 __dict__ 里塞同名键,数据描述符照常拦截,非数据描述符被盖掉:
== after d.__dict__['data']/['non'] = 'instance-value' ==
d.data = DATA-DESCRIPTOR __get__ <- data descriptor wins over instance dict
d.non = instance-value <- instance dict shadows non-data descriptor普通函数只有 __get__、没有 __set__,所以是非数据描述符。这带来一个很多人不知道的事实:你可以在实例上盖掉一个方法:
== functions are NON-data descriptors ==
f.bar = I overwrote the method on the instance <- instance attribute shadows the function四、property 与 ORM:描述符的两大落地
property 本身就是一个用 C 实现的数据描述符(demo3 实测 hasattr(C.x,'__get__') 与 __set__ 均为 True)。@property 把「读属性」重定向到你写的 getter,把「赋值」拦到 setter——这与我们手写的 Typed 描述符是同一个机制。demo3 用一个类型校验字段复刻了 ORM 的核心:
== Typed field rejects wrong type on assignment ==
TypeError: qty expects int, got three (str)Django 的 models.IntegerField()、SQLAlchemy 的 Column() 本质都是类级别的描述符:模型类上放一个字段描述符,赋值时做类型/约束校验、读取时从实例状态取值。理解了描述符,就理解了所有「声明式字段」框架的底座。
demo3 还演示了 LazyProperty:用非数据描述符做懒加载——首次访问计算后把结果写进 obj.__dict__,由于实例字典优先级更高,第二次起直接命中缓存、不再触发 __get__:
== LazyProperty computes once, then reads from __dict__ ==
[lazy] computing 'report' ...
first call: report(sku=A-100, qty=3)
second call: report(sku=A-100, qty=3) (no [lazy] line above -> cached)
'report' now in p.__dict__ ? True这正是 functools.cached_property 的实现原理:它特意做成非数据描述符,好让实例字典接管后续访问。
五、实测 demo4:函数是描述符——绑定方法的真相
obj.method() 为什么自动传 self?因为函数对象实现了 __get__。obj.add 这一步不是返回原始函数,而是触发函数描述符,返回一个绑定方法对象(d4_bound_method/bound_method.py):
== 3. calc.add does NOT return the raw function ==
type(calc.add) = method
m.__self__ is calc ? True
m.__func__ is the class function ? True
== 4. bound method auto-passes self ==
calc.add(2, 3) = 5
equals Calculator.add(calc, 2, 3) = 5
== 5. class-level access (obj=None) returns the plain function ==
Calculator.add = <function Calculator.add at 0x...>机制一句话:calc.add → Calculator.__dict__['add'].__get__(calc, Calculator) → 返回一个把 calc 固化为 __self__ 的 bound method;调用它时自动把 __self__ 作为第一个参数(即 self)传进去。所以 calc.add(2,3) 等价于 Calculator.add(calc,2,3)。这个「取属性时临时把接收者绑进函数」的设计,和 C++ 成员函数隐式携带 this 指针是同一个思想,只不过 C++ 在编译期由调用约定把 this 放进寄存器,而 Python 在运行期靠描述符 __get__ 现场生成一个绑定方法对象——也正因如此 Python 可以做到 C++ 很难做的事:把「绑定了对象的方法」当成一个普通可调用对象传来传去(回调、信号槽)。staticmethod 和 classmethod 也只是两种不同 __get__ 行为的描述符:前者 __get__ 忽略实例、原样返回函数;后者 __get__ 绑定的是类而非实例(demo4 实测两者 isinstance(..., staticmethod/classmethod) 均为 True,且 Demo().cm() 拿到的是类 Demo)。
六、元类:造类的类
如果说描述符回答「属性访问发生什么」,元类回答「类是怎么来的」。在 Python 里类本身也是对象,既然是对象,就有造它的「类」——造类的类就叫元类,默认是 type。
- 「实例是类造的」:
obj = MyClass()时调用MyClass.__new__+__init__; - 「类是元类造的」:
class MyClass: ...定义体执行完后,Python 调用type(name, bases, namespace)造出类对象。
这个「造类」过程比多数人以为的更具体,可以拆成四步:① 元类的 __prepare__ 先返回一个命名空间映射(默认是普通 dict,你可以返回 collections.OrderedDict 或自定义映射来记录属性定义顺序);② Python 在这个命名空间里逐行执行类体——每个方法定义、每个类属性赋值,本质都是往这个 dict 里塞键值对;③ 类体执行完,调用元类 __new__(mcs, name, bases, namespace),此时 namespace 里装着类体里定义的全部名字,元类可以增删改、做校验、甚至拒绝创建(raise TypeError);④ 最后调用元类 __init__ 做初始化。这就是为什么 demo5 能在类还没出生时就检查出「没写 run()」——因为检查发生在第③步,类对象此时尚未返回。也正因如此,元类特别适合做「框架级的横切约束」:注册所有子类(插件、序列化器、模型)、强制接口、自动收集配置——Django 的 Model 元类、SQLAlchemy 的 declarative base 都是在这一步把「类属性 = 字段声明」翻译成内部映射表的。
因此你可以继承 type 写一个元类,在类被创建的那一刻(注意是 import/定义时,不是实例化时)介入。demo5 用元类做了两件事:自动注册插件类、强制每个插件实现 run():
== registry auto-filled by the metaclass at class-creation time ==
AudioPlugin -> AudioPlugin
VideoPlugin -> VideoPlugin
== metaclass rule enforced: a plugin without run() is rejected ==
TypeError: plugin BadPlugin must define run()
== type() proof: a class is an instance of its metaclass ==
type(AudioPlugin) = PluginMeta
isinstance(AudioPlugin, PluginMeta) ? True注意 BadPlugin 是在 class 定义阶段就报错,而不是等你调用它——这就是元类「在类出生时施加约束」的威力,也是 ORM 能在你写错字段时 import 就炸的原因。
元类的 __call__ 则拦截「实例化」这个动作。MyClass() 实际是 type(MyClass).__call__(MyClass, ...)。重写元类 __call__ 就能控制「每次调用类名」的行为,单例是最经典的应用(demo6):
== Config() twice returns the SAME object, __init__ runs once ==
[Config.__init__ runs]
c1 is c2 ? True
== call order: MyClass() -> Meta.__call__ -> cls.__new__ -> cls.__init__ ==
Meta.__call__ intercepts T
__new__ allocates
__init__ initializessuper().__call__ 内部才依次调用类的 __new__(分配)和 __init__(初始化);把它包住,就能实现单例缓存、对象池、实例计数等「控制出生」的策略。
七、与本站主线的衔接
| Python 机制 | 本站对应 | 共同的工程直觉 |
|---|---|---|
| 描述符拦截属性访问 | C++ 虚表/动态分派(CRTP、模板实例化) | 「访问/调用」被一层运行时机制重定向,而非裸取字段 |
| 函数描述符 → 绑定方法 | C++ 成员函数隐式 this 指针 | 方法绑定的本质是把接收者固化为第一参数 |
| 元类在类创建时施加约束 | 编译期 constexpr/模板实例化 | 把错误提前到「定义/编译期」而非运行期 |
__new__ 分配 vs __init__ 初始化 | CPython 对象模型 | 对象出生分「分配内存」与「初始化状态」两步 |
| 描述符/元类均为类级共享对象 | 模块单例与 sys.modules | 类是全局唯一对象,定义在类上的机制对所有实例生效 |
装饰器是另一条常与描述符配合使用的「改写机制」,入门可先读 生成器、迭代器与装饰器——装饰器改写函数对象,描述符改写属性访问,元类改写类的创建,三者是 Python 元编程由浅入深的三层。
八、常见坑速查
- 在描述符
__get__里忘了处理obj is None:通过类访问(如Order.price)时obj为None,不判断会对None做getattr报错;惯例是类访问返回描述符自身。 - 把描述符定义成实例属性:描述符协议只对类的类型(
type(obj)及其 MRO)上的属性生效,写在__init__里self.d = Logged()不会触发,必须定义在类体。 - 以为实例字典能盖住
property:盖不住,property是数据描述符,优先级高于obj.__dict__;而普通方法是非数据描述符,可以被实例属性遮蔽。 - 元类
__new__里忘记return cls:类会变成None,务必返回super().__new__(...)的结果。 - 滥用元类:99% 的需求用描述符、类装饰器或
__init_subclass__就够了;元类是「造类的类」,只在需要拦截类创建本身(注册、约束、接口强制)时才用。 - 单例元类 + 带参
__init__:第二次Config(x=1)不会重新初始化,返回的还是第一个对象;参数被静默忽略。
代码位置
本文所有 demo 位于仓库 demos/python-expert/descriptor-metaclass/,均在服务器(CentOS 7,Python 3.6.8)bash run_all.sh 实测通过:
d1_descriptor_basic/desc.py:描述符基础(共享描述符 + 每实例数据 + 类访问返回自身)。d2_data_nondata/nondata.py:数据 vs 非数据描述符优先级、函数是非数据描述符。d3_typed_lazy/typed_lazy.py:类型校验字段(ORM 核心)、LazyProperty懒加载缓存、property即数据描述符。d4_bound_method/bound_method.py:函数描述符、绑定方法__self__/__func__、staticmethod/classmethod。d5_metaclass_registry/registry.py:元类插件注册表 +run()约束、type()证明类是元类实例。d6_singleton/singleton.py:元类__call__实现单例、__call__ → __new__ → __init__调用次序。
动手练习
- 给
d1的Logged加上__delete__,验证del o1.price时被调用、且实例字典里的_price被清掉。 - 把
d3的LazyProperty改名成cached_property,再写一个「每次都重新计算」的普通描述符版本,对比两者第二次访问时__dict__的差异。 - 写一个元类
AutoSlots,让没有__slots__的子类自动补上__slots__ = ()(提示:在__new__里改ns字典后再super().__new__)。 - 用元类实现「实例计数」:每个类造了多少个实例,存在类属性
_count上(重写__call__)。 - 在
d5基础上给BasePlugin加一个name类属性约束:缺了它就在类创建时报错,体会「定义期约束」。
一句话总结
属性访问 = 「数据描述符 → 实例字典 → 非数据描述符」的三级查找,property、ORM 字段、绑定方法、cached_property 全是这条规则的产物;元类是造类的类,type.__new__ 在类出生时让你注册/施加约束,type.__call__ 在实例化时让你控制对象的出生——描述符改属性、元类改类,这就是 Python 元编程的两块地基。
深入:回到 Python 进阶总纲;对象与内存底层见 CPython 内部机制,模块加载与单例直觉见 导入机制与包工程,改写函数对象的轻量手段见 生成器、迭代器与装饰器。