Python 描述符简介
最近接触到 Python 描述符的概念,官方文档没太看懂,搜了一大圈发现 realpython 这篇文章可以,就顺便翻译过来了。
我的新书《LangChain编程从入门到实践》 已经开售!推荐正在学习AI应用开发的朋友购买阅读!
写在前面
我觉得抓住以下几处重点大概就搞明白这玩意儿了 :smile:
- 一个描述符是一个有“绑定行为”的对象属性(object attribute),它的访问控制会被描述器协议方法重写。
- 任何定义了
__get__
,__set__
或者__delete__
任一方法的类称为描述符类,其实例对象便是一个描述符,这些方法称为描述符协议。 - 当对一个实例属性进行访问时,Python 会按
obj.__dict__
→type(obj).__dict__
→type(obj)的父类.__dict__
顺序进行查找,如果查找到目标属性并发现是一个描述符,Python 会调用描述符协议来改变默认的控制行为。 - 描述符是 @property @classmethod @staticmethod 和 super 的底层实现机制。
- 同时定义了
__get__
和__set__
的描述符称为 数据描述符(data descriptor);仅定义了__get__
的称为 非数据描述符(non-data descriptor) 。两者区别在于:如果obj.__dict__
中有与描述符同名的属性,若描述符是数据描述符,则优先调用描述符,若是非数据描述符,则优先使用obj.__dict__
中属性。描述符协议必须定义在类的层次上,否则无法被自动调用。翻译原文
描述符是Python的一项特定功能,可为语言隐藏的许多魔力提供强大的支持。如果您曾经以为Python描述符是很少使用的高级主题,那么本教程就是帮助您了解此强大功能的理想工具。您将了解为什么Python描述符如此有趣,以及在什么情况下使用它们。
在本教程结束时,您将了解:
- 什么是 Python 的描述符
- 它们在 Python 内部使用的地方
- 如何实现自己的描述符
- 何时使用 Python 描述符
本教程适用于中级到高级 Python 开发人员,因为它涉及 Python 内部。但是,如果您还没有达到这个水平,那就继续阅读吧!您会找到有关 Python 和 属性查找链的有用信息。
什么是Python描述符?
描述符是实现描述符协议方法的Python对象,当您将其作为其他对象的属性进行访问时,该描述符使您能够创建具有特殊行为的对象。在这里,您可以看到描述符协议的正确定义:
1 | __get__(self, obj, type=None) -> object |
如果您的描述符仅实现__get__()
,则称其为非数据描述符。如果它实现__set__()
或__delete__()
,则称其为数据描述符。请注意,这种区别不仅在于名称,还在于行为上的区别。这是因为数据描述符在查找过程中具有优先级,这将在后面介绍。
请看以下示例,该示例定义了一个描述符,该描述符在访问控制台时将其记录在控制台上:
1 | # descriptors.py |
在上面的示例中,Verbose_attribute()实现了描述符协议。将其实例化为Foo的属性后,就可以视为描述符。
作为描述符,当使用点表示法访问时,它具有绑定行为。在这种情况下,每次访问描述符以获取或设置值时,描述符都会在控制台上记录一条消息:
- 当访问
__get__()
值时,它总是返回值42。 - 当访问
__set__()
的特定值时,它会引发AttributeError异常,这是实现只读描述符的推荐方法。
现在,运行上面的示例,您将看到描述符在返回常量值之前将其记录在控制台上:
1 | python descriptors.py |
在这里,当您尝试访问attribute1时,描述符按照.__ get __()中的定义将此访问记录到控制台
描述符在Python内部的工作方式
如果您是具有丰富的面向对象(开发)经验的Python开发人员,那么您可能会认为上一个示例的方法有些过度。通过使用属性,您可以实现相同的结果。虽然这是事实,但您可能会惊讶地发现Python中的属性也是……描述符!稍后您会看到,属性不是唯一使用Python描述符的功能。
属性中的Python描述符
如果要在不显式使用Python描述符的情况下获得与上一个示例相同的结果,则最直接的方法是使用 property。以下示例使用 property,该属性在访问时将信息记录到控制台:
1 | # property_decorator.py |
译者注:使用 property 装饰后,name 变成 property 类的一个实例,第二个name 函数使用 name.setter 来装饰,本质是调用 propetry.setter 来产生一个新的 property 实例赋值给第二个 name。第一个 name 和第二个 name 是两个不同 property 实例,但他们都属于同一个描述符类 property。当对 name 赋值时,就会进入
property.__set__
,当对 name 取值时,就会进入property.__get__
。
上面的示例使用装饰器来定义属性,但是您可能知道,装饰器只是语法糖。实际上,前面的示例可以编写如下:
1 | # property_function.py |
现在您可以看到该属性是通过使用property()创建的。该函数的签名如下:
1 | property(fget=None, fset=None, fdel=None, doc=None) -> object |
property()返回实现描述符协议的属性对象。它使用参数fget,fset和fdel来表示协议的三种方法的实际实现。
方法中的Python描述符
如果您曾经用Python编写过面向对象的程序,那么您肯定会使用方法。这些常规函数为对象实例保留第一个参数。使用点表示法访问方法时,您将调用相应的函数并将对象实例作为第一个参数传递。
将obj.method(* args)转换为 method(obj,* args)的魔力在于函数对象的__get__()
实现内部,实际上是一个非数据描述符。特别是,该函数对象实现__get__()
,以便在您使用点表示法访问它时返回一个绑定方法。后面的(* args)通过传递所有需要的额外参数来调用函数。
要了解其工作原理,请看一下官方文档中的这个纯Python示例:
1 | import types |
在上面的示例中,当使用点符号访问该函数时,将调用__get__()
并返回一个绑定方法。
这适用于常规实例方法,同样适用于类方法或静态方法。因此,如果您使用obj.method(* args)调用静态方法,则该方法会自动转换为method(* args)。同样,如果您使用obj.method(type(obj),* args)调用类方法,则该类方法会自动转换为method(type(obj),* args)。
在官方文档中,您可以找到一些示例,说明如果使用纯Python而不是C实现编写如何实现静态方法和类方法。例如,可能的静态方法实现可能是这样的:
1 | class StaticMethod(object): |
同样,这可能是可能的类方法实现:
1 | class ClassMethod(object): |
请注意,在Python中,类方法只是将类引用作为参数列表的第一个参数的静态方法。
如何使用查找链访问属性
要了解有关Python描述符和Python内部的更多信息,您需要了解访问属性时Python中会发生什么。在Python中,每个对象都有一个内置的__dict__
属性。这是一个字典,其中包含对象本身中定义的所有属性。要查看实际效果,请考虑以下示例:
1 | class Vehicle(): |
此代码创建一个实例,并打印实例和类的__dict__
属性的内容。现在,运行脚本并分析输出以查看__dict__
属性集:
1 | {'color': 'red'} |
__dict__
属性集符合预期。请注意,在Python中一切都是对象。类实际上也是一个对象,因此它还将具有__dict__
属性,其中包含该类的所有属性和方法。
那么,当您访问Python中的属性时,到底发生了什么?让我们使用前一个示例的修改版本进行一些测试。考虑以下代码:
1 | # lookup.py |
在此示例中,您将创建一个Car类的实例,Car类继承自Vehicle类。然后,您访问一些属性。如果运行此示例,则可以看到获得了所有期望的值:
1 | $ python lookup.py |
在这里,当您访问实例my_car的属性颜色时,实际上是在访问对象my_car的__dict__
属性的单个值。当您访问对象my_car的属性number_of_wheels时,实际上是在访问Car类的__dict__
属性的单个值。最后,当您访问can_fly属性时,实际上是在使用Vehicle类的__dict__
属性来访问它。
这意味着可以重写上面的示例:
1 | # lookup2.py |
在测试这个新示例时,您应该得到相同的结果:
1 | python lookup2.py |
那么,当您使用点符号访问对象的属性时会发生什么?Python 解释器是如何知道您的真正需求?好吧,这里有一个叫做查找链的概念:
- 首先,您将从以所要查找的属性命名的数据描述符
__get__
方法返回结果。 - 如果失败,那么您将获得实例对象的
__dict__
值,该值是根据要查找的属性命名的键。 - 如果失败,那么将从以您要查找的属性命名的非数据描述符
__get__
方法中返回结果。 - 如果失败,那么您将获得类型对象的
__dict__
值,该值是根据要查找的属性命名的键。 - 如果失败,那么您将获得父类的
__dict__
值,该值是根据要查找的属性命名的键。 - 如果失败,那么将按照对象的方法解析顺序对所有父类重复上一步。
- 如果其他所有操作均失败,则将出现AttributeError异常。
现在,您明白为什么要知道描述符是数据描述符还是非数据描述符是如此重要了吧?它们位于查找链的不同层次上,稍后您会发现这种行为上的差异非常方便。
译者注:同时定义了
__get__
和__set__
的描述符称为 数据描述符(data descriptor);仅定义了__get__
的称为 非数据描述符(non-data descriptor) 。两者区别在于:如果 obj.__dict__
中有与描述符同名的属性,若描述符是数据描述符,则优先调用描述符,若是非数据描述符,则优先使用 obj.__dict__
中属性。通过类型对象的__dict__
属性访问,通过父类对象的__dict__
属性访问。
如何正确使用Python描述符
如果要在代码中使用Python描述符,则只需实现描述符协议。该协议最重要的方法是__get__()
和__set__()
,它们具有以下签名:
1 | __get__(self, obj, type=None) -> object |
在实现协议时,请记住以下几点:
- self是您正在编写的描述符的实例。
- obj是描述符附加到的对象的实例。
- type是描述符附加到的对象的类型。
在__set__()
中,您没有类型变量,因为您只能在对象上调用__set__()
。相反,您可以在对象和类上都调用__get__()
。
要知道的另一件事是,每个类仅将Python描述符实例化一次。这意味着包含描述符的类的每个实例都共享该描述符实例。这是您可能不会想到的,并且可能导致经典的陷阱,如下所示:
1 | # descriptors2.py |
在这里,您有一个Foo类,它定义一个属性number,它是一个描述符。该描述符接受一个数字数值并将其存储在描述符本身的属性中。但是,这种方法行不通,因为Foo的每个实例都共享相同的描述符实例。您实质上创建的只是一个新的类属性。
尝试运行代码并检查输出:
1 | python descriptors2.py |
您可以看到Foo的所有实例都具有相同的属性编号值,即使最后一个实例是在设置my_foo_object.number属性之后创建的。
那么,如何解决这个问题呢?您可能会认为,最好使用字典来保存描述符所附加的所有对象的所有描述符值。这似乎是一个不错的解决方案,因为__get__()
和__set__()
具有obj属性,这是您附加到的对象的实例。您可以将此值用作字典的键。
不幸的是,此解决方案有很大的缺点,您可以在以下示例中看到:
1 | # descriptors3.py |
在此示例中,您使用字典来存储描述符内所有对象的number属性值。运行此代码时,您会看到它运行正常并且行为符合预期:
1 | python descriptors3.py |
不幸的是,这里的缺点是描述符对所有者对象(描述符附加到的对象实例)保持强引用。这意味着如果销毁对象,则不会释放内存,因为垃圾收集器会在描述符中查找到对该对象的引用!
您可能认为这里的解决方案可能是使用弱引用。尽管可能那样,但您必须面对这样一个事实,即并非所有事物都可以被认为是弱的,并且当您收集对象时,它们会从字典中消失。
您可能认为这里的解决方案可能是使用弱引用。尽管可能那样,但您必须面对这样一个事实,并不是所有类型(tuple,int)都支持弱引用,并且当您收集对象时,它们会从字典中消失。
最好的解决方案是不将值存储在描述符本身中,而是将它们存储在描述符所附加的对象实例中。接下来尝试这种方法:
1 | # descriptors4.py |
在此示例中,当您为对象的number属性设置一个值时,描述符将使用与描述符本身相同的名称将其存储在所附加对象的__dict__
属性中。 唯一的问题是,在实例化描述符时,必须将名称指定为参数:
1 | number = OneDigitNumericValue("number") |
number= OneDigitNumericValue()
是更好的方案吗?可能是,但如果您运行的Python版本低于3.6,您将需要一些带有元类和装饰器的魔法。但是,如果您使用Python 3.6或更高版本,描述符协议具有一个新方法__ set_name __()
,它可以为您完成所有这些魔法,是在 PEP 487提出的:
1 | __set_name__(self, owner, name) |
使用此新方法,无论何时实例化描述符,都会调用此方法并自动设置name参数。
现在,尝试为Python 3.6及更高版本重写前面的示例:
1 | # descriptors5.py |
现在,已删除__init__()
并实现了__ set_name __()
。这样就可以创建描述符,而无需指定用于存储值的内部属性的名称。您的代码现在看起来也更好更干净了!
再运行一次此示例,以确保一切正常:
1 | python descriptors5.py |
如果您使用的是Python 3.6或更高版本,则此示例应该可以正常运行。
为什么要使用Python描述符
现在,您知道什么是Python描述符,以及Python本身如何使用它们来支持其某些功能,例如方法和属性。您还了解了如何创建Python描述符,同时避免了一些常见的陷阱。现在一切都应该清楚了,但是您可能仍然想知道为什么要使用它们。
以我的经验,我认识许多高级Python开发人员,他们以前从未使用过此功能,也不需要它。这是很正常的,因为在很多情况下都不需要使用Python描述符。但是,这并不意味着Python描述符仅仅是针对高级用户的学术主题。仍然有一些很好的用例可以证明学习使用描述符是值得的。
Lazy Properties
第一个也是最直接的示例是惰性属性。这些属性的初始值只有在首次访问它们时才会加载。然后,他们加载其初始值并保留该值以供以后重用。 考虑以下示例。您有一个DeepThought类,其中包含一个方法meaning_of_life(),该方法在很久之后会返回一个值:
1 | # slow_properties.py |
如果您运行此代码并尝试访问该方法三次,则每三秒钟您将得到一个答案,这是方法内部睡眠时间的长度。
现在,惰性属性可以在第一次执行此方法时对其进行一次评估。然后,它将缓存结果值,这样,如果再次需要它,就可以立即获得它。您可以使用Python描述符来实现:
1 | # lazy_properties.py |
花些时间研究此代码并了解其工作原理。您可以在这里看到Python描述符的功能吗?在此示例中,当您使用@LazyProperty装饰器时,mean_of_life将变成LazyProperty的一个实例(跟@property装饰器作用一样)。该描述符将方法及其名称都存储为实例变量。
因为它是一个非数据描述符,所以当您第一次访问meaning_of_life属性的值时,将自动调用__get__()
并在my_deep_thought_instance对象上执行meaning_of_life()。结果值存储在对象本身的__dict__
属性中。当您再次访问Meaning_of_life属性时,Python将使用查找链在__dict__
属性中查找该属性的值,并且该值将立即返回。
译者注 : 实现惰性求值(访问时才计算,并将值缓存)利用了
obj.__dict__
优先级高于 non-data descriptor 的特性第一次调用__get__
以同名属性存于实例字典中,之后就不再调用__get__
请注意,此方法之所以有效,是因为在此示例中,您仅使用了描述符协议的一种方法__get__()
。您只实现了一个非数据描述符。如果您实现了数据描述符,那么该技巧将无法奏效。在查找链之后,它将优先于__dict__
中存储的值。要对此进行测试,请运行以下代码:
1 | # wrong_lazy_properties.py |
在此示例中,您可以看到实现__set__()
以后,即使它根本不执行任何操作,也会创建一个数据描述符。现在,惰性属性的诀窍不再起作用。
D.R.Y. Code
描述符的另一个典型用例是编写可重用的代码并使代码 D.R.Y. (DRY原则)Python描述符为开发人员提供了一个出色的工具,可以编写可在不同属性甚至不同类之间共享的可重用代码。
考虑一个示例,其中您具有五个具有相同行为的不同属性。每个属性只能设置为特定值,即它要么是偶数要么为0:
1 | class Values: |
如您所见,这里有很多重复的代码。可以使用Python描述符在所有属性之间共享行为。您可以创建一个EvenNumber描述符,并将其用于所有这样的属性:
1 | # properties2.py |
这段代码看起来好多了!重复项不复存在,现在可以在一个地方实现逻辑,因此,如果需要更改它,则可以轻松实现。
结论
既然您已经知道Python如何使用描述符来支持其一些强大功能,那么您将成为一个更具意识的开发人员,能够了解为什么某些Python功能已经按这种方式实现。
您已经了解到:
- 什么是Python描述符以及何时使用它们
- 在Python内部使用描述符的地方
- 如何实现自己的描述符
而且,您现在知道了一些特定的用例,其中Python描述符特别有用。例如,当您具有必须在许多属性(甚至不同类的属性)之间共享的常见行为时,描述符非常有用。
如有任何疑问,请在下方留言或在Twitter上与我联系!如果您想更深入地了解Python描述符,请查看官方的Python描述符指南。
Python 描述符简介