描述符是「属性访问被劫持给对象」的协议:实现 __get__ / __set__ / __delete__ 的类属性对象,能接管宿主类对它的读写。property、方法绑定、ORM 字段、校验器全建在这块地基上。

描述符协议

三个钩子,实现任意一个即成为描述符:

class Descriptor:
    def __get__(self, obj, objtype=None):
        print("__get__")         # 读:obj.attr 或 Class.attr
    def __set__(self, obj, value):
        print("__set__", value)  # 写:obj.attr = value
    def __delete__(self, obj):
        print("__delete__")      # 删:del obj.attr
 
class Host:
    attr = Descriptor()  # 描述符必须作为类属性存在
 
h = Host()
h.attr       # 触发__get__(h, Host),类访问时obj为None
h.attr = 1   # 触发__set__(h, 1)
del h.attr   # 触发__delete__(h)

描述符只活在类 __dict__ 里、从不在实例侧(实例存储位置的区别见 类属性与实例属性),但默认拿不到自己被绑定的属性名——直到 __set_name__ 出现。

数据描述符 vs 非数据描述符

同时定义 __get__ 与 __set__(或 __delete__)的是数据描述符(data descriptor);只定义 __get__ 的是非数据描述符(non-data descriptor)。区别决定它与实例 __dict__ 的优先级次序:

数据描述符 > 实例 __dict__ > 非数据描述符 > 类 __dict__

class Data:
    def __get__(self, obj, objtype=None): return "data"
    def __set__(self, obj, value): pass  # 有__set__即数据描述符
 
class NonData:
    def __get__(self, obj, objtype=None): return "non-data"
 
class A:
    d, nd = Data(), NonData()
 
a = A()
a.__dict__["d"] = "instance"   # 被数据描述符覆盖
a.__dict__["nd"] = "instance"  # 压过非数据描述符
a.d   # 'data'      ——数据描述符赢
a.nd  # 'instance'  ——实例字典赢

这条优先级链是 属性访问与查找 的核心规则:property 总能覆盖实例属性、函数总被同名实例属性遮蔽,都是它的直接推论。

两大内置描述符

property 是数据描述符的实现:getter / setter / deleter 编排进 __set__ / __get__ / __delete__,因带 __set__ 而稳压实例字典——给 property 赋值必须走 setter,往实例 __dict__ 塞同名键也没用:

class Temp:
    @property
    def c(self) -> float:
        return self._c
 
    @c.setter
    def c(self, v: float):
        self._c = v
 
t = Temp()
t.__dict__["c"] = 999  # 无效,读t.c仍走property的__get__

函数是非数据描述符:函数有 __get__ 无 __set__,经 __get__(obj, cls) 返回绑定方法(bound method),自动把 self 填进第一个参数——「方法」本质是函数描述符的求值结果。这也解释了实例属性能遮蔽方法、却遮蔽不了 property。绑定细节展开见 属性访问与查找,方法查找顺序见 继承与 MRO。

set_name

创建类时解释器对每个描述符调用 __set_name__(owner, name),告知绑定的属性名——描述符从此知道自己叫什么,不用手工传名字:

class Named:
    def __set_name__(self, owner, name):
        print(f"bound as {name!r}")  # User类创建时自动触发
 
class User:
    name = Named()  # 打印 bound as 'name'

存储路由的完整写法(__set_name__ 记下属性名作存储键、值落实例 __dict__)见下文校验器。

典型应用

校验器:__set__ 挡住非法赋值,错误在赋值瞬间抛出而非使用时。只写 __set__ 也是数据描述符;未定义 __get__,读取自动回落到实例 __dict__:

class Positive:
    def __set_name__(self, owner, name):
        self.name = name  # 记下属性名,用作实例__dict__的存储键
 
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")
        obj.__dict__[self.name] = value
 
class Order:
    quantity = Positive()
 
o = Order()
o.quantity = 5      # 值落o.__dict__['quantity']
o.quantity = -1     # ValueError: quantity must be positive

ORM 字段:Django / SQLAlchemy 的 name = CharField(...) 即描述符——__get__ / __set__ 负责 Python 值与数据库值互转,类定义即表结构。

缓存属性:functools.cached_property 是非数据描述符——首次 __get__ 计算并写入实例 __dict__,此后实例字典优先、直接命中缓存不再触发描述符。设计成非数据描述符,正是为了让缓存值能遮蔽自己。

判断何时需要手写描述符:多个属性共享同一套读写逻辑(校验、转换、惰性缓存),或需要在类定义时声明字段语义(ORM)——超出单个 property 的表达力时,描述符是更底层的正解。