本文转载自 此处
我们可以用@property装饰器将方法包装成属性,这样的属性,相比于其他属性有一个优点就是可以在对属性赋值时,进行变量检查,举例代码如下
1 | class A: |
当我们有很多这样的属性时,如果每一个都去使用@property,代码就会过于冗余。如下
1 | class A: |
因为每一次检验的方法都是一样的,所以最好有方法可以批量实现这件事,只写一次if isinstance
。描述器就可以用来实现这件事。
为了能够更清楚地理解描述器如何实现,我们先跳开这个话题,先讲一讲描述器的基本理论。
描述器基本理论及简单实例
描述器功能强大,应用广泛,它可以控制我们访问属性、方法的行为,是@property、super、静态方法、类方法、甚至属性、实例背后的实现机制,是一种比较底层的设计,因此理解起来也会有一些困难。
定义:从描述器的创建来说,一个类中定义了__get__
、__set__
、__delete__
中的一个或几个,这个类的实例就可以叫做一个描述器。
为了能更真切地体会描述器是什么,我们先看一个最简单的例子,这个例子不实现什么功能,只是使用了描述器
1 | # 创建一个描述器的类,它的实例就是一个描述器 |
我们分析一下上面这个例子
- 创建aa实例和普通类没什么区别,我们从
aa.m
开始看 aa.m
是aa实例调用了m这个类属性,然而这个类属性不是普通的值,而是一个描述器,所以我们从访问这个类属性变成了访问这个描述器- 如果调用时得到的是一个描述器,python内部就会自动触发一套使用机制
- 访问的话自动触发描述器的
__get__
方法 - 修改设置的话就自动触发描述器的
__set__
方法 - 这里就是
aa.m
触发了__get__
方法,得到的是self.x
的值,在前面__init__
中定义的为1 aa.m = 2
则触发了__set__
方法,赋的值2传到value
参数之中,改变了self.x
的值,所以下一次aa.m
调用的值也改变了
进一步思考:当访问一个属性时,我们可以不直接给一个值,而是接一个描述器,让访问和修改设置时自动调用__get__
方法和__set__
方法。再在__get__
方法和__set__
方法中进行某种处理,就可以实现更改操作属性行为的目的。这就是描述器做的事情。
相信有的读者已经想到了,开头引言部分的例子,就是用描述器这样实现的。在讲具体如何实现之前,我们要先了解更多关于描述器的调用机制
描述器的调用机制
aa.m
命令其实是查找m属性的过程,程序会先到哪里找,没有的话再到哪里找,这是有一个顺序的,说明访问顺序时需要用到__dict__
方法。
先看下面的代码了解一下__dict__
方法
1 | class C: |
__dict__
方法返回的是一个字典,类和实例都可以调用,键就是类或实例所拥有的属性、方法,可以用这个字典访问属性,但是方法就不能这样直接访问,原因我们之后再说。
下面我们来说一下,当我们调用aa.m
时的访问顺序
- 程序会先查找
aa.__dict__['m']
是否存在 - 不存在再到
type(aa).__dict__['m']
中查找 - 然后找
type(aa)
的父类 - 期间找到的是普通值就输出,如果找到的是一个描述器,则调用
__get__
方法
下面我们来看一下__get__
方法的调用机制
1 | class M: |
此外还有特例,与描述器的种类有关
- 同时定义了
__get__
和__set__
方法的描述器称为资料描述器 - 只定义了
__get__
的描述器称为非资料描述器 - 二者的区别是:当属性名和描述器名相同时,在访问这个同名属性时,如果是资料描述器就会先访问描述器,如果是非资料描述器就会先访问属性 举例如下
1 | # 既有__get__又有__set__,是一个资料描述器 |
注:要想制作一个只读的资料描述器,需要同时定义 __set__
和 __get__
,并在 __set__
中引发一个 AttributeError 异常。定义一个引发异常的 __set__
方法就足够让一个描述器成为资料描述器。
描述器的细节
本节分为如下两个部分
- 调用描述器的原理
__get__
和__set__
方法中的参数解释
1.首先是调用描述器的原理 当调用一个属性,而属性指向一个描述器时,为什么就会去调用这个描述器呢,其实这是由object.__getattribute__()
方法控制的,其中object
是新式类定义时默认继承的类,即py2这么写的class(object)
中的object
。新定义的一个类继承了object类,也就继承了__getattribute__
方法。当访问一个属性比如b.x
时,会自动调用这个方法 __getattribute__()
的定义如下
1 | def __getattribute__(self, key): |
上面的定义显示,如果b.x
是一个描述器对象,即能找到__get__
方法,则会调用这个get方法,否则就使用普通的属性。 如果在一个类中重写__getattribute__
,将会改变描述器的行为,甚至将描述器这一功能关闭。
2.__get__
和__set__
方法中的参数解释 官网中标明了这三个方法需要传入哪些参数,还有这些方法的返回结果是什么,如下所示
1 | descr.__get__(self, obj, type=None) --> value |
我们要了解的就是self obj type value
分别是什么 看下面一个例子
1 | class M: |
总结如下
- self是描述器类M中的实例
- obj是调用描述器的类a中的实例
- type是调用描述器的类A
- value是对这个属性赋值时传入的值,即上面的30
上面的代码逻辑如下
a.m
访问描述器,调用__get__
方法- 三次打印分别调用了
m.name a.age A.name
a.m = 30
调用了__set__
方法,令a(即obj)的属性中的’age’(即M(‘age’)这里传入的self.name)为30
实例方法、静态方法和类方法的描述器原理
本节说明访问些方法其实都访问的是描述器,并说明它们调用顺序是怎样的,以及类方法和静态方法描述器的python定义。
1 | class B: |
1 | # 但其实字典里的还不是可以直接调用的函数 |
上面结果表明,实例直接调用时,类方法和实例方法都是bound method
,而静态方法是function
。因为静态方法本身就是定义在类里面的函数,所以不属于方法范畴。
除此之外,由于实例直接调用后得到的结果可以直接接一个括号,当成函数来调用。而使用字典调用时,得到的结果和实例调用都不一样,所以它们是不可以直接接括号当成函数使用的。
其实从显示的结果我们可以看出,静态方法和类方法用字典调用得到的其实分别是staticmethod和classmethod两个类的对象,这两个类其实是定义描述器的类,所以用字典访问的两个方法得到的都是描述器对象。它们需要用一个__get__
方法才可以在后面接括号当成函数调用。
而普通实例方法用字典调用得到的是一个function即函数,理论上是可以用括号直接调用的,但是调用时报错说少了self参数,其实它也是描述器对象,用通过__get__
方法将self传入来调用
三种方法本质上调用__get__
方法的情况展示如下
1 | B.__dict__['print_classname'].__get__(None, B)() |
所以说我们平常调用的方法都是本质上在调用描述器对象,访问描述器时自动调用__get__
方法。
上面调用时注意到,前两个__get__
的第一个参数都是None
,而实例方法是一个b
,这是因为实例方法需要具体的实例来调用而不能用类直接调用。在python2中,用类直接调用实例方法得到的是一个unbound method
,用实例调用才是一个bound method
,(在python3删除了unbound method
的概念,改为function
),而类方法本身就可以被类调用,所以参数是None
时就是一个bound method
了。所以说__get__
的第一个参数使用b
可以理解成方法的bound
过程。
既然三种方法都是调用了描述器对象,那么这些对象都是各自类的实例,它们的类是如何定义的呢?python中这些类的定义是用底层的C语言实现的,为了理解其工作原理,这里展示一个用python语言实现classmethod装饰器的方法,(来源),即构建能产生类方法对应描述器对象的类。
1 | class myclassmethod(object): |
下面我们分析一下上述代码
- 我们看到使用@myclassmethod装饰器达到的效果和使用@classmethod装饰器没有什么区别
- 首先定义了myclassmethod类,里面使用了
__get__
方法,所以它的实例会是一个描述器对象 - 将
myclassmethod
当做装饰器作用于method函数,根据装饰器的知识,相当于这样设置method=myclassmethod(method)
- 调用
Myclass.method()
时调用了改变后了的method
方法,即myclassmethod(method)(a)
myclassmethod(method)
这是myclassmethod
类的一个实例,即一个描述器,此处访问于是调用__get__
方法,返回一个匿名函数__get__
中其实是将owner(cls)部分传入method方法,因为methon在Myclass类中调用,这个owner也就是Myclass类。这一步其实是提前传入了method的第一个参数cls,后面的参数a由myclassmethod(method)(a)
第二个括号调用- 仔细分析上面的定义与调用过程,我们会发现,我们常常说的类方法第一个参数要是cls,其实是不对的,第一个参数是任意都可以,它只是占第一个位置,用于接收类实例引用类属性,随便换成任意变量都可以,用cls只是约定俗成的。比如下面的代码正常运行
1 | class Myclass: |
下面看一下staticmethod类的等价python定义(来源)
1 | class mystaticmethod: |
注:从源码角度来理解静态方法和类方法
- 静态方法相当于不自动传入实例对象作为方法的第一个参数,类方法相当于将默认传入的第一个参数由实例改为类
- 使用
@classmethod
后无论类调用还是实例调用,都会自动转入类作为第一个参数,不用手动传入就可以调用类属性,而没有@classmethod
的需要手动传入类 - 既不用
@classmethod
也不用@staticmethod
则类调用时不会自动传入参数,实例调用时自动传入实例作为第一个参数 - 所以说加
@classmethod
是为了更方便调用类属性,加@staticmethod
是为了防止自动传入的实例的干扰 - 除此之外要说明一点:当属性和方法重名时,调用会自动访问属性,是因为这些方法调用的描述器都是非资料描述器。而当我们使用
@property
装饰器后,自动调用的就是新定义的get set
方法,是因为@property
装饰器是资料描述器
property装饰器的原理
到这里我们可以讲一讲开头提出的问题了,即@property
装饰器是如何使用描述器实现的,调用机制是怎样的,如何通过描述器达到精简多次使用@property
装饰器的问题。
首先要明确,property有两种调用形式,一种是用装饰器,一种是用类似函数的形式,下面会用引言中的例子分别说明两种形式的调用机制。
下面贴出property的等价python定义(来源于官网的中文翻译)
1 | class Property(object): |
从上面的定义中我们可以看出,定义时分为两个部分,一个是__get__
等方法的定义,另一部分是getter
等方法的定义,同时注意到这个类要传入fget
等三个函数作为属性。getter
等方法的定义是为了让它可以完美地使用装饰器形式,我们先不看这一部分,先看看不是使用第一种即不使用装饰器的形式的调用机制。
1 | # 类似函数的形式 |
分析上述调用score的过程
- 初始化时即开始访问score,发现有两个选项,一个是属性,另一个是
property(getscore, setscore)
对象,因为后者中定义了__get__
与__set__
方法,因此是一个资料描述器,具有比属性更高的优先级,所以这里就访问了描述器 - 因为初始化时是对属性进行设置,所以自动调用了描述器的
__set__
方法 __set__
中对fset
属性进行检查,这里即传入的setscore
,不是None
,所以调用了fset
即setscore
方法,这就实现了设置属性时使用自定义函数进行检查的目的__get__
也是一样,查询score时,调用__get__
方法,触发了getscore
方法
下面是另一种使用property的方法
1 | # 装饰器形式,即引言中的形式 |
下面进行分析
- 在第一种使用方法中,是将函数作为传入property中,所以可以想到是否可以用装饰器来封装
- get部分很简单,访问
score
时,加上装饰器变成访问property(score)
这个描述器,这个score
也作为fget
参数传入__get__
中指定调用时的操作 - 而set部分就不行了,于是有了
setter
等方法的定义 - 使用了
property
和setter
装饰器的两个方法的命名都还是score,一般同名的方法后面的会覆盖前面的,所以调用时调用的是后面的setter
装饰器处理过的score
,是以如果两个装饰器定义的位置调换,将无法进行属性赋值操作。 - 而调用
setter
装饰器的score
时,面临一个问题,装饰器score.setter
是什么呢?是score
的setter
方法,而score
是什么呢,不是下面定义的这个score
,因为那个score
只相当于参数传入。自动向其他位置寻找有没有现成的score
,发现了一个,是property
修饰过的score
,这是个描述器,根据property
的定义,里面确实有一个setter
方法,返回的是property
类传入fset
后的结果,还是一个描述器,这个描述器传入了fget
和fset
,这就是最新的score
了,以后实例只要调用或修改score
,使用的都是这个描述器 - 如果还有
del
则装饰器中的score
找到的是setter
处理过的score
,最新的score
就会是三个函数都传入的score
- 对最新的
score
的调用及赋值删除都跟前面一样了
property
的原理就讲到这里,从它的定义我们可以知道它其实就是将我们设置的检查等函数传入get set
等方法中,让我们可以自由对属性进行操作。它是一个框架,让我们可以方便传入其他操作,当很多对象都要进行相同操作的话,重复就是难免的。如果想要避免重复,只有自己写一个类似property
的框架,这个框架不是传入我们希望的操作了,而是就把这些操作放在框架里面,这个框架因为只能实现一种操作而不具有普适性,但是却能大大减少当前问题代码重复问题
下面使用描述器定义了Checkint类之后,会发现A类简洁了非常多
1 | class Checkint: |
描述器的应用
因为我本人也刚刚学描述器不久,对它的应用还不是非常了解,下面只列举我现在能想到的它有什么用,以后如果想到其他的再补充
- 首先是上文提到的,它是实例方法、静态方法、类方法、property的实现原理
- 当访问属性、赋值属性、删除属性,出现冗余操作,或者苦思无法找到答案时,可以求助于描述器
- 具体使用1:缓存。比如调用一个类的方法要计算比较长的时间,这个结果还会被其他方法反复使用,我们不想每次使用和这个相关的函数都要把这个方法重新运行一遍,于是可以设计出第一次计算后将结果缓存下来,以后调用都使用存下来的结果。只要使用描述器在
__get__
方法中,在判断语句下,obj.__dict__[self.name] = value
。这样每次再调用这个方法都会从这个字典中取得值,而不是重新运行这个方法。