K 的一隅

Python Python 语言核心

属性访问的中间层:描述符协议

__get__、__set__、__del__ 在属性查找链中拦截访问;data 与 non-data 描述符优先级不同;property 是最常见的描述符用法。

10 分钟阅读 更新于 2026-07-28
本文目录

给实例写 obj.x = 1,看起来是直接改 __dict__;但若 x 在类上绑了一个实现了 __get__ / __set__ 的对象,访问会先经过这层描述符,再决定读实例字典还是调用钩子。@property 就是内置的描述符封装——语法简洁,底层仍是同一套协议。

上一篇讲 dunder 如何挂接运算符与内置函数;描述符挂接的是属性点号访问obj.attr / obj.attr = v / del obj.attr)。搞清 data / non-data 之分,能解释「为什么类上的方法总是通过函数描述符绑定实例」「为什么有的类属性会盖住实例字段」这类看似诡异的行为。

描述符是 Python 对象模型里最容易被忽略、却无处不在的一层:property、函数方法、classmethodstaticmethod、部分 ORM 字段,全是描述符。学会识别「类属性上的协议对象」,读框架源码会轻松一截。

属性查找与赋值在 CPython 里高度优化,但语义仍由上述协议定义。换实现(PyPy 等)行为应一致;调试时在 object.__getattribute__ 或具体描述符的 __get__ 下断点,能看到点号访问的完整路径。

协议:getsetdel

描述符是定义了以下至少一个方法的对象(通常是类属性里挂的实例):

方法触发
__get__(self, obj, objtype=None)读取 obj.attr
__set__(self, obj, value)赋值 obj.attr = value
__del__(self, obj)del obj.attr

obj 是持有描述符的实例;objtype 是类。通过类访问(Cls.attr)时 objNone__get__ 仍会被调用——函数、property 都利用这一点在「类访问」与「实例访问」时返回不同结果。

python
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__ 之后。因此:

python
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 描述符:

python
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:

python
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 也是描述符

classmethodstaticmethod 把函数包装成描述符,改变 __get__ 时的绑定行为:classmethod 把类传给第一个参数;staticmethod 不做绑定,直接返回原函数。

python
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.fCls.f 拿到同一个可调用对象;classmethod 则始终把类传给第一个参数。选哪种装饰器,是在选描述符 __get__ 时的绑定策略,而不是另一套独立机制。

实例属性查找顺序(简化)

obj.nameobject.__getattribute__ 大致按以下顺序(省略 MRO 细节):

  1. 在类及其基类上找 name,若找到且是 data 描述符 → 调 __get__,结束。
  2. 查实例 obj.__dict__ 等实例命名空间,若有 name → 返回值。
  3. 在类上找 name,若找到且是 non-data 描述符 → 调 __get__
  4. 在类 __dict__ 找普通类属性。
  5. __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 返回未绑定函数:

python
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.cobject.__getattribute__ 的实现值得对照本文的查找顺序——语义以语言参考为准,实现细节帮助理解性能与边界 case。

日常开发中,90% 的属性需求用 property 或公开字段即可;描述符类在字段多、规则重复、或需要与框架协议对齐时才显式出场。先把 property 用熟,再下沉到手写 __get__/__set__,学习曲线更平滑。

验证描述符行为的最小用例:assert obj.field == expectedobj.field = vwith pytest.raises(...): obj.field = bad。覆盖 get/set 与类访问(Cls.field)三种路径,比读长文档更快建立直觉。

与 dataclass 联用时,字段 descriptor 由框架注入;手写 dataclass 等价物时要自己处理 __set_name____init__ 赋值顺序——先懂协议,再读框架生成代码,顺序不能反。这一篇与 metaclass、装饰器、dunder 一起,构成 Python 对象模型的完整拼图。