本章介绍了一个在 Python 开发中更高级的新概念,因为它具有描述符。此外,描述符不是其他语言的程序员所熟悉的,因此不存在简单的类比或类比。
描述符是 Python 的另一个显著特性,它将面向对象编程提升到了另一个层次,它们的潜力允许用户构建更强大和可重用的抽象。大多数时候,在库或框架中可以观察到描述符的全部潜力。
在本章中,我们将实现与描述符相关的以下目标:
- 了解什么是描述符,它们是如何工作的,以及如何有效地实施它们
- 分析两种描述符(数据描述符和非数据描述符)的概念差异和实现细节
- 通过描述符有效地重用代码
- 分析描述符的良好使用示例,以及如何在我们的 API 库中利用它们
首先,我们将探索描述符背后的主要思想,以了解其机制和内部工作原理。一旦清楚了这一点,就可以更容易地理解不同类型的描述符是如何工作的,我们将在下一节中对此进行探讨。
一旦我们对描述符背后的思想有了一个大致的了解,我们将看一个例子,在这个例子中,描述符的使用为我们提供了一个更干净、更具 python 风格的实现。
描述符的工作方式并没有那么复杂,但它们的问题是需要考虑很多警告,因此实现细节在这里至关重要。
要实现描述符,我们至少需要两个类。对于这个通用示例,client
类将利用我们希望在descriptor
中实现的功能(这通常只是一个域模型类,是我们为解决方案创建的常规抽象),而descriptor
类将实现描述符本身的逻辑。
因此,描述符只是一个对象,它是实现描述符协议的类的实例。这意味着该类的接口必须至少包含以下神奇方法之一(Python 3.6+的描述符协议的一部分):
__get__
__set__
__delete__
__set_name__
在本初始高级介绍中,将使用以下命名约定:
| 名称 | 意思 | | `ClientClass` | 域级抽象,它将利用描述符要实现的功能。此类被称为描述符的客户机。该类包含一个 class 属性(按此约定命名为`descriptor`,它是`DescriptorClass`的实例。 | | `DescriptorClass` | 实现`descriptor`本身的类。这个类应该实现前面提到的一些包含描述符协议的神奇方法。 | | `client` | `ClientClass`的一个实例。`client = ClientClass()`。 | | `descriptor` | `DescriptorClass`的一个实例。`descriptor = DescriptorClass()`。此对象是放置在`ClientClass`中的类属性。 |表 6.1:本章中使用的描述符命名约定
此关系如图 6.1所示:
图 6.1:ClientClass 和 DescriptorClass 之间的关系
需要记住的一个非常重要的观察结果是,为了使该协议有效,descriptor
对象必须定义为class
属性。将此对象创建为实例属性将不起作用,因此它必须在类的主体中,而不是在__init__
方法中。
始终将descriptor
对象作为类属性放置!
更重要的是,读者还可以注意到,部分实现描述符协议是可能的,并非所有方法都必须定义;相反,我们只能实现我们所需要的,我们很快就会看到。
所以,现在我们有了适当的结构,我们知道设置了哪些元素以及它们是如何相互作用的。我们需要一个用于descriptor
的类,另一个类将使用descriptor
的逻辑,而descriptor
将有一个descriptor
对象(一个DescriptorClass
的实例)作为类属性,当我们调用名为descriptor
的属性时,ClientClass
的实例将遵循描述符协议。但是现在呢?所有这些在运行时是如何适应的?
通常,当我们有一个常规类并访问它的属性时,我们只需获得我们期望的对象,甚至它们的属性,如下例所示:
>>> class Attribute:
... value = 42
...
>>> class Client:
... attribute = Attribute()
...
>>> Client().attribute
<__main__.Attribute object at 0x...>
>>> Client().attribute.value
42
但是,在描述符的情况下,发生了一些不同的事情。当一个对象被定义为一个class
属性(而这个是一个descriptor
)时,当client
请求这个属性时,我们得到的是调用__get__
魔术方法的结果,而不是获取对象本身(正如我们在前面的示例中所期望的那样)。
让我们从一些只记录上下文信息的简单代码开始,并返回相同的client
对象:
class DescriptorClass:
def __get__(self, instance, owner):
if instance is None:
return self
logger.info(
"Call: %s.__get__(%r, %r)",
self.__class__.__name__,
instance,
owner
)
return instance
class ClientClass:
descriptor = DescriptorClass()
当运行此代码,并请求一个ClientClass
实例的descriptor
属性时,我们会发现我们实际上并没有得到一个DescriptorClass
实例,而是得到了它的__get__()
方法返回的结果:
>>> client = ClientClass()
>>> client.descriptor
INFO:Call: DescriptorClass.__get__(<ClientClass object at 0x...>, <class 'ClientClass'>)
<ClientClass object at 0x...>
>>> client.descriptor is client
INFO:Call: DescriptorClass.__get__(ClientClass object at 0x...>, <class 'ClientClass'>)
True
请注意,如何调用放置在__get__
方法下的记录行,而不仅仅是返回我们创建的对象。在本例中,我们让该方法返回client
本身,从而对上一条语句进行真正的比较。此方法的参数将在下面的小节中进行更详细的解释,因此暂时不用担心这些参数。本例的关键是要理解,当其中一个属性是描述符时,属性的查找行为不同(在本例中,因为它有一个__get__
方法)。
从这个简单但演示性的例子开始,我们可以开始创建更复杂的抽象和更好的装饰器,因为这里重要的一点是我们有一个新的(强大的)工具可以使用。请注意,这是如何以完全不同的方式更改程序的控制流的。有了这个工具,我们可以抽象出__get__
方法背后的各种逻辑,并使descriptor
透明地运行各种转换,而客户甚至都不会注意到。这将封装提升到一个新的水平。
到目前为止,我们已经看到了相当多的描述符在起作用的例子,并且我们知道了它们是如何工作的。这些例子让我们第一次看到了描述符的威力,但您可能想知道一些实现细节和习惯用法,我们没有解释这些细节和习惯用法。
由于描述符只是对象,这些方法将self
作为第一个参数。对他们来说,这只是指descriptor
对象本身。
在本节中,我们将详细探讨描述符协议的每种方法,解释每个参数的含义以及它们的用途。
此魔术法的签名如下:
__get__(self, instance, owner)
第一个参数instance
是指调用descriptor
的对象。在我们的第一个例子中,这意味着client
对象。
owner
参数是对该对象类的引用,根据我们的示例(来自图 6.1),该类将是ClientClass
。
从上一段可以得出结论,__get__
签名中名为instance
的参数是描述符正在对其进行操作的对象,owner
是instance
的类。精明的读者可能想知道为什么签名是这样定义的。毕竟,课程可以直接从instance
(owner = instance.__class__
中获取。当从类(ClientClass
调用descriptor
而不是从实例(client
调用descriptor
时,存在一种边缘情况,instance
的值为None
,但在这种情况下,我们可能仍然需要进行一些处理。这就是 Python 选择将类作为不同参数传递的原因。
通过以下简单的代码,我们可以演示从class
调用的descriptor
与从instance
调用的descriptor
之间的区别。在这种情况下,__get__
方法为每种情况分别做两件事:
# descriptors_methods_1.py
class DescriptorClass:
def __get__(self, instance, owner):
if instance is None:
return f"{self.__class__.__name__}.{owner.__name__}"
return f"value for {instance}"
class ClientClass:
descriptor = DescriptorClass()
当我们从ClientClass
直接调用它时,它会做一件事,那就是用类的名称组成一个名称空间:
>>> ClientClass.descriptor
'DescriptorClass.ClientClass'
然后,如果我们从已创建的对象调用它,它将返回另一条消息:
>>> ClientClass().descriptor
'value for <descriptors_methods_1.ClientClass object at 0x...>'
一般来说,除非我们真的需要使用owner
参数,否则最常见的习惯用法是在instance
为None
时只返回描述符本身。这是因为当用户从类中调用描述符时,他们可能希望得到描述符本身,所以这是有意义的。当然,这取决于示例(在本章后面,我们将看到不同的用法及其解释)。
本方法签字如下:
__set__(self, instance, value)
当我们尝试将某个内容分配给descriptor
时,会调用此方法。它通过以下语句激活,descriptor
是实现__set__ ()
的对象。在本例中,instance
参数将是client
,而value
将是"value"
字符串:
client.descriptor = "value"
您可以注意到,此行为与前面章节中的@property.setter
装饰器有一些相似之处,其中 setter 函数的参数是语句的右侧值(在本例中为字符串"value"
)。我们将在本章稍后部分重新讨论这一点。
如果client.descriptor
没有实现__set__()
,那么"value"
(语句右侧的任何对象)将完全覆盖描述符。
为descriptor
属性赋值时要小心。确保它实现了__set__
方法,并且我们没有造成不希望的副作用。
默认情况下,此方法最常用的用途只是将数据存储在对象中。然而,到目前为止,我们已经看到描述符是多么强大,我们可以利用它们,例如,如果我们要创建可以多次应用的通用验证对象(同样,如果我们不抽象,我们可能会在属性的 setter 方法中重复多次)。
下面的列表说明了我们如何利用此方法为属性创建通用validation
对象,可以使用函数动态创建这些对象,以便在将值分配给对象之前对其进行验证:
class Validation:
def __init__(
self, validation_function: Callable[[Any], bool], error_msg: str
) -> None:
self.validation_function = validation_function
self.error_msg = error_msg
def __call__(self, value):
if not self.validation_function(value):
raise ValueError(f"{value!r} {self.error_msg}")
class Field:
def __init__(self, *validations):
self._name = None
self.validations = validations
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self._name]
def validate(self, value):
for validation in self.validations:
validation(value)
def __set__(self, instance, value):
self.validate(value)
instance.__dict__[self._name] = value
class ClientClass:
descriptor = Field(
Validation(lambda x: isinstance(x, (int, float)), "is not a
number"),
Validation(lambda x: x >= 0, "is not >= 0"),
)
我们可以在下面的列表中看到这个对象的作用:
>>> client = ClientClass()
>>> client.descriptor = 42
>>> client.descriptor
42
>>> client.descriptor = -42
Traceback (most recent call last):
...
ValueError: -42 is not >= 0
>>> client.descriptor = "invalid value"
...
ValueError: 'invalid value' is not a number
我们的想法是,我们通常放置在属性中的东西可以抽象为descriptor
,并被多次重用。在这种情况下,__set__()
方法将做@property.setter
应该做的事情。
这是一种比使用属性更通用的机制,因为我们将在后面看到,属性是描述符的一种特殊情况。
delete
方法的签名比较简单,如下图:
__delete__(self, instance)
通过以下语句调用此方法,在本例中,self
将是descriptor
属性,instance
将是client
对象:
>>> del client.descriptor
在下面的示例中,我们使用此方法创建一个descriptor
,目的是防止您在没有所需管理权限的情况下从对象中删除属性。注意,在本例中,descriptor
具有用于使用它的对象的值进行谓词的逻辑,而不是不同的相关对象:
# descriptors_methods_3.py
class ProtectedAttribute:
def __init__(self, requires_role=None) -> None:
self.permission_required = requires_role
self._name = None
def __set_name__(self, owner, name):
self._name = name
def __set__(self, user, value):
if value is None:
raise ValueError(f"{self._name} can't be set to None")
user.__dict__[self._name] = value
def __delete__(self, user):
if self.permission_required in user.permissions:
user.__dict__[self._name] = None
else:
raise ValueError(
f"User {user!s} doesn't have {self.permission_required} "
"permission"
)
class User:
"""Only users with "admin" privileges can remove their email address."""
email = ProtectedAttribute(requires_role="admin")
def __init__(self, username: str, email: str, permission_list: list = None) -> None:
self.username = username
self.email = email
self.permissions = permission_list or []
def __str__(self):
return self.username
在看到这个对象如何工作的示例之前,重要的是要说明这个描述符的一些标准。注意,User
类需要username
和email
作为强制参数。根据它的__init__
方法,如果它没有email
属性,它就不能是用户。如果我们删除该属性并将其从对象中完全提取,我们将创建一个不一致的对象,其中包含一些无效的中间状态,与类User
定义的接口不对应。为了避免出现问题,像这样的细节非常重要。其他一些对象希望使用此User
,并且它也希望它具有email
属性。
因此,决定将电子邮件的“删除”设置为None
,这是代码列表中粗体部分。出于同样的原因,我们必须禁止任何人尝试为其设置None
值,因为这将绕过我们在__delete__
方法中设置的机制。
在这里,我们可以看到它的作用,假设只有具有“admin
权限的用户才能删除其电子邮件地址:
>>> admin = User("root", "[email protected]", ["admin"])
>>> user = User("user", "[email protected]", ["email", "helpdesk"])
>>> admin.email
'[email protected]'
>>> del admin.email
>>> admin.email is None
True
>>> user.email
'[email protected]'
>>> user.email = None
...
ValueError: email can't be set to None
>>> del user.email
...
ValueError: User user doesn't have admin permission
在这里,在这个简单的descriptor
中,我们可以看到,我们只能从包含“admin
权限”的用户处删除电子邮件。至于其余部分,当我们尝试调用该属性的del
时,我们将得到一个ValueError
异常。
一般来说,descriptor
的这种方法不像前两种方法那个样常用,但为了完整起见,这里展示了它。
这是 Python 3.6 中添加的一个相对较新的方法,其结构如下:
__set_name__(self, owner, name)
当我们在将要使用它的类中创建descriptor
对象时,我们通常需要descriptor
知道它将要处理的属性的名称。
这个属性名是我们在__get__
和__set__
方法中分别用来读取和写入__dict__
的属性名。
在 Python3.6 之前,descriptor
不能自动使用这个名称,所以最常用的方法是在初始化对象时显式地传递它。这很好,但它有一个问题,那就是每次我们想要为新属性使用descriptor
时,都需要复制名称。
如果我们没有这种方法,典型的descriptor
就是这样的:
class DescriptorWithName:
def __init__(self, name):
self.name = name
def __get__(self, instance, value):
if instance is None:
return self
logger.info("getting %r attribute from %r", self.name, instance)
return instance.__dict__[self.name]
def __set__(self, instance, value):
instance.__dict__[self.name] = value
class ClientClass:
descriptor = DescriptorWithName("descriptor")
我们可以看到descriptor
是如何使用这个值的:
>>> client = ClientClass()
>>> client.descriptor = "value"
>>> client.descriptor
INFO:getting 'descriptor' attribute from <ClientClass object at 0x...>
'value'
现在,如果我们想避免两次写入属性的名称(一次用于类内指定的变量,另一次作为descriptor
的第一个参数的名称),我们必须求助于一些技巧,比如使用类装饰器,或者(更糟糕的是)使用元类。
在 Python3.6 中,添加了新方法__set_name__
,它接收创建描述符的类,以及为descriptor
指定的名称。最常见的习惯用法是对descriptor
使用此方法,以便它可以在此方法中存储所需的名称。
为了兼容性,通常最好在__init__
方法中保留一个默认值,但仍然利用__set_name__
。
使用此方法,我们可以将前面的descriptor
重写如下:
class DescriptorWithName:
def __init__(self, name=None):
self.name = name
def __set_name__(self, owner, name):
self.name = name
...
__set_name__
对于获取描述符所分配属性的名称非常有用,但是如果我们想要覆盖该值,__init__
方法仍然优先,因此我们保留了灵活性。
尽管我们可以随意命名描述符,但我们通常使用描述符的名称(属性名称)作为客户机对象的__dict__
键,这意味着它将被解释为属性。因此,请尝试命名用作有效 Python 标识符的描述符。
如果要为描述符设置自定义名称,请使用有效的 Python 标识符。
根据我们刚刚探索的方法,我们可以根据描述符的工作方式对它们进行重要区分。理解这一区别对于有效使用描述符起着重要作用,也有助于避免运行时出现警告或常见错误。
如果描述符实现了__set__
或__delete__
方法,则称为数据描述符。否则,单独实现__get__
的描述符是非数据描述符。请注意,__set_name__
根本不影响此分类。
当试图解析对象的属性时,数据描述符将始终优先于对象的字典,而非数据描述符则不会。这意味着在非数据描述符中,如果对象的字典上有一个与描述符同名的键,那么该键将始终被调用,描述符本身将永远不会运行。
相反,在数据描述符中,即使字典中有一个与描述符同名的键,也永远不会使用这个键,因为描述符本身总是会被调用。
以下两个部分将通过示例对此进行更详细的解释,以更深入地了解每种类型的描述符的预期效果。
我们将从一个descriptor
开始,该只实现__get__
方法,并且看看它是如何使用的:
class NonDataDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self
return 42
class ClientClass:
descriptor = NonDataDescriptor()
通常,如果我们询问descriptor
,我们会得到其__get__
方法的结果:
>>> client = ClientClass()
>>> client.descriptor
42
但是,如果我们将descriptor
属性更改为其他属性,我们将无法访问该值,而是获取分配给它的内容:
>>> client.descriptor = 43
>>> client.descriptor
43
现在,如果我们删除descriptor
并再次请求,让我们看看我们得到了什么:
>>> del client.descriptor
>>> client.descriptor
42
让我们回顾一下刚才发生的事情。当我们第一次创建client
对象时,descriptor
属性在类中,而不是在实例中,因此如果我们请求client
对象的字典,它将是空的:
>>> vars(client)
{}
然后,当我们请求.descriptor
属性时,它在名为"descriptor"
的client.__dict__
中找不到任何键,所以它会转到类,在那里它会找到它。。。但仅作为描述符,因此它返回__get__
方法的结果。
但随后,我们将.descriptor
属性的值更改为其他值,这样做的目的是将值99
设置到instance
的字典中,这意味着这次它不会为空:
>>> client.descriptor = 99
>>> vars(client)
{'descriptor': 99}
因此,当我们在这里请求.descriptor
属性时,它会在对象中查找它(这次它会找到它,因为在对象的__dict__
属性中有一个名为descriptor
的键,正如vars
结果所示),并返回它,而不必在类中查找它。因此,descriptor
协议从未被调用,下次我们请求此属性时,它将返回我们已用(99
覆盖的值。
之后,我们通过调用del
来删除该属性,这样做的目的是从对象的字典中删除名为"descriptor"
的密钥,让我们回到第一个场景,在第一个场景中,它将默认为触发描述符协议的类:
>>> del client.descriptor
>>> vars(client)
{}
>>> client.descriptor
42
这意味着,如果我们将descriptor
的属性设置为其他属性,我们可能会意外地破坏它。为什么?因为descriptor
不处理删除操作(其中一些不需要)。
这被称为非数据描述符,因为它没有实现的__set__
魔术方法,我们将在下一个示例中看到。
现在,让我们看看使用数据描述符的差异。为此,我们将创建另一个实现__set__
方法的简单descriptor
:
class DataDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self
return 42
def __set__(self, instance, value):
logger.debug("setting %s.descriptor to %s", instance, value)
instance.__dict__["descriptor"] = value
class ClientClass:
descriptor = DataDescriptor()
让我们看看descriptor
的值返回了什么:
>>> client = ClientClass()
>>> client.descriptor
42
现在,让我们尝试将此值更改为其他值,并查看它返回的结果:
>>> client.descriptor = 99
>>> client.descriptor
42
descriptor
返回的值没有改变。但当我们为其指定不同的值时,必须将其设置为对象的字典(与以前一样):
>>> vars(client)
{'descriptor': 99}
>>> client.__dict__["descriptor"]
99
因此,调用了__set__()
方法,它确实为对象的字典设置了值,只是这次,当我们请求这个属性时,descriptor
优先(因为它是一个覆盖描述符),而不是使用字典的__dict__
属性。
还有一件事,删除属性将不再有效:
>>> del client.descriptor
Traceback (most recent call last):
...
AttributeError: __delete__
原因如下,考虑到现在descriptor
总是优先,对对象调用del
不会试图从其字典(__dict__
中删除属性,而是尝试调用descriptor
的__delete__()
方法(本例中未实现,因此属性错误)。
这就是数据描述符和非数据描述符之间的差异。如果描述符实现了__set__()
,那么无论对象的字典中存在什么属性,它都将始终优先。如果未实现此方法,则将首先查找字典,然后运行描述符。
你可能已经注意到一个有趣的观察结果是set
方法上的这一行:
instance.__dict__["descriptor"] = value
关于这条线有很多问题,但让我们把它分成几个部分。
首先,为什么它只是改变一个"descriptor"
属性的名称?这只是这个例子的一个简化,但是,正如它发生的那样,描述符此时不知道它被分配到的属性的名称,所以我们只使用了这个例子中的一个,知道它将是"descriptor"
。这是一种简化,使示例使用更少的代码,但可以通过使用我们在上一节中研究的__set_name__
方法轻松解决。
在一个真实的例子中,您可以做两件事中的一件,要么接收名称作为参数并将其存储在init
方法的内部,这样这个方法将只使用内部属性,或者更好地使用__set_name__
方法。
为什么直接访问实例的__dict__
属性?另一个好问题,至少有两种解释。首先,你可能会想,为什么不做下面的事情呢?
setattr(instance, "descriptor", value)
请记住,当我们尝试将某个内容分配给descriptor
属性时,会调用此方法(__set__
。所以,使用setattr()
会再次调用descriptor
,反过来,它会再次调用,依此类推。这将以无限递归结束。
不要直接在__set__
方法内的描述符上使用setattr()
或赋值表达式,因为这将触发无限递归。
那么,描述符为什么不能保留其所有对象的属性值呢?
client
类已经有对描述符的引用。如果我们将描述符中的引用添加回client
对象,我们将创建循环依赖项,这些对象将永远不会被垃圾收集。由于它们相互指向,它们的引用计数将永远不会低于删除阈值,这将导致程序内存泄漏。
使用描述符(或一般对象)时,请注意潜在的内存泄漏。确保不创建循环依赖项。
这里一个可能的替代方法是使用弱引用和weakref
模块,如果我们想这样做的话,创建一个弱引用密钥字典。本章后面将解释这个实现,但是对于本书中的实现,我们更喜欢使用这个习惯用法(而不是weakref
),因为它在编写描述符时非常常见并被接受。
到目前为止,我们已经研究了不同类型的描述符,它们是什么,以及它们是如何工作的,我们甚至对如何利用它们发挥我们的优势有了初步的想法。下一节将强调最后一点:我们将看到描述符的作用。从现在开始,我们将采用更实际的方法,并了解如何使用描述符来实现更好的代码。在那之后,我们甚至会探索好的描述符的例子。
现在我们已经看到了什么是描述符,它们是如何工作的,以及它们背后的主要思想是什么,我们可以看到它们在起作用。在本节中,我们将探讨一些可以通过描述符优雅地处理的情况。
在这里,我们将看一些使用描述符的示例,我们还将介绍它们的实现注意事项(创建它们的不同方式,以及它们的优缺点),最后,我们将讨论最适合描述符的场景。
我们将从一个简单的示例开始,该示例有效,但会导致一些代码重复。稍后,我们将设计一种将重复逻辑抽象为描述符的方法,这将解决重复问题,并且我们将观察到客户机类上的代码将大幅减少。
我们现在要解决的问题是,我们有一个具有某些属性的常规类,但我们希望跟踪特定属性随时间而具有的所有不同值,例如,在list
中。想到的第一个解决方案是使用一个属性,每次在该属性的 setter 方法中更改该属性的值时,我们都会将其添加到一个内部列表中,该列表将根据需要保留该跟踪。
假设我们的类在我们的应用程序中代表了一个拥有当前城市的旅行者,我们希望在程序运行期间跟踪用户访问过的所有城市。以下代码是满足这些要求的可能实现:
class Traveler:
def __init__(self, name, current_city):
self.name = name
self._current_city = current_city
self._cities_visited = [current_city]
@property
def current_city(self):
return self._current_city
@current_city.setter
def current_city(self, new_city):
if new_city != self._current_city:
self._cities_visited.append(new_city)
self._current_city = new_city
@property
def cities_visited(self):
return self._cities_visited
我们可以轻松检查此代码是否符合我们的要求:
>>> alice = Traveler("Alice", "Barcelona")
>>> alice.current_city = "Paris"
>>> alice.current_city = "Brussels"
>>> alice.current_city = "Amsterdam"
>>> alice.cities_visited
['Barcelona', 'Paris', 'Brussels', 'Amsterdam']
到目前为止,这是我们所需要的,没有其他需要实施的。就这个问题而言,财产就足够了。如果我们在应用程序的多个位置需要完全相同的逻辑,会发生什么?这意味着这实际上是一个更一般的问题的实例,该问题跟踪另一个属性中某个属性的所有值。如果我们想在其他属性上做同样的事情,比如记录爱丽丝买的所有票,或者她去过的所有国家,会发生什么?我们必须在所有这些地方重复这一逻辑。
此外,如果我们在不同的类中需要相同的行为,会发生什么?我们必须重复代码或提出一个通用的解决方案(可能是一个装饰器、一个属性生成器或一个描述符)。由于房地产开发商是一个特殊的(更复杂的)描述符案例,他们超出了本书的范围,相反,描述符被建议作为一种更干净的处理方式。
作为这个问题的另一个解决方案,我们可以使用第 2 章、Python 代码中介绍的__setattr__
魔术方法。在上一章中,我们讨论了类装饰器作为使用__getattr__
的替代方案时,已经看到了此类解决方案。这些解决方案的考虑因素是类似的:我们需要创建一个实现此泛型方法的新基类,然后定义一些类属性来通知需要跟踪的属性,最后在方法中实现此逻辑。这个类将是一个 mixin,可以添加到类的层次结构中,但它也有前面讨论过的相同问题(与概念上不正确的层次结构的更强耦合和潜在问题)。
正如我们在上一章中所看到的,我们分析了这些差异,并且我们看到了类装饰器如何比在基类中使用这种神奇的方法更好;在这里,我还假设描述符将提供一个更干净的解决方案,因此将避免使用神奇的方法,我们将在下一节探讨如何使用描述符解决这个问题。也就是说,我们非常欢迎读者实现使用__setattr__
进行比较和类似分析的解决方案。
现在我们来看看如何通过使用一个足够通用的描述符来解决上一节中的问题,该描述符可以应用于任何类。同样,这个例子并不是真正需要的,因为需求并没有指定这样的通用行为(我们甚至没有遵循之前创建抽象的类似模式的三个实例的规则),但它的目的是描述实际的描述符。
不要实现描述符,除非有我们试图解决的重复的实际证据,并且复杂性被证明已经得到了回报。
现在,我们将创建一个通用描述符,为属性指定一个名称以保存另一个属性的跟踪,该描述符将在列表中存储属性的不同值。
正如我们前面提到的,代码超出了我们解决问题所需的范围,但其目的只是展示描述符在这种情况下如何帮助我们。鉴于描述符的一般性质,读者会注意到其上的逻辑(方法和属性的名称)与当前的领域问题(旅行者对象)无关。这是因为描述符的思想是能够在任何类型的类中使用它,可能在不同的项目中使用,并且具有相同的结果。
为了解决这一差距,对代码的某些部分进行了注释,并在以下代码中描述了每个部分的各自解释(它的作用以及它与原始问题的关系):
class HistoryTracedAttribute:
def __init__(self, trace_attribute_name: str) -> None:
self.trace_attribute_name = trace_attribute_name # [1]
self._name = None
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self._name]
def __set__(self, instance, value):
self._track_change_in_value_for_instance(instance, value)
instance.__dict__[self._name] = value
def _track_change_in_value_for_instance(self, instance, value):
self._set_default(instance) # [2]
if self._needs_to_track_change(instance, value):
instance.__dict__[self.trace_attribute_name].append(value)
def _needs_to_track_change(self, instance, value) -> bool:
try:
current_value = instance.__dict__[self._name]
except KeyError: # [3]
return True
return value != current_value # [4]
def _set_default(self, instance):
instance.__dict__.setdefault(self.trace_attribute_name, []) # [6]
class Traveler:
current_city = HistoryTracedAttribute("cities_visited") # [1]
def __init__(self, name: str, current_city: str) -> None:
self.name = name
self.current_city = current_city # [5]
描述符背后的想法是,它将创建一个新属性,负责跟踪其他属性发生的更改。出于本解释的目的,我们可以分别称它们为 tracer 和 tracked 属性。
一些注释和对代码的注释如下(列表中的数字对应于上一个列表中的数字注释):
- 属性的名称是分配给
descriptor
的变量之一,在本例中为current_city
(跟踪属性)。我们将变量名传递给descriptor
,它将在其中存储descriptor
变量的跟踪。在本例中,我们告诉对象跟踪名为cities_visited
(跟踪器)的属性中current_city
的所有值。 - 第一次调用描述符时,在
__init__
中,用于跟踪值的属性将不存在,在这种情况下,我们将其初始化为空列表,以便稍后向其追加值。 - 在
__ init__
方法中,属性current_city
的名称也将不存在,因此我们也希望跟踪此更改。这相当于使用上一个示例中的第一个值初始化列表。 - 仅当新值与当前设置的值不同时,轨迹才会更改。
- 在
__init__
方法中,descriptor
已经存在,此赋值指令触发步骤 2(创建空列表开始跟踪值)和步骤 3(将值追加到此list
中,并将其设置为对象中的键以便稍后检索)的动作。 - 字典中的
setdefault
方法用于避免使用KeyError
。在这种情况下,对于那些仍然不可用的属性,将返回一个空列表(请参见https://docs.python.org/3/library/stdtypes.html#dict.setdefault 供参考)。
descriptor
中的代码确实相当复杂。另一方面,client
类中的代码要简单得多。当然,只有当我们多次使用这个descriptor
时,这种平衡才会得到回报,这是我们已经讨论过的问题。
此时可能不太清楚的是,描述符确实完全独立于client
类。其中没有任何关于业务逻辑的建议。这使得它完全适用于任何其他类别;即使它做了完全不同的事情,描述符也会有相同的效果。
这才是描述词真正的 python 性质。它们更适合于定义库、框架和内部 API,但不适合于业务逻辑。
现在我们已经看到了一些最初实现的描述符,我们可以看看编写描述符的不同方法。到目前为止,示例使用了单一的形式,但正如本章前面所预期的,我们可以用不同的方式实现描述符,我们将看到。
我们必须首先理解一个共同的问题,这是特定于描述符性质的,然后再考虑实现它们的方法。首先,我们将讨论全局共享状态的问题,然后,我们将继续讨论描述符的不同实现方式,同时考虑到这一点。
正如我们已经提到的,描述符需要设置为类属性才能工作。这在大多数情况下都不应该是个问题,但它确实带来了一些需要考虑的警告。
类属性的问题是它们在该类的所有实例中共享。描述符在这里也不例外,所以如果我们试图将数据保存在descriptor
对象中,请记住,所有描述符都可以访问相同的值。
让我们看看当我们错误地定义一个descriptor
来保存数据本身而不是将其存储在每个对象中时会发生什么:
class SharedDataDescriptor:
def __init__(self, initial_value):
self.value = initial_value
def __get__(self, instance, owner):
if instance is None:
return self
return self.value
def __set__(self, instance, value):
self.value = value
class ClientClass:
descriptor = SharedDataDescriptor("first value")
在本例中,descriptor
对象存储数据本身。这带来了不便,当我们修改instance
的值时,相同类的所有其他实例也会使用此值进行修改。下面的代码清单将该理论付诸实施:
>>> client1 = ClientClass()
>>> client1.descriptor
'first value'
>>> client2 = ClientClass()
>>> client2.descriptor
'first value'
>>> client2.descriptor = "value for client 2"
>>> client2.descriptor
'value for client 2'
>>> client1.descriptor
'value for client 2'
注意我们是如何改变一个对象的,突然之间它们都来自同一个类,我们可以看到这个值被反映出来。这是因为ClientClass.descriptor
是独一无二的;对他们来说都是一样的。
在某些情况下,这可能是我们实际想要的(例如,如果我们要创建一种 Borg 模式实现,我们希望在其上跨类中的所有对象共享状态),但通常情况并非如此,我们需要区分对象。这种模式在第 9 章、通用设计模式中有更详细的讨论。
为了实现这一点,描述符需要知道每个instance
的值,并相应地返回它。这就是为什么我们一直在使用每个instance
的字典(__dict__
,并从中设置和检索值。
这是最常见的方法。我们已经讨论了为什么我们不能在这些方法上使用getattr()
和setattr()
,因此修改__dict__
属性是最后一个固定选项,在这种情况下是可以接受的。
在本书中,我们实现描述符的方法是使descriptor
对象将值存储在对象__dict__
的字典中,并从中检索参数。
始终存储并返回实例的__dict__
属性中的数据。
到目前为止,我们看到的所有示例都使用这种方法,但在下一节中,我们将介绍一些替代方法。
另一种替代方法(如果我们不想使用__dict__
的话)是让descriptor
对象在内部映射中跟踪每个实例本身的值,并从该映射返回值。
不过有一个警告。此映射不能只是任何字典。由于client
类有一个对描述符的引用,现在描述符将保留对使用它的对象的引用,这将创建循环依赖关系,因此,这些对象将永远不会被垃圾收集,因为它们彼此指向。
为了解决这个问题,字典必须是弱键字典,如weakref (WEAKREF 01)
模块中所定义。
在这种情况下,descriptor
的代码可能如下所示:
from weakref import WeakKeyDictionary
class DescriptorClass:
def __init__(self, initial_value):
self.value = initial_value
self.mapping = WeakKeyDictionary()
def __get__(self, instance, owner):
if instance is None:
return self
return self.mapping.get(instance, self.value)
def __set__(self, instance, value):
self.mapping[instance] = value
这解决了这些问题,但也带来了一些考虑:
- 对象不再保存其属性,而是由描述符保存。这有点争议,从概念的角度来看,可能并不完全准确。如果我们忘记了这个细节,我们可能会通过检查对象的字典来要求对象查找不存在的内容(例如,调用
vars(client)
不会返回完整的数据,)。 - 它提出了要求对象必须是可散列的。如果不是,它们就不能成为映射的一部分。这可能对某些应用程序的要求太高(或者可能迫使我们实现定制的
__hash__
和__eq__
魔术方法)。
出于这些原因,我们更喜欢本书中迄今为止展示的实现,它使用每个实例的字典。然而,为了完整性,我们也展示了这个替代方案。
在这里,我们将讨论关于描述符的一般考虑因素,即我们可以使用它们做什么,何时使用它们是一个好主意,以及如何通过描述符改进我们最初认为通过另一种方法解决的问题。然后,我们将分析原始实现与使用描述符后实现的优缺点。
描述符是一种通用工具和一种功能强大的抽象,我们可以使用它来避免代码重复。
描述符可能有用的一个好场景是,如果我们发现自己处于一种需要编写属性的情况下(如在一个用@property @<property>.setter
或@<property>.deleter
修饰的方法中),但我们需要多次执行相同的属性逻辑。也就是说,如果我们需要一个泛型属性之类的东西,或者我们会发现自己使用相同的逻辑和重复的样板编写了多个属性。属性只是描述符的一种特殊情况(@property
装饰器是一种实现完整描述符协议的描述符,用于定义其get
、set
和delete
动作),这意味着我们甚至可以使用描述符来完成更复杂的任务。
我们在重用代码方面看到的另一种强大的类型是 decorators,如第 5 章中所述,使用 decorator 改进代码。描述符可以帮助我们创建更好的装饰器,确保它们也能够正确地用于类方法。
当涉及到装饰器时,我们可以说总是在装饰器上实现__get__()
方法是安全的,并且将其作为描述符。当试图决定装饰器是否值得创造时,考虑我们在《To1 T1》第 5 章中提到的三个问题规则,即使用装饰器来改进我们的代码 Ty4 T4,但是注意到对于描述符没有额外的考虑。
至于泛型描述符,除了上述适用于装饰器(以及,一般而言,任何可重用组件)的三个实例规则之外,建议还记住,在需要定义内部 API 的情况下,应该使用描述符,这是一些会让客户机使用它的代码。这是一个面向设计库和框架的特性,而不是一次性解决方案。
除非有很好的理由这样做,或者代码看起来会更好,否则我们应该避免将业务逻辑放在描述符中。相反,描述符的代码将包含更多的实现代码,而不是业务代码。它更类似于定义一个新的数据结构或对象,我们业务逻辑的另一部分将使用它作为工具。
一般来说,描述符将包含实现逻辑,而不是太多的业务逻辑。
如果我们回想一下我们在第 5 章中使用的类装饰器,使用装饰器来改进代码,以确定事件对象将如何序列化,我们最终得到了一个实现,该实现(对于 Python 3.7+)依赖于两个类装饰器:
@Serialization(
username=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
@dataclass
class LoginEvent:
username: str
password: str
ip: str
timestamp: datetime
第一个从注释中获取属性来声明变量,而第二个定义如何处理每个文件。让我们看看是否可以将这两个装饰符改为描述符。
其想法是创建一个描述符,该描述符将对每个属性的值应用转换,并根据我们的要求返回修改后的版本(例如,隐藏敏感信息,并正确格式化日期):
from dataclasses import dataclass
from datetime import datetime
from functools import partial
from typing import Callable
class BaseFieldTransformation:
def __init__(self, transformation: Callable[[], str]) -> None:
self._name = None
self.transformation = transformation
def __get__(self, instance, owner):
if instance is None:
return self
raw_value = instance.__dict__[self._name]
return self.transformation(raw_value)
def __set_name__(self, owner, name):
self._name = name
def __set__(self, instance, value):
instance.__dict__[self._name] = value
ShowOriginal = partial(BaseFieldTransformation, transformation=lambda x: x)
HideField = partial(
BaseFieldTransformation, transformation=lambda x: "**redacted**"
)
FormatTime = partial(
BaseFieldTransformation,
transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M"),
)
这个descriptor
很有趣。它是用一个接受一个参数并返回一个值的函数创建的。此函数将是我们希望应用于字段的转换。根据基本定义,它一般定义了如何工作,descriptor
类的其余部分都是通过更改每个类所需的特定函数来定义的。
示例使用functools.partial
(https://docs.python.org/3/library/functools.html#functools.partial )作为模拟子类的一种方式,通过对该类应用部分转换函数,留下一个可以直接实例化的新可调用函数。
为了使示例保持简单,我们将实现__init__()
和serialize()
方法,尽管它们也可以抽象出来。根据这些考虑,事件的类现在将定义如下:
@dataclass
class LoginEvent:
username: str = ShowOriginal()
password: str = HideField()
ip: str = ShowOriginal()
timestamp: datetime = FormatTime()
def serialize(self) -> dict:
return {
"username": self.username,
"password": self.password,
"ip": self.ip,
"timestamp": self.timestamp,
}
我们可以看到对象在运行时的行为:
>>> le = LoginEvent("john", "secret password", "1.1.1.1", datetime.utcnow())
>>> vars(le)
{'username': 'john', 'password': 'secret password', 'ip': '1.1.1.1', 'timestamp': ...}
>>> le.serialize()
{'username': 'john', 'password': '**redacted**', 'ip': '1.1.1.1', 'timestamp': '...'}
>>> le.password
'**redacted**'
与以前使用装饰器的实现相比,存在一些差异。本例添加了serialize()
方法,并在将字段显示到其结果字典之前隐藏了字段,但如果我们在任何时候从内存中的事件实例中请求这些属性中的任何一个,它仍然会给我们原始值,而不会对其应用任何转换(我们可以选择在设置值时应用转换,并直接在__get__()
上返回)。
根据应用程序的敏感性,这可能是可接受的,也可能是不可接受的,但在这种情况下,当我们询问对象的public
属性时,描述符将在显示结果之前应用转换。仍然可以通过请求对象的字典(通过访问__dict__
来访问原始值),但是当我们请求值时,默认情况下,它将返回转换后的值。
在本例中,所有的描述符都遵循一个通用逻辑,该逻辑在基类中定义。描述符应该将值存储在对象中,然后应用它定义的转换请求它。我们可以创建类的层次结构,每个类定义自己的转换函数,以模板方法设计模式的方式工作。在这种情况下,由于派生类中的更改相对较小(只有一个函数),因此我们选择将派生类创建为基类的部分应用程序。创建任何新的转换字段都应该像定义一个新类一样简单,该类将作为基类,该基类将部分应用于我们需要的函数。这甚至可以临时完成,因此可能不需要为其设置名称。
不管这种实现如何,关键是因为描述符是对象,所以我们可以创建模型,并将面向对象编程的所有规则应用于它们。设计模式也适用于描述符。我们可以定义层次结构,设置自定义行为,等等。本例遵循开/关原则(OCP),我们在第 4 章、实体原则中介绍了这一原则,因为添加一种新类型的转换方法只会创建一个新类,该类从具有所需函数的基础类派生而来,不必修改基类本身(公平地说,以前使用 decorators 的实现也是 OCP 兼容的,但是每个转换机制都不涉及类)。
让我们举一个例子,我们创建了一个实现__init__()
和serialize()
方法的基类,这样我们就可以简单地通过派生LoginEvent
类来定义它,如下所示:
class LoginEvent(BaseEvent):
username = ShowOriginal()
password = HideField()
ip = ShowOriginal()
timestamp = FormatTime()
一旦我们实现了这段代码,类看起来就更干净了。它只定义所需的属性,通过查看每个属性的类可以快速分析其逻辑。基类将只抽象通用方法,每个事件的类将看起来更简单、更紧凑。
不仅每个事件的类看起来更简单,而且描述符本身非常紧凑,比类装饰器简单得多。最初使用类装饰器的实现很好,但是描述符使它变得更好。
到目前为止,我们已经看到了描述符是如何工作的,并探索了一些有趣的情况,在这些情况下,描述符通过简化逻辑和利用更紧凑的类来促进干净的设计。
到目前为止,我们知道通过使用描述符,我们可以实现更干净的代码,抽象掉重复的逻辑和实现细节。但我们如何知道描述符的实现是干净和正确的呢?什么是好的描述符?我们是正确使用这个工具还是过度使用它?
在本节中,我们将分析描述符以回答这些问题。
*什么是好的描述符?*一个简单的答案是,一个好的描述符与任何其他好的 Python 对象非常相似。它与 Python 本身是一致的。遵循这一前提的想法是,分析 Python 如何使用描述符将使我们对良好的实现有一个很好的了解,这样我们就知道我们编写的描述符会带来什么。
我们将看到最常见的场景,Python 本身使用描述符来解决其内部逻辑的一部分,我们还将发现优雅的描述符,这些描述符一直存在于人们的视线中。
作为描述符的对象最能引起共鸣的情况可能是函数。函数实现__get__
方法,因此在类内定义时可以作为方法使用。
在 Python 中,方法只是常规函数,只是它们需要一个额外的参数。按照惯例,方法的第一个参数命名为self
,它表示在其中定义该方法的类的实例。然后,该方法对self
所做的任何操作都将与接收对象并对其应用修改的任何其他函数相同。
换句话说,当我们定义这样的东西时:
class MyClass:
def method(self, ...):
self.x = 1
这实际上与我们定义的相同:
class MyClass: pass
def method(myclass_instance: MyClass, ...):
myclass_instance.x = 1
method(MyClass())
因此,它只是另一个函数,修改对象,只是在类中定义了它,并被称为绑定到对象。
当我们以这种形式称某事时:
instance = MyClass()
instance.method(...)
事实上,Python 正在做与此等效的事情:
instance = MyClass()
MyClass.method(instance, ...)
注意,这只是 Python 内部处理的语法转换。其工作方式是通过描述符。
由于函数在调用方法之前实现了描述符协议(请参见下面的列表),因此首先调用__get__()
方法(正如我们在本章开头看到的,这是描述符协议的一部分:当被检索的对象实现__set__
时,调用它并返回其结果)。然后在这个__get__
方法中,在内部可调用对象上运行代码之前会发生一些转换:
>>> def function(): pass
...
>>> function.__get__
<method-wrapper '__get__' of function object at 0x...>
在instance.method(...)
语句中,在处理括号内可调用的所有参数之前,"instance.method"
部分被求值。
因为method
是一个定义为类属性的对象,并且它有一个__get__
方法,所以称之为。这样做的目的是将函数转换为方法,这意味着将可调用对象绑定到它要处理的对象的实例。
让我们通过一个示例来了解这一点,这样我们就可以了解 Python 内部可能在做什么。
我们将在一个类中定义一个可调用对象,该类将充当一种我们希望定义为外部调用的函数或方法。Method
类的实例应该是在不同类中使用的函数或方法。这个函数将只打印它的三个参数,即它接收到的instance
(这将是定义它的类上的self
参数),以及另外两个参数。在__call__()
方法中,self
参数并不表示MyClass
的实例,而是表示Method
的实例。名为instance
的参数是指MyClass
类型的对象:
class Method:
def __init__(self, name):
self.name = name
def __call__(self, instance, arg1, arg2):
print(f"{self.name}: {instance} called with {arg1} and {arg2}")
class MyClass:
method = Method("Internal call")
根据这些考虑,并且在创建对象之后,根据前面的定义,以下两个调用应该是等效的:
instance = MyClass()
Method("External call")(instance, "first", "second")
instance.method("first", "second")
但是,只有第一个选项按预期工作,因为第二个选项会出现错误:
Traceback (most recent call last):
File "file", line , in <module>
instance.method("first", "second")
TypeError: __call__() missing 1 required positional argument: 'arg2'
我们看到了与第 5 章中的一位修饰符所面临的错误相同使用修饰符来改进代码。参数被一个左移:instance
代替self
,"first"
代替instance
,"second"
代替arg1
。arg2
没有任何规定。
为了解决这个问题,我们需要制作一个描述符。
这样,当我们首先调用instance.method
时,我们将调用它的__get__()
,在此基础上,我们将此可调用对象相应地绑定到对象(绕过对象作为第一个参数),然后继续:
from types import MethodType
class Method:
def __init__(self, name):
self.name = name
def __call__(self, instance, arg1, arg2):
print(f"{self.name}: {instance} called with {arg1} and {arg2}")
def __get__(self, instance, owner):
if instance is None:
return self
return MethodType(self, instance)
现在,两个调用都按预期工作:
External call: <MyClass object at 0x...> called with first and second
Internal call: <MyClass object at 0x...> called with first and second
我们所做的是使用types
模块中的MethodType
将function
(实际上是我们定义的可调用对象)转换为一个方法。该类的第一个参数应该是可调用的(self
,在本例中,根据定义是一个参数,因为它实现了__call__
,第二个参数是绑定该函数的对象。
类似于这一点的是函数对象在 Python 中的用途,因此当它们在类中定义时,它们可以作为方法使用。在这个例子中,MyClass
抽象试图模拟一个函数对象,因为在实际的解释器中,这是用 C 实现的,所以很难进行实验,但是通过这个例子,我们可以了解 Python 在调用对象的方法时在内部做什么。
因为这是一个非常优雅的解决方案,所以在定义我们自己的对象时,将其作为一种 python 方法牢记在心是值得探索的。例如,如果我们要定义自己的可调用项,那么最好将其作为描述符,以便在类中也可以将其用作类属性。
从查看官方文档(PYDESCR-02),您可能已经知道,所有的@property
、@classmethod
和@staticmethod
装饰符都是描述符。
我们已经多次提到,当直接从类调用描述符时,该习惯用法使描述符返回自身。由于属性实际上是描述符,这就是为什么当我们从类中询问它时,我们没有得到计算属性的结果,而是得到整个property
对象的原因:
>>> class MyClass:
... @property
... def prop(self): pass
...
>>> MyClass.prop
<property object at 0x...>
对于类方法,描述符中的__get__
函数将确保类是传递给被修饰函数的第一个参数,而不管它是直接从类调用还是从实例调用。对于静态方法,它将确保除函数定义的参数外,没有其他参数被绑定,即撤消__get__()
对使self
成为该函数第一个参数的函数所做的绑定。
让我们举一个例子;我们创建了一个@classproperty
装饰器,它的工作原理与常规@property
装饰器类似,但它是针对类的。对于这样的装饰器,以下代码应该能够解决我们的用例:
class TableEvent:
schema = "public"
table = "user"
@classproperty
def topic(cls):
prefix = read_prefix_from_config()
return f"{prefix}{cls.schema}.{cls.table}"
>>> TableEvent.topic
'public.user'
>>> TableEvent().topic 'public.user'
制作这件作品的代码简洁明了:
class classproperty:
def __init__(self, fget):
self.fget = fget
def __get__(self, instance, owner):
return self.fget(owner)
正如我们在前一章中所看到的,初始化方法采用了在使用 decorator 语法时要修饰的函数。这里有趣的一点是,我们利用__get__
魔术方法在调用该函数时,将该类作为参数来调用该函数。
您可以理解,当从类调用时,此示例与__get__
方法的常规样板有何不同:在这些情况下,大多数情况下,我们询问instance
是否为None
,以及是否返回self
,但不在这里。在这种情况下,我们实际上希望实例是None
(因为它是从类而不是对象调用的),所以我们确实需要 owner 参数(即所作用的类)。
__slots__
是一个类属性,用于定义该类的对象可以具有的一组固定字段。
从迄今为止给出的例子中,读者可能已经注意到在 Python 中,对象的内部表示是通过字典完成的。这就是为什么一个对象的属性在其__dict__
属性中存储为字符串。这就是为什么我们可以动态地向对象添加新属性或删除当前属性的原因。没有为对象声明属性的"frozen"
定义。我们还可以动态地注入方法(在前面的示例中我们已经这样做了)。
所有这些都随__slots__
类属性而改变。在该属性中,我们将类中允许的属性名称定义为字符串。从那一刻起,我们将无法动态地向这个类的实例添加任何新属性。尝试向定义了__slots__
的类动态添加额外属性将导致AttributeError
。通过定义此属性,该类将变为静态类,因此它将不会具有可动态添加更多对象的__dict__
属性。
那么,如果不是从对象的字典中,如何检索其属性呢?通过使用描述符。插槽中定义的每个名称都有自己的描述符,用于存储值以供以后检索:
from dataclasses import dataclass
@dataclass
class Coordinate2D:
__slots__ = ("lat", "long")
lat: float
long: float
def __repr__(self):
return f"{self.__class__.__name__}({self.lat}, {self.long})"
通过使用__slots__
,Python 将只为创建新对象时在其上定义的属性保留足够的内存。这将使对象没有__dict__
属性,因此无法动态更改,任何尝试使用其字典(例如,使用function vars(...)
的行为)都将导致TypeError
。
因为没有__dict__
属性来存储实例变量的值,Python 所做的是为每个插槽创建一个描述符并将值存储在那里。这有一个副作用,即我们不能将类属性与实例属性混合(例如,如果我们的一个常见习惯用法是使用类属性作为实例属性的默认值,那么使用这种方法我们将无法这样做,因为值将被覆盖)。
虽然这是一个有趣的特性,但必须谨慎使用,因为它带走了 Python 的动态特性。一般来说,这应该只保留给我们知道是静态的对象,如果我们绝对确定我们没有在代码的其他部分动态地向它们添加任何属性。
这样做的好处是,使用插槽定义的对象使用更少的内存,因为它们只需要一组固定的字段来保存值,而不需要整个字典。
我们现在了解 Python 如何在函数中使用描述符,使它们在类中定义时作为方法工作。我们还看到了一些例子,通过使用接口的__get__()
方法使 decorator 适应它所调用的对象,使 decorator 符合描述符协议,从而使 decorator 工作。这为我们的装饰器解决了问题,就像 Python 解决函数作为对象中的方法一样。
以这种方式调整装饰器的一般方法是在装饰器上实现__get__()
方法,并使用types.MethodType
将可调用(装饰器本身)转换为绑定到它正在接收的对象的方法(由__get__
接收的instance
参数)。
为了让它工作,我们必须将装饰器作为一个对象来实现,因为否则,如果我们正在使用一个函数,它将已经有一个__get__()
方法,它将做一些不同的事情,除非我们调整它,否则将无法工作。更简洁的方法是为 decorator 定义一个类。
定义要应用于类方法的装饰器时,请使用装饰器类,并在其上实现__get__()
方法。
在总结我们对描述符的分析时,我想分享一些关于干净代码和良好实践或经验建议的想法。
当我们回顾第 4 章中的接口分离原则实体原则(实体中的“I”)时,我们说保持接口小是一种良好的做法,因此,我们可能希望将它们分为更小的接口。
这个想法再次出现在这里,不是在抽象基类的接口的意义上,而是作为描述符本身将呈现的接口。
如前所述,描述符协议包含四种方法,但允许部分实现。这意味着您不需要一直实现所有这些功能。事实上,如果您只实现所需的最少方法,那就更好了。
大多数情况下,您会发现您可以通过实现__get__
方法来满足您的需求。
执行的方法不要超过必要的数量。描述符协议的实现方法越少越好。
此外,您会发现很少需要__delete__
方法。
有了这个概念,我并不是说我们可以仅仅通过使用描述符来改进面向对象的设计能力(我们已经讨论过了)。但由于描述符只是规则对象,因此面向对象设计的规则也适用于它们。例如,我们可以拥有描述符的基类,利用继承来创建更具体的描述符,等等。
请记住,良好做法的所有规则和建议也适用。例如,如果你有一个只实现了__get__
方法的描述符的基类,那么创建一个同时实现__set__
方法的描述符的子类不是一个好主意,因为不符合 Liskov 的替换原则(因为我们会有一个更具体的类型来实现一个父级不提供的增强接口)。
在大多数情况下,在描述符上应用类型注释可能会非常复杂。
循环依赖关系可能会出现问题(这意味着包含描述符定义的 Python 文件必须从使用者的文件中读取才能获得类型,但是客户端需要读取包含描述符对象定义的文件才能使用它)。即使通过使用字符串而不是实际类型来克服这些问题,也存在另一个问题。
如果您知道注释描述符方法的确切类型,这意味着描述符可能只对一种类型的类有用。这通常与描述符的用途背道而驰:本书的建议是在我们知道可以从泛化中获益的场景中使用描述符,并重用大量代码。如果我们不重用代码,那么拥有描述符的复杂性是不值得的。
出于这个原因,尽管总是在定义中添加注释通常是一种好的做法,但对于描述符来说,不添加注释可能更简单。相反,可以将其视为编写有用的 docstring 的好机会,这些 docstring 可以准确地记录描述符的行为。
描述符是 Python 中更高级的特性,它将边界推向元编程。他们最有趣的一个方面是,他们如何清楚地表明 Python 中的类只是常规对象,因此,它们具有我们可以交互的属性。从这个意义上讲,描述符是类可以拥有的最有趣的属性类型,因为它们的协议有助于实现更高级的面向对象的可能性。
我们已经看到了描述符的机制,它们的方法,以及所有这些是如何结合在一起的,这使得面向对象的软件设计更加有趣。通过理解描述符,我们能够创建功能强大的抽象,从而生成干净紧凑的类。我们已经了解了如何修复要应用于函数和方法的装饰符,并且我们已经了解了更多关于 Python 如何在内部工作,以及描述符如何在语言实现中发挥如此核心和关键的作用。
这项关于如何在 Python 内部使用描述符的研究应该作为参考,以确定描述符在我们自己的代码中的良好用途,从而实现惯用的解决方案。
尽管描述符代表了对我们有利的所有强大选项,但我们必须记住何时正确使用它们而不过度工程化。在这一行中,我们建议为真正的通用案例保留描述符的功能,例如内部开发 API、库或框架的设计。沿着这些思路的另一个重要考虑是,一般来说,我们不应该将业务逻辑放在描述符中,而是应该将实现技术功能的逻辑放在其他包含业务逻辑的组件中。
谈到高级功能,下一章还将介绍一个有趣而深入的主题:生成器。从表面上看,生成器相当简单(大多数读者可能已经熟悉了它们),但它们与描述符的共同点是它们也可能很复杂,产生更高级和优雅的设计,并使 Python 成为一种独特的语言。
以下列出了一些您可以参考的内容,以获取更多信息:
- Python 关于描述符的官方文档:https://docs.python.org/3/reference/datamodel.html#implementing-描述符
- WEAKREF 01:Python 的 WEAKREF 模块(https://docs.python.org/3/library/weakref.html
- PYDESCR-02:内置装饰器作为描述符(https://docs.python.org/3/howto/descriptor.html#static-方法和分类方法