描述符简介
为什么需要描述符
在介绍对象的属性时,介绍了 property
属性。property
属性为一大堆 getter 和 setter 方法提供了一个完美的封装方式,使用者可以像操作普通属性一样操作 property
属性,同时隐藏了底层相应的 getter 和 setter 方法的实现细节,实现了对属性的透明访问和控制,使得代码更加清晰、灵活,并提高了封装性。
property
最大的缺点在于它无法复用:每定义一个 property
属性,就需要编写 3 个方法,并且通过 property
定义的属性只能被该类以及它的子类使用。在类之间甚至同一个类内都可能存在一些处理方式类似的属性,如果要为它们逐个编写 property
就会显得有些麻烦。
例如,以下实现了一个文章类,并提供了一个 published_at
属性管理发布的时间:
property
为 published_at
属性隐藏了实现细节,并提供了一个更加易用的借口:
但是,如果这个类中还有其它类似的日期时间属性如更新时间,或者其它类似的类如评论类也会有类似属性,那就需要为每个属性编写相似的方法,这还是显得有些复杂且不便维护。
为此,Python 提供了一种称为 描述符(descriptor) 的特殊类,一个描述符类可以管理一类属性的检索、存储和删除,使用了描述符的类可以共用实现描述符的逻辑而无需重写一遍。
描述符的应用非常广泛,实际上之前也曾经接触过描述符,不是别的,正是 property
对象。property
对象的原始形式用法是这样的:
这么做看起来有一个问题:通过 property
得到的似乎是一个类属性,而不是实例属性,这里并不是通过 self.updated_at = property(...)
的形式添加实例属性,而是直接写在类的定义中。但是通过这样形式创建的属性,用起来却像一个实例属性。
这并没用违背 Python 的语法,也不是 Python 为 property
开了特例,而是底层的描述符在起作用。在深究它的底层原理之前,需要对描述符有一个清晰的认识。
创建并使用描述符
在 Python 中,存在这样三个特殊方法:
__set__(self, instance, value)
__delete__(self, instance)
这三个特殊方法称为描述符协议(descriptor protocol),实现了描述符协议的对象称为描述器。描述符对实例属性的管理,从 instance
参数就可以窥见。
首先介绍 .__get__()
方法,它是一个实例方法。.__get__()
方法定义对象的属性读取行为,并控制对象在访问属性时应该返回的值。
因为描述符有些复杂,接下来通过一个简单的测试类来说明它的使用,也更好地说明它参数的含义:
注意,描述符对象都是充当类属性而不是实例属性的,这个问题会在后续说明。所以,描述符用起来都像这样:
首先看看直接以类属性形式访问描述符对象会怎样:
结果显示,直接访问类属性经过描述符对象的 .__get__()
取到了值,而不是直接访问得到描述符对象。.__get__()
方法的参数 owner
代表描述符所绑定的类。因为不同的描述符实例将作为不同类的属性,这时参数 owner
随之改变。
由于实例也可以访问类属性,也可以从实例的角度尝试访问描述符对象:
当通过描述符绑定的类的实例来访问描述符类属性时,参数 instance
代表该实例,否则通过类访问类属性时,该参数为 None
。注意区分 self
参数和 instance
参数。它们都代表实例,但是 self
代表的是描述符类的实例,而 instance
代表的是描述符绑定类的实例。
可以看出,描述符的特点首先体现在属性访问上:当通过点号表达式去访问对象的属性时,如果 Python 发现这个属性是一个描述符,那么就转而调用这个描述符的 .__get__()
方法,并将该方法的返回值作为属性访问的结果。
换句话说,描述符为对象的属性访问添加了一个新的步骤,通过这一步骤提供的额外信息,描述符可以在属性访问时实现更复杂的功能。例如,通过检查 instance
参数是否为 None
,就可以知道是否通过实例访问该属性,如果是的话,还可以通过 instance
参数读取实例的其它属性。
事实上,之前的章节已经见过了利用这一过程实现的功能:classmethod
和 staticmethod
类就是利用这一过程实现的描述符(虽然它们底层源码是用 C 语言编写的,但是它们的行为和描述符是一致的,检查它们可以发现确实拥有 __get__
方法),所以它们的第一个参数可以不为 self
。以 staticmethod
为例,用纯 Python 模拟它的实现非常简单:
因为 StaticMethod
作为装饰器的本质就是创建了一个类属性 method = StaticMethod(StaticMethod)
。它首先被实例或类访问,发现这是一个描述符而不是方法,走的是普通属性访问而不是方法调用的路线,因此 Python 并不会将实例作为第一个位置参数传入。
利用这个特点,可以进一步扩展 classmethod
类,实现一个只能由类调用的类方法:
顺便一提,这段代码来自著名的 Python 框架 Django 。除了直接抛出错误外,自定义的操作还可以执行更加复杂的功能,例如对属性进行计算、验证或惰性加载等,从而实现更灵活、可控的属性管理。不过在介绍这些用法之前,还需要认识描述符的另外两个方法。
数据描述符
在认识了 .__get__()
方法的基础上,便不难理解 .__set__() 和 .__delete__()
的用法了:
当一个描述符绑定类的实例尝试为描述符充当的类属性赋值,即 ins.desc = val
时,便会调用 .__set__()
方法,参数 instance
就代表通过来修改描述符类属性的实例,而参数 value
就代表试图赋予的新值。该方法通过拦截属性的赋值行为,使得在赋值前可以对值做一些检查或修改。
同样地,当一个描述符绑定类的实例尝试使用 del
语句删除描述符类属性时,便会调用 .__delete__()
方法。在这种情况下,参数 instance
同样代表通过来删除描述符类属性的实例。该方法通过拦截属性的删除行为,使得可以使用清零、恢复初始值等操作代替实际删除属性的行为。
如果一个对象同时实现了这三个方法,那么该描述符被称为数据描述符(data descriptor)。注意,这些方法有一定联系,但同时也可能会造成混淆,理清这些方法的区别有助于理解它们的用法:
property
、property.setter
、property.deleter
可以拦截对属性的操作,并添加自定义的行为,实现对属性的封装__get__
、__set__
、__delete__
也可以拦截对属性的操作,但它可以应用到不同类当中。记住这句话就等于学会了描述符:描述符是可以复用的property
属性,instance
参数实现了对复用时用在哪的支持。所以当不知道应该使用描述符还是property
时,只需要想清楚这个属性到底要不要复用__getattr__
、__setattr__
、__delattr__
同样可以拦截对属性的操作,但它是针对所有属性的,而其它两类方法只是针对单一属性。如果不需要更改所有属性的表现行为,一般不要重写该方法
所以,回到本文最开始的问题:存储日期时间的字段在获取、设置、删除时都需要一些操作来规范它们的行为,所以需要 property
提供统一的属性接口;但表示文章、评论等结构的各个日期时间字段的行为都是类似的,这时就出现了复用的问题,可以考虑使用描述符来处理。
针对这一过程,将 property
的各个行为提取为以下描述符类:
注意使用描述符相比 property
的区别:为了考虑复用,具体用到的属性不能写的太死,这就需要动态添加和获取属性。而且由于缺少了初始化环节,还需要使用 hasattr
做必要的初始化。
封装了这样一个描述符对象后,批量生成属性就方便多了:
这样生成的属性和通过 property
定义的属性用起来是一样的。
完善描述符
虽然以上定义的描述符已经解决了属性复用的问题了,但它还有一个小问题。注意到之前描述符都是这样使用的:
在使用描述符时,需要将这个字段名写两遍:创建类属性时要给出它的名称,描述符也要通过初始化函数知道这个名称,不然描述符不知道自己绑定给了哪个属性。但 Python 需要先创建描述符,再将其赋值给属性,在创建描述符时它不可能得知自己将被绑定到哪个属性上,只能借助初始化方法。
这可能会导致一些潜在的问题。例如,描述符在使用时,也存在之前说过的无限递归的问题:访问描述符绑定的属性会触发描述符的 .__get__()
方法获取值,如果在该方法内又一次访问了这个属性,就会造成无限递归。例如,对于以下这个描述符:
如果这个描述符被绑定到了 .desc
属性上:
那么通过实例访问 .desc
属性立刻就会造成无限递归:
这个问题是无法避免的,因为递归发生在描述符内部:只要描述符的实现需要访问属性,就有可能发生这个问题。
好在 Python3.6 引入了另一个与描述符有关的特殊方法,解决了这个名称问题:
该方法是一个实例方法,会在描述符绑定类被创建(类属性赋值了一个描述符实例)时调用,通过 name
参数通知描述符绑定属性的变量名称。
如果以上示例的 Desc
类实现了 __set_name__
方法,那么在创建描述符 desc = Desc()
时,就会自动调用 desc.__set_name__(Demo, 'desc')
。__set_name__
方法为描述符引入了一个专门用于设置字段名的额外初始化环节,这样初始化方法就可以用作其它用途了。
因此,以上示例的日期时间字段类只需要将初始化方法改成 __set_name__
方法:
这样字段名就不用写两遍了:
不过有些时候也会利用递归调用的特性实现一些功能。例如缓存属性:如果 __get__
方法需要执行一些耗时的操作才能返回结果(例如从某个大型文件中读出配置,或者从网络下载数据),但不希望每次访问属性时都执行这一耗时操作,就可以在第一次调用获取结果后,将描述符替换为该结果:
这个描述符可以作为装饰器使用:
最后再看一个更复杂的示例。由于描述符本身是一个类,可以通过继承实现更广泛的用途,并隐藏基类的实现细节。以下是一个校验器类,它可以在赋值前检查这个值是否合理:
通过继承这个抽象类并重写 .validate()
方法,可以实现自定义的校验逻辑,例如限制属性的字符串长度:
又或是限制属性只能为某些特定值之一:
这些自定义的校验器可以应用于属性之中,并对不规范的赋值行为报错:
这项技术被用在很多对象关系映射(ORM)的数据库中,ORM 可以把编程语言(特别是面向对象的编程语言)里面的一个对象映射成数据库里面的一个表,通过操作对象代替直接操作数据库中的表实现增删改查的操作,在增加了可读性的同时也有效减少了注入的风险。
SQLAlchemy 是 Python 的一个关于 SQL 类数据库的 ORM 框架。使用 SQLAlchemy ,可以通过定义一个类的方式创建一张表:
可以看出,这里的 sa.Column
就是一个描述符,它负责管理属性的行为。只需要在类定义时引入 sa.Column
,它就能自动将属性映射到数据库中的字段,并处理自增、默认值等相关的工作。并且数据库中关于字段的创建、查找、过滤、排序等功能,都可以对应到 Python 中关于属性的操作:
其它话题
关于属性
在之前介绍属性时,曾经将属性粗略地划分为实例属性和类属性,并指出查找属性时先查找实例属性,再查找类属性。实际上这个表述是不对的,特别是在上文中看到了描述符作为类属性是如何影响实例属性的行为的。在认识了描述符这一概念后,便可以介绍 Python 属性查找的完整逻辑了。
之前说过,假设 obj
是一个类或实例,在使用 obj.attr
查找属性时,会先调用 .__getattribute__()
方法查找。如果查找失败,再调用 .__getattr__()
方法查找。基类 object
规范了标准的属性查找方式,它的 .__getattribute__()
方法实现细节是这样的:
- 首先查找属性在类中是否是一个描述符
- 如果是描述符,并且是数据描述符,则尝试调用数据描述符的
__get__()
方法,其调用细节为: - 如果
obj
是实例,那么尝试调用type(obj).__dict__['attr'].__get__(obj, type(obj))
- 如果
Obj
是类,那么直接调用Obj.__dict__['attr'].__get__(None, Obj)
- 如果不是数据描述符,则从实例的
__dict__
中查找(在这一步,可以查找到实例属性) - 如果
__dict__
中查找不到,再检查它是否是一个非数据描述符;如果是,则调用非数据描述符的__get__()
方法 - 如果也不是一个非数据描述符,则从类属性中查找(包括类的
__dict__
和父类的__dict__
) - 如果还是找不到,则抛出 AttributeError 异常
当类中提供了 __slots__
属性后,情况有点特殊:Python 解释器会自动为 __slots__
中的每个名称创建一个特殊的描述符对象,这也就是为什么 __slots__
声明的属性不能设置默认值(否则默认值会覆盖自动创建的描述符)。
这也就是为什么描述符需要定义为类属性。因为一个描述符就可以用于处理所有实例的属性,将描述符定义为类属性可以使一个类型只保存一份描述符对象,而不需要每个实例都保存一个副本。
关于方法
在之前介绍方法时,曾经将“在类中定义的函数”称为“方法”,并指出实例调用方法时,Python 解释器会自动将实例作为第一个位置参数传入。实际上这个表述也是有一定偏差的,但在有了描述符相关知识后,就可以明白 Python 的方法究竟是什么。
接下来定义一个很简单的类作为示例:
如果直接通过类查看定义的方法,会发现它其实就是一个函数:
但通过实例检查方法时,结果又不同了:它是一个称为“bound method”的对象:
细心的读者可能会发现两者的地址似乎不同,这说明它们应该是不同的函数/方法。使用 is
运算符可以得出它们确实不是同一个对象,并且不同实例得到的实例方法也不是同一个对象:
在有了对描述符的认识后,应该就可以反应过来:say_hello
就是一个描述符。不仅如此,实际上任何使用 def
关键字定义的所谓“函数”都是一个描述符,不管它们是在类内部还是外部定义的,它们都提供了一个 __get__
方法:
当使用实例调用类属性后,函数的 __get__
方法就发挥用途了,它将函数包装成了一个类似 functools.partial
的偏函数,这一过程中实例永远作为第一个位置参数,所以被称为“绑定”的方法。它们的 .__self__
属性指示了绑定的对象:
还可以通过 .__func__
属性获取原本的函数:
所以,描述符在 Python 中无处不在,只是一般情况下没有意识到它的作用。完全可以将一个函数动态地添加为方法,并将它的第一个参数作为 self
。只不过为了发挥它的描述符功能,要添加到类上:
如果要深究下去的话,其实这些所谓的“函数”也分为很多类型。例如,有些方法的返回结果是“slot wrapper”、“method-wrapper”:
这些奇怪的类名只是因为它们是用 C 语言实现的,总的来说它们都可以被归为函数和绑定方法这两类。
所以,Python 的类其实并没有什么特殊的,它只是为一些函数提供了统一的命名空间,外加提供了一个生产实例的接口。而实例也没什么特殊的,它只不过访问了一个可调用的属性,再使用这个属性的 __call__
方法。真正特殊的是 Python 的描述符机制,它会自动发生函数向绑定方法的偏函数转换,并在这一过程中自动发生绑定的机制。
参考资料/延伸阅读
Python 官方文档——描述器使用指南,本文中的部分示例代码改编或直接搬自该文档。