obj.x这行代码背后有一条严格的查找链。理解它,你就理解了property、方法绑定、字段验证——以及为什么它们能工作。
你将学到
obj.attr从字典到描述符的完整查找顺序(以及 MRO 在其中的角色)- 数据描述符与非数据描述符的区别,以及为什么这个区别决定生死
- 用描述符实现带类型校验的字段,读懂
property的底层实现 - 为什么方法能自动绑定
self,以及实例属性为什么能遮蔽方法
前置知识
01 - 元类与类创建机制。本篇会用到 __set_name__ 和类的创建流程。
一、obj.x 到底走了哪条路
很多人以为 obj.x 就是「去 obj.__dict__ 里查」。错。Python 走的是一条固定顺序的查找链,核心是类型上的数据描述符优先于实例字典。看这个对照实验:
class NonData: # 只实现 __get__
def __get__(self, obj, objtype=None):
return "非数据描述符"
class Data: # 实现 __get__ + __set__
def __get__(self, obj, objtype=None):
return "数据描述符"
def __set__(self, obj, value):
obj.__dict__["_data"] = value
class Holder:
a = NonData()
b = Data()
h = Holder()
h.a = "实例字典里的 a" # 没有 __set__,直接写进实例字典
h.b = "实例字典里的 b" # 有 __set__,被拦截
print(h.a) # 输出: 实例字典里的 a —— 非数据描述符被遮蔽
print(h.b) # 输出: 数据描述符 —— 数据描述符赢了
print(h.__dict__) # 输出: {'a': '实例字典里的 a', '_data': '实例字典里的 b'}
两者差别只有一个:有没有 __set__。这条规则决定了 property 能拦住赋值、而普通方法拦不住 obj.method = ...。这是描述符的全部威力,也是下一节的主题。property、staticmethod、classmethod、方法本身,全都是描述符。
二、描述符协议三件套:数据 vs 非数据
一个对象只要定义了下面任意一个方法,就成了描述符:
class Descriptor:
def __get__(self, obj, objtype=None): ... # 读:obj.attr / type.attr
def __set__(self, obj, value): ... # 写:obj.attr = value
def __delete__(self, obj): ... # 删:del obj.attr
关键分类只看一件事——有没有 __set__ 或 __delete__:
| 实现的方法 | 类型 | 优先级 |
|---|---|---|
只有 __get__ |
非数据描述符(non-data) | 低于实例字典 |
有 __get__ + __set__(或 __delete__) |
数据描述符(data) | 高于实例字典 |
obj.attr 的完整查找顺序(默认行为):
- 在
type(obj).__mro__中找attr; - 找到且是数据描述符 → 调用descr.__get__(obj, type(obj)),立刻返回(实例字典根本没机会说话); - 找
obj.__dict__["attr"];找到就返回; - 回到第 1 步找到的那个类属性:
- 是非数据描述符 → 调用
descr.__get__(obj, type(obj)); - 是普通值 → 直接返回; - 以上都没有 → 若有
type(obj).__getattr__,调用它;否则AttributeError。
记忆口诀:数据描述符 > 实例字典 > 非数据描述符 >
__getattr__。 这也是为什么property能拦住obj.value = ...,而普通方法拦不住obj.method = ...。
三、描述符的 obj is None:类访问
C.attr 和 c.attr 都会触发 __get__,区别是第一个参数:
class Reveal:
def __get__(self, obj, objtype=None):
return f"obj={obj!r}, objtype={objtype.__name__}"
class Box:
x = Reveal()
print(Box.x) # 输出: obj=None, objtype=Box —— 通过类访问
print(Box().x) # 输出: obj=<__main__.Box object at ...>, objtype=Box
通过类访问时 obj 是 None。几乎所有描述符都要先判断 if obj is None,把「类访问」和「实例访问」分开处理——不判断就是坑 2。
四、实战:带类型校验的字段
把校验逻辑写进 __set__,做成可复用的描述符。结合 __set_name__(类创建时自动告知属性名):
class Typed:
expected_type = object
def __set_name__(self, owner, name): # 类创建时由元类机制自动调用
self.name = name
def __get__(self, obj, objtype=None):
if obj is None: # 类访问:Box.age -> 返回描述符自身
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} 需要 {self.expected_type.__name__},收到 {type(value).__name__}")
obj.__dict__[self.name] = value # 存进实例字典,不碰属性名避免递归
class Integer(Typed):
expected_type = int
class String(Typed):
expected_type = str
class Person:
name = String()
age = Integer()
def __init__(self, name, age):
self.name = name # 触发 String.__set__
self.age = age # 触发 Integer.__set__
p = Person("Ada", 36)
print(p.name, p.age) # 输出: Ada 36
print(Person.age) # 输出: <__main__.Integer object at ...>
p.age = "36" # TypeError: age 需要 int,收到 str
注意 __set__ 里写的是 obj.__dict__[self.name],不是 obj.name——后者会再次触发 __set__,直接无限递归(见坑 3)。这正是 Pydantic、SQLAlchemy 的 Column、Django 的 ModelField 的共同骨架。
五、property 的底层原理
property 不是魔法,它就是一个数据描述符。我们手写一个:
class MyProperty:
def __init__(self, fget=None, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.fget(obj) # 调用 getter
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value) # 调用 setter
def setter(self, fset):
self.fset = fset
return self # 返回自身,供 @xxx.setter 链式使用
class Circle:
def __init__(self, r):
self._r = r
@MyProperty
def radius(self): # radius = MyProperty(radius)
return self._r
@radius.setter # radius = radius.setter(fset)
def radius(self, v):
if v < 0:
raise ValueError("半径不能为负")
self._r = v
c = Circle(3)
print(c.radius) # 输出: 3
c.radius = 5
print(c.radius) # 输出: 5
c.radius = -1 # ValueError: 半径不能为负
把 MyProperty 换成内建 property,行为完全一致。内建的 property 只是用 C 实现、带了 getter/setter/deleter 和 __doc__ 等糖。知道它是数据描述符,你就知道为什么 c.radius = 5 会走 setter、而 c._r = 5 不会。
六、方法为什么能绑定 self
函数(function 对象)自带 __get__,所以它是个非数据描述符:
class C:
def greet(self):
return "hi"
print(C.__dict__["greet"]) # 输出: <function C.greet at 0x...>
c = C()
# 以下三种写法完全等价
print(c.greet()) # 输出: hi
print(C.__dict__["greet"].__get__(c, C)()) # 输出: hi —— 手动绑定
print(type(c.greet)) # 输出: <class 'method'>
机制拆解:
c.greet触发查找,在type(c).__mro__里找到C.__dict__["greet"],是一个function;- 实例字典里没有
greet; - 调用
function.__get__(c, C),返回一个绑定方法(method对象),把c记在内部; ()调用时,c作为第一个参数塞进去,于是self is c。
反过来,C.greet 触发 __get__(None, C),返回函数本身,所以你得自己传 self:C.greet(c)。
同一机制也解释了一个经典现象:
c = C()
c.greet = "我不是方法" # 函数是非数据描述符,会被实例字典遮蔽
print(c.greet) # 输出: 我不是方法
staticmethod 和 classmethod 同样是描述符,只是 __get__ 返回的东西不同:前者返回裸函数,后者返回绑定到类的方法。
常见坑
坑 1:想做只读,却写成了非数据描述符。
# ❌ 只有 __get__,实例属性照样能遮蔽它
class ReadOnly:
def __get__(self, obj, objtype=None):
return 42
class A:
x = ReadOnly()
a = A()
a.x = 99
print(a.x) # 输出: 99 —— 只读根本没生效!
# ✅ 补上 __set__,变成数据描述符
class ReadOnly:
def __get__(self, obj, objtype=None):
return 42
def __set__(self, obj, value):
raise AttributeError("x 是只读的")
class B:
x = ReadOnly()
b = B()
b.x = 99 # AttributeError: x 是只读的
坑 2:__get__ 忘了处理 obj is None。
# ❌ 通过类访问时 obj 是 None,直接崩
class Upper:
def __get__(self, obj, objtype=None):
return obj.name.upper() # C.name -> AttributeError: 'NoneType' ...
# ✅ 类访问时返回描述符自身
class Upper:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.name.upper()
坑 3:在 __set__ 里写同名属性,无限递归。
# ❌ obj.value = value 又触发 __set__ -> RecursionError
class Bad:
def __set__(self, obj, value):
obj.value = value
# ✅ 写进实例字典(或用别的名字)
class Good:
def __set__(self, obj, value):
obj.__dict__["_value"] = value
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get("_value")
小结
obj.attr的查找顺序:数据描述符 > 实例字典 > 非数据描述符 >__getattr__。- 数据描述符与非数据描述符的分界线是有没有
__set__/__delete__;只读字段必须实现__set__(哪怕只是抛异常)。 - 描述符通过类访问时
obj为None,务必分支处理。 property就是数据描述符;function就是非数据描述符——这一条同时解释了property的拦截能力和方法的self绑定。__set_name__让描述符在类创建时拿到自己的属性名,是写通用字段类的基础设施。
延伸阅读
- Python 官方文档 Descriptor HowTo Guide
- 《Fluent Python》第 22 章「Attribute Descriptors」
- 亲手读一遍
functools.cached_property的源码——一个漂亮的非数据描述符案例
文章回复
0 条公开回复