Python高级技术之:深入理解`descriptor`协议:如何实现`__get__`、`__set__`和`__delete__`方法来创建可重用属性。

各位观众老爷们,大家好! 今天咱们来聊点高级的,但保证不枯燥,那就是Python里的descriptor协议。 别一听“协议”俩字儿就觉得头大,其实它就是一套规则,一套让你的类属性变得更灵活、更强大的规则。 简单来说,descriptor允许你控制一个属性的访问、设置和删除行为。 想象一下,你有一个房子(类),descriptor就像是房屋管理员,他决定谁能进(访问)、谁能装修(设置)、谁能拆房子(删除)。

那么,这个房屋管理员是怎么工作的呢? 这就要靠三个特殊的方法:__get____set____delete__

一、descriptor是个啥?

首先,我们要明确descriptor是什么。 一个类如果定义了__get____set____delete__中的任何一个方法,那么它的实例就可以被用作另一个类的属性,我们就称这个实例为descriptor。 换句话说,它就是一个特殊的属性,可以控制其他类中属性的行为。

二、__get__:读取属性的秘诀

__get__方法负责处理属性的读取操作。 它的签名是这样的:

descriptor.__get__(self, instance, owner) -> value
  • self: descriptor自身的实例。
  • instance: 拥有该descriptor属性的类的实例。 如果是通过类访问descriptor,那么instance将是None
  • owner: 拥有该descriptor属性的类本身。

举个例子,假设我们有一个Temperature类,它使用Celsius类作为descriptor来表示摄氏度:

class Celsius:
    def __init__(self, temperature=0):
        self.temperature = temperature

    def __get__(self, instance, owner):
        print(f"正在读取摄氏度,我是descriptor: {self}")
        return self.temperature

    def __set__(self, instance, value):
        print(f"正在设置摄氏度,我是descriptor: {self}")
        if value < -273.15:
            raise ValueError("温度低于绝对零度!")
        self.temperature = value

class Temperature:
    celsius = Celsius()  # Celsius 实例作为 descriptor

    def __init__(self, fahrenheit=None):
        if fahrenheit is not None:
            self.celsius = self.fahrenheit_to_celsius(fahrenheit) # 会调用Celsius的__set__方法

    def fahrenheit_to_celsius(self, fahrenheit):
        return (fahrenheit - 32) * 5 / 9

# 使用例子
temp = Temperature()
print(temp.celsius) # 会调用Celsius的__get__方法

temp.celsius = 30  # 会调用Celsius的__set__方法
print(temp.celsius)

print(Temperature.celsius) # 通过类访问, instance is None

在这个例子中,当我们访问temp.celsius时,Python会自动调用Celsius实例(也就是celsius descriptor)的__get__方法。 __get__方法返回了self.temperature,也就是摄氏度的值。 当通过类访问Temperature.celsius时,instance参数会是None

三、__set__:设置属性的卫士

__set__方法负责处理属性的设置操作。 它的签名是这样的:

descriptor.__set__(self, instance, value) -> None
  • self: descriptor自身的实例。
  • instance: 拥有该descriptor属性的类的实例。
  • value: 要设置的新值。

继续上面的例子,Celsius类的__set__方法对温度进行了验证,确保它不低于绝对零度。 如果要设置的值无效,它会抛出一个ValueError

四、__delete__:删除属性的刽子手

__delete__方法负责处理属性的删除操作。 它的签名是这样的:

descriptor.__delete__(self, instance) -> None
  • self: descriptor自身的实例。
  • instance: 拥有该descriptor属性的类的实例。

虽然__delete__方法不常用,但它在某些情况下非常有用。 例如,你可以使用它来防止属性被意外删除,或者在属性被删除时执行一些清理操作。

class Celsius:
    # ... (之前的 __init__、__get__ 和 __set__ 方法)

    def __delete__(self, instance):
        print(f"正在删除摄氏度,我是descriptor: {self}")
        del self.temperature # 谨慎使用,这会删除descriptor实例自身的属性
        # 或者可以做一些清理工作,例如:
        # print("正在清理...")

class Temperature:
    celsius = Celsius()  # Celsius 实例作为 descriptor

    def __init__(self, fahrenheit=None):
        if fahrenheit is not None:
            self.celsius = self.fahrenheit_to_celsius(fahrenheit)

    def fahrenheit_to_celsius(self, fahrenheit):
        return (fahrenheit - 32) * 5 / 9

temp = Temperature()
del temp.celsius # 会调用Celsius的__delete__方法
# print(temp.celsius) # 会报错,因为descriptor的temperature属性已经被删除了

在这个例子中,当我们执行del temp.celsius时,Python会自动调用Celsius实例的__delete__方法。

五、descriptor的优先级

当访问一个属性时,Python会按照一定的优先级顺序来查找属性。 了解这个顺序对于理解descriptor的工作方式至关重要。 优先级顺序如下:

  1. Data descriptors: 定义了__get____set__方法的descriptor。
  2. Instance dictionary: 实例的__dict__属性。
  3. Non-data descriptors: 只定义了__get__方法的descriptor。
  4. Class dictionary: 类的__dict__属性。
  5. 父类: 沿着继承链向上查找。

这意味着,如果一个属性同时存在于实例的__dict__和类中(并且类中的属性是一个descriptor),那么descriptor会优先被调用。 如果descriptor同时定义了__get____set__方法,那么它被称为“data descriptor”,它会覆盖实例__dict__中的属性。 如果descriptor只定义了__get__方法,那么它被称为“non-data descriptor”,它会被实例__dict__中的同名属性覆盖。

六、descriptor的实际应用

descriptor在Python中有很多实际应用,例如:

  • 属性验证: 正如我们上面的例子所示,descriptor可以用来验证属性的值,确保它们满足一定的条件。
  • 延迟计算: descriptor可以用来实现延迟计算,只有在属性被访问时才进行计算。
  • 属性代理: descriptor可以用来将属性的访问和设置操作代理给另一个对象。
  • property装饰器: property装饰器实际上就是一种descriptor,它可以将一个方法转换为一个可读写的属性。

让我们来看一些更具体的例子:

1. 属性验证

除了温度,我们还可以用descriptor来验证其他类型的属性。 比如,我们想验证一个字符串属性的长度:

class SizedString:
    def __init__(self, min_length=0, max_length=None):
        self.min_length = min_length
        self.max_length = max_length

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.name] # 从实例的__dict__中取值

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError("必须是字符串")
        if len(value) < self.min_length:
            raise ValueError(f"字符串长度必须大于等于 {self.min_length}")
        if self.max_length is not None and len(value) > self.max_length:
            raise ValueError(f"字符串长度必须小于等于 {self.max_length}")
        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name): # Python 3.6+
        self.name = name

class User:
    name = SizedString(min_length=3, max_length=20)

    def __init__(self, name):
        self.name = name  # 会调用 SizedString 的 __set__ 方法

user = User("Alice")
print(user.name)  # 会调用 SizedString 的 __get__ 方法

try:
    user.name = "Bo"
except ValueError as e:
    print(e)

try:
    user.name = 123
except TypeError as e:
    print(e)

在这个例子中,SizedString descriptor验证了User类的name属性的长度。 注意,这里使用了__set_name__方法(Python 3.6+),这个方法会在descriptor被创建时自动调用,并将拥有该descriptor的类的名称和descriptor的属性名称传递给descriptor。 这使得我们可以避免在__init__方法中手动设置name属性,从而使代码更加简洁。 如果你的Python版本低于3.6, 你需要手动设置self.name

2. 延迟计算

延迟计算是一种优化技术,它可以将计算推迟到真正需要结果的时候再进行。 我们可以使用descriptor来实现延迟计算。

import time

class LazyProperty:
    def __init__(self, func):
        self.func = func
        self.name = None

    def __get__(self, instance, owner):
        if instance is None:
            return self
        print(f"正在计算 {self.name}...")
        value = self.func(instance)
        instance.__dict__[self.name] = value  # 将计算结果缓存到实例的 __dict__ 中
        return value

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

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @LazyProperty
    def area(self):
        print("计算面积...")
        time.sleep(2) # 模拟耗时计算
        return 3.14 * self.radius * self.radius

circle = Circle(5)
print("第一次访问 area:")
print(circle.area)  # 第一次访问,会进行计算
print("第二次访问 area:")
print(circle.area)  # 第二次访问,直接从实例的 __dict__ 中获取缓存的结果

在这个例子中,LazyProperty descriptor将Circle类的area属性的计算推迟到第一次访问时才进行。 计算结果会被缓存到实例的__dict__中,后续的访问会直接从缓存中获取,避免重复计算。

3. 属性代理

属性代理是指将一个对象的属性访问和设置操作委托给另一个对象。 我们可以使用descriptor来实现属性代理。

class ProxyDescriptor:
    def __init__(self, attribute_name):
        self.attribute_name = attribute_name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance.proxied_object, self.attribute_name)

    def __set__(self, instance, value):
        setattr(instance.proxied_object, self.attribute_name, value)

class MyClass:
    proxied_attribute = ProxyDescriptor("attribute")

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

class ProxiedObject:
    def __init__(self, attribute):
        self.attribute = attribute

proxied = ProxiedObject("原始值")
my_object = MyClass(proxied)

print(my_object.proxied_attribute)  # 输出: 原始值
my_object.proxied_attribute = "新值"
print(my_object.proxied_attribute)  # 输出: 新值
print(proxied.attribute) # 输出: 新值

在这个例子中,ProxyDescriptor descriptor将MyClass类的proxied_attribute属性的访问和设置操作代理给proxied_object对象的attribute属性。 这样,我们可以通过MyClass的实例来访问和设置ProxiedObject的属性。

七、property装饰器:descriptor的简化版

Python提供了一个内置的property装饰器,它可以让我们更方便地创建descriptorproperty装饰器实际上就是一种descriptor,它可以将一个方法转换为一个可读写的属性。

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    def get_width(self):
        return self._width

    def set_width(self, width):
        if width <= 0:
            raise ValueError("宽度必须大于0")
        self._width = width

    def get_height(self):
        return self._height

    def set_height(self, height):
        if height <= 0:
            raise ValueError("高度必须大于0")
        self._height = height

    width = property(get_width, set_width)
    height = property(get_height, set_height)

    def area(self):
        return self.width * self.height

rect = Rectangle(10, 5)
print(rect.width)  # 调用 get_width()
rect.width = 20  # 调用 set_width()
print(rect.area())

在这个例子中,我们使用property装饰器将get_widthset_width方法转换为width属性。 这样,我们可以像访问普通属性一样访问width,而Python会自动调用get_widthset_width方法。

更简洁的写法是使用@property装饰器:

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, width):
        if width <= 0:
            raise ValueError("宽度必须大于0")
        self._width = width

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, height):
        if height <= 0:
            raise ValueError("高度必须大于0")
        self._height = height

    def area(self):
        return self.width * self.height

这种写法更加简洁易懂,也更符合Python的风格。

八、descriptor 的一些注意事项

  • __set_name__: 如果你的Python版本是3.6或更高版本,建议使用__set_name__方法来设置descriptor的名称,而不是在__init__方法中手动设置。
  • 避免循环引用: 在使用descriptor时,要注意避免循环引用,否则可能会导致内存泄漏。
  • 性能: descriptor的访问和设置操作会比普通属性慢一些,因为它们需要调用__get____set____delete__方法。 因此,在对性能要求较高的场景下,要谨慎使用descriptor
  • 存储位置: descriptor本身的状态通常存储在拥有它的类的实例的__dict__中。 需要注意的是,descriptor实例本身也可能拥有自己的状态,比如Celsius例子中的self.temperature

九、总结

descriptor是Python中一个非常强大的特性,它可以让我们控制属性的访问、设置和删除行为。 掌握descriptor可以让你编写更加灵活、可重用和可维护的代码。 虽然descriptor的概念可能有点抽象,但通过实际的例子和练习,你可以逐渐掌握它,并将其应用到你的项目中。

希望今天的讲座对你有所帮助! 咱们下回再见! 记住,编程的乐趣在于不断学习和探索!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注