RSS
菜单
全部文章快讯开发科技深度热点

描述符协议(Python 从精通到入门 · 02)

内容摘要

obj.x 这行代码背后有一条严格的查找链。理解它,你就理解了 property、方法绑定、字段验证——以及为什么它们能工作。

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 的完整查找顺序(默认行为):

  1. 在 type(obj).__mro__ 中找 attr; - 找到且是数据描述符 → 调用 descr.__get__(obj, type(obj)),立刻返回(实例字典根本没机会说话);
  2. 找 obj.__dict__["attr"];找到就返回;
  3. 回到第 1 步找到的那个类属性: - 是非数据描述符 → 调用 descr.__get__(obj, type(obj)); - 是普通值 → 直接返回;
  4. 以上都没有 → 若有 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'>

机制拆解:

  1. c.greet 触发查找,在 type(c).__mro__ 里找到 C.__dict__["greet"],是一个 function;
  2. 实例字典里没有 greet;
  3. 调用 function.__get__(c, C),返回一个绑定方法(method 对象),把 c 记在内部;
  4. () 调用时,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 的源码——一个漂亮的非数据描述符案例
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《描述符协议(Python 从精通到入门 · 02)》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。