外观
Python 中的描述符(descriptor)
Descriptors in Python
在本章中,让我们继续上面的讨论,并谈谈上述做法的一个局限。接下来,我们将看到 Python 中的描述符(descriptor)如何提供一种更加优雅的取值与设值方式。
再来看一下上面这个类的实现:

这里最大的问题在于:我们必须为每一个实例级别的属性都定义一个 getter 和一个 setter。那么假如我们的类有 3 个这样的属性,并且都要求为正数呢?当然,我们就会有 3 个 getter 和 3 个 setter,这会让整体实现变得冗长、混乱且重复。

之所以有冗余,是因为每个 setter 方法里几乎都是相同的几行代码(用于校验的 if 语句)。而且仔细想想,getter 方法也多少有些冗余、没有必要,因为它们仅仅是返回一个属性。如果这一点已经清楚了,那么上述实现还有一个问题。回想一下我前面提到的:"我们的类将有 3 个这样的实例级属性,并且都必须为正数?"
看看当我们用一个无效输入来创建对象时会发生什么:

如上所示,Python 并没有抛出任何错误,而理想情况下它本应该报错。
程序员试图消除冗余的一种常见方式,是定义显式的校验函数。例如,我们可以定义一个专门校验所接收值的函数,如下所示:

接下来,我们可以在需要的地方调用这个方法:

但这也没有解决问题:每个 setter 方法里仍然有显式且重复的函数调用。所有的 getter 方法仍然在做同样的事情,冗余度很高。而最重要的是,init 方法现在被多次函数调用搞得一团糟。
简单来说,描述符(descriptor)是带有方法(如 get、set 等)的对象,用于管理对另一个类属性的访问。因此,每个描述符对象只会被赋给另一个类的某一个属性。需要说明的是,这个"另一个类"才是我们真正关心的类——比如前面看到的 DummyClass。

- 属性 number1 → 拥有它自己的描述符。
- 属性 number2 → 拥有它自己的描述符。
- 属性 number3 → 拥有它自己的描述符。
一个典型的描述符类通常用三个方法来实现,如下所示:

当属性被赋予一个新值时,会调用 set 方法。我们可以在这里定义自定义的校验。当描述符对象被赋给某个类属性时,会调用 set_name 方法;它让描述符能够记录自己被赋给的那个属性在类中的名字。当属性被访问时,会调用 get 方法。此外:

instance 参数指的是目标类的对象——DummyClass()。owner 参数是目标类本身——DummyClass。value 参数是被赋给目标类某个属性的值。name 参数是属性的名字。如果还不够清楚,我来给你做一个简单的演示。
来看下面这个描述符类:

我稍后会解释这个实现,但在此之前,先来看它的用法,演示如下:

现在,让我们回到 DescriptorClass 的实现:
- set_name(self, owner, name):当描述符被赋给某个类属性时(第 3 行)会调用此方法。它把属性名保存在描述符中,以便后续使用。
- set(self, instance, value):当有值被赋给该属性时(第 6 行)会调用此方法。如果值为负数就抛出错误;否则,它会把该值按之前定义的属性名存入实例的字典中。
- get(self, instance, owner):当该属性被访问时会调用此方法。它从实例的字典中返回对应的值。现在看看这个方案是如何巧妙地解决我们前面讨论过的所有问题的。让我们创建一个 DummyClass 的对象:
- 如上所示,给属性赋予一个无效值会抛出错误。接下来,看看初始化时指定的属性是无效值会发生什么:
- 506


很好!它连初始化也一并校验了。这里回想一下,我们从没在 init 方法里定义任何显式校验,这一点非常酷。继续,现在我们在 DummyClass 中定义多个属性:

创建一个对象,并给其中任意一个属性设一个无效值,都会抛出错误:

运行得非常顺畅!507
回想一下,我们从未像之前用 @property 装饰器那样为每个属性单独定义多个 getter 和 setter。这很棒,不是吗?我发现描述符在减少工作量和代码冗余方面非常有用,同时还能让整个实现更加优雅。如果你想动手试试,我为你准备了这个 notebook 帮你入门:https://bit.ly/4cqsyM6。