一、为什么需要属性描述符?从 property 的局限性说起
在 Python 的面向对象编程中,属性的控制与查找是核心知识点之一,而属性描述符作为实现属性精细化控制的重要工具,更是 ORM 框架(如 Django Model、SQLAlchemy)的底层实现基础。
在 Python 中,我们常用 property 装饰器来控制属性的获取、设置和删除过程,实现对属性的简单校验和逻辑封装。比如我们要限制用户类中 age 为整数类型、name 为字符串类型,使用 property 可以轻松实现:
class User:
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int):
raise ValueError("int value need")
self._age = value
但在实际开发中,尤其是在 ORM 场景下,一个数据模型类往往对应数据库的一张表,包含十几个甚至几十个字段,其中很多字段会有相同的类型校验规则(如 name、email、mobile 都是字符串类型,age、id 都是整数类型)。
如果继续使用 property,我们需要为每个字段编写重复的 getter 和 setter 方法,这会导致代码冗余度极高,维护成本大幅增加。为了解决代码复用的问题,Python 为我们提供了更优雅的解决方案——属性描述符。
二、属性描述符的定义与基础使用
2.1 什么是属性描述符?
属性描述符的定义非常简单:一个自定义类,只要实现了 __get__、__set__、__delete__ 三个魔法函数中的任意一个,这个类就是属性描述符。通过这个类的实例,我们可以将属性的控制逻辑封装起来,实现多字段的逻辑复用。
2.2 基础实现:整数类型校验描述符
我们以实现整数类型校验为例,编写第一个属性描述符,解决多个整数类型字段的校验复用问题:
import numbers
class IntField:
def __set__(self, instance, value):
if not isinstance(value, numbers.Integral):
raise ValueError("int value need")
if value < 0:
raise ValueError("positive value need")
self.value = value
def __get__(self, instance, owner):
return self.value
2.3 在模型类中使用描述符
定义好描述符后,我们可以在模型类中直接将其作为类属性使用,实现对字段的统一控制:
class User:
age = IntField()
user = User()
user.age = 30
print(user.age)
user.age = "abc"
user.age = -5
2.4 关键注意点:避免赋值死循环
在实现 __set__ 方法时,切忌将值保存到传入的 instance(模型类实例)中,比如写成 instance.age = value。
因为当我们对 instance.age 赋值时,Python 会再次调用描述符的 __set__ 方法,从而陷入无限递归,最终导致栈溢出。正确的做法是将值保存到描述符自身的实例(self)中,在 __get__ 方法中再从 self 中取出。
三、属性描述符的分类:数据描述符与非数据描述符
根据实现的魔法函数不同,属性描述符分为两类,二者的核心区别在于在 Python 属性查找过程中的优先级不同,这也是理解属性查找的关键。
3.1 数据描述符(Data Descriptor)
实现了 __get__ 和 __set__ 方法的描述符称为数据描述符,是我们开发中最常用的类型,比如上文的 IntField 就是典型的数据描述符。
数据描述符拥有最高的属性查找优先级,会覆盖实例自身的属性值。
3.2 非数据描述符(Non-data Descriptor)
只实现了 __get__ 方法(未实现 __set__)的描述符称为非数据描述符,常见的例子是 Python 中的函数(函数实现了 __get__ 方法,成为绑定方法)。
非数据描述符的优先级低于实例自身的属性值,仅在实例中未找到该属性时才会生效。
四、Python 完整的属性查找过程:描述符的核心作用
在学习属性描述符之前,我们对 Python 属性查找的认知通常是:先查找实例的 __dict__,再查找类的 __dict__,最后查找基类的 __dict__。
但当引入属性描述符后,Python 的属性查找过程会变得更加精细,而这一过程也是 getattr 和 __getattribute__ 两个魔法函数的底层逻辑。当我们使用 实例。属性(如 user.age)的方式访问属性时,等价于调用全局函数 getattr(实例,属性名),其完整的查找顺序如下:
4.1 核心查找顺序
- 调用
__getattribute__:无论属性是否存在,都会先调用类中的 __getattribute__ 方法,这是属性查找的入口;
- 检查数据描述符:在实例的类或基类的
__dict__ 中查找该属性,若该属性是数据描述符,则直接调用其 __get__ 方法,返回结果;
- 检查实例自身属性:在实例的
__dict__ 中查找该属性,若找到则直接返回值;
- 检查非数据描述符/类属性:在实例的类或基类的
__dict__ 中查找该属性:
- 若为非数据描述符,调用其
__get__ 方法返回结果;
- 若不是描述符,直接返回类属性的值;
- 调用
__getattr__:若以上步骤均未找到属性,会触发 AttributeError,此时若类中定义了 __getattr__ 方法,会调用该方法;
- 抛出异常:若未定义
__getattr__,则直接抛出 AttributeError。
4.2 关键验证:数据描述符覆盖实例属性
数据描述符的优先级高于实例自身属性,即使我们手动给实例的 __dict__ 添加该属性,访问时仍会优先调用数据描述符的 __get__ 方法:
user = User()
user.age = 30
print(user.__dict__)
user.__dict__['age'] = "abc"
print(user.age)
4.3 关键验证:非数据描述符被实例属性覆盖
若将 IntField 改为非数据描述符(仅实现 __get__),则实例自身的属性会覆盖描述符:
class NonDataIntField:
def __get__(self, instance, owner):
return 10
class User:
age = NonDataIntField()
user = User()
user.age = 30
print(user.age)
五、属性描述符的实际应用:ORM 框架的底层基础
属性描述符的核心价值在于属性逻辑的封装与复用,这也是所有 Python ORM 框架的底层实现原理。
在 Django Model、SQLAlchemy 等框架中,我们定义的 CharField、IntegerField、EmailField 等字段,本质上都是封装了不同校验规则和数据库映射逻辑的属性描述符:
- 字段的类型校验(如
CharField 限制字符串)由描述符的 __set__ 方法实现;
- 字段的数据库字段映射(如字段长度、是否为主键)由描述符的初始化参数实现;
- 字段的取值逻辑由描述符的
__get__ 方法实现。
通过属性描述符,ORM 框架将数据库表的字段与 Python 类的属性进行了完美映射,让我们可以用面向对象的方式操作数据库,而无需编写重复的校验和映射代码。
六、总结
- 属性描述符的诞生:为解决
property 在多字段场景下的代码冗余问题,实现属性控制逻辑的复用;
- 定义规则:实现
__get__、__set__、__delete__ 任一方法的自定义类,即为属性描述符;
- 两大分类:实现
__get__+__set__ 的数据描述符(高优先级)、仅实现 __get__ 的非数据描述符(低优先级);
- 属性查找核心:
实例。属性 的查找顺序为「数据描述符 → 实例属性 → 非数据描述符/类属性 → getattr → 异常」;
- 实际价值:Python ORM 框架的底层核心,是实现属性精细化控制和数据库映射的关键工具。