跳至主要内容

Python 描述符教程

了解什么是 Python 描述符、何时使用它们以及为什么要使用它们。
· 13分钟 阅读

使用 AI 探索

Python 的描述符(Descriptors),更广义地说,描述符为您提供了一种强大的技术,用于编写可在类之间共享的可复用代码。它们看起来可能与继承类似,但从技术上讲并非如此。描述符是一种拦截属性访问的通用方式。属性、静态方法、类方法、super 方法等特性背后用到的正是描述符机制。

描述符在 Python 2.2 版本中被引入,此后常被视为为传统类带来新风格的“魔法”。它们是一些类,允许您在另一个类中对属性进行受控管理。具体来说,它们实现了 __get__()、__set__() 和 __delete__() 接口方法,这让它们在许多情境下都很有用。例如,您在 Python 中见过的类装饰器和属性装饰器。

简单来说,实现了对象的 __get__()、__set()__ 或 __delete()__ 方法的类被称为“描述符”。直接引用 Python 官方文档的说法,描述符是具有绑定行为的对象属性,其属性访问被描述符协议中的方法所重写。这些方法是 __get__()、__set__() 和 __delete__()(来源)。

对于描述符而言,绑定行为指的是绑定对某个变量、对象或数据集的赋值(set)、查询(get)或删除(delete)的方式。这整套交互被绑定到这份数据上,因为它只适用于您为其设置的那份数据,从而仅绑定到该特定数据部分。

描述符还可细分为数据描述符和非数据描述符。如果您编写的描述符只实现了 __get__() 方法,那么它是非数据描述符;而实现了 __set__() 和 __delete__() 的则称为数据描述符。非数据描述符只能读取,而数据描述符既可读取也可写入。

请注意,描述符是赋给类的,而不是类的实例。修改类会覆盖或删除描述符本身,而不是触发其代码(IBM Developer)。

最后,描述符类并不仅限于这三个方法,这意味着它还可以包含除 get、set 和 delete 方法之外的任何其他属性。

让我们参考这篇 IBM Developer 页面,更详细地理解 get、set 和 delete 方法:

  • self 是您创建的描述符实例(Real Python)。
  • object 是附加了您的描述符的对象实例(Real Python)。
  • type 是附加了描述符的对象的类型(Real Python)。
  • value 是赋给描述符属性的值。get(self, object, type) set(self, object, value) delete(self, object)
  • __get__() 用于访问属性或提取某些信息。它返回属性的值,或在请求的属性不存在时抛出 AttributeError 异常。

  • __set__() 在进行属性赋值操作时调用,用于设置属性的值。不返回任何内容,但可以抛出 AttributeError 异常。

  • __delete__() 控制删除操作,即当您想从对象中删除属性时调用。不返回任何内容。

现在让我们通过一些示例来理解描述符的用途!

描述符的用途

先定义一个类 car,它有三个属性:make、model 和 fuel_cap。您将使用 __init__() 方法初始化类的属性。然后使用魔术方法 __str__(),它会简单返回创建对象时传入类的这三个属性的输出。

请注意,__str__() 方法返回对象的字符串表示形式。当对类的对象调用 print() 或 str() 函数时会触发该方法。

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.

如上面的输出所示,一切看起来都很好!

现在把汽车的油箱容量改为负 40。

car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.

等等,这有问题,对吧?汽车的油箱容量不可能为负数。然而,Python 接受了它且没有报错。这是因为 Python 是动态语言,不会显式进行类型检查。

为避免该问题,我们在 __init__() 方法中添加一个 if 条件,检查输入的油箱容量是否合法。如果无效,则抛出 ValueError 异常。

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
      5         self.fuel_cap = fuel_cap
      6         if self.fuel_cap < 0:
----> 7             raise ValueError("Fuel Capacity can never be less than zero")
      8
      9     def __str__(self):


ValueError: Fuel Capacity can never be less than zero

从上面的输出可以看到,目前一切正常,因为当油箱容量小于零时,程序会抛出 ValueError。

不过,还可能有另一个问题:如果输入的油箱容量是浮点数或字符串怎么办?不仅是油箱容量,汽车的 make 和 model 也可能被传入整数。在这些情况下,程序将无法抛出异常。

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

为处理上述情况,您可能会考虑再加一个 if 条件,或使用 isinstance 方法进行类型检查。

这次我们使用内置的 isinstance 方法来处理错误。

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
      2 print(car2)


<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
      7             print(self.make)
      8         else:
----> 9             raise ValueError("Make of the car can never be an integer")
     10
     11         if self.fuel_cap < 0:


ValueError: Make of the car can never be an integer

很好!这个错误也被妥善处理了。

但是,如果您想在之后将油箱容量属性显式改为负 40,会怎样?此时就不起作用了,因为类型检查只会在 __init__() 中执行一次。您知道,__init__() 是构造函数,只在创建对象时调用一次。因此,之后的自定义类型检查会失效。

我们举个例子来理解。

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2.make = -40
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

看到了吗?您成功绕过了类型检查。

再想一想,如果汽车还有很多其他属性,如油耗、价格、配件等,也需要类型检查,而且您还希望其中一些属性只有 读取 权限。这难道不会很烦吗?

为了解决以上所有问题,Python 的描述符就派上用场了!

如上所述,任何为描述符协议对象实现了 __get__()、__set()__ 或 __delete()__ 魔术方法的类,都是描述符。它们还能为属性的行为提供额外控制,例如是否应具备读写权限。

现在让我们通过添加 Python 描述符方法来扩展上面的示例。

class Descriptor:
    def __init__(self):
        self.__fuel_cap = 0
    def __get__(self, instance, owner):    
        return self.__fuel_cap
    def __set__(self, instance, value):
        if isinstance(value, int):
            print(value)
        else:
            raise TypeError("Fuel Capacity can only be an integer")

        if value < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

        self.__fuel_cap = value

    def __delete__(self, instance):
        del self.__fuel_cap
class Car:
    fuel_cap = Descriptor()
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
40
BMW model X7 with a fuel capacity of 40 ltr.

别担心如果 Descriptor 类看起来有点晦涩。让我们把它拆开来看看每个方法都在做什么。

  • Descriptor 类的 __init__() 方法将局部变量 __fuel_cap 置为 0。开头的 dunder(双下划线)表示该变量是私有的。这里使用双下划线只是为了将描述符类的油箱容量属性与 Car 类的区分开来。
  • 正如您现在所知,__get__() 方法用于获取属性,并返回油箱容量变量。它接收三个参数:描述符对象、包含该描述符对象实例的类的实例(即 car2),以及 owner,即该实例所属的类(Car 类)。在这个方法中,您只需返回由 __set__() 方法设置的值属性 fuel_cap。
  • __set__() 方法在为属性设置值时被调用,与 __get__() 不同,它不返回任何内容。除描述符对象本身外,它还有两个参数:与 __get__() 方法相同的实例,以及您要赋给属性的值。在该方法中,您检查要赋给 fuel_cap 的值是否为整数;若不是,则抛出 TypeError 异常。随后还检查该值是否小于 0;若小于 0,则抛出 ValueError 异常。完成检查后,将 fuel_cap 属性更新为该值。
  • 最后是 __delete__() 方法,当从对象中删除属性时调用,与 __set__() 一样不返回任何内容。

Car 类保持与之前相同。但唯一的变化是添加了 Descriptor() 类的实例 fuel_cap。请注意,如前所述,描述符类的实例必须作为类属性添加到类上,而不是作为实例属性。

一旦您在 __init__() 中将局部变量 fuel_cap 赋给实例的 fuel_cap,就会触发 Descriptor 类的 __set__() 方法。

现在把油箱容量改为负值,看看程序是否会抛出 ValueError 异常。

car2 = Car("BMW","X7",-40)
print(car2)
-40



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
      4         self.make = make
      5         self.model = model
----> 6         self.fuel_cap = fuel_cap
      7
      8     def __str__(self):


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero

如果您还记得这里,当您后来把属性值改为负数时,类型检查并未生效,因为它仅在 __init__() 中执行一次。让我们把 fuel_cap 的值更新为字符串,看看是否会报错。

car2.fuel_cap = -1
-1



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------

TypeErrorTraceback (most recent call last)

<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
      8             print(value)
      9         else:
---> 10             raise TypeError("Fuel Capacity can only be an integer")
     11
     12         if value < 0:


TypeError: Fuel Capacity can only be an integer

完美!如您所见,在之后更新油箱容量属性时,检查也能正常工作。

不过,描述符有一个小问题:当您创建一个新实例或第二个实例时,先前实例的值会被覆盖。原因在于描述符绑定在类上,而不是实例上。

我们通过下面的例子来理解。

car3 = Car("BMW","X7",48) #created a new instance 'car3' with different values
48

当您打印实例 car2 时,会发现其值已被 car3 覆盖。

print(car2)
BMW model X7 with a fuel capacity of 48 ltr.

结论

恭喜您完成本教程。

本教程适合已经熟悉 Python 并希望进阶到高级水平的读者。作为一个不错的练习,您或许可以思考如何解决今天教程中提到的实例值被覆盖的问题。

如有与本教程相关的问题,欢迎在下方评论区提问。

如果您想进一步学习 Python,请查看 DataCamp 的完整 Python Programming 技能路径。

也来看看我们的 Python 函数教程。

主题