描述符协议与动态属性管理

4 阅读3分钟

什么是描述符协议?

描述符(Descriptor)是 Python 中一个强大但常被忽视的特性。它是实现属性访问协议的核心机制,让我们能够自定义属性的获取、设置和删除行为。

简单来说,描述符是实现了 __get__ __set__ __delete__ 方法的对象。当一个描述符对象作为类属性存在时,对该属性的访问会被描述符方法拦截。

描述符协议的三个核心方法

class Descriptor:
    def __get__(self, obj, objtype=None):
        """获取属性值时调用"""
        if obj is None:
            return self
        # 返回属性值
        return ...
    
    def __set__(self, obj, value):
        """设置属性值时调用"""
        # 验证并存储值
        ...
    
    def __delete__(self, obj):
        """删除属性时调用"""
        # 清理操作
        ...
  • __get__: 访问属性时触发,obj 是实例,objtype 是类
  • __set__: 赋值时触发,实现了此方法的是数据描述符
  • __delete__: 删除属性时触发

实战:实现类型检查描述符

让我们创建一个实用的类型检查描述符,确保属性只能被赋予指定类型的值:

class Typed:
    """类型检查描述符"""
    
    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # 从实例的 __dict__ 获取值
        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__}, '
                f' got {type(value).__name__}'
            )
        obj.__dict__[self.name] = value
    
    def __delete__(self, obj):
        if self.name in obj.__dict__:
            del obj.__dict__[self.name]


# 使用描述符的类
class Person:
    name = Typed('name', str)
    age = Typed('age', int)
    salary = Typed('salary', float)
    
    def __init__(self, name, age, salary):
        self.name = name      # 触发 Typed.__set__
        self.age = age
        self.salary = salary
    
    def __repr__(self):
        return f'Person({self.name}, {self.age}, {self.salary})'


# 测试
p = Person("张三", 25, 8500.0)
print(p)  # Person(张三,25, 8500.0)

# 类型检查生效
try:
    p.age = "二十五"  # TypeError!
except TypeError as e:
    print(f"捕获错误:{e}")

描述符的优先级

理解描述符的优先级对于调试至关重要:

  1. 数据描述符(实现了 __set__)> 实例字典
  2. 实例字典 > 非数据描述符(只实现 __get__
  3. 类属性 > __getattr__
class DataDesc:
    def __get__(self, obj, objtype=None):
        return "data_desc"
    def __set__(self, obj, value):
        obj.__dict__['temp'] = value

class NonDataDesc:
    def __get__(self, obj, objtype=None):
        return "non_data_desc"

class Test:
    data = DataDesc()
    non_data = NonDataDesc()

t = Test()
t.data = "instance_value"  # 数据描述符优先,但值存入 __dict__
print(t.data)  # 仍输出 "data_desc"(数据描述符拦截)
print(t.non_data)  # "non_data_desc"
t.__dict__['non_data'] = "direct"
print(t.non_data)  # "direct"(实例字典优先于非数据描述符)

实用场景:懒加载属性

描述符非常适合实现懒加载(Lazy Loading),即属性值在首次访问时才计算:

class LazyProperty:
    """懒加载属性描述符"""
    
    def __init__(self, func):
        self.func = func
        self.name = func.__name__
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        
        # 首次访问时计算并缓存
        value = self.func(obj)
        obj.__dict__[self.name] = value
        return value


class Image:
    def __init__(self, filepath):
        self.filepath = filepath
    
    @LazyProperty
    def data(self):
        """仅在首次访问时加载图像数据"""
        print(f"正在加载 {self.filepath}...")
        # 模拟耗时操作
        import time
        time.sleep(1)
        return b"image_data_placeholder"


img = Image("photo.jpg")
print("Image 对象已创建,但数据尚未加载")
print(img.data)  # 此时才加载
print(img.data)  # 直接从缓存获取,不再加载

与 @property 的区别

@property 实际上是描述符的语法糖。对比一下:

# 使用 @property
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


# 使用描述符实现相同功能
class Positive:
    def __init__(self, name):
        self.name = name
    
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)
    
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name} 不能为负")
        obj.__dict__[self.name] = value

class CircleDesc:
    radius = Positive('radius')
    
    def __init__(self, radius):
        self.radius = radius

选择建议

  • 简单场景用 @property,语法更简洁
  • 需要复用时用描述符,避免重复代码
  • 框架开发中描述符更灵活

小结

描述符协议是 Python 面向对象编程的基石之一:

特性说明
数据描述符实现 __set__,优先级高于实例字典
非数据描述符只实现 __get__,优先级低于实例字典
典型应用类型检查、懒加载、属性验证、ORM 字段
与 property 关系@property 是描述符的语法糖

掌握描述符,你就能理解 Python 属性访问的底层机制,写出更优雅、更可复用的代码。