Python-描述符教程-全-
Python 描述符教程(全)
一、什么是描述符?
简而言之,描述符是一个类,可用于调用具有简单属性访问的方法,但显然不止于此。如果不深入研究描述符是如何实现的,就很难解释这一点。这是描述符协议的高级视图。
描述符至少实现了以下三种方法之一:__get__()
、__set__()
或__delete__()
。这些方法中的每一个都有一个需要的参数列表,这将在稍后讨论,并且每一个都由描述符所表示的属性的不同类型的访问来调用。做简单的a.x
访问会调用x
的__get__()
方法;使用a.x = value
设置属性将调用x
的__set__()
方法;并且使用del a.x
将调用x
的__delete__()
方法。
注意
从 3.6 版本开始,描述符可以利用另一种方法,叫做__set_name__()
,但是仅仅使用这种方法并不能像其他三种方法一样使它成为描述符。这种方法在一段时间内会被忽略,因为它对描述符的工作方式没有太大的影响。它只会在最相关的地方被提及。
如上所述,为了被认为是描述符,只需要实现其中一个方法,但是可以实现任意数量的方法。此外,根据描述符类型和实现的方法,不实现某些方法会限制某些类型的属性访问或为它们提供有趣的替代行为。根据实现这些方法的集合,有两种类型的描述符:数据和非数据。
数据描述符与非数据描述符
一个数据描述符至少实现了__set__()
或__delete__()
,但也可以同时包含两者。数据描述符通常还包括__get__()
,因为很少想要设置某个东西而又不能获取它。你可以得到值,即使描述符不包含__get__()
,但是要么是迂回的,要么是描述符把它写到实例中。这将在后面详细讨论。
非数据描述符只有实现__get__()
。如果它添加了一个__set__()
或__delete__()
方法,它就变成了一个数据描述符。
不幸的是,PyPy 解释器(直到版本 2.4.0)在这一点上有点错误。直到它知道__delete__()
是一个数据描述符,它才考虑它,PyPy 不相信某个东西是数据描述符,除非__set__()
被实现。幸运的是,由于绝大多数数据描述符都实现了__set__()
,这很少成为问题。
这种区别似乎毫无意义,但事实并非如此。它在属性查找时发挥作用。这将在后面详细讨论,但基本上,区别在于它提供的使用类型。
Python 对描述符的使用
值得注意的是,描述符是 Python 工作方式的固有部分。众所周知,Python 是一种多范式语言,因此支持函数式编程、命令式编程和面向对象编程等范式。本书并不试图深入探讨不同的范式;将只观察面向对象的编程范例。描述符在 Python 中被隐式地用于该语言的面向对象机制。正如将要解释的,方法是使用描述符实现的。正如你可能从阅读这篇文章中猜到的,正是由于描述符,在 Python 中面向对象编程成为可能。描述符非常强大和先进,这本书旨在教 Python 程序员如何充分使用它们。
摘要
正如您所看到的,描述符占据了 Python 语言的很大一部分,因为它们可以用方法调用代替属性访问,甚至可以限制允许哪些类型的属性访问。现在,您对描述符是如何实现的以及语言对它们的使用有了一个大致的了解,我们将会更深入地挖掘,更好地理解它们是如何工作的。
二、描述符协议
为了更好地理解描述符有什么用处,让我们展示完整的描述符协议。是时候看看协议方法的完整签名和参数是什么了。
get(self,instance,owner)方法
这个方法显然是检索描述符想要维护的任何数据或对象的方法。显然,self
是一个参数,因为它是一个方法。此外,它还接收instance
和/或owner
。我们从owner
开始。
是访问描述符的类,或者是访问描述符的实例的类。当你调用A.x
( 是一个类),并且x
是一个带有__get__()
的描述符对象时,它被带有instance
设置为None
的owner
调用。所以查找被有效地转换成了A.__dict__['x'].__get__(None, A)
。这让描述符知道__get__()
是从一个类调用的,而不是从一个实例。owner
也经常被写成默认值为None
,但这在很大程度上是一种只有内置描述符才能利用的优化。
现在,讨论最后一个参数。instance
是描述符被访问的实例,如果是从实例访问的话。如前所述,如果None
被传入instance
,描述符知道它是从类级别被调用的。但是,如果instance
是而不是 None
,那么它会告诉描述符它是从哪个实例被调用的。因此一个a.x
呼叫将被有效地转换为type(a).__dict__['x'].__get__(a, type(a))
。请注意,它仍然接收实例的类。还要注意,调用仍然以type(a)
开始,而不仅仅是a
,因为描述符存储在类中。为了能够应用每个实例以及每个类的功能,给出了描述符instance
和 owner
(实例的类)。这种翻译和应用是如何发生的将在后面讨论。
记住——这也适用于__set__()
和__delete__()
——self
是描述符本身的一个实例。它不是从中调用描述符的实例;instance
参数是描述符被调用的实例。这可能一开始听起来令人困惑,但是如果你现在不明白,不要担心——一切都将被进一步解释。
__get__()
方法是唯一一个麻烦单独得到类的方法。这是因为它是非数据描述符的唯一方法,而非数据描述符通常是在类级别上创建的。内置装饰器classmethod
是使用描述符和__get__()
方法实现的。在这种情况下,它将单独使用owner
参数。
set(self,instance,value)方法
如前所述,__set__()
没有接受类的owner
参数。__set__()
不需要它,因为数据描述符通常是为存储每个实例的数据而设计的。即使数据存储在每个类的级别上,它也应该存储在内部,而不需要引用该类。
self
现在应该不言自明了;下一个参数是instance
。这和在__get__()
方法中是一样的。不过,在这种情况下,您的初始呼叫是a.x = someValue
,然后被翻译成type(a).__dict__['x'].__set__(a, someValue)
。
最后一个参数是value
,它是属性被赋值的值。
需要注意的一点是:当设置一个当前是类级描述符的属性时,它会用正在设置的描述符替换描述符。例如,A.x = someValue
没有被翻译成任何东西;someValue
替换存储在x
中的描述符对象。要操作该类,请参见以下注释。
delete(self,instance)方法
在了解了__get__()
和__set__()
方法之后,__delete__()
应该很容易弄清楚。self
和instance
与其他方法中的相同,但该方法在del a.x
被调用时被调用,并被转换为type(a).__dict__['x'].__delete__(a)
。
不要无意中将其命名为__del__()
,因为这不会像预期的那样工作。__del__()
将是描述符实例的析构函数,而不是存储在其中的属性的析构函数。
必须注意的是,__delete__()
并不像__set__()
一样从类的层次上工作。从类级别使用del
将从类的字典中移除描述符,而不是调用描述符的__delete__()
方法。
注意
如果您希望描述符的__set__()
或__delete__()
方法在类级别工作,这意味着描述符必须在类的元类上创建。这样做的时候,所有涉及到owner
的都是指元类,而涉及到instance
的都是指类。毕竟,类只是元类的实例。元描述符一节将对此进行更详细的解释。
摘要
这是描述符协议的总和。对它的工作原理有了一个基本的概念后,现在您将对使用描述符可以做的事情类型有一个高层次的了解。
三、描述符有什么用?
这个世界上没有什么是完美的,Python 的描述符也不例外。描述符允许你做一些非常酷的事情,但是这些酷的事情是有代价的。在这里,我们讨论好的和坏的。
Python 描述符的优点
显然,我们要回顾一下描述符的优点。如果它们不能被认为是好东西,会有一整本书来介绍它们吗?
包装
描述符最有用的一个方面是它们很好地封装了数据。使用描述符,您可以使用属性访问符号(a.x
)以简单的方式访问属性,同时在后台执行更复杂的操作。例如,一个Circle
类可能有半径、直径、周长和面积,就像它们是属性一样可用,但是因为它们都是链接的,所以您只需要存储一个(我们将使用半径作为例子)并基于它计算其他的。但是从外面看,它们都像存储在对象上的属性。
读/写模式的重用
通过使用专门的描述符,您可以重用用于读取和/或写入属性的代码。这些可以用于同一类中的重复属性或者其他类共享的属性类型。下面几节描述了一些可重用模式的例子。
惰性实例化
您可以使用描述符定义一个非常简单的语法来延迟实例化一个属性。在本书的后面将会提供一个很好的惰性属性实现的代码。
在Circle
示例中,非 radius 属性在使其缓存无效后,不需要立即计算它们的值;他们可以等到需要的时候。那是懒惰。
确认
编写许多描述符只是为了确保传入的数据符合类或属性的不变量。这样的描述符通常也可以被设计成方便的装饰器。
再次以Circle
为例:所有这些属性都应该是正数,所以所有的描述符也可以确保被设置的值是正数。
触发动作
描述符可用于在访问属性时触发某些操作。例如,observer 模式可以在每个属性的意义上实现,以便在属性改变时触发对 observer 的调用。
最后一个Circle
例子:所有的“属性”都是基于懒人计算的半径。为了避免每次都必须计算它们,您可以缓存结果。然后,只要其中一个发生变化,就会导致所有其他缓存失效。
为班级水平写作
因为描述符存储在类范围而不是实例范围,所以它允许您在类级别做更健壮的事情。例如,描述符使classmethod
和staticmethod
工作,这将在下一章解释。
Python 描述符的缺点
尽管描述符很棒,但它们也是有代价的,就像编程中的其他事情一样。
包装
等等……封装是专业的。这怎么可能也是骗局呢?问题是你可以在看起来像属性使用的东西后面隐藏难以置信的复杂性。使用 getters 和 setters,用户至少可以看到有一个函数被调用,并且在一个函数调用中可以发生很多事情。但是用户不一定期望看似属性访问的事情也会导致其他事情的发生。大多数情况下,这不是问题,但它会妨碍用户尝试调试任何问题,因为显然代码不会是问题。
可能很难写
当考虑到描述符存储在类级别,但通常用于在实例级别处理属性的事实时,我们很容易陷入混乱。除此之外,在决定如何保存所表示的属性时,无论是在描述符上还是在属性所针对的对象上,都有许多需要考虑的事项和常见的陷阱要处理。描述符工具库就是为此而专门创建的。
附加对象
因为描述符添加了另一个间接/抽象层,它们还在内存中添加了至少一个额外的对象,以及至少一个额外的调用堆栈级别。在大多数情况下,两者都不止一个。这增加了膨胀,使用 getters 和 setters 至少可以部分减轻膨胀。
摘要
描述符是很棒的,它允许各种好的特性,这些特性可以很好地对代码的用户隐藏它们的复杂性,但是你一定要意识到这种能力是有代价的。
四、标准库中的描述符
Python 有三个基本的、众所周知的描述符:property
、classmethod
和staticmethod
。还有第四种,你一直在使用,但不太可能知道它是一个描述符。
在本章显示的所有描述符中,您可能只知道property
是一个描述符。很多人甚至从中学习了描述符的基本知识,但是很多人不知道classmethod
和staticmethod
是描述符。它们感觉像是语言中内置的超级魔法构造,没有人能用纯 Python 复制。一旦有人理解了描述符,他们的基本实现就变得相对明显了。事实上,这三个示例代码都将以简化的纯 Python 代码提供。
最后,将显示所有的方法实际上都是用描述符实现的。普通方法实际上是“神奇地”完成的,因为描述符的创建是隐式的,但它仍然不完全神奇,因为它是使用任何人都可以创建的语言构造完成的。
我发现真正有趣的是,前三个都是函数装饰器,这是 Python 的另一个非常棒的特性,值得写一本书,尽管它们要简单得多。
财产类
这本书不包括如何使用property
类和装饰器的说明;它侧重于理解和创建描述符。使用property
的官方文档可以在 Python 的文档 2 中找到。
在所有的描述符中,property
可能是最通用的。这是因为它本身并不真正做任何事情,而是允许用户通过提供自己的 getters、setters 和 deleters 将他们想要的功能注入其中。
为了更好地理解它是如何工作的,这里有一个简化的纯 Python 实现property
。
class property:
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, instance, owner):
if instance is None:
return self
elif self.fget is None:
raise AttributeError("unreadable attribute")
else:
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
else:
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("can't delete attribute")
else:
self.fdel(instance)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel)
正如您现在所看到的,property
类几乎没有自己真正的功能;它只是委托给它的功能。当没有为委托给某个方法的某个函数提供时,property
认为这是一个禁止的动作,并引发一个带有适当消息的AttributeError
。
关于property
类的一件好事是它很大程度上只接受方法。甚至它的构造函数,可以一次给它三个方法,也可以只用一个方法调用,甚至不用。正因为如此,构造函数和其他方法可以在非常方便的语法中用作装饰器。查看文档 2 以了解更多信息。
这个代码示例中省略了doc
功能,它根据通过__init__()
的doc
参数传入的内容设置自己的__doc__
属性,或者如果没有给定,则使用来自fget
的__doc__
属性。还省略了在 property 上设置其他属性的代码,比如__name__
,以帮助它看起来更像一个简单的属性。他们似乎没有重要到需要担心的程度,因为人们更关注的是主要的功能。
类方法描述符
classmethod
是另一个可以用作装饰器的描述符,但是,与property
不同的是,没有理由不把它用作装饰器。classmethod
是一个有趣的概念,在许多其他语言(如果有的话)中都不存在。Python 的类型系统使用类作为对象,这使得制作classmethod
变得简单而值得。
下面是classmethod
的 Python 代码。
class classmethod:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
return functools.partial(self.func, owner)
仅此而已。classmethod
是非数据描述符,所以只实现__get__()
。这个__get__()
方法完全忽略了instance
参数,因为顾名思义,这个方法与类的实例无关,只处理类本身。真正好的是,它仍然可以从一个实例中被调用,没有任何问题。
但是,为什么__get__()
方法返回一个传入了owner
的functools.partial
对象呢?为了理解这一点,考虑一个标记为classmethod
的函数的参数列表。第一个参数是类参数,通常命名为cls
。这个类参数被填充到对partial
的调用中,这样返回的函数就可以用用户想要显式提供的参数来调用。真正的实现不使用partial
,但工作方式类似。
再次,设置__name__
、__doc__
等的代码。只是为了展示主要功能是如何工作的。
staticmethod 描述符
标有staticmethod
的方法很奇怪,因为它实际上只是一个函数,但是它“附属”于一个类。作为该类的一部分,除了向用户显示它与该类相关联,并给它一个更具体的名称空间之外,不会做任何事情。此外,有趣的是,因为staticmethod
和classmethod
是使用描述符实现的,所以它们被子类继承。
staticmethod
的实现甚至比classmethod
更简单;它只是接受一个函数,然后在调用__get__()
时返回它。
class staticmethod:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
return self.func
常规方法
记住,前面已经说过,正则方法也隐式地使用描述符。事实上,所有的函数都可以作为方法使用。这是因为函数既是可调用的,也是非数据描述符。
下面是一个 Python 实现,大致展示了一个函数的样子。
class function:
def __call__(self, *args, **kwargs):
# do something
def __get__(self, instance, owner=None):
if instance is None:
return self
else:
return functools.partial(self, instance)
这不是一个非常准确的表示;return
的说法有点离谱。当您从实例访问一个方法而不调用它时,返回的对象不是一个partial
对象;它是一种“绑定方法”。一个“绑定方法”是一个已经“绑定”了self
的方法,但是还没有被调用,如果需要的话可以传入其他参数。当从类中调用它时,它只返回函数本身。在 Python 2 中,这是一个“未绑定的方法”,基本上是一样的。当instance
为None
时创建“未绑定”版本的想法稍后会出现,所以请记住。
摘要
在本章中,我们已经看到了最常见的内置描述符。现在我们已经看到了一些例子,让我们通过挖掘数据描述符和非数据描述符之间的真正差异来更近距离、更好地了解它们是如何工作的。
五、属性访问和描述符
前面已经说过,属性访问调用被转换成描述符调用,但是没有说明如何转换。简单的回答是__getattribute__()
、__setattr__()
和__delattr__()
去做。这可能不是一个很好的答案,所以我会深入研究它。这三个方法存在于所有普通对象中,通过 object 类继承(类从 type 元类继承)。正如您所想象的,当检索、设置或删除对象的属性时,会分别调用这些方法,正是这些方法决定是否使用描述符、__dict__
或__slots__
,以及是否在类或实例上返回/设置某些内容。
关于这个决策过程的解释在一点点给出,但是现在我必须解释一些可能会困扰你的事情:为什么 set 和 delete 方法以attr
结尾,而 get 方法以attribute
结尾?
这个问题的部分答案是,实际上是的一个__getattr__()
方法,但是它的用法和其他的不太一样。__getattribute__()
处理所有正常的属性查找逻辑,而__getattribute__()
调用__getattr__()
是最后的努力,如果其他都失败的话。Python 建议,除非在极端情况下,并且只有当你真的知道自己在做什么时,才不要对__getattribute__()
进行修改。有了一些经验,我可以同意这个建议。
我不知道为什么设置和删除没有类似的设置,但我可以理论化。这可能与这样的想法有关,如果通常的方法不起作用,典型的属性查找覆盖是作为故障保险,但是如果有人覆盖一个或两个其他的,有一个很好的机会,它可能是一个完全的替代,或者至少是第一件尝试的事情,而不是备份的事情。另外,事实上,在正常情况下(不使用__slots__
),不是一个命名的元组,等等。),设置总是有效的,删除是非常罕见的。但是你可能想问一个核心开发者,如果你真的那么好奇的话。
最后澄清一下:在本书的开头,我说过属性访问被“转换”成对描述符方法的调用。这听起来像是一个编译时的决定,但实际上不是。Python 是一种动态类型的语言,它不应该在编译时知道一个属性是否存在于一个对象上,以及它是否需要像描述符或普通属性一样被访问,特别是因为这在运行时会改变。它可以根据周围的代码做出某些猜测,但它永远不可能 100%确定。
不,有效地使用属性被转换成对前面提到的方法中的描述符方法的调用,这些方法描述了语言如何决定做什么。这是真正动态的部分。所以让我们继续,看看这个决策过程是什么样子的。
*## 实例访问
简单地查找属性是属性的三种用法中最复杂的,因为有多个地方可以查找属性:在实例上和在类上。另外,如果是类的描述符,那么数据和非数据描述符有两种不同的行为。
有一个优先级顺序,描述了在哪里寻找属性以及如何处理它们。这个优先级是数据描述符和非数据描述符的主要区别。以下是优先事项列表:
-
数据描述符
-
实例属性
-
非数据描述符和类属性
-
__getattr__
(可能与__getattribute__
分开调用)
__getattribute__()
做的第一件事是在类字典中查找属性。如果没有找到,它会按照类(线性顺序的超类)的方法解析顺序(MRO)继续寻找。如果仍然没有找到,它将移动到下一个优先级。如果找到了,则检查它是否是数据描述符。如果不是,它就转移到下一个优先级。如果结果是一个数据描述符,它将调用__get__()
并返回结果,假设它有一个__get__()
方法。如果它没有__get__()
方法,那么它就转移到下一个优先级。
这有很多 if,而这只是确定是否有可行的数据描述符可用的第一要务。幸运的是,下一个优先级更简单。
优先级列表中的下一步是检查实例字典(或者插槽,如果对象正在使用的话)。如果它存在,我们简单地返回它。否则,它会移动到下一个优先级。
在这个优先级中,它再次检查类别字典,如果需要的话,沿着 MRO 列表向下工作。如果什么都没有找到,它将移动到下一个优先级。否则,它会检查找到的对象,看它是否是一个描述符(此时,我们只需要检查它是否是非数据描述符,因为如果我们已经找到了,它肯定不是数据描述符)。如果是,它调用描述符的__get__()
方法并返回结果。否则,它只返回对象。这一次,如果它没有__get__()
,它没有返回描述符对象本身的备份,因为它是一个非数据描述符,保证它有__get__()
。
如果到目前为止所有其他的都失败了,它用__getattr__()
检查任何可能的关于属性访问的定制行为。如果什么都没有,就会引发一个AttributeError
。
有了这个复杂的定义,Python 用户应该感激大量的工作被投入到优化这个访问算法中,以至于它非常快。图 5-1 中的流程图显示了如何访问描述符,蓝色带表示每个优先级。
图 5-1
班级访问
在一般情况下,类的元类是type
,或者元类上没有新的属性,与实例访问相比,类访问可以用一种简化的方式来看待;它甚至没有优先列表。它仍然使用__getattribute__()
,但是它是在它的元类上定义的。它只是在类别字典中搜索,根据需要在 MRO 中前进。如果找到了,它用__get__()
方法检查它是否是一个描述符。如果是,它进行适当的调用并返回结果。否则,它只返回对象。但是,在类级别,它不关心描述符是数据还是非数据;如果描述符有一个__get__()
方法,则使用该方法。
如果什么都没有找到,则引发AttributeError
,如图 5-2 所示。
图 5-2
引发了 AttributeError
不幸的是,如果元类上有个新属性,这种简化是没有用的,因为它们可能会在查找中使用。事实上,类访问看起来几乎和实例访问一模一样(用“元类”代替“类”,用“类”代替“实例”),只有一个很大的区别。它不仅检查当前的实例/类字典,还检查它的 MRO。它仍然将类上的描述符视为描述符,而不是自动返回描述符对象。了解了这一点,图 5-3 显示了完整类访问图,带有所有优先级。
图 5-3
完整的类访问图
设置和删除通话
设置和删除只是有一点不同。如果所需的__set__()
或__delete__()
方法不存在,并且它是一个数据描述符,则会引发一个AttributeError
。另一个区别是,设置和删除永远不会超出实例优先级。如果实例上不存在该属性,设置将添加它,删除将引发一个AttributeError
。
图 5-4 显示了最后一个流程图,描述了设置和删除发生的情况。
图 5-4
设置和删除过程
数据与非数据描述符背后的推理
既然已经解释了数据和非数据描述符的区别,那么应该解释一下为什么会有这两个版本。
首先要看的是语言和标准库中每种类型的内置用例。数据描述符的主要例子是property
。顾名思义,它的目的是为类创建属性(用看起来像简单属性用法的语法替换 getter 和 setter 方法)。这意味着不打算进行类级访问,因为属性表示实例上的字段。
同时,非数据描述符的主要用例是不同用法的修饰方法(classmethod
、staticmethod
,尤其是用于普通方法的隐式描述符)。虽然这些可以从实例中调用(正常的方法应该从实例中调用),但它们并不意味着从实例中设置或删除。方法是在类上分配的。一个函数可以被分配给一个实例属性,但是它不能使它成为一个方法,因为self
在被调用时不会自动作为第一个参数被提供。此外,当通过正常的“神奇的”方式调用“神奇的”dunder 方法(具有两个前导下划线和两个尾随下划线的方法)时,Python 被优化为直接查看类,跳过任何可能已经分配给实例的内容。
摘要
知道属性调用背后发生的事情的全部深度几乎没有用处,甚至知道基本的优先级列表也很少发挥作用,因为一旦你理解了它们是如何被访问的,描述符通常做显而易见的事情。但是,有时优先级列表,甚至可能是完整的深度,将有助于理解为什么描述符不能像希望的那样工作,或者如何设置描述符来完成更复杂的任务。*
六、需要哪些方法?
设计描述符时,必须决定要包含哪些方法。它有时有助于立即决定描述符应该是数据还是非数据描述符,但有时“发现”它是哪种描述符会更好。
__delete__()
很少需要,即使它是一个数据描述符。然而,这并不意味着它不应该被包括在内。如果描述符将被发布到一个开放的域中,那么在数据描述符上添加__delete__()
方法不会有什么坏处,仅仅是为了完整性,以防用户决定对它调用del
。如果不这样做,当有人试图删除它时,就会引发一个AttributeError
。
数据描述符和非数据描述符几乎总是需要用到__get__()
。它对于非数据描述符是必需的,而对于数据描述符不需要__get__()
的典型情况是,如果__set__()
将数据以与描述符相同的名称分配到实例字典中(我称之为“一劳永逸”的描述符)。否则,几乎总是需要它来检索在数据描述符中设置的数据,因此,除非数据被分配给实例以便在没有__get__()
的情况下自动检索,或者数据是只写的,否则__get__()
方法将是必要的。请记住,如果描述符没有__get__()
方法,并且instance
在__dict__
中没有与描述符同名的内容,那么将返回实际的描述符对象本身。
就像__delete__()
,__set__()
只用于数据描述符。与__delete__()
不同,__set__()
并不被视为多余。鉴于__delete__()
在大多数情况下是不使用的,所以__set__()
几乎是创建数据描述符的一个需求(需要__set__()
或__delete__()
)。如果描述符的状态是数据还是非数据,那么__set__()
通常是决定性因素。即使数据应该是只读的,也应该包含__set__()
来引发AttributeError
以加强只读特性。否则,它可能会被视为非数据描述符。
调用 get()时没有实例参数
描述符的__get__()
方法通常是描述符上最复杂的方法,因为有两种不同的方法可以调用它:带或不带instance
参数(尽管“不带”意味着给出的是None
而不是实例)。
当描述符是类级别的描述符(通常是非数据)时,实现__get__()
而不使用instance
是微不足道的,因为这是预期的用途。但是当描述符用于实例级时,如果描述符不是从实例中调用的,就很难弄清楚该做什么。
在这里,我提出几个选择。
引发异常或返回自身
想到的第一件事可能是引发一个异常,因为类级别的访问是无意的,但这应该避免。Python 中一种常见的编程风格叫做【EAFP】,意思是比 p 权限多一份askf的原始性这意味着,仅仅因为某样东西没有按预期使用,并不意味着它的使用应该被禁止。如果使用会损害不变量并导致问题,通过引发异常来禁止它是好的;否则,还有其他更好的选择可以考虑。常规的解决方案是简单地返回self
。如果描述符是从类级别访问的,那么用户很可能意识到它是一个描述符,并希望使用它。这样做可能是不恰当使用的标志,但是 Python 允许自由,并且它的用户也应该在一定程度上允许自由。举例来说,如果从类中访问,property
内置函数将返回self
(property
对象)。据我所见,这是目前最常见的方法。
“未绑定”属性
方法使用的另一种解决方案是返回属性的“未绑定”版本。当从类级别访问一个函数时,函数的__get__()
检测到它没有实例,所以只返回函数本身。在 Python 2 中,它实际上返回了一个“未绑定”的方法,这就是我使用的名称的来源。但是在 Python 3 中,他们把它改成了函数,因为它本来就是函数。
这也适用于不可调用的属性。这有点奇怪,因为它将属性变成了一个必须接收实例才能返回值的 callable。这使得它成为一个特定的属性查找,类似于len()
和iter()
,在这里您只需要传入实例来接收想要的值。
下面是一个以这种方式工作的精简的__get__()
实现。
def __get__(self, instance, owner):
if instance is None:
def unboundattr(inst):
return self.__get__(inst, owner)
return unboundattr
else:
...
当被调用时,内部的unboundattr()
函数将使用__get__()
方法的else
分支结束(假设它们没有传入None
)。使用内部函数有时会令人困惑,每次都键入整个内容有点烦人,所以这里有一个可重用的类实现,它可以被任何描述符使用。
class UnboundAttribute:
def __init__(self, descriptor, owner):
self.descriptor = descriptor
self.owner = owner
def __call__(self, instance):
return self.descriptor.__get__(instance, self.owner)
使用这个类,使用未绑定属性的__get__()
方法可以这样实现:
def __get__(self, instance, owner):
if instance is None:
return UnboundAttribute(self, owner)
else:
...
最初的版本依赖于围绕self
和owner
的闭包,这移除了它的可重用性,而不是通过复制和粘贴。但是该类的构造函数接受这两个变量,并存储在一个新的实例中。如果你打印一个未绑定的属性对象,它说这是一个未绑定的属性,这也是一件好事。(如果您实现了自己的版本,这也是可行的,特别是如果您接受一些方便的元数据,比如被访问的属性的名称。在下一章中会有更多关于如何做的内容。)
这项技术真正有趣(并且有用)的地方在于,未绑定属性可以传递给接收函数的高阶函数,比如map()
。它避免了编写 getter 方法或难看的 lambda。例如,如果有这样一个类:
class Class:
attr = UnbindableDescriptor()
对一列Class
对象的map()
调用如下:
result = map(lambda c: c.attr, aList)
可以替换为:
result = map(Class.attr, aList)
没有传入 lambda 来完成访问Class
实例的属性的工作,而是传入了Class.attr
,它返回属性的“未绑定”版本——一个接收实例的函数,以便在描述符上查找属性。本质上,描述符提供了一个隐式的 getter 方法来引用属性。
对于实现描述符的__get__()
方法来说,这是一种非常有用的技术,但是它有一个主要缺点:返回self
是如此普遍,以至于不这样做是非常意外的。希望这个想法在社区中得到一些支持,并成为新的标准。此外,正如在下一章只读描述符中看到的,可能需要一种方法来访问描述符对象。幸运的是,您需要做的就是从返回的UnboundAttribute
中获取descriptor
属性。
尽管这不是预期的行为,但内置的function
描述符已经做到了这一点,所以对他们来说习惯这一点不会太难。当从类级别访问时,人们期望“未绑定的方法”函数,所以对他们来说,将约定应用于属性应该不是很难。
自从写了这本书的第一版,我就发现标准库中有一个创建未绑定属性的函数,在某些重要的方面比UnboundAttribute
更有用。在operator
模块中,有一个名为attrgetter()
的函数,它接受一个属性的字符串名称并返回一个函数,该函数接受一个实例并(我假设)用属性的名称在实例上调用getattr()
。还支持传入多个属性名;最终结果是实例上所有这些属性的元组。
与基于描述符的未绑定属性相比,这有几个显著的好处(甚至不考虑多属性支持)。首先是对继承的更大支持。如果一个子类用不同的描述符覆盖了描述符,但是超类版本被传递,它实际上将使用超类描述符,这就消除了继承的令人敬畏的动态特性。出于同样的原因,除非你完全确定你正在使用的类没有任何子类,否则你也应该对方法使用attrgetter()
。
基于描述符的未绑定属性可以支持相同级别的继承支持,但是需要做更多的工作。首先,您需要属性的名称,这并不总是很容易得到。同样,这样做的方法在下一章。在那之后,改变是非常简单的。你把__call__()
改成用getattr()
而不是descriptor.__get__()
。这就消除了对descriptor
和owner
属性的需要,尽管您应该保留descriptor
以便有人可以查找描述符,如前所述。遗憾的是,我看不到任何支持多属性的可行方法。
第二个主要好处是它适用于所有类型的属性,而不仅仅是方法或基于描述符的属性。
然而,attrgetter()
也有一些缺点。首先,也可能是最明显的,是缺少代码完成帮助。您正在传递一个属性的字符串名称,这意味着无论您使用什么编辑器都不会帮助您避免属性名称的拼写错误。第二,它失去了一点点上下文。当使用类名时,您包括属性名适用的上下文,而attrgetter()
只包括属性名。
如果你升级到UnboundAttribute
,我仍然完全支持使用它。但是知道什么时候使用attrgetter()
当然是好的。
摘要
我们已经研究了构建通用描述符背后的决策过程,并弄清楚我们需要哪些方法,以及可能使用带有__get__()
的未绑定属性。在下一章中,我们将深入探讨更多必须做出的设计决策,至少在用描述符存储值时。
七、存储属性
既然所有的准备工作都已经完成了,是时候看看描述符中有用的部分了:存储描述符所代表的属性。用描述符存储属性有很多种方法,本章将从最简单的开始,介绍我所知道的每一种方法。
类别级存储
类级存储很容易;它是描述符上的正常存储。例如,下面是创建基本类级变量的描述符:
class ClassAttr:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
self.value = value
这个描述符将一个值保存在自身上作为典型的实例属性,这个值只是在__get__()
方法中返回,不管是否提供了instance
,因为它是一个类级别的属性。也可以通过实例访问该属性,但是从实例对其进行任何更改都会将更改应用到该类的每个实例。不幸的是,由于从类级别访问描述符时没有调用__set__()
,存储描述符的变量将被重新赋值给新值,而不是传递给__set__()
。
关于制作可以使用__set__()
和__delete__()
的类级描述符的更多细节,请查看本章末尾关于元描述符的部分。
然而,描述符不仅仅用于类级别的属性;它们也用于实例级属性。使用描述符存储实例级属性有两种主要策略:
-
在描述符上
-
在实例字典中
对于可重用的描述符,每种策略都有一些障碍需要清除。当将它存储在描述符上时,如何在没有内存泄漏或散列问题的情况下存储它存在障碍。至于在实例字典中存储属性,困难在于试图找出在字典中以什么名称存储属性以避免冲突。
在描述符上存储数据
如前所示,在描述符上保存一个简单的值就是类级别值的存储方式。要在一个地方存储每个实例的值,必须做什么?所需要的是某种将实例映射到其属性值的方法。映射的另一个名字是字典。也许字典会有用。下面是使用字典进行存储的样子。
class Descriptor:
def __init__(self):
self.storage = {}
def __get__(self, instance, owner):
return self.storage[instance]
def __set__(self, instance, value):
self.storage[instance] = value
def __delete__(self, instance):
del self.storage[instance]
__get__()
方法不处理if instance is None
的情况,在所有其他例子中,为了简洁和消除阅读代码时的干扰,它将被忽略。
代码示例中的dict
已经解决了每个实例的第一个存储问题。不幸的是,使用普通的老式dict
有几个缺点。
要解决的第一个缺点是内存泄漏。一个典型的dict
将在对象因未被使用而被垃圾收集后很久才存储用作键的实例。这对于不会使用大量内存的短命程序来说很好,如果实例不会遭受后面提到的第二个缺点,但如果不是这样,我们需要一种方法来处理这个问题。
让我们看看如何解决这个问题。描述符需要一种方法来停止关心不再使用的实例。weakref
模块正好提供了这一点。弱引用允许变量引用一个实例,只要在某个地方有一个对它的正常引用,否则允许它被垃圾收集。它们还允许您指定移除引用后立即运行的行为。
该模块还提供了几个集合,这些集合被设计为在项目被垃圾回收时从自身移除项目。其中,我们想看一个WeakKeyDictionary
。一个WeakKeyDictionary
保存了一个对它的键的弱引用,因此一旦被用作键的实例不再被使用,字典就会清除整个条目。
所以,这又是一个例子,这次使用了WeakKeyDictionary
。
from weakref import WeakKeyDictionary
class Descriptor:
def __init__(self):
self.storage = WeakKeyDictionary()
def __get__(self, instance, owner):
return self.storage[instance]
def __set__(self, instance, value):
self.storage[instance] = value
def __delete__(self, instance):
del self.storage[instance]
前一个例子和这个例子之间的每一个变化都被加粗了,这表明实际上没有太大的区别。唯一的区别是需要导入特殊的字典,并且需要创建一个WeakKeyDictionary
而不是普通的dict
。这是一个非常容易的升级,许多描述符指南到此为止。它在大多数情况下都有效,所以这是一个不错的解决方案。
不幸的是,它仍然有常规dict
的另一个缺点:它不支持不可销毁的类型。
要使用一个对象作为dict
中的键,它必须是可散列的。有一些内置类型不能被散列,即可变集合(list
、set
和dict
),也许还有更多。任何可变的对象(内部的值可以改变)和覆盖__eq__()
来比较内部值的对象必须是不可改变的。如果对象以改变相等性的方式被改变,散列码突然改变,使得它不能作为字典键被查找。因此,通常建议这样的可变对象使用__hash__ = None
将自己标记为不可改变的。覆盖__eq__()
将自动执行此操作;因此,仅当相等不变时,才应覆盖__hash__
。
如果不是 Python 提供了默认的实现__eq__()
和__hash__() (
相等与相同——一个对象等于它自己,而不是其他),大多数对象都不会是可散列的,因此支持使用散列集合的描述符。幸运的是,这意味着默认情况下类型是可散列的,但是仍然有许多不可散列的类型。
还是那句话,WeakKeyDictionary
是而不是一个糟糕的解决方案;只是没有涵盖所有的可能性。大多数时候,它已经足够好了,但是它通常建议不要将它用于公共库,至少在文档中没有好的警告的情况下是这样的。毕竟,描述符协议提供了设置和删除属性的方法,所以它们应该支持可变类的实例。
需要有一个不受这个问题困扰的解决方案,而且确实有。最简单的解决方案是使用instance
的 ID 作为键,而不是实例本身。万岁!现在字典不再保留未使用的instance
,也不要求类是可散列的。
这是解决方案的样子。
class Descriptor:
def __init__(self):
self.storage = {}
def __get__(self, instance, owner):
return self.storage[id(instance)]
def __set__(self, instance, value):
self.storage[id(instance)] = value
def __delete__(self, instance):
del self.storage[id(instance)]
该示例切换回正常的dict
,因此提到的更改是基于该示例与第一个示例之间的差异,而不是与前一个示例进行比较。每次访问存储时,都是由id(instance)
而不仅仅是instance
来访问。
这似乎是一个很好的解决方案,因为它没有前两个解决方案的任何一个问题。但这不是一个好的解决方案。它不会遇到与以前的解决方案完全相同的问题,但它仍然会遇到内存泄漏。是的,字典不再存储实例,所以不会保留这些实例,但是没有机制可以从字典中清除无用的 id。事实上,有一种可能性(这种可能性很小,但确实存在),该类的一个新实例可能是用一个已删除的旧实例的相同 ID 创建的,因此新实例的属性与旧实例的属性相同,直到它被更改。那是假设可以改变;如果描述符被设计成只读的(后面会有更多介绍)会怎么样?那么新实例就完全被旧值卡住了。
所以,这仍然没有解决描述符上的存储问题,但是它朝着正确的方向发展了。我们需要的是一个像字典一样工作的存储系统,以instance
为键,但使用id(instance)
而不是hash(instance)
进行存储。如果实例不再被使用,它还需要清理自己。
因为这样的东西不是内置的;它必须是定制的。这是专门为这本书设计的自定义词典。
import weakref
class DescriptorStorage:
def __init__(self, **kwargs):
self.storage = {}
for k, v in kwargs.items():
self.__setitem__(k, v)
def __getitem__(self, item):
return self.storage[id(item)]
def __setitem__(self, key, value):
self.storage[id(key)] = value
weakref.finalize(key, self.storage.__delitem__, id(key))
def __delitem__(self, key):
del self.storage[id(key)]
现实版显然方法更多,比如__iter__
、__len__
等。,但是这里实现了带描述符的存储的三个主要用途。其余的实现可以在描述符工具库中找到。
这个类出奇的简单。它的基础是有一个 facade 类,它的作用就像一个字典,将大多数功能委托给一个内部字典,但是将给定的键转换成它们的 id。唯一真正的区别是,在__setitem__()
中,这个新类创建了一个finalize
弱引用,当引用被垃圾收集时,它接受一个引用、一个函数和任何要发送给那个函数的参数。在这种情况下,它从内部字典中删除该项(同样,使用id()
存储)。
这个存储类如何工作的关键是使用一个 ID 作为键(这意味着实例不需要是可散列的)和弱引用回调(从字典中删除未使用的对象)。本质上,这个类是一个WeakKeyDictionary
,它在内部使用给定键的 ID 作为实际键。
将属性安全地存储在描述符中比大多数人实际考虑的要多得多,但是现在有一个很好的、包罗万象的解决方案可以做到这一点。前两种解决方案并不完美,但并非毫无用处。如果描述符的用例允许使用这两种解决方案中的任何一种,那么考虑它们也无妨。它们在很多情况下都是可行的,并且可能比这里提供的定制存储系统的性能略高。但是,对于公共库,应该考虑定制字典或下一节中的实例解决方案。
存储在实例字典中
如果能够找到一个有价值的策略来导出键,那么将数据存储在实例上比存储在描述符中更好。这是因为它不需要额外的存储对象;使用instance
的内置存储字典。然而,一些类将定义__slots__
,因此,不会有存储字典来捣乱。这在一定程度上限制了实例策略的有用性,但是__slots__
很少使用,几乎不值得考虑。
如果您希望使用__slots__
使描述符安全,同时仍然默认使用实例字典,那么您可能希望创建某种替代方法,在创建时设置一个布尔标志,使用描述符上的存储。有很多方法可以实现这一点,无论是使用工厂选择不同的描述符(如果设置了标志)还是其中的类具有基于标志值的替代路径。另一个更简单的方法是记录描述符存储其值的名称,以便想要使用__slots__
的描述符用户可以为它准备一个槽。这要求描述符直接进行实例属性设置(要么用点符号,要么用getattr()
、setattr()
和delattr()
),而不是先获取实例字典。
另一种解决方法(不需要明确询问用户)是检查类是否有存储字典;如果有,就简单地使用它,但是如果没有,可以直接存储在描述符实例上。检查__slots__
的存在是不可靠的,因为子类可能没有定义__slots__
(而基类有),所以它们既有实例字典又有__slots__
。
使用实例字典在实例上存储数据很容易(尽管通常很冗长,因为为了避免递归调用描述符,通常需要引用属性为vars(a)['x']
而不是a.x
),如下例所示。这是一个简单的例子,存储数据的位置被硬编码为"desc_store"
。
class InstanceStoringDescriptorBasic:
name = "desc_store"
def __get__(self, instance, owner):
return vars(instance)[self.name]
def __set__(self, instance, value):
vars(instance)[self.name] = value
def __delete__(self, instance):
del vars(instance)[self.name]
如图所示,在实例上存储非常容易。不过,你们有些人可能不知道vars()
,所以我来解释一下。在对象上调用vars()
会返回实例字典。你们中的许多人可能知道__dict__
。vars()
函数返回相同的字典,并且是访问它的首选方式(读“Pythonic”)。这是首选,主要是因为缺少双下划线。像几乎所有其他带有双下划线的“神奇”属性一样,使用它有一种简洁的方式。希望现在你能告诉你所有使用 Python 的伙伴,这将成为一个更广为人知的功能。
但是为什么要通过vars()
而不是简单的点符号来访问这些值呢?实际上,在很多情况下,使用点符号也能很好地工作。事实上,它在大多数情况下都有效。唯一出现问题的时候是当数据描述符与字典中用于存储的名称相同,或者使用的名称不是合法的 Python 标识符。通常,出现这种情况是因为描述符有意将属性存储在自己的名称下,这几乎可以保证防止名称冲突。但是仍然有可能外部数据描述符与主描述符试图存储其数据的位置同名。为了避免这种情况,最好总是直接引用实例的字典。另一个很好的理由是,它使数据存储在哪里变得更加明确和明显。
接下来要弄清楚的是描述符如何知道存储属性的名称。希望很明显,硬编码位置是一个坏主意;它防止在同一个类上使用该类型描述符的多个实例,因为它们都将争用同一个名称。
询问位置
获取位置名称的最简单方法是在构造函数中请求它。这样的描述符应该是这样的:
class GivenNameInstanceStoringDescriptor:
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
def __delete__(self, instance):
del instance.__dict__[self.name]
这一个与前一个唯一的真正区别是它有一个__init__()
方法,该方法从用户那里接收首选的位置名称,而不是硬编码它。实际上,代码的其余部分完全相同。
在创建描述符时,请求存储属性值的位置很容易,但是对于用户来说很繁琐,甚至在要求位置与描述符同名的情况下很危险,因为用户可能会搞砸。这就是“一劳永逸”描述符的情况,例如下面的描述符,它是一个使用所提供的函数来验证数据的描述符。
class Validated:
def __init__(self, name, validator):
self.name = name
self.validator = validator
def __set__(self, instance, value):
if self.validator(value):
instance.__dict__[self.name] = value
else:
raise ValueError("not a valid value for" + self.name)
在这个Validated
描述符中,__init__()
请求存储实际数据的位置。因为这是一个“一劳永逸”的描述符,它让实例处理检索,而不是提供一个__get__()
,所以用户提供的位置必须与描述符在类中的名称相同,以便描述符能够按预期工作。例如,如果一个类被意外地写成这样:
class A:
validatedAttr = Validated('validatedAttribute', validatorFunc)
全都搞砸了。为了设置它,用户写a.validatedAttr = someValue
,但是检索它需要用户写a.
validatedAttribute
。这看起来并没有那么糟糕,因为它可以很容易地修复,但是这些类型的错误通常很难发现,并且可能需要很长时间才能注意到。此外,当可以以某种方式导出位置时,为什么要要求用户写入位置呢?
一劳永逸的描述符
现在,一劳永逸的描述符终于可以得到解释了。在描述符协议的三种方法中,这些描述符通常只实现__set__()
,如示例所示。然而,情况并非总是如此。例如,下面的惰性初始化描述符只使用了__get__()
。
class lazy:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
value = self.func(instance)
instance.__dict__[func.__name__] = value
return value
这个惰性描述符也可以作为一个函数的装饰器,它代替并用来进行惰性初始化。在这种情况下,以及在其他“一劳永逸”的描述符的情况下,描述符直接将值设置到实例上,使用描述符引用的相同名称。这允许描述符要么是从不使用超过一次的非数据描述符——如在lazy
的情况下——要么是不需要实现__get__()
的数据描述符,这是大多数“一劳永逸”描述符的情况。在许多情况下,一劳永逸的描述符可以通过查看实例来提高查找速度,甚至提供其他优化,如lazy
描述符。
间接询问位置
关于“一劳永逸”部分中的lazy
描述符,还可以注意到其他一些东西,这就是它如何能够确定在哪里存储属性;它把它从它所修饰的函数中提取出来。
这是间接询问描述符名称的好方法。由于描述符被初始化为一个装饰器,提供了一个描述符要替换的函数,它可以使用这个函数的名字来查找这个名字来存储关于instance
的信息。
名字叫莽林
但是,像这样直接使用名称对于大多数非数据描述符来说是危险的,因为直接将它设置到那个位置会覆盖它自己的访问(这是lazy
实际上想要发生的)。当构建不想覆盖自身的非数据描述符时——尽管出现这种情况的可能性很小——最好在存储数据时进行一些“名称篡改”。为此,只需在名称的开头添加一两个下划线。使用至少两个前导下划线和最多一个尾随下划线会导致 Python 在名称中添加自己的 mangling 使用一个前导下划线只是表明该属性对于使用该对象的人来说是“私有”的。这个名字已经在instance
上被占用的可能性极低。
接下来,如果向用户询问名称是个坏主意,并且描述符也不是装饰符,该怎么办?那么描述符是如何决定它的名字的呢?有几个选项,第一个将被讨论的是描述符如何试图挖掘它自己的名字。
获取名称
查找描述符的名称似乎很简单,但是,像任何对象一样,描述符可以被分配给多个不同名称的变量。不,需要一种更迂回的方式来发现自己的名字。
注意
这项技术的灵感来自于 YouTube 上的“滑索秀”,特别是他们关于描述符 3 的视频。这项技术在 22 分钟后出现。他们可能从他们在视频开头提到的书里学到了技巧,但是我从他们那里学到了这个想法,而不是这本书。
我稍加修改的这种技术的原始版本使用了下面的代码。
def name_of(self, instance):
for attr in type(instance).__dict__:
if attr.startswith('__'): continue
obj = type(instance).__dict__[attr]
if obj is self:
self.name = self.mangle(attr)
break
这个方法可以添加到任何描述符中,以便查找它的名称。如果描述符的name
属性没有设置,描述符就运行这个方法来设置它。在倒数第二行,它将名称发送到一个名称处理器——它只是确保名称以两个下划线开头——而不是使用原来的名称。正如在名称篡改一节中提到的,这可能是必要的,但并不总是如此。
不过,这种方法有一个问题:它不处理子类。如果一个具有这个描述符的类被子类化,并且该子类的一个实例试图在原始类的一个实例之前使用该描述符,那么它将无法查找它的名称。这是因为描述符在原始类上,而不是在子类上,但是name_of()
方法在类的字典中查找自己。子类的字典中没有描述符。
不过,别担心。库中的版本解决了这个问题,它使用dir()
来获取所有属性的名称,包括来自超类的名称,然后它将这些名称委托给一个函数,该函数深入研究 MRO 上每个类的__dict__
,直到找到它所寻找的内容。我还删除了名称篡改功能,允许您仅在必要时使用它。最后,它不会忽略以双下划线开头的属性。这种检查实际上可能比访问属性和比较身份要慢,但即使不是这样,它在很大程度上只会使代码变得混乱。另外,你永远不知道;你的描述符可以用来代替一个特殊的方法。
最终结果如下所示:
def name_of(descriptor, owner):
return first(attr for attr in dir(owner)
if (get_descriptor(owner, attr) is descriptor))
def first(iter):
return next(iter, None)
def get_descriptor(cls, descname):
selected_class = first(clss for clss in cls.__mro__
if descname in clss.__dict__)
return selected_class.__dict__[descname]
Python 3.2 还在 inspect 模块中添加了一个名为getattr_static()
的新函数,它的工作方式与getattr()
类似,只是它不会在查找时激活描述符的__get__()
方法。你可以用getattr_static()
代替对get_descriptor()
的调用,效果是一样的。
set_name()
在 Python 3.6 中,添加了一些东西 else ,使得获取名称更加容易!Python 在其协议中获得了一个额外的可选方法:__set_name__()
。在创建包含描述符对象的类的过程中调用这个新方法。其参数为self
、owner
和name
。第一个,self
,超级明显;这是所有方法都有的第一个参数。您应该认识到第二个是描述符所在的类owner
。最后一个,name
,显然也是我们要找的名字;存储描述符对象的变量的名称。
存储原始文件和受损文件
当存储用于描述符的名称时,通常最好同时存储原始名称和损坏的名称。保留损坏的名称是显而易见的,但是为什么还要存储原来的名称呢?查看错误消息。如果在尝试使用描述符时出现问题,您至少需要向用户提供属性的名称,以便更好地了解问题出在哪里。
键入 ID
为了在实例上存储相对安全的位置,可以做的另一件事是使用描述符的id()
以某种方式在实例上生成一个位置。这看起来很奇怪,但是一个非字符串可以被用作实例字典中的键。
不幸的是,它只能通过vars(instance)[id(desc)]
直接访问,而不能通过点符号或get
/ set
/ hasattr()
访问。这实际上似乎是一个优点,因为它防止了对属性的不必要的访问,但是它也搞乱了dir(instance)
,当它发现一个非字符串键时会引发一个异常。
有利的一面是,这个位置不可能与用户定义的属性冲突,因为那些属性必须是字符串,而这是一个整数。但是导致dir()
失败是不可取的,所以必须找到不同的解决方案。定义一个__dir__()
方法在大多数情况下是多余和不合适的。然而,激进的程序员可以调用object.__dir__()
并在返回之前从列表中删除id()
。然而,如上所述,这是矫枉过正。
一个简单的解决方案是将 ID 改为一个字符串,即str(id(desc))
而不仅仅是id(desc)
。这修复了dir()
问题,也开放了get
/ set
/ hasattr()
的使用,同时仍然防止点符号访问,因为它是一个无效的 Python 标识符。名称冲突的可能性仍然非常低,因此这仍然是一个可以接受的解决方案。
注意
str(id(desc))
的一个有趣的小改动是使用十六进制值,作为hex(id(desc))
而不是数字的直串版本,最好去掉开头的'0x'
,比如hex(id(desc))[2:]
。这样做的好处是十六进制字符串通常会更短,这将计算哈希值(在__dict__
中查找和赋值)所需的时间缩短了一点点。是的,计算十六进制值所需的时间比计算普通字符串值所需的时间长,但是只需要做一次(您可以保存十六进制字符串供以后使用),而属性查找可能会发生多次。这是一个微小的优化,甚至可能不值得注意。
没有充分的理由在键的前面添加可接受的字符来支持点符号,因为点符号要求用户提前知道名称是什么,这是他们无法知道的,因为当使用id()
来派生它时,程序每次运行时名称都会改变。持续变化的键还会带来其他限制,其中之一是它会使序列化和反序列化(分别用pickle
模块完成的 pickle 和 unpickling 是其中一种方法)变得更加困难。
如果希望能够从保存位置获得某种信息,可以将附加信息添加到键中。例如,描述符的类名可以添加到键的前面,例如type(self).__name__ + str(id(self))
。这为使用dir()
查看实例名称的用户提供了一些线索,让他们知道这个名称指的是什么,尤其是如果有多个描述符将它们的名称基于实例上的id()
。
让用户来照顾它
这一节的标题听起来像是在向用户询问描述符构造函数中的名称,但事实并非如此。相反,这指的是property
使用的方法。
有人可能会说property
“欺骗”是通过简单地将你赋予它的功能分配给它不同的方法。它作为终极描述符,几乎可以无限地定制,这就是它的本质。descriptor-y 不能做的最大的事情是成为非数据描述符(因为它定义了描述符协议的所有三个方法),这很好,因为这不符合它的意图。此外,提供给描述符的函数不容易访问描述符的内部,所以在那里可以做的事情是有限的。
有趣的是,很大一部分描述符可以用property
来编写——而且实际上效果更好,因为在确定数据保存位置时不会有困难——但是它确实有很大的缺陷。其中最大的问题是在重用相同的描述符时缺乏干涩。(不要重复自己;干燥是缺乏不必要的重复代码。)如果相同的代码需要用property
重写很多次才能达到相同的效果,那么就应该把它变成一个封装了重复部分的自定义描述符。遗憾的是,由于要存储一个值,这不太可能是一个真正容易的复制。但是,如果描述符不需要搞清楚这一点(有时就是这样),那么转换就容易多了。
总之,property
是一个高度通用的描述符,它甚至使一些事情变得非常简单(也就是这一整章所要讲述的困难的事情),但是它不容易重用。自定义描述符是最好的解决方案,这也是这本书存在的原因!
以property
的方式重新创建“存储”的用例并不多,但是有足够多的用例以微小的方式扩展了属性的功能,值得一试。
元描述符
描述符的限制和它们在类中的使用可能会很麻烦,限制了描述符的一些可能性,比如类常量。事实证明,有可以绕过它,这个解决方案在本书中将被亲切地称为元描述符(希望这个想法和名称能够在整个高级 Python 社区中传播)。
之所以称它们为元描述符,是因为描述符不是存储在类中,而是存储在元类中。这导致元类取代了owner
,而类取代了instance
。从技术上讲,这就是元描述符的全部内容。甚至不要求描述符为了成为元描述符而进行特殊设计。
虽然元描述符的概念实际上非常简单,但是元类的限制使得元描述符的使用更加困难。必须注意的最大限制是,任何类都不能从一个以上的元类派生,无论是直接在类上指定,还是多个子类有不同的元类。不要忘记,即使没有指定元类,一个类仍然是从type
元类派生出来的。
因此,选择使用元描述符时必须小心。幸运的是,如果代码库遵循组合优先于继承的原则,这就不太可能成为问题。
关于元描述符的一个很好的例子,请看下一章末尾的ClassConstant
元描述符。
摘要
在这一章中,我们看了很多在描述符中存储值的技术的例子,包括在描述符和实例本身上存储的选项。现在我们知道了适用于大多数描述符的基础知识,我们将开始研究一些其他相对常见的功能以及如何实现它。
八、只读描述符
只读(或不可变)属性描述符有许多好的用途。事实上,有很多东西支持让一切都有效地不可变的想法。不幸的是,由于 Python 天生缺乏使任何东西实际上不可变的能力,解释器优化并不是 Python 可能的优势之一。(PyPy 可能因此可以进行 JIT 优化,但不要相信我的话。)
不变性还有很多其他好处,但这些超出了本书的范围。本章的重点是展示描述符如何使实例级属性有效地不可变。
创建只读描述符的第一步可能是不给它一个__set__()
方法,但是只有在有一个__delete__()
方法的情况下才有效。如果也没有__delete__()
方法,它就变成了一个非数据描述符。如果它是一个非数据描述符,并且有人给它赋值,那么它只是创建一个实例属性来覆盖描述符。这显然不是我们想要的。
不,要真正阻止用户分配新值,需要__set__()
,但它显然不能正常工作。那么,它能做什么呢?它可能会引发异常。AttributeError
可能是内置异常的最佳选项,但是其功能几乎是独一无二的,足以创建一个自定义异常。这取决于你,但是例子使用了AttributeError
。
既然属性不能更改,那么如何为它提供初始值呢?试图通过描述符的构造函数来发送它,只会以每个实例都有相同的值而告终,因为构造函数只在类创建时被调用。需要有某种后门。将讨论三种不同的技术:一次性设置、秘密设置和强制设置。
一次性设置描述符
set-once 描述符是三个只读属性中最具限制性的,因为它最严格地将赋值次数限制为每个实例一次。
“设置一次”描述符的工作原理是简单地检查一个值是否已经被设置并相应地采取行动。如果它已经被赋值,它会引发一个异常;如果不是,那么它设置它。
例如,如果描述符在实例属性storage
中使用描述符上存储,那么基本的__set__()
方法应该是这样的。
def __set__(self, instance, value):
if instance in self.storage:
raise AttributeError("Cannot set new value on read-only property")
self.storage[instance] = value
首先,它检查是否已经为实例设置了一个值。如果有,它会引发一个AttributeError
。否则,它设置值。很简单。
在三个只读描述符中,它也是最容易使用的,因为它的设置方式与描述符的正常设置方式相同:使用简单赋值。其他的每一个都有一个迂回的方法来获得值集。此外,因为它有一个典型的设置值的用途,所以它也是最容易通用的。
秘密集描述符
秘密集描述符在描述符上使用“秘密”方法来初始化值。该方法使用与__set__()
相同的参数,并按照__set__()
使用普通描述符的方式设置值。但是使用这种技术,__set__()
方法只会引发一个错误。
要访问 secret 方法,需要访问实际的描述符对象。根据当前的通用标准,当没有提供instance
时,在__get__()
方法中返回self
,从实例中获取描述符就像type(a).x
一样简单(您可以将其更改为直接使用类名,但是这忽略了继承,如果您重构名称的话,会产生更多)。即使返回未绑定的属性,这也是可能的,尽管这需要一个额外的步骤。你可能还记得UnboundAttribute
有自己的descriptor
属性。因此,查找变得稍微长了一点。不再只是type(a).x
,变成了type(a).x.descriptor
。一旦访问了描述符对象,需要做的就是调用“secret”set 方法。下面是一个在__init__()
方法中使用名为ROValue
的秘密集合描述符的类的例子。
class A:
val = ROValue()
def __init__(self, value):
type(self).val.set(self, value)
访问描述符,然后调用set()
——描述符的“secret”set 方法——来初始化实例的值。这比self.val = value
更罗嗦,但是很管用。
在库中,有一些助手函数(其中一些在库中是标准化的)可以使用。最能保证在所有情况下(包括实例属性)都有效的是setattribute(instance, attr_name, value)
。还有一些带有默认值的可选参数,可以设置这些参数来指定特定的行为,但是默认情况下会尝试各种方法(包括这里还没有显示的技术),直到有什么方法起作用。
强制集合描述符
强制设置描述符的工作方式是,不使用一个全新的方法作为后门,它仍然使用__set__()
,但是有所改变。除了典型的三个参数(self
、instance
和value
,它还有第四个带有默认值的参数。这个参数类似于forced=False
。这使得调用__set__()
的内置方式不会导致该值被设置。相反,需要访问描述符对象,并使用附加的forced=True
参数显式调用__set__()
。因此,如果ROValue
是一个强制集合描述符,而不是之前的秘密集合描述符,那么基本的__set__()
方法应该是这样的:
def __set__(self, instance, value, forced=False):
if not forced:
raise AttributeError("Cannot set new value on read-only property")
# setter implementation here
现在,__set__()
方法检查forced
参数是否被设置为True
。如果不是,那么这个方法就会像其他只读描述符一样失败。但是,如果它是True
,那么该方法知道让它通过并实际设置值。
如果描述符真的只能在对象创建期间写入,那么使用 set-once 描述符是最好的选择。与其他两个选项相比,描述符的用户更难阻止 set-once 描述符的只读特性。在另外两者中选择哪一个是个人喜好的问题。有些人可能会发现改变一个“神奇”方法的签名并不适合他们,尽管有些人可能喜欢不需要另一个方法。有些人实际上可能更喜欢其他方法,因为他们可能已经在使用了,如第十一章中的一些示例所示。在很大程度上,在秘密集和强制集描述符设计之间进行选择只是个人偏好。
类别常数
类常量非常像只读描述符,除了如果处理得当,它们不需要设置一次;相反,他们开始创作。不过,这需要一点小小的调整。
首先,您必须认识到类常量的描述符必须作为元描述符(如果您忘记了,那是元类的描述符)而不是普通的描述符来实现。第二,每个有常量的类都可能有自己的一组常量,这意味着每个类都需要一个自己的自定义元类。
首先,这里是将要使用的实际描述符。
class Constant:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
raise AttributeError("Cannot change a constant")
def __delete__(self, instance):
raise AttributeError("Cannot delete a constant")
这是一个非常简单的描述符,在其构造函数中接收一个值,用一个__get__()
调用返回该值,如果有人试图更改或删除该值,则引发一个AttributeError
。
但是,要使用这个描述符,必须将它放在一个元类中,然后这个元类必须有一个从它派生的类。举个例子,这里有一个元类的实例和一个保存了几个数学常数的类。
class MathMeta(type):
PI = Constant(3.14159)
e = Constant(2.71828)
GOLDEN_RATIO = Constant(1.61803)
class Math(metaclass=MathMeta):
pass
现在PI
、e
和GOLDEN_RATIO
是Math
类的常量。对付它们的唯一方法是通过元类。为此使用元描述符的一个缺点是,不能再通过带有常量的类的实例来访问常量。不过这并不是一个真正的问题,因为许多其他语言从一开始就不允许这种访问。具有不同元类的类也会出现多类问题,但这种问题非常罕见。
所以,现在有了一个Constant
元描述符,并且它知道如何使用它,我现在将通过说“一定有更好的方法”来引导我内心的雷蒙德·赫廷格没有人想创建一个元类,只是为了让一个普通的类拥有常量。
有一个更好的方法。Python 允许动态定义类和元类,如果它们是在一个函数中创建的,那么这个定义可以被一次又一次地动态重用。事情是这样的。
def withConstants(**kwargs):
class MetaForConstants(type):
pass
for k, v in kwargs.items():
MetaForConstants.__dict__[k] = Constant(v)
return MetaForConstants
这个函数使用每个给定的关键字参数作为新的Constant
来创建一个元类,并返回这个元类。下面是新的Math
类定义看起来是什么样子,用这个函数代替了完整编写的元类。
class Math(metaclass=withConstants(PI=3.14159, e=2.71828, GOLDEN_RATIO=1.61803)):
pass
那里!现在,只需将产生的元类设置为Math
的元类,它就拥有了由赋予withConstants()
的关键字参数提供的常量。使用这种方法有一个巨大的缺点:自动完成。你很难找到一个编辑器可以自动完成像这样完全动态创建的东西。
摘要
本章研究了几种不同的技术来为只读属性(或者至少是类似只读的属性)创建描述符。在所有这些中需要注意的一点是,没有一种技术实际上使改变值成为不可能;它们只是使这样做变得困难,需要额外的步骤来向用户表明这样做不是想要的。这就是 Python 的方式;毕竟,我们都是自愿的成年人。
九、编写__delete__()
这将是一个很短的章节,因为没有那么多要说的,但它并不适合其他章节。另外,__get__()
和__set__()
也有各自的章节。
大多数描述符教程甚至没有提到用__delete__()
做什么,他们甚至经常在他们的示例描述符上没有方法。
如果描述符只在内部使用(而不是在公共库中),并且在内部代码中从未调用过del
,那么实现__delete__()
方法就没有意义。但是在公共库中,没有办法知道用户是否会在描述符属性上使用del
。因此,通常最安全的方法是在库中包含对数据描述符的工作__delete__()
方法。这些方法的外观取决于属性的存储方式。
对于内部存储,删除dict
中的条目:
del self.storage[instance]
对于外部存储,从实例字典中删除:
del vars(instance)[name]
如果描述符不代表一个存储值,什么也不做。除了描述符可能具有的额外功能之外,__delete__()
方法看起来几乎没有什么变化。
摘要
我们已经看到__delete__()
是一个实现起来非常简单的方法,但是决定是否实际实现它可能是一个困难的决定。不过,最终它会被用得如此之少,以至于它的实现可能会被推迟到需要的时候。由于缺少实现而引发异常的默认行为应该可以让您坚持到那时。
十、描述符也是类
是时候用描述符来描述一些更高级的东西了。实际上,它并不是真正的高级,因为它适用于所有的职业。在这一章中不会有太多的深入研究;这只是提醒我们,通常可用于类的特性也可用于描述符。
遗产
描述符可以从其他类(通常是其他描述符或描述符工具/助手)继承和被继承。使用继承,可以使用预先构建的 mixins 和模板类来构建描述符,这些类已经实现了存储属性所需的基本功能。事实上,在下一章中会讨论到其中的一组,并且在库中有完整的提供。仅举一个例子,可以创建一个基类来处理使用描述符上存储的次要细节,派生的专门化可以将这些细节委托给它。同样,在下一章中有更多关于这个想法的内容,在库中有完整的代码示例。
更多方法
一个描述符可以有比描述符协议更多的方法,__set_name__()
和__init__()
。这表现在有后门方法的秘密描述符上,比如set()
。
像这样的外部使用的方法应该受到限制,因为对这些方法的访问也应该受到限制,但是使用只在类内使用的内部使用的“私有”方法绝对是公平的。同样,实现__str__()
和__repr__()
也是一个好主意。实现__eq__()
或__hash__()
很少有用或必要,因为描述符本身不太可能被比较或作为关键字存储在散列集合中。
可选/默认参数
就像在强制设置描述符中一样,可选/默认参数可以添加到协议方法中。由于提供可选参数的用户仍然需要获得描述符对象并直接调用协议方法,这应该是有限的,就像其他外部使用的方法一样。
此外,为了组合和继承,应该对其进行限制。如果提供可选参数的类被包装或被子类化,新类要么必须知道可选参数,要么提供一个**kwargs
参数并向下传递它,这将在库中提供的大部分代码中看到。
描述符上的描述符
因为描述符是类,所以描述符上也可以有描述符!有好几次我几乎这样做了,但是 setter 比描述符提供的要复杂得多,所以我不得不接受。我也考虑过使用描述符使属性成为只读的,但是我还没有完全确定下来。
传递实例
从来没有人说过描述符必须为它所在的每个类创建一个新的实例。描述符的实例可以在类定义之外创建,然后分配给多个类的类属性。
当存储在描述符上时,这可以节省一点空间,因为它只有一个字典的开销,而不是每个类一个。事实上,如果将值存储在描述符上,这比存储在实例上要容易得多。描述符在实例上存储值的问题是,您需要名称来存储它,如果该名称被认为与描述符在类上的名称相同或从该名称派生,您必须处理描述符有多个名称的可能性。有趣的是,每次在类定义中将描述符分配给一个类时,__set_name__()
都会被调用。如果您不需要这个名称(对于错误消息,您应该需要),您仍然可以在多个类上使用一个描述符。最好的用例是描述符非常具体,并且在每个类中使用相同的名称。这消除了所有的问题。
但是,如果您希望在多个类中使用描述符的单个实例,而这些类可能使用不同的名称,那么您需要为这些名称创建一个专门的存储,该存储由类控制,但也可以考虑继承。实际上,我会享受这个挑战,并考虑创建一个放入描述符工具中,但是我不想过多地鼓励这个想法。
无论如何,不要对同一个类的多个属性重复使用同一个描述符。这根本行不通。所有属性将具有相同的值。
描述符只是抽象的方法调用
基本上,描述符只是完成某些方法调用的一种更简单的方式。那些方法调用没有有以类似于property
的方式工作,获取和/或设置某个值。
描述符方法本质上可以替换类中任何不带参数并返回对象的方法。更重要的是,它甚至不需要返回任何东西,因为不返回任何东西意味着它返回None
。__set__()
描述符方法可以替代任何只有一个参数并且不返回任何东西的方法。__delete__()
方法替换没有参数的方法,并且不返回任何东西。
虽然描述符可以以这些方式使用,但是对于描述符的用户来说,这样做很可能是不直观的,主要是因为语法在许多情况下看起来很奇怪,尤其是在__delete__()
的情况下。
摘要
任何可以用其他类完成的事情都可以用描述符来完成,包括这里没有提到的事情。虽然大部分功能可以在没有任何负面影响的情况下完成,但很多功能并不需要,但是在编写描述符时记住这些并没有坏处。
十一、重复使用轮子
只要有可能和明智的,人们应该尽量避免重新发明轮子。本章介绍了一组用作超类的类和策略,以帮助更快地构建新的描述符。这里只给出了准系统代码;完整的代码示例在库中。
存储解决方案
第一个代码示例涵盖了描述符可以用于存储的存储“策略”(我称之为“解决方案”)。这些策略可以被硬编码到新的描述符中,或者被传递到描述符的初始化器中,以便根据具体情况进行选择。这里只显示两种基本策略;其余的可以在图书馆找到。
class OnDescriptorStorageSolution:
def __init__(self):
self.storage = DescriptorStorage()
def get(self, instance):
return self.storage[instance]
def set(self, instance, value):
self.storage[instance] = value
def delete(self, instance):
del self.storage[instance]
class NameGetter:
def __init__(self, name_lookup_strategy):
self.lookup_strategy = name_lookup_strategy
self.name = None
def __call__(self, instance, descriptor):
if self.name is None:
self.name = self.lookup_strategy(instance, descriptor)
return self.name
def set(self, name):
self.name = name
class OnInstanceStorageSolution:
def __init__(self, name_lookup_strategy):
self.name = NameGetter(name_lookup_strategy)
def get(self, instance):
return instance.__dict__[self.name(instance, self)]
def set(self, instance, value):
instance.__dict__[self.name(instance, self)] = value
def delete(self, instance):
del instance.__dict__[self.name(instance, self)]
def set_name(self, name):
self.name.set(name)
显然,这些存储解决方案是为每个实例的存储而设计的。这是由于两个原因:每个类的存储是微不足道的,因此不需要预先构建的解决方案;基于实例的存储更为常见。
类和它的使用可能有点令人困惑。正如关于存储的章节中所述,在实例上存储最困难的事情是弄清楚如何找到存储位置的名称,所以OnInstanceStorageSolution
类接受了一个name_lookup_strategy
。这个策略只是一个函数,它接受instance
和描述符,并返回要存储的名称。该策略接受这两个参数,因为它们是唯一保证可用于查找的信息,并且它们也是通过name_of()
进行查找所必需的,正如本书前面提到的。如果名字已经决定了,查找策略可以简单的是None
,你调用set()
。从__set_name__()
调用set()
方法也很有用,这就是为什么OnInstanceStorageSolution
也有一个从描述符调用的set_name()
方法。
NameGetter
技术上不需要做必要的工作,但是用于在计算名称后缓存名称。这样,查找方法就不需要被调用多次;它被调用一次,然后被存储以便在后续查找中快速返回。
现在已经展示了存储解决方案,下面是使用存储解决方案对象或准备为其提供的一些示例描述符(为了简单起见,删除方法被省略)。
class ExampleWithHardCodedStrategy:
def __init__(self):
self.storage = OnDescriptorStorageSolution()
def __get__(self, instance, owner):
# any pre-fetch logic
value = self.storage.get(instance)
# any post-fetch logic
return value
def __set__(self, instance, value):
# any pre-set logic
self.storage.set(instance, value)
class ExampleWithOpenStrategy:
def __init__(self, storage_solution):
self.storage = storage_solution
def __get__(self, instance, owner):
# any pre-fetch logic
value = self.storage.get(instance)
# any post-fetch logic
return value
def __set__(self, instance, value):
# any pre-set logic
self.storage.set(instance, value)
这些策略也可以被子类化,使得策略方法更像模板调用的方法。例如:
class ExampleWithSuperclassStrategy(OnDescriptorStorageSolution):
def __get__(self, instance, owner):
# any pre-fetch logic
value = self.get(instance) # calls the solution method on itself
# any post-fetch logic
return value
def __set__(self, instance, value):
# any pre-set logic
self.set(instance, value) # same here
像这样使用存储解决方案是硬编码解决方案的一种更干净的方式。
只读解决方案
另一个可以构建的工具类是一个包装器,它可以将任何其他描述符转换成只读描述符。这里有一个使用 set-once 风格的例子。
class ReadOnly:
def __init__(self, wrapped):
self.wrapped = wrapped
self.setInstances = set()
def __set__(self, instance, value):
if instance in self.setInstances:
raise AttributeError("Cannot set new value on read-only property")
else:
self.setInstances.add(instance)
self.wrapped.__set__(instance, value)
def __getattr__(self, item):
# redirects any calls other than __set__ to the wrapped descriptor
return getattr(self.wrapped, item)
def readOnly(deco): # a decorator for wrapping other decorator descriptors
def wrapper(func):
return ReadOnly(deco(func))
return wrapper
它甚至包括一个装饰器 decorator,用于装饰作为装饰器使用的描述符。(Yo dawg 我听说你喜欢装修工,所以我把装修工放在你的装修工里。)这并不意味着包装任何装饰者;它只用于包装产生描述符的装饰器。它不太可能经常被使用,因为大多数从 decorators 创建的描述符都是非数据描述符,这使得ReadOnly
包装不是很有用。不过反正有也无妨,以防万一;尤其是在声称它可以包装任何其他描述符之后。
可以注意到,ReadOnly
只实现了描述符协议的__set__()
方法。这是因为这是它唯一涵盖的内容。它使用__getattr__()
来将调用重定向到潜在的__get__()
和__delete__()
方法,因为它不知道哪些方法可能会被实现。可惜这不管用。当隐式调用“魔法”方法时,Python 通常不查找方法。为了提高速度,它只直接检查类的字典,不做进一步的检查。
不幸的是,这使得使用面向对象的装饰模式变得极其困难。本质上,您需要以模仿__getattribute__()
本身的方式实现这些方法。在descriptor_tools.decorators.DescriptorDecoratorBase
里,你能明白我的意思。它检查包装的描述符有哪些方法,并决定是否委托给包装的描述符、实例或引发错误。
另一种方法是设计你的描述符,以便在创建时采取策略,但是这只适用于你自己的描述符,不允许你扩展超出你控制的描述符。
简单未绑定属性
当没有提供instance
时,可以创建可重用的代码使__get__()
方法返回未绑定的属性,而不是返回描述符。这可以通过包装类(假设它被设计成处理正确的方法),通过继承,甚至方法装饰器来实现:
def binding(get):
@wraps(get)
def wrapper(self, instance, owner):
if instance is None:
return UnboundAttribute(self, owner)
else:
return get(self, instance, owner)
return wrapper
这个简单的装饰器可以很容易地用在描述符中:
class Descriptor:
# other implementation details
@binding
def __get__(self, instance, owner):
# implementation that assumes instance is not None
通过简单地添加对装饰器的调用,您可以简化您必须编写的代码,忽略编写任何必须处理instance
成为None
的可能性的代码,而不是装饰器。
库中还有一个 object decorator(即四人组 decorator)版本,因此任何现有的描述符都可以被转换以返回未绑定的属性。例如,如果用户想要将属性绑定与不提供它们的现有描述符一起使用,他们可以这样做:
class MyClass:
@Binding
@property
def myProp(self):
# gets the actual property
Binding 是一个包装整个描述符的类。现在property
可以和未绑定属性一起使用了。(有一些警告:如果您继续并为myProp
定义一个 setter,myProp
将被一个新的property 对象替换;仅将@Binding
调用添加到用property
修饰的最后一个方法中。)对于没有被用作装饰符的描述符,它看起来像这样:
class MyClass:
myProp = Binding(SomeDescriptor(...))
因为调用任何一个 decorators 都比试图为新描述符创建一个超类来继承更容易,所以没有版本可以使用继承。
摘要
这是库中提供的所有类别的有用代码(除了整个下一章的内容),但绝不是唯一的代码。有大量有用的部分可以帮助您构建自己的描述符,将某些部分混合和匹配成一个有凝聚力的整体描述符,您只需做最少的工作就可以在其余部分中添加核心逻辑。
在这一章中,我们已经看到了如何制作可重用的片段,使描述符的实现更快更容易,也更标准化。如前所述,所有这些工具(以及更多)都可以在库中以及 GitHub 上获得。希望当你尝试创建自己的描述符时,它们能让你的生活变得更轻松。
十二、实例级描述符
关于类似属性的数据描述符,最令人困惑的是什么?让您的头脑接受这样一个事实:它正被用来控制来自其类的与实例不同的属性。
你必须做出的最艰难的决定是什么?是存储在描述符上还是存储在实例上(以及您计划如何实现这一点)。
对于实例属性,这些问题被委托给 nano 框架,这样您就可以专注于描述符的重要部分,创建一个以您期望的方式工作的属性。让我们用一点历史来理解我所说的。
其他语言的属性
当你在其他语言中看到属性时,比如 C#,这些属性的工作方式很像方法,因为它们是在类中定义的,但是当你工作时,你要把注意力放在实例上。事实上,它们的定义非常像方法,并且可能在后面有相同或相似的实现。
Python 的property
描述符允许你做一些非常类似的事情,尽管以一种稍微冗长和不直观的方式,但是你仍然可以做。
接下来,我们将看看 Kotlin,它允许你以与 C#非常相似的方式定义属性,但是它们还有一个名为委托属性的辅助系统。这是您为具有get()
和set()
方法的对象提供属性定义的地方。这听起来熟悉吗?听起来很像描述符,对吧?不过,有一个很大的区别:每个实例有一个委托属性对象。这使得委托属性只需要担心它对每个实例做什么。这也意味着,由于每个实例都创建了一个新的属性,所以它可以在其构造函数中取一个起始值,如果它想成为只读的,就永远不要实现set()
方法;它不需要set()
给它第一个值。在大多数情况下,这比 Python 的描述符好得多。
回到 Python
现在,不要误会我的意思;Python 的描述符是一个惊人的特性,事实上,它们驻留在类级别,这开启了一个全新的可能性世界。但问题是,可以说,大多数描述符用例并不需要这样。事实上,我敢说大多数时候,人们只是想要一个可重复使用的财产。
那么,我们能做些什么呢?当然,我们可以创建自己的委托属性!
对我来说,完成这个至少经历了四次不同的迭代,从使用一种完全不同的 Python 元编程开始。你可以在我的博客“用 Jake 编程思想”上看到前两次尝试,在我关于描述符的文章下面。
尝试 1
我尝试的第一件事是更直接地操纵 Python 类的工作方式,使其看起来和行为更像 Kotlin。当您第一次在需要委托属性的实例上设置属性时,您为它分配了该委托属性对象的一个实例。然后你可以调整__getattribute__()
和__setattr__()
,这样如果这个属性拥有一个委托属性,它就会调用相应的方法。重用调整后的__getattribute__()
和__setattr__()
版本可以通过继承或类装饰器很容易地完成。
虽然这很有效,但我不太喜欢,因为我讨厌弄乱那些属性访问挂钩。对我来说这似乎太不可思议了。
尝试 2
我相信我躺在床上快要睡着的时候,这个想法出现了,让我在写下它的时候多待了一会儿。这个想法起初是不成熟的,但它的基础运行了其余的尝试。然后,当我开始用代码编写它时,我开始发现某些问题,并出现了一种情况,这可能会让您想起一些关于 Java 框架的笑话。
这个想法的基础是,我们不是调整属性访问方法,而是将这些更改转移到描述符中。该描述符在其属性的任何和所有使用中被调用,其中它委托给一个委托属性对象。这是一个非常简单的想法。
从那里,问题归结为一个问题:我们如何实例化委托属性实例?你可能已经有了一个很好的猜测,也可能没有,使我的生活如此困难的部分是我认为框架必须以这样的方式工作,关于属性的一切都必须在类的初始行中定义,构造函数几乎只需要提供起始值。
所以描述符需要用工厂函数来构造,以创建委托属性。但我也想让它使委托的属性可以:
-
最初创建时没有值。例如,工厂提供的惰性初始化函数的惰性属性。或者不能是
None
但最初可能没有值的属性。 -
可以跳过将 setter 方法实现为只读。
-
可能会接受一些元数据,比如它的名称以及它用于哪个实例。
为此,第一次访问该属性时,描述符创建了一个空白版本的委托属性对象,并在“InstancePropertyInitializer”中传递它和元数据,该“instance property initializer”有一个initialize()
方法,您必须在常规构造函数中调用该方法。这个初始化器方法委托给委托属性上的initialize()
方法,发送元数据和开发人员想发送到属性中的任何东西。初始值设定项的存在和灵活性允许委托属性完成这一系列可能性。如果你不想要初始值,那就不要给初始化器一个。如果你想跳过只读属性的 setter 方法(但是框架不能在构造函数中提供初始值),初始化器就像一个特殊的后门 setter。它也是提供元数据的工具。
这个想法当时对我来说似乎很优雅,但我开始意识到它有多麻烦。首先,委托属性需要提供一个初始化器方法,还需要提供一个工厂方法。此外,初始化属性很奇怪,看起来像self.attr.initialize(value)
而不仅仅是self.attr = value
。
尝试 3
然后,当我在一次野营旅行中开始编辑这个新版本时,我想到了一个更好的主意。它遵循了大部分相同的思想,但是对于在构造函数中给定了起始值的属性,它变得更好。
为此,对工厂进行了更改,以接受元数据和初始值。现在,委托属性可以在构造函数中接受所有这些东西。因此,第一次设置属性时,描述符会创建包含所有属性的属性。这允许构造器代码返回到self.attr = value
格式。
但是那些不想要初始值的呢?那些课程必须采取额外的步骤。他们的工厂必须有一个接受元数据的default()
方法。如果还没有为实例创建委托属性,但是正在调用描述符的__get__()
方法,那么就会调用这个函数。从那里,描述符可以开始委托给属性。
我们有一个不同于普通工厂的默认工厂的原因是,大多数使用默认工厂的属性仍然允许首先初始化值。
尝试 4
在我完成那次野营旅行之前,我意识到我一直以来是多么的愚蠢,于是开始了第四次尝试,希望是最后一次。我们不需要工厂。相反,在类级别,您所做的就是创建基本的InstanceProperty
描述符(如下所示)。描述符只是用来激活属性以使用委托属性。它只是假设对属性的第一次赋值是对属性本身赋值,而不仅仅是赋值。描述符不需要知道它将存储什么类型的属性或者如何创建它。
相反,您可以在类的构造函数中创建委托属性实例。这样做的额外好处是,如果描述符在实例上存储了委托属性,可以确保该属性在构造函数中被赋值,这在 Python 3.3+ 中是推荐的,因为存在键共享字典。当然,现在已经不是self.attr = value
了。现在是self.attr = PropertyType(value)
,它更麻烦,但感觉不那么奇怪,它允许委托属性类型的设计明显更容易。
在 property 类上还有一件棘手的事情需要处理。它需要一种提供元数据的方法。要么这样做,要么让属性初始化行看起来像self.attr = PropertyType(value, self, "attr", type(self).attr)
,假设属性需要所有三部分元数据(实例、属性名和控制属性的描述符)。
那么这个描述符是什么样子的呢?这是一个简化的版本:
class InstanceProperty:
def __init__(self):
self._storage = DescDict()
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
else:
return self._storage[instance].get()
def __set__(self, instance, value):
if instance not in self._storage:
value.set_meta(instance, self._name, self)
self._storage[instance] = value
else:
self._storage[instance].set(value)
def __delete__(self, instance):
del self._storage[instance]
包含在 1.1 版描述符工具库中的真实名称(在撰写本文时仍未发布)有更多内容,允许在不支持__set_name__()
的版本中设置名称。真正的方法还使属性在默认情况下不可删除(一个Deletable
包装器允许这样做),并允许您对描述符使用一个简单的包装器,使其成为只读的,这样您就不必创建一个可变的只读版本的委托属性。
例子
我敢打赌,你想看到所有这一切的行动,不是吗?我们将创建一个不允许属性为None
的委托属性:
class NotNone:
def __init__(self, value):
self.value = value
def set_meta(self, instance, name, descriptor):
self.instance = instance
self.name = name
self.descriptor = descriptor
if value is None:
raise AttributeError(self.name + "cannot be None")
def get(self):
return self.value
def set(self, value):
if value is None:
raise AttributeError(self.name + "cannot be None")
else:
self.value = value
这个例子还展示了框架的一个小不便:如果您想要一个做某种验证的属性,并且想要使用错误消息中的任何元数据,您需要等到set_meta()
来做初始验证。从用户的角度来看,这实际上是在完全相同的时间点,但是从必须编写属性的人的角度来看,这很尴尬。
但是你知道这个例子还说明了什么吗?它展示了创建委托属性的其余部分是多么简单和直观。
那么使用所有这些看起来像什么呢?
class Foo:
bar = InstanceAttribute()
def __init__(self, baz):
self.bar = NotNone(baz)
...
只需做一点额外的工作,就可以简单明了地获得特殊属性。
发疯
虽然 descriptor-tools 中有一个默认的实例属性描述符选项,但它被设计成了我所知道的最通用的选项。如果您根本不关心元数据,您可以创建自己的实例属性描述符,并去掉所有的部分。这本书你快看完了;你能行的!
十三、描述符的其他用途
本书中描述符的大部分用处只是将它们作为专门化的属性来使用。虽然这是描述符的主要目的之一,但这并不是它们所能做的全部,尽管更具创新性的用途仍然在很大程度上服务于这一目的。
sqllcemy4
这可能是最著名的使用描述符来描述其强大功能的库。(大概;我做了一些挖掘,没有找到任何使用描述符的提示,尽管继承层次很深,所以我放弃了。如果它没有使用描述符,那么我完全不知道它是如何做的。)当对数据类使用声明性映射风格时,Column
描述符的使用允许用户指定关于属性表示的列的所有种类的数据库元数据,包括数据类型、列名、是否是主键等。
这个Column
类也有许多其他方法,在创建数据查询时会用到,比如排序方法、__lt__()
、__gt__()
等。以及它在哪个桌子。
吉尼亚
Jigna 是一个库,它提供了 Python 和 JavaScript 之间的一种桥梁,允许您编写创建网页的 Python 代码,包括单页应用。使用特征描述符,它可以创建双向数据绑定,生成可用于 HTML 页面的 AngularJS 代码。
这种用法非常创新和强大,这都要归功于描述符,它可以像它一样容易使用。
欲了解更多信息,请访问 GitHub 知识库 5 或查看创建者在 2014 年欧洲 Python 大会 6 上的演示。
麋鹿
Elk 是一个几乎全是描述符的 Python 库,允许以更严格的方式定义类。实例的每个属性都应该用一个ElkAttribute
描述符在类中定义。使用ElkAttribute
s 可以完成的一些示例如下:
-
根据需要设置属性
-
制作惰性属性
-
委托给属性上的方法
-
将属性设为只读
-
自动创建构造函数
库中还有其他特性试图使类定义的繁琐部分变得简单一点,它们可以在它的文档 7 中看到。
验证器
这并不是现有内容的一个具体实例,而是描述符的一个众所周知的用法。例如,如果一个属性需要是一个遵循特定模式的字符串,那么可以创建一个采用验证器的描述符,每次在描述符中设置一个值时,它都会验证新值是否符合验证。
可以编写一堆不同的验证描述符来允许一个类维护它的不变量。
摘要
现在你已经看到了描述符的一些很酷的用法。此外,这是本书的结尾,所以我建议你出去做你自己的非常棒的描述符。去把 Python 社区变成一个更棒的地方吧。
第一部分:关于描述符
About Descriptors
第一部分深入解释了什么是描述符,它们是如何工作的,以及它们是如何使用的。它提供了足够的信息,假设代码的作者使代码足够清晰,您应该能够查看任何描述符并理解它是如何工作的以及为什么会这样工作。
一旦掌握了第一部分的信息,创建自己的描述符并不困难,但是很少甚至没有指导来帮助你。相反,第二部分介绍了创建新描述符的一系列选项,以及避免常见错误的技巧。
第二部分:制作描述符
Making Descriptors
终于,好玩的部分来了。尽管描述符协议很简单,但是描述符有很多种使用方式,即使最后一部分很长,这一部分也会更长。
第一部分告诉了您足够的信息,让您可以创建自己的描述符,但是它没有给出任何提示、模式或真正的指导。第二部分充满了这些。