本文目录
给实例写 obj.x = 1,看起来是直接改 __dict__;但若 x 在类上绑了一个实现了 __get__ / __set__ 的对象,访问会先经过这层描述符,再决定读实例字典还是调用钩子。@property 就是内置的描述符封装——语法简洁,底层仍是同一套协议。
上一篇讲 dunder 如何挂接运算符与内置函数;描述符挂接的是属性点号访问(obj.attr / obj.attr = v / del obj.attr)。搞清 data / non-data 之分,能解释「为什么类上的方法总是通过函数描述符绑定实例」「为什么有的类属性会盖住实例字段」这类看似诡异的行为。
描述符是 Python 对象模型里最容易被忽略、却无处不在的一层:property、函数方法、classmethod、staticmethod、部分 ORM 字段,全是描述符。学会识别「类属性上的协议对象」,读框架源码会轻松一截。
属性查找与赋值在 CPython 里高度优化,但语义仍由上述协议定义。换实现(PyPy 等)行为应一致;调试时在 object.__getattribute__ 或具体描述符的 __get__ 下断点,能看到点号访问的完整路径。
协议:get、set、del
描述符是定义了以下至少一个方法的对象(通常是类属性里挂的实例):
| 方法 | 触发 |
|---|---|
__get__(self, obj, objtype=None) | 读取 obj.attr |
__set__(self, obj, value) | 赋值 obj.attr = value |
__del__(self, obj) | del obj.attr |
obj 是持有描述符的实例;objtype 是类。通过类访问(Cls.attr)时 obj 为 None,__get__ 仍会被调用——函数、property 都利用这一点在「类访问」与「实例访问」时返回不同结果。
class Verbose:
def __get__(self, obj: object | None, objtype: type | None = None) -> str:
if obj is None:
return "<class-level Verbose>"
return f"got via descriptor on {obj!r}"
def __set__(self, obj: object, value: str) -> None:
print(f"set {value!r} on {obj!r}")
class Box:
label = Verbose()
b = Box()
print(b.label) # got via descriptor on Box()
b.label = "hi" # set 'hi' on Box()
print(Box.label) # <class-level Verbose>实例没有 label 键时,查找沿 MRO 在类上找到 Verbose 实例,识别为描述符并调 __get__。若你在实例 __dict__ 里手动塞 label,而类上的仍是 non-data 描述符,读取会优先实例 dict——这与下一节的优先级表一致。
data 与 non-data 描述符
查找顺序里,描述符与实例 __dict__ 的优先级取决于描述符类型:
- data 描述符:实现了
__set__或__del__。 - non-data 描述符:只有
__get__(或连__get__都没有,就不是描述符)。
规则(简化):data 描述符优先于实例 __dict__;non-data 描述符排在实例 __dict__ 之后。因此:
class NonData:
def __get__(self, obj: object | None, objtype: type | None = None) -> int:
return 99
class Holder:
nd = NonData()
def __init__(self) -> None:
self.nd = 1 # 写入实例 __dict__
h = Holder()
print(h.nd) # 1 — 实例字段盖住 non-data 描述符若把 NonData 改成带 __set__ 的 data 描述符,则 h.nd = 1 会走 __set__,实例 __dict__ 里通常不会出现 nd,读取始终走描述符——这就是「属性被 property 管住了,赋值也绕不进 __dict__」的原因。
函数是 non-data 描述符:obj.method 触发 function.__get__,绑定 obj 后返回 bound method。实例 __dict__ 里若真有同名键(极少见),会盖住方法描述符。
记忆口诀:有 set/del 的 data 描述符,比实例 dict 硬;只有 get 的 non-data,实例 dict 能遮。 排查 attribute 行为时,先问「类上的属性是不是 data 描述符」。
property:内置的描述符
property 把 getter / setter / deleter 绑成描述符;有 setter 时属于 data 描述符:
class Celsius:
def __init__(self, value: float) -> None:
self._c = value
@property
def celsius(self) -> float:
return self._c
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("below absolute zero")
self._c = value
@property
def fahrenheit(self) -> float:
return self._c * 9 / 5 + 32
t = Celsius(25.0)
print(t.celsius) # 25.0
print(t.fahrenheit) # 77.0
t.celsius = 30.0
# t.celsius = -300 # ValueError@property 装饰的方法变成 property 对象,挂在类上;访问 t.celsius 时触发 property.__get__,赋值触发 property.__set__。只读属性只定义 getter,不设 setter,则是 non-data 描述符——但通常没有同名实例字段,表现仍是只读。
等价于手写描述符类,只是更短。删除属性时若有 deleter,走 property.__delete__。
只读 property 不设 setter 时,对 obj.x = v 会报 AttributeError,因为 data 路径上找不到 __set__,而 Python 又不允许随便写实例 dict 覆盖——这正是「受控属性」想要的。
何时用手写描述符而不是 property
单个字段的校验或惰性计算,用 @property 足够清晰。同一模式重复出现在许多字段上(类型检查、范围、惰性加载、审计日志)时,抽成描述符类更 DRY。ORM 字段、表单字段、dataclass 的 field() 背后都是这一思路:声明一次协议,多处复用。
手写描述符:类型检查示例
需要跨多个字段复用校验逻辑时,自定义描述符比每个字段写 @property 更 DRY:
from typing import Any
class TypedField:
def __init__(self, expected: type) -> None:
self.expected = expected
self.storage_name: str = ""
def __set_name__(self, owner: type, name: str) -> None:
self.storage_name = f"_{name}"
def __get__(self, obj: object | None, objtype: type | None = None) -> Any:
if obj is None:
return self
return obj.__dict__.get(self.storage_name)
def __set__(self, obj: object, value: Any) -> None:
if not isinstance(value, self.expected):
raise TypeError(f"want {self.expected.__name__}, got {type(value).__name__}")
obj.__dict__[self.storage_name] = value
class Profile:
age = TypedField(int)
name = TypedField(str)
p = Profile()
p.age = 30
p.name = "Ada"
# p.age = "x" # TypeError__set_name__(3.6+)在类体执行时告知描述符绑定的属性名,便于把值存进实例 __dict__ 的私有键,避免与描述符名冲突。dataclass、SQLAlchemy 等大量框架行为都建立在描述符与 __set_name__ 之上。
多个字段共用同一描述符类时,每个类属性应是描述符的独立实例(age = TypedField(int) 每次创建新对象),否则多个字段会共享 storage_name 状态。这是手写描述符时第二个常见 bug。
classmethod 与 staticmethod 也是描述符
classmethod、staticmethod 把函数包装成描述符,改变 __get__ 时的绑定行为:classmethod 把类传给第一个参数;staticmethod 不做绑定,直接返回原函数。
class Config:
root: str = "/app"
@classmethod
def load(cls) -> "Config":
print("load for", cls.__name__)
return cls()
@staticmethod
def version() -> str:
return "1.0"
Config.load()
Config.version()理解它们与 property 同属「类上的协议对象」,对象模型几条线就串起来了:函数方法、类方法、静态方法、计算属性,全是描述符的不同口味。
staticmethod 不参与绑定,因此 obj.f 与 Cls.f 拿到同一个可调用对象;classmethod 则始终把类传给第一个参数。选哪种装饰器,是在选描述符 __get__ 时的绑定策略,而不是另一套独立机制。
实例属性查找顺序(简化)
对 obj.name,object.__getattribute__ 大致按以下顺序(省略 MRO 细节):
- 在类及其基类上找
name,若找到且是 data 描述符 → 调__get__,结束。 - 查实例
obj.__dict__等实例命名空间,若有name→ 返回值。 - 在类上找
name,若找到且是 non-data 描述符 → 调__get__。 - 在类
__dict__找普通类属性。 - 调
__getattr__(若定义且前面都失败)。
赋值 obj.name = v 时,若类上 name 是 data 描述符 → 调 __set__;否则写入实例 __dict__。这条链解释了大量「明明给实例赋了值,读出来却不是」的行为——多半是 data 描述符或 property 在中间拦截。
手动 obj.__dict__["x"] = 1 可以绕过部分描述符,但不应作为常规 API;框架代码有时用这个技巧做序列化或拷贝。理解查找顺序后,看到「实例 dict 里有值却读不到」就知道该查类上的 data 描述符。
方法绑定也是描述符
类体里的 def method 产生函数对象;函数实现了 __get__,属于 non-data 描述符。因此 obj.method 会绑定 obj,返回 bound method,而 Cls.method 返回未绑定函数:
class Greeter:
def hi(self) -> str:
return "hi"
g = Greeter()
print(g.hi()) # hi
print(Greeter.hi) # <function ...>
bound = g.hi
print(bound()) # hi理解这一点,就明白为什么不能靠「在实例 __dict__ 里塞同名键」轻易覆盖方法——除非你用 non-data 规则且实例 dict 里真有该键。staticmethod、classmethod 换成各自的描述符类型,绑定行为随之改变。
和 getattribute 的分工
实例的 __getattribute__ 是总入口;描述符是其中一条分支规则(在 object.__getattribute__ 实现里硬编码)。一般业务用 property 或小型描述符即可;重写 __getattribute__ 影响面大,容易无限递归,留给框架级代码(如 ORM 懒加载)。
__getattr__ 只在常规查找失败时触发,与描述符不是同一层。在 __getattribute__ 里访问 self.x 会再次触发完整查找,误写会栈溢出;__getattr__ 只在 x 完全找不到时才调用,更适合懒加载默认值。
描述符与 slots
使用 __slots__ 的类仍支持描述符:描述符逻辑在类上,实例 slot 存实际数据。property + __slots__ 的组合很常见——公开属性走 property,底层字段藏在 slot 里,避免随意增删实例属性。
常见误解
| 误解 | 实际 |
|---|---|
| 描述符只能用在 class 属性 | 实例属性上的 callable 不是描述符;协议对象通常挂在类上 |
| property 会复制值 | property 管访问路径,值可在实例 dict 或 slot 里 |
重写 __getattr__ 能替代 property | __getattr__ 仅在没有找到属性时触发;property 在查找链更靠前 |
排查「属性读不到」时,按顺序检查:类上是否有 data 描述符 → 实例 dict/slot 是否有键 → 类上 non-data 描述符 → 类普通属性 → __getattr__。比死记口诀更有效的是在最小复现里 print(obj.__dict__) 与 print(type(obj).mro())。
小结
| 概念 | 要点 |
|---|---|
| 描述符 | 实现 __get__ / __set__ / __del__ 的类属性对象 |
| data | 有 __set__ 或 __del__,优先于实例 dict |
| non-data | 仅 __get__,实例 dict 可覆盖 |
| property | 描述符语法糖,只读或读写计算属性 |
属性访问不是简单的 dict 查找:类上的描述符在 obj.attr 时介入;property 是最顺手的封装。读 ORM、dataclass 或验证库源码时,看到类属性上的描述符对象,就知道「点号背后还有一层协议」。
对象模型从类、实例、callable 一路读到描述符,主线已经闭合:造类(metaclass)→ 包装(装饰器)→ 语法挂钩(dunder)→ 属性查找(描述符)。下一批章节转向异常、迭代与模块,但在自定义类型与读库时,这四层仍会反复相遇。
把 property 当成「只有一个字段的描述符工厂」:property(fget, fset, fdel, doc) 与 @property 装饰器链等价。需要多个受控字段时,重复 @property 或抽成 TypedField 一类描述符,取决于复用程度与可读性之间的权衡。
读 CPython 源码时,Objects/descrobject.c 与 object.__getattribute__ 的实现值得对照本文的查找顺序——语义以语言参考为准,实现细节帮助理解性能与边界 case。
日常开发中,90% 的属性需求用 property 或公开字段即可;描述符类在字段多、规则重复、或需要与框架协议对齐时才显式出场。先把 property 用熟,再下沉到手写 __get__/__set__,学习曲线更平滑。
验证描述符行为的最小用例:assert obj.field == expected、obj.field = v、with pytest.raises(...): obj.field = bad。覆盖 get/set 与类访问(Cls.field)三种路径,比读长文档更快建立直觉。
与 dataclass 联用时,字段 descriptor 由框架注入;手写 dataclass 等价物时要自己处理 __set_name__ 与 __init__ 赋值顺序——先懂协议,再读框架生成代码,顺序不能反。这一篇与 metaclass、装饰器、dunder 一起,构成 Python 对象模型的完整拼图。