Descriptor:Python 面向对象真正的隐藏核心——从 `A().x == 42` 到 ORM Field 设计

Descriptor:Python 面向对象真正的隐藏核心——从 A().x == 42 到 ORM Field 设计

很多 Python 开发者已经熟练使用 property、实例方法、classmethod、ORM 模型字段,甚至每天都在写 Django、SQLAlchemy、Pydantic 风格的业务代码,却未必真正理解它们背后的共同机制。

这个机制,就是 Descriptor——描述符协议

先看一段极其简单的代码:

class D:
    def __get__(self, obj, owner):
        return 42


class A:
    x = D()


print(A().x)

运行结果:

42

问题来了:

x 明明只是类 A 中保存的一个 D() 对象,为什么访问 A().x 时,拿到的不是 D 的实例,而是 42

更值得追问的是:

  • data descriptor 和 non-data descriptor 到底谁优先?
  • property 属于哪一种 descriptor?
  • 普通函数为什么放进类里以后,就能自动变成 bound method?
  • ORM 中的 User.nameProduct.price 为什么能够自动完成校验、类型转换甚至数据库读写?
  • 如果我们自己设计一个 ORM Field,descriptor 应该怎么用?

如果这些问题能彻底想明白,你对 Python 面向对象的理解会从“会用 class”,进入“理解 Python 对象模型”的阶段。


一、Descriptor 到底是什么?

Descriptor 并不是某个特殊的类,而是一套 对象协议

只要一个对象实现了下面这些特殊方法中的一个或多个,它就可能成为 descriptor:

__get__(self, instance, owner)
__set__(self, instance, value)
__delete__(self, instance)

最简单的 descriptor:

class D:
    def __get__(self, instance, owner):
        print("调用了 __get__")
        return 42

然后把它作为类属性

class A:
    x = D()

访问:

a = A()
print(a.x)

Python 并不是简单地执行:

A.__dict__["x"]

然后把 D() 返回给你。

它会发现:

A.__dict__["x"]

这个对象实现了:

__get__

于是触发 descriptor 协议,大致相当于:

A.__dict__["x"].__get__(a, A)

因此:

a.x

最终得到:

42

这就是:

A().x == 42

的根本原因。


二、真正需要理解的是属性查找顺序

Descriptor 最容易学错的地方,是只记住:

“实现 __get__ 就会自动调用。”

这句话不够准确。

真正重要的问题是:

当实例属性、类属性和 descriptor 同名时,到底听谁的?

例如:

class D:
    def __get__(self, obj, owner):
        return 42


class A:
    x = D()


a = A()
a.__dict__["x"] = 100

print(a.x)

你觉得结果是什么?

答案:

100

为什么不是 42

因为这个 D 只有:

__get__

它属于 non-data descriptor

Python 的实例属性查询顺序,简化以后可以理解为:

1. data descriptor
2. instance.__dict__
3. non-data descriptor / 普通类属性
4. 父类继续查找
5. __getattr__

这是理解 descriptor 最关键的一条规则。

可以记成:

Data Descriptor
      ↓
实例属性
      ↓
Non-data Descriptor
      ↓
普通类属性

三、Data Descriptor 与 Non-data Descriptor

1. Non-data Descriptor

如果一个 descriptor 只定义:

__get__

而没有定义:

__set__
__delete__

通常称为:

non-data descriptor

例如:

class D:
    def __get__(self, obj, owner):
        return 42

测试:

class A:
    x = D()


a = A()

print(a.x)

a.x = 100

print(a.x)
print(a.__dict__)

输出类似:

42
100
{'x': 100}

发生了什么?

第一次:

a.x

实例字典没有 x,因此找到:

A.__dict__["x"]

触发:

D.__get__()

得到 42

但执行:

a.x = 100

之后,100 被直接写入:

a.__dict__["x"]

此时实例属性优先于 non-data descriptor,因此再次执行:

a.x

得到的是:

100

四、什么是 Data Descriptor?

只要 descriptor 实现了:

__set__

或者:

__delete__

它通常就属于 data descriptor

例如:

class D:
    def __get__(self, obj, owner):
        return 42

    def __set__(self, obj, value):
        print("设置值:", value)

使用:

class A:
    x = D()


a = A()

a.__dict__["x"] = 100

print(a.x)

结果仍然是:

42

即使:

a.__dict__["x"]

真的存在。

因为:

data descriptor

优先级高于:

instance.__dict__

所以:

a.x

依然优先调用:

D.__get__()

这也是一个非常容易在面试中出现的问题:

data descriptor 和实例属性谁优先?

答案是:

data descriptor > instance.__dict__

而:

instance.__dict__ > non-data descriptor

五、用伪代码理解 Python 的属性查找

为了理解本质,可以把:

obj.x

想象成 Python 内部进行了类似下面的工作。

注意,这只是帮助理解的简化版:

def object_getattribute(obj, name):
    cls = type(obj)

    # 查找类属性
    cls_var = find_name_in_mro(cls, name)

    # 1. data descriptor
    if hasattr(type(cls_var), "__get__"):
        if (
            hasattr(type(cls_var), "__set__")
            or hasattr(type(cls_var), "__delete__")
        ):
            return cls_var.__get__(obj, cls)

    # 2. instance dictionary
    if hasattr(obj, "__dict__"):
        if name in obj.__dict__:
            return obj.__dict__[name]

    # 3. non-data descriptor
    if hasattr(type(cls_var), "__get__"):
        return cls_var.__get__(obj, cls)

    # 4. 普通类属性
    if cls_var is not None:
        return cls_var

    raise AttributeError(name)

于是整个逻辑就清楚了:

obj.x
  │
  ├─ 类中有 data descriptor? ──→ __get__
  │
  ├─ obj.__dict__ 有 x? ───────→ 返回实例属性
  │
  ├─ 类中有 non-data descriptor?→ __get__
  │
  ├─ 普通类属性? ──────────────→ 返回类属性
  │
  └─ 否则 AttributeError

这套规则不仅解释 descriptor。

它实际上解释了 Python 大量 OO 行为。


六、property 属于哪一种 Descriptor?

答案:

propertydata descriptor

来看最常见的代码:

class User:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

使用:

user = User(18)

print(user.age)

本质上:

@property
def age(self):
    ...

约等于:

def age(self):
    return self._age

age = property(age)

所以:

User.__dict__["age"]

并不是普通函数,而是一个:

property

对象。

你甚至可以验证:

print(type(User.__dict__["age"]))

结果:

<class 'property'>

property 实现了 descriptor 协议。

因此:

user.age

会触发类似:

User.__dict__["age"].__get__(user, User)

最终调用 getter。


七、没有 setter 的 property 为什么仍然是 data descriptor?

这是更深入的一层。

例如:

class User:
    @property
    def name(self):
        return "Jack"

没有:

@name.setter

是不是就变成 non-data descriptor 了?

不是。

它仍然属于 data descriptor。

例如:

u = User()

u.name = "Tom"

会抛出:

AttributeError

原因是 property 类型本身具备 descriptor 的赋值处理能力;没有配置 setter 时,它不会退化成普通实例赋值,而是拒绝赋值。

因此它仍然可以阻止:

instance.__dict__

简单覆盖这个属性。

这也是 property 能很好实现封装的重要原因。


八、真正精彩的问题:function 为什么会变成 bound method?

来看 Python 最普通的一段代码:

class User:
    def hello(self):
        print("Hello")

然后:

u = User()

u.hello()

我们每天都这么写。

但有一个非常值得思考的问题:

你定义的是:

def hello(self):

Python 为什么知道:

u.hello()

应该自动把:

u

传给 self

答案仍然是:

Descriptor。


九、Python 函数本身就是 Descriptor

先观察:

class User:
    def hello(self):
        print("Hello")


print(User.__dict__["hello"])

你得到的是一个普通 function:

<function User.hello at ...>

函数对象实现了:

__get__

因此:

User.__dict__["hello"]

实际上是一个:

non-data descriptor

于是执行:

u.hello

时,本质类似:

User.__dict__["hello"].__get__(u, User)

这个 __get__ 不再返回原始函数,而是产生一个:

bound method

验证一下:

u = User()

print(User.hello)
print(u.hello)

你会发现:

User.hello

接近:

<function User.hello ...>

而:

u.hello

则接近:

<bound method User.hello of <User object ...>>

这就是 绑定方法


十、手动模拟 bound method

我们可以自己实现一个迷你版本:

class Function:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, owner):
        if instance is None:
            return self.func

        def bound(*args, **kwargs):
            return self.func(instance, *args, **kwargs)

        return bound

使用:

def hello(self, name):
    print(f"{self} says hello to {name}")


class User:
    say = Function(hello)

调用:

u = User()

u.say("Tom")

实际上:

u.say

触发:

Function.__get__(u, User)

返回了一个已经记住:

instance = u

的函数。

因此:

u.say("Tom")

等价思想就是:

hello(u, "Tom")

Python 内置 function 的实现当然更加完善、高效,但底层思想就是 descriptor binding。

理解这一点之后,self 就不再神秘了:

self 并不是 Python 的关键字,也没有什么魔法。真正发生的是函数 descriptor 在属性访问阶段完成了实例绑定。


十一、为什么函数设计成 Non-data Descriptor?

这是 Python 对象模型一个非常优雅的设计。

普通函数只提供 __get__,因此是 non-data descriptor。

而:

instance.__dict__ > non-data descriptor

意味着你可以在实例级别覆盖方法:

class A:
    def hello(self):
        return "class method"


a = A()

a.hello = lambda: "instance method"

print(a.hello())

输出:

instance method

为什么?

因为:

a.__dict__["hello"]

覆盖了类中的函数 descriptor。

如果 function 是 data descriptor,就无法这么自然地进行实例级覆盖。

这体现了 Python 一贯强调的动态性。


十二、Descriptor 最经典的工程场景:字段系统

理解 descriptor 以后,一个问题自然出现:

我们为什么需要自己写 descriptor?

实际项目中非常典型的答案,就是:

字段管理。

比如我们希望实现:

class User:
    age = IntegerField()
    name = StringField()

然后:

user = User()

user.age = 18
user.name = "Jack"

系统自动实现:

  • 类型检查
  • 范围检查
  • 数据存储
  • 默认值
  • 字段元数据
  • ORM 映射
  • 序列化
  • 数据库列生成

这正是 descriptor 最擅长解决的问题。


十三、实现一个真正可用的 ORM Field

先从基础 Field 开始:

class Field:
    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return getattr(instance, self.storage_name, None)

    def __set__(self, instance, value):
        setattr(instance, self.storage_name, value)

这里又出现了一个非常实用的特殊方法:

__set_name__

当 descriptor 被放到类里时:

class User:
    name = Field()

Python 创建 User 类以后,会告诉这个 Field:

你在 User 中的名字叫 name

于是:

Field.__set_name__(User, "name")

会被调用。

这比以前手工写:

name = Field("name")

优雅很多。


十四、给 ORM Field 增加类型验证

进一步设计:

class Field:
    expected_type = object

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return getattr(instance, self.storage_name, None)

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.name} 必须是 "
                f"{self.expected_type.__name__}"
            )

        setattr(instance, self.storage_name, value)


class IntegerField(Field):
    expected_type = int


class StringField(Field):
    expected_type = str

现在模型就可以写成:

class User:
    name = StringField()
    age = IntegerField()

测试:

user = User()

user.name = "Alice"
user.age = 30

print(user.name)
print(user.age)

输出:

Alice
30

如果:

user.age = "30"

则直接抛错:

TypeError: age 必须是 int

一个最小字段系统已经出现了。


十五、为什么数据不能直接存在 descriptor 自己身上?

很多初学者第一次写 descriptor,会这样实现:

class Field:
    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        self.value = value

看起来没问题。

实际上有严重 Bug。

例如:

class User:
    age = Field()


u1 = User()
u2 = User()

u1.age = 18
u2.age = 30

print(u1.age)

你可能期待:

18

但得到的可能是:

30

为什么?

因为:

User.age

对应的 descriptor 只有一个。

也就是说:

u1
u2
u3

共享的是同一个:

Field()

因此实例数据绝不能简单存储在:

self.value

中。

正确方式应该存到具体 instance 身上:

instance.__dict__

或者使用私有存储名:

_age
_name

例如:

instance.__dict__[self.storage_name] = value

这是一条设计 ORM descriptor 时非常重要的原则:

Descriptor 管理“字段行为”,instance 保存“字段数据”。


十六、进一步增加范围限制

我们继续强化:

class IntegerField:
    def __init__(self, *, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return getattr(instance, self.storage_name, None)

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError(f"{self.name} 必须是 int")

        if self.min_value is not None:
            if value < self.min_value:
                raise ValueError(
                    f"{self.name} 不能小于 {self.min_value}"
                )

        if self.max_value is not None:
            if value > self.max_value:
                raise ValueError(
                    f"{self.name} 不能大于 {self.max_value}"
                )

        setattr(instance, self.storage_name, value)

使用:

class User:
    age = IntegerField(
        min_value=0,
        max_value=150,
    )

现在:

user = User()

user.age = 25
print(user.age)

正常。

而:

user.age = -10

会立即失败。

这就是 descriptor 的工程价值:

把横切于多个模型的字段逻辑,从业务类中抽离出来。


十七、Descriptor 如何进一步演化成 ORM?

真正的 ORM 当然比上面的例子复杂得多。

假设:

class User(Model):
    id = IntegerField(primary_key=True)
    name = StringField(max_length=50)
    age = IntegerField()

Field 不仅可以承担实例数据访问,还可以保存字段元信息:

class Field:
    def __init__(
        self,
        *,
        column_type,
        nullable=True,
        primary_key=False
    ):
        self.column_type = column_type
        self.nullable = nullable
        self.primary_key = primary_key

    def __set_name__(self, owner, name):
        self.name = name

于是:

User.__dict__["id"]

不仅表示:

实例的 id 属性

还可以描述数据库 Schema:

name = id
type = INTEGER
primary_key = True

然后 metaclass 或 __init_subclass__ 就可以扫描:

User.__dict__

收集所有 Field:

fields = {}

for name, value in User.__dict__.items():
    if isinstance(value, Field):
        fields[name] = value

最终生成:

CREATE TABLE user (
    id INTEGER PRIMARY KEY,
    name VARCHAR(50),
    age INTEGER
);

这就是很多 ORM 框架设计思想的一个缩影:

Descriptor
    │
    ├── 控制实例属性访问
    │
    ├── 类型验证
    │
    ├── 数据转换
    │
    └── 字段元数据
             │
             ↓
        Model Metadata
             │
             ↓
         SQL / Schema

descriptor 负责“一个字段如何表现”。

metaclass 或模型构建系统负责:

“整个类包含哪些字段,以及这些字段如何共同形成模型。”


十八、还可以利用 Descriptor 实现 Lazy Loading

ORM 中另一个典型场景是延迟加载。

假设:

order.user

对应数据库中的外键。

我们不希望加载 Order 时立刻查询 User,而希望第一次访问:

order.user

时再查数据库。

就可以使用 descriptor:

class LazyField:
    def __init__(self, loader):
        self.loader = loader

    def __set_name__(self, owner, name):
        self.name = name
        self.cache_name = f"_{name}_cache"

    def __get__(self, instance, owner):
        if instance is None:
            return self

        if hasattr(instance, self.cache_name):
            return getattr(instance, self.cache_name)

        value = self.loader(instance)

        setattr(
            instance,
            self.cache_name,
            value,
        )

        return value

例如:

def load_user(order):
    print("查询数据库...")
    return {
        "id": order.user_id,
        "name": "Alice",
    }


class Order:
    user = LazyField(load_user)

    def __init__(self, user_id):
        self.user_id = user_id

使用:

order = Order(1001)

print("订单创建完成")

print(order.user)
print(order.user)

输出类似:

订单创建完成
查询数据库...
{'id': 1001, 'name': 'Alice'}
{'id': 1001, 'name': 'Alice'}

可以看到:

第一次:

order.user

执行查询。

第二次:

order.user

直接使用缓存。

这已经接近真实 ORM 中 relation lazy loading 的核心思想。


十九、Descriptor、__getattribute____getattr__ 的关系

这里需要再澄清一个经常混淆的问题。

访问:

obj.x

首先进入的是:

obj.__getattribute__("x")

Descriptor 协议实际上就是属性访问机制的一部分。

而:

__getattr__

角色不同。

只有正常查找失败后,它才作为兜底机制被调用。

例如:

class A:
    def __getattr__(self, name):
        return f"{name} 不存在"

调用:

a = A()

print(a.hello)

输出:

hello 不存在

因此可以这样理解:

obj.x
 │
 ↓
__getattribute__
 │
 ├─ descriptor
 ├─ instance.__dict__
 ├─ class / MRO
 │
 ↓
查找失败
 │
 ↓
__getattr__

如果你在设计代理对象、ORM、RPC Client、动态配置系统,这几层机制经常会一起出现。


二十、实战中什么时候应该自己写 Descriptor?

Descriptor 很强,但并不代表所有属性都应该用 descriptor。

如果需求只是:

@property
def full_name(self):
    return f"{self.first_name} {self.last_name}"

直接 property 通常最好。

当你发现同一种属性行为需要被多个字段、多个类重复使用时,descriptor 才开始真正发挥优势。

典型场景包括:

  • ORM Field
  • 类型检查字段
  • 参数范围验证
  • Lazy Loading
  • 缓存属性
  • 数据转换
  • 单位转换
  • 权限控制
  • 属性访问日志
  • 序列化字段
  • API Schema Field

例如:

class Product:
    price = PositiveFloat()
    stock = PositiveInteger()


class Account:
    balance = PositiveFloat()

如果不用 descriptor,你可能需要给每个属性重复写:

@property

和:

@xxx.setter

而 descriptor 可以把规则抽象一次,然后重复复用。

这才是它真正的工程价值。


二十一、几个高频坑一定要避开

1. Descriptor 必须通常定义在类上

下面这种写法:

a.x = D()

不会因为 D 实现 __get__,就自然拥有和类级 descriptor 完全相同的效果。

Descriptor 协议的核心场景是:

class A:
    x = D()

也就是 descriptor 作为类属性参与属性查找。


2. 不要把实例数据放在 descriptor 对象自身

错误:

self.value = value

正确思路:

instance.__dict__[self.name] = value

否则多个实例可能共享状态。


3. instance is None 要处理

访问:

User.age

时,通常:

instance is None

因此一个成熟 descriptor 经常写成:

def __get__(self, instance, owner):
    if instance is None:
        return self

    ...

这样:

User.age

还能拿到 Field 本身。

这对 ORM 尤其重要,因为 ORM 经常希望:

User.age

不仅仅是一个字段,而是进一步构造 SQL 表达式,例如概念上的:

User.age > 18

最终转换成:

age > 18

descriptor 因此同时承担了:

实例访问语义

和:

类访问语义

这是一种非常强大的 API 设计技巧。


二十二、最后彻底回答三个追问

回到文章最开始的问题。

问题一:data descriptor 和 non-data descriptor 谁优先?

核心顺序:

Data Descriptor
      ↓
instance.__dict__
      ↓
Non-data Descriptor
      ↓
普通类属性

所以:

data descriptor > 实例属性 > non-data descriptor

这条规则值得直接记住。


问题二:property 属于哪一种?

答案:

Data Descriptor

即使只定义 getter,没有 setter,它依旧不会简单退化成普通 non-data descriptor。


问题三:function 为什么会变成 bound method?

因为:

Python function 本身实现了 descriptor protocol。

函数放进类后成为类属性。

访问:

obj.method

时触发 function 的:

__get__

从而把:

obj

绑定进去,形成:

bound method

因此:

obj.method(arg)

本质上可以理解为:

Class.method(obj, arg)

正是 descriptor,让 self 的自动绑定成为可能。


二十三、从 Descriptor 重新理解 Python OO

学 Python 面向对象时,我们通常先看到的是:

class
self
@property
method
inheritance

但学到 descriptor 以后,你会发现很多看似互不相关的机制,其实共享同一个底层模型:

@property
      │
      ├──── Descriptor
      │
普通实例方法 ──┤
      │
ORM Field ─────┤
      │
Lazy Attribute ┤
      │
字段验证 ──────┘

这也是为什么我认为:

Descriptor 是 Python OO 世界里最值得深入理解的隐藏核心之一。

它并不是一个天天需要手写的高级语法。

真正重要的是,一旦理解 descriptor,你就能解释 Python 为什么会这样工作。

你会知道:

为什么:

A().x

不是简单地“从对象中取出 x”。

你会知道:

为什么:

obj.method()

可以自动传递 self

你会知道:

为什么:

@property

能够控制属性读取和写入。

你也会开始看懂 ORM、字段验证框架、缓存系统和许多 Python 库内部那些原本看起来像“魔法”的设计。

真正高级的 Python 编程,并不是使用更多魔法,而是理解魔法背后的协议,然后在正确的地方使用它。

如果你正在学习 Python 源码、ORM 架构、框架设计或者元编程,Descriptor 很适合作为一个分水岭:在它之前,你主要是在“使用 Python”;理解它以后,你开始真正进入 Python 对象模型内部。🚀

最后留三个值得继续思考的问题:

  1. 如果同时重写 __getattribute__,是否可能破坏 descriptor 的默认行为?
  2. classmethodstaticmethod 又分别是怎样借助 descriptor 实现的?
  3. 如果让 User.age > 18 自动生成 SQL,应该如何扩展上面的 Field

当你能独立设计出一个支持字段验证、默认值、Lazy Loading、类级查询表达式和模型元数据收集的小型 ORM 时,Descriptor 就不再只是一个知识点,而会真正成为你手里的架构工具。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值