MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python类的属性描述符使用

2021-05-125.3k 阅读

Python类的属性描述符基础概念

在Python中,属性描述符是一种特殊的类,它定义了访问类属性时的行为。属性描述符是Python数据模型中的一个强大特性,它允许我们在获取、设置或删除属性时执行自定义代码。

属性描述符类需要实现以下三个特殊方法中的一个或多个:

  1. __get__(self, instance, owner):当获取属性值时调用。self 是描述符实例,instance 是拥有该属性的类实例(如果是通过类访问属性,instanceNone),owner 是拥有该属性的类。
  2. __set__(self, instance, value):当设置属性值时调用。self 是描述符实例,instance 是拥有该属性的类实例,value 是要设置的值。
  3. __delete__(self, instance):当删除属性时调用。self 是描述符实例,instance 是拥有该属性的类实例。

如果一个类定义了 __set____delete__ 方法,它被称为数据描述符。如果只定义了 __get__ 方法,它被称为非数据描述符。

简单的数据描述符示例

让我们从一个简单的数据描述符示例开始。假设我们有一个类,我们希望其中某个属性的值始终为正数。我们可以使用属性描述符来实现这个逻辑。

class PositiveNumber:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError('Value must be positive')
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        del instance.__dict__[self.name]


class MyClass:
    num = PositiveNumber('num')


obj = MyClass()
obj.num = 5
print(obj.num)  
try:
    obj.num = -1
except ValueError as e:
    print(e)  

在这个例子中,PositiveNumber 类是一个数据描述符。__init__ 方法接受属性的名称。__get__ 方法从实例的 __dict__ 中获取属性值。__set__ 方法检查要设置的值是否为正数,如果不是则抛出 ValueError__delete__ 方法从实例的 __dict__ 中删除属性。

MyClass 中,num 属性被定义为 PositiveNumber 描述符的实例。当我们尝试设置 obj.num 为负数时,会触发 ValueError

非数据描述符示例

非数据描述符通常用于实现一些只读属性或在获取属性时进行一些计算。

class ReadOnlyAttribute:
    def __init__(self, value):
        self.value = value

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


class MyReadOnlyClass:
    read_only = ReadOnlyAttribute(42)


obj = MyReadOnlyClass()
print(obj.read_only)  
try:
    obj.read_only = 10
except AttributeError as e:
    print(e)  

在这个例子中,ReadOnlyAttribute 是一个非数据描述符,因为它只定义了 __get__ 方法。MyReadOnlyClass 中的 read_only 属性是 ReadOnlyAttribute 的实例,值为 42。由于它是一个非数据描述符,尝试设置该属性会导致 AttributeError,因为默认情况下非数据描述符不允许设置值。

描述符查找顺序

理解描述符的查找顺序对于正确使用它们至关重要。Python使用以下顺序查找属性:

  1. 实例字典:如果属性在实例的 __dict__ 中存在,并且该属性不是数据描述符,那么直接返回实例 __dict__ 中的值。
  2. 类字典:如果属性不在实例的 __dict__ 中,Python会在类的字典中查找。如果找到的是数据描述符(定义了 __set____delete__),则调用其 __get__ 方法。
  3. 父类字典:如果在类的字典中没有找到属性,Python会在父类的字典中查找,查找方式与在类字典中查找相同。
  4. 非数据描述符:如果在实例、类和父类的字典中都没有找到属性,并且类的字典中有一个非数据描述符,那么调用该非数据描述符的 __get__ 方法。
  5. __getattr__ 方法:如果上述步骤都没有找到属性,Python会调用实例的 __getattr__ 方法(如果定义了)。

描述符与 property 装饰器的关系

property 是Python中用于创建属性的内置函数,它本质上也是基于描述符实现的。property 函数创建了一个数据描述符,它结合了 __get____set____delete__ 方法。

class MyClassWithProperty:
    def __init__(self):
        self._value = 0

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_value):
        if new_value < 0:
            raise ValueError('Value must be non - negative')
        self._value = new_value

    @value.deleter
    def value(self):
        del self._value


obj = MyClassWithProperty()
obj.value = 10
print(obj.value)  
try:
    obj.value = -5
except ValueError as e:
    print(e)  
del obj.value
try:
    print(obj.value)
except AttributeError as e:
    print(e)  

在这个例子中,@property 装饰器将 value 方法转换为一个属性的 __get__ 方法。@value.setter 装饰器定义了 __set__ 方法,@value.deleter 装饰器定义了 __delete__ 方法。这与我们之前手动定义描述符类的功能类似,但使用 property 装饰器更加简洁和直观。

描述符在缓存中的应用

描述符在缓存属性值方面非常有用。假设我们有一个计算开销较大的属性,我们希望只计算一次并缓存结果。

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = self.func(instance)
        setattr(instance, self.func.__name__, value)
        return value


class ExpensiveCalculation:
    def __init__(self):
        self.data = [i for i in range(1000000)]

    @CachedProperty
    def sum_of_data(self):
        return sum(self.data)


obj = ExpensiveCalculation()
print(obj.sum_of_data)  
print(obj.sum_of_data)  

在这个例子中,CachedProperty 是一个描述符。__init__ 方法接受一个函数,该函数是我们要缓存结果的计算方法。__get__ 方法首先检查是否是通过类访问属性(instanceNone),如果是则返回自身。否则,它调用原始函数计算值,然后使用 setattr 将计算结果设置为实例的属性(属性名与原始函数名相同),这样下次访问该属性时,直接从实例的 __dict__ 中获取值,而不需要再次计算。

描述符在类型检查中的应用

我们可以使用描述符来实现类型检查,确保属性被设置为正确的类型。

class TypedProperty:
    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f'Expected {self.expected_type.__name__}, got {type(value).__name__}')
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        del instance.__dict__[self.name]


class MyTypedClass:
    num = TypedProperty('num', int)
    text = TypedProperty('text', str)


obj = MyTypedClass()
obj.num = 10
obj.text = 'Hello'
try:
    obj.num = 'ten'
except TypeError as e:
    print(e)  
try:
    obj.text = 10
except TypeError as e:
    print(e)  

在这个例子中,TypedProperty 描述符确保 num 属性只能设置为 int 类型,text 属性只能设置为 str 类型。如果设置了错误的类型,会抛出 TypeError

多重描述符的情况

当一个类中有多个描述符作用于同一个属性时,情况会变得稍微复杂一些。例如,假设我们有一个类,同时有一个数据描述符和一个非数据描述符作用于同一个属性。

class DataDescriptor:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        instance.__dict__[self.name] = value


class NonDataDescriptor:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, owner):
        return f'Non - data descriptor value for {self.name}'


class MyMultiDescriptorClass:
    data_desc = DataDescriptor('data_desc')
    non_data_desc = NonDataDescriptor('non_data_desc')


obj = MyMultiDescriptorClass()
obj.data_desc = 'data value'
print(obj.data_desc)  
print(obj.non_data_desc)  

在这个例子中,data_desc 是数据描述符,non_data_desc 是非数据描述符。由于数据描述符的优先级高于非数据描述符,当我们访问 obj.data_desc 时,会调用数据描述符的 __get__ 方法。而访问 obj.non_data_desc 时,会调用非数据描述符的 __get__ 方法。

描述符在元类中的应用

描述符在元类中也有重要的应用。元类是用于创建类的类,我们可以在元类中使用描述符来修改类的属性定义。

class DescriptorMeta(type):
    def __new__(mcls, name, bases, attrs):
        for key, value in attrs.items():
            if isinstance(value, Descriptor):
                value.name = key
        return super().__new__(mcls, name, bases, attrs)


class Descriptor:
    def __init__(self):
        self.name = None

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        instance.__dict__[self.name] = value


class MyMetaClass(metaclass=DescriptorMeta):
    my_attr = Descriptor()


obj = MyMetaClass()
obj.my_attr = 42
print(obj.my_attr)  

在这个例子中,DescriptorMeta 是一个元类。在 __new__ 方法中,它遍历类的属性,如果属性是 Descriptor 的实例,它会设置描述符的 name 属性为属性名。这样在描述符的 __get____set__ 方法中就可以正确使用属性名来访问实例的 __dict__

描述符的继承

描述符类本身也可以被继承,从而复用一些通用的逻辑。

class BaseDescriptor:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        instance.__dict__[self.name] = value


class RestrictedDescriptor(BaseDescriptor):
    def __init__(self, name, max_value):
        super().__init__(name)
        self.max_value = max_value

    def __set__(self, instance, value):
        if value > self.max_value:
            raise ValueError(f'Value must be less than or equal to {self.max_value}')
        super().__set__(instance, value)


class MyRestrictedClass:
    restricted_attr = RestrictedDescriptor('restricted_attr', 100)


obj = MyRestrictedClass()
obj.restricted_attr = 50
print(obj.restricted_attr)  
try:
    obj.restricted_attr = 150
except ValueError as e:
    print(e)  

在这个例子中,RestrictedDescriptor 继承自 BaseDescriptor。它复用了 BaseDescriptor__get__ 和部分 __set__ 逻辑,并在 __set__ 方法中添加了值的限制逻辑。MyRestrictedClass 使用 RestrictedDescriptor 来定义 restricted_attr 属性,确保该属性的值不超过 100

描述符的性能考虑

虽然描述符提供了强大的功能,但在性能敏感的应用中,需要考虑其性能影响。每次访问属性时,如果涉及描述符,会有一定的额外开销,因为要调用描述符的方法。

例如,与直接访问实例属性相比,使用描述符来访问属性会慢一些。

import timeit


class SimpleClass:
    def __init__(self):
        self.value = 0


class DescriptorClass:
    def __init__(self):
        self._value = 0

    def get_value(self):
        return self._value

    def set_value(self, value):
        self._value = value

    value = property(get_value, set_value)


simple_obj = SimpleClass()
desc_obj = DescriptorClass()

simple_time = timeit.timeit(lambda: simple_obj.value, number = 1000000)
desc_time = timeit.timeit(lambda: desc_obj.value, number = 1000000)

print(f'Simple access time: {simple_time}')
print(f'Descriptor access time: {desc_time}')

在这个性能测试中,我们可以看到直接访问实例属性的 SimpleClass 比使用 property 描述符的 DescriptorClass 访问属性要快。因此,在性能关键的代码部分,如果不需要描述符提供的额外功能,应尽量避免使用描述符。

描述符与动态属性

描述符还可以与动态属性相结合。动态属性是在运行时动态添加或修改的属性。

class DynamicDescriptor:
    def __init__(self):
        self.value = None

    def __get__(self, instance, owner):
        if self.value is None:
            self.value = self.calculate_value()
        return self.value

    def calculate_value(self):
        # 这里可以是复杂的计算逻辑
        return 42


class MyDynamicClass:
    dynamic_attr = DynamicDescriptor()


obj = MyDynamicClass()
print(obj.dynamic_attr)  
print(obj.dynamic_attr)  

在这个例子中,DynamicDescriptor__get__ 方法在属性值未初始化时调用 calculate_value 方法来计算值,并缓存结果。每次访问 obj.dynamic_attr 时,如果值已经计算过,就直接返回缓存的值。这展示了描述符如何与动态属性的按需计算相结合。

描述符在数据库映射中的应用

在数据库映射库(如SQLAlchemy)中,描述符被广泛应用。例如,假设有一个简单的数据库表映射场景。

class Column:
    def __init__(self, name, data_type):
        self.name = name
        self.data_type = data_type

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, self.data_type):
            raise TypeError(f'Expected {self.data_type.__name__}, got {type(value).__name__}')
        instance.__dict__[self.name] = value


class User:
    id = Column('id', int)
    name = Column('name', str)


user = User()
user.id = 1
user.name = 'John'
try:
    user.id = 'one'
except TypeError as e:
    print(e)  

在这个简单的数据库映射示例中,Column 类是一个描述符,用于定义表的列。它进行类型检查,确保属性设置为正确的数据类型。User 类使用 Column 描述符来定义 idname 属性,模拟数据库表字段的映射。

通过以上多个方面对Python类的属性描述符进行深入探讨,我们可以看到描述符在Python编程中是一个非常强大且灵活的工具,无论是用于数据验证、缓存、类型检查还是在更复杂的框架如数据库映射中的应用,都能发挥重要作用。同时,我们也了解了描述符的查找顺序、性能影响以及与其他Python特性如 property 装饰器、元类等的关系,这有助于我们在实际编程中更准确、高效地使用描述符。