你可能已经用过 property 来把方法伪装成属性,但你或许不知道,property 的底层正是基于 描述符(Descriptor)实现的。描述符是 Python 属性访问控制的核心机制,它决定了访问一个对象属性时到底发生了什么。
描述符协议
只要一个类定义了以下任一方法,它的实例就成为描述符:
get(self, instance, owner):获取属性时被调用set(self, instance, value):设置属性时被调用delete(self, instance):删除属性时被调用
最简单的例子:
class MyDescriptor:
def __get__(self, instance, owner):
print("获取属性")
return 42
class MyClass:
attr = MyDescriptor() # 类属性,是一个描述符实例
obj = MyClass()
print(obj.attr) # 触发 __get__,打印“获取属性”,返回42
描述符的类型
- 非数据描述符:只定义了
get。它们只能拦截读取操作,可以被子实例的属性覆盖。 - 数据描述符:定义了
get和set(或delete)中至少一个。它们同时拦截读取、写入、删除,并且优先级高于实例属性字典dict。
属性查找的优先级
当访问 obj.attr 时,Python 会按照以下顺序查找(简化版):
- 如果
attr是数据描述符,调用其get。 - 否则,检查实例的
obj.dict(普通实例属性)。 - 否则,检查类的
type(obj).dict(类属性)。 - 如果类属性是非数据描述符,调用其
get;否则直接返回该属性。 - 最后调用
getattr(如果定义了)。
这个优先级规则解释了为什么数据描述符可以拦截实例属性的直接赋值——即使是 obj.dict['attr'] = ... 也会触发 set。
property 与描述符的关系
内置的 property 就是一个用 C 实现的数据描述符。我们通常这样用:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""获取半径"""
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("半径必须为正")
self._radius = value
实际等价于:
radius = property(get_radius, set_radius, del_radius, doc)
这里的 property 把三个方法绑定成一个描述符对象,然后存放在类属性中。当你访问 circle.radius 时,由于它是一个数据描述符,会直接触发 get,而不是去 dict 里找。
自己实现一个类似 property 的描述符
class ValidRange:
"""自定义数据描述符:确保值在给定范围内"""
def __init__(self, min_value, max_value):
self.min = min_value
self.max = max_value
self.data = {} # 用字典存储每个实例的实际值
def __get__(self, instance, owner):
if instance is None:
return self # 类访问时返回描述符本身
return self.data.get(instance, None)
def __set__(self, instance, value):
if not (self.min <= value <= self.max):
raise ValueError(f"{value} 不在 [{self.min}, {self.max}] 范围内")
self.data[instance] = value
def __delete__(self, instance):
del self.data[instance]
class Student:
score = ValidRange(0, 100) # 班级里定义一个描述符
s1 = Student()
s2 = Student()
s1.score = 90 # 触发 __set__,存储到描述符的内部字典里
s2.score = 85
print(s1.score) # 90
print(s2.score) # 85
注意:描述符内部用了 data 字典,把值按实例存储,这是常见做法。你也可以直接存入实例的 dict,但为了避免与描述符属性名冲突,通常会加下划线前缀,例如 instance.dict['_score'] = value。
实际应用场景
- 类型检查与验证:像
ValidRange一样,确保属性值合法。 - 惰性计算:第一次访问时才计算值,并缓存结果。
- ORM 字段列:Django/Peewee 的字段类都是描述符,拦截对数据库列的操作。
- property 的平替:当多个属性共享同一逻辑时,用描述符比写多个 property 更简洁。
注意事项
- 类级别共享:描述符必须定义在类的层级,不能是实例属性。因为 Python 查找描述符是从
type(obj).mro中找的。 - 存储数据:如果所有实例共享一个类变量作为存储,会用同一空间。正确做法是为每个实例独立存储(如使用实例
dict或描述符内部的WeakKeyDictionary)。 - 性能:描述符介入每次属性访问,会有轻微额外开销,但通常可忽略。
描述符是 Python 对象行为中一个相对底层的特性,理解了它,你就明白 property、classmethod、staticmethod 甚至 slots 背后的原理。它不是日常必用技巧,但一旦用对,可以让代码更优雅且可复用。