0、引言

Descriptors(描述器)是Python语言中一个深奥但很重要的一个黑魔法,它被广泛应用于Python语言的内核,熟练掌握描述器将会为Python程序员的工具箱添加一个额外的技巧。本文我将讲述描述符的定义以及一些常见的场景,并且在文末会补充一下__getattr____getattribute____getitem__这三个同样涉及到属性访问的魔术方法

一、描述器类的定义

只要一个对象属性定义了下面三个方法中的任意一个,那么这个类就可以被称为描述器类。

 官网中标明了这三个方法需要传入哪些参数,还有这些方法的返回结果是什么,如下所示:

descr.__get__(self, instance, owner=None) --> value
descr.__set__(self, instance, value) --> None
descr.__delete__(self, instance) --> None
__get__():调用一个属性时,触发
__set__():为一个属性赋值时,触发
__delete__():采用del删除属性时,触发

参数解释:
self: 描述器实例
instance: 调用描述器的类的对象实例 (有时候用obj来表示instance)
owner:调用描述器的类自身 (有时候用cls来表示owner)

为了通俗理解上述参数含义,这里新建一个描述器类lazyproperty来对一个Circle类的面积计算函数area进行描述,代码如下:

#描述器类为lazyproperty,用来描述Circle类的area

class lazyproperty(object):
	def __init__(self,func):
		self.func=func
	def __get__(self,instance,owner):
		print('self in lazyproperty:{}'.format(self))
		print('self:{}\ninstance:{}\nowner:{}'.format(self,instance,owner))
		value=self.func(instance)
		return value


class Circle(object):
	def __init__(self,r):
		self.r=r
	@lazyproperty     #等效于 area=lazyproperty(area)
	def area(self):
		print('computing area:')
		return 3.14*self.r*2

cir=Circle(2.0)

cir.area

输出:

self in lazyproperty:<__main__.lazyproperty object at 0x0000000003D95BE0>
self:<__main__.lazyproperty object at 0x0000000003D95BE0>
instance:<__main__.Circle object at 0x0000000003FB5390>
owner:<class '__main__.Circle'>
computing area:
12.56

这里说明:self 就是描述器类lazyproperty的对象实例,instance是调用描述器的类Circle的对象实例,而owner就是Circled类本身(未实例化)。

二、描述符基础

下面这个例子中我们创建了一个RevealAcess,并且实现了__get__方法,现在这个类可以被称为一个描述符类

class RevealAccess(object):
    def __get__(self, obj, objtype):
        print('self in RevealAccess: {}'.format(self))
        print('self: {}\nobj: {}\nobjtype: {}'.format(self, obj, objtype))
class MyClass(object):
    x = RevealAccess()
    def test(self):
        print('self in MyClass: {}'.format(self))

(1)EX1实例属性

接下来我们来看一下__get__方法的各个参数的含义,在下面这个例子中,先实例化Myclass,再访问其属性xself即RevealAccess类的实例x,obj即MyClass类的实例m,objtype顾名思义就是MyClass类自身。从输出语句可以看出,m.x访问描述符x会调用__get__方法。

>>> m = MyClass()
>>> m.test()
self in MyClass: <__main__.MyClass object at 0x7f19d4e42160>
>>> m.x
self in RevealAccess: <__main__.RevealAccess object at 0x7f19d4e420f0>
self: <__main__.RevealAccess object at 0x7f19d4e420f0>
obj: <__main__.MyClass object at 0x7f19d4e42160>
objtype: <class '__main__.MyClass'>

(2)EX2类属性

如果通过类直接访问属性x,那么obj直接为None,这还是比较好理解,因为不存在MyClass的实例。

>>> MyClass.x
self in RevealAccess: <__main__.RevealAccess object at 0x7f53651070f0>
self: <__main__.RevealAccess object at 0x7f53651070f0>
obj: None
objtype: <class '__main__.MyClass'>

三、描述符的原理

(1)描述符触发

上面这个例子中,我们分别从实例属性类属性的角度列举了描述符的用法,下面我们来仔细分析一下内部的原理:

  • 如果是对实例属性行访问,实际上调用了基类object的__getattribute__方法,在这个方法中将obj.d转译成了type(obj).__dict__['d'].__get__(obj, type(obj))
  • 如果是对类属性进行访问,相当于调用了元类type的__getattribute__方法,它将cls.d转译成cls.__dict__['d'].__get__(None, cls),这里__get__()的obj为的None,因为不存在实例。

简单讲一下__getattribute__魔术方法,这个方法在我们访问一个对象的属性的时候会被无条件调用,详细的细节比如和__getattr__getitem__的区别我会在文章的末尾做一个额外的补充,我们暂时并不深究。

(2)描述符优先级

首先,描述符分为两种:

  • 如果一个对象同时定义了__get__()和__set__()方法,则这个描述符被称为数据描述器(data descriptor
  • 如果一个对象只定义了__get__()方法,则这个描述符被称为非数据描述器(non-data descriptor
  • 二者的区别是:当属性名和描述器名相同时,在访问这个同名属性时,如果是数据描述器就会先访问描述器,如果是非数据描述器就会先访问属性。

我们知道当访问实例 a 属性 x 的时候,python 会先查看 a.__dict__['x'],然后会访问 type(a).__dict__['x'],然后依次访问 type(a) 的基类。当我们调用 obj.x的时候,如果 x 是描述器,会根据 obj 是对象还是类有不同的调用顺序:

如果是对象,自动访问是在 obj.__getattribute__() 函数中完成的,这个函数会把 a.x 转化成 type(a).__dict__['x']. __get__(a, type(a))。这个调用的优先级如下:

  1.   首先调用数据描述器(如果定义了的话)
  2.   其次调用实例变量
  3.   然后是非数据描述器(如果定义了的话)
  4.   最后是 __getattr__ 内部函数(当以上调用都没有返回的时候)

举例如下:

# 既有__get__又有__set__,是一个资料描述器
class M(object):
    def __init__(self):
        self.x = 1
        
    def __get__(self, instance, owner):
        print('get m here') # 打印一些信息,看这个方法何时被调用
        return self.x
    
    def __set__(self, instance, value):
        print('set m here') # 打印一些信息,看这个方法何时被调用
        self.x = value + 1 # 这里设置一个+1来更清楚了解调用机制

# 只有__get__是一个非资料描述器
class N(object):
    def __init__(self):
        self.x = 1
        
    def __get__(self, instance, owner):
        print('get n here') # 打印一些信息,看这个方法何时被调用
        return self.x
        
# 调用描述器的类
class AA(object):
    m = M() # m就是一个描述器类
    n = N() # n是一个非资料描述器类
    def __init__(self, m, n):
        self.m = m # 属性m和描述器m名字相同,调用时发生一些冲突
        self.n = n # 非资料描述器的情况,与m对比
    
aa = AA(2,5)
print(aa.__dict__) # 只有n没有m, 因为资料描述器同名时,不会访问到属性,会直接访问描述器,所以属性里就查不到m这个属性了
#结果为:{'n': 5}

print(AA.__dict__) # m和n都有
#结果为:{'__module__': '__main__', 'm': <__main__.M object at 0x0000000002E009B0>, 'n': <__main__.N object at 0x0000000002E08CF8>, '__init__': <function AA.__init__ at 0x0000000004CBCBF8>, '__dict__': <attribute '__dict__' of 'AA' objects>, '__weakref__': <attribute '__weakref__' of 'AA' objects>, '__doc__': None}


print(aa.n) # 5, 非资料描述器同名时调用的是属性,为传入的5
print(AA.n) # 1, 如果是类来访问,就调用的是描述器,返回self.x的值

print(aa.m) # 3, 其实在aa=AA(2,5)创建实例时,进行了属性赋值,其中相当于进行了aa.m=2
# 但是aa调用m时却不是常规地调用属性m,而是资料描述器m
# 所以定义实例aa时,其实触发了m的__set__方法,将2传给value,self.x变成3
# aa.m调用时也访问的是描述器,返回self.x即3的结果
# 其实看打印信息也能看出什么时候调用了__get__和__set__

aa.m = 6 # 另外对属性赋值也是调用了m的__set__方法
print(aa.m) # 7,调用__get__方法

print('-'*20)
# 在代码中显式调用__get__方法
print(AA.__dict__['n'].__get__(None, AA)) # 1
print(AA.__dict__['n'].__get__(aa, AA)) # 1

注:要想制作一个只读的资料描述器,需要同时定义 __set__ 和 __get__,并在 __set__ 中引发一个 AttributeError 异常。定义一个引发异常的 __set__ 方法就足够让一个描述器成为资料描述器。

四、Property(最著名的描述器之一)

每次使用描述符的时候都定义一个描述符类,这样看起来非常繁琐。Python提供了一种简洁的方式用来向属性添加数据描述符。首先要明确,property有两种调用形式,一种是用装饰器,一种是用类似函数的形式,下面会用引言中的例子分别说明两种形式的调用机制。

下面贴出property的等价python定义(来源于中文翻译

class Property(object):
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

从上面的定义中我们可以看出,定义时分为两个部分,一个是__get__等方法的定义,另一部分是getter等方法的定义,同时注意到这个类要传入fget等三个函数作为属性。getter等方法的定义是为了让它可以完美地使用装饰器形式,

(1)调用形式1:类似函数形式

property(fget=None, fset=None, fdel=None, doc=None) -> property attribute

fget、fset和fdel分别是类的getter、setter和deleter方法。先不使用装饰器的形式的调用机制:

# 类似函数的形式
class A:
    def __init__(self, name, score):
        self.name = name # 普通属性
        self.score = score
        
    def getscore(self):
        return self._score
    
    def setscore(self, value):
        print('setting score here')
        if isinstance(value, int):
            self._score = value
        else:
            print('please input an int')
            
    score = property(getscore, setscore)
        
a = A('Bob',90)
a.name # 'Bob'
a.score # 90
a.score = 'bob' # please input an int

分析上述调用score的过程:

  • 初始化时即开始访问score,发现有两个选项,一个是属性,另一个是property(getscore, setscore)对象,因为后者中定义了__get____set__方法,因此是一个资料描述器,具有比属性更高的优先级,所以这里就访问了描述器
  • 因为初始化时是对属性进行设置,所以自动调用了描述器的__set__方法
  • __set__中对fset属性进行检查,这里即传入的setscore,不是None,所以调用了fsetsetscore方法,这就实现了设置属性时使用自定义函数进行检查的目的
  • __get__也是一样,查询score时,调用__get__方法,触发了getscore方法

 

(2)调用形式2:装饰器形式

Python也提供了@property装饰器,对于简单的应用场景可以使用它来创建属性。一个属性对象拥有getter,setter和deleter装饰器方法,可以使用它们通过对应的被装饰函数的accessor函数创建属性的拷贝。

# 装饰器形式,即引言中的形式
class A:
    def __init__(self, name, score):
        self.name = name # 普通属性
        self.score = score
        
    @property
    def score(self):
        print('getting score here')
        return self._score
    
    @score.setter
    def score(self, value):
        print('setting score here')
        if isinstance(value, int):
            self._score = value
        else:
            print('please input an int')
        
a = A('Bob',90)
# a.name # 'Bob'
# a.score # 90
# a.score = 'bob' # please input an int

如果想让属性只读,只需要去掉setter方法。

下面进行分析:

  • 在第一种使用方法中,是将函数作为传入property中,所以可以想到是否可以用装饰器来封装
  • get部分很简单,访问score时,加上装饰器变成访问property(score)这个描述器,这个score也作为fget参数传入__get__中指定调用时的操作
  • 而set部分就不行了,于是有了setter等方法的定义
  • 使用了propertysetter装饰器的两个方法的命名都还是score,一般同名的方法后面的会覆盖前面的,所以调用时调用的是后面的setter装饰器处理过的score,是以如果两个装饰器定义的位置调换,将无法进行属性赋值操作。
  • 而调用setter装饰器的score时,面临一个问题,装饰器score.setter是什么呢?是scoresetter方法,而score是什么呢,不是下面定义的这个score,因为那个score只相当于参数传入。自动向其他位置寻找有没有现成的score,发现了一个,是property修饰过的score,这是个描述器,根据property的定义,里面确实有一个setter方法,返回的是property类传入fset后的结果,还是一个描述器,这个描述器传入了fgetfset,这就是最新的score了,以后实例只要调用或修改score,使用的都是这个描述器
  • 如果还有del则装饰器中的score找到的是setter处理过的score,最新的score就会是三个函数都传入的score
  • 对最新的score的调用及赋值删除都跟前面一样了

property的原理就讲到这里,从它的定义我们可以知道它其实就是将我们设置的检查等函数传入get set等方法中,让我们可以自由对属性进行操作。它是一个框架,让我们可以方便传入其他操作,当很多对象都要进行相同操作的话,重复就是难免的。如果想要避免重复,只有自己写一个类似property的框架,这个框架不是传入我们希望的操作了,而是就把这些操作放在框架里面,这个框架因为只能实现一种操作而不具有普适性,但是却能大大减少当前问题代码重复问题。

下面使用描述器定义了Checkint类之后,会发现A类简洁了非常多

class Checkint:
    
    def __init__(self, name):
        self.name = name
        
    def __get__(self, instance, owner):
        if instance is None:
            return self
        else:
            return instance.__dict__[self.name]
        
    def __set__(self, instance, value):
        if isinstance(value, int):
            instance.__dict__[self.name] = value
        else:
            print('please input an integer')

# 类似函数的形式
class A:
    score = Checkint('score')
    age = Checkint('age')
    
    def __init__(self, name, score, age):
        self.name = name # 普通属性
        self.score = score
        self.age = age
        
a = A('Bob', 90, 30)
a.name # 'Bob'
a.score # 90
# a.score = 'bob' # please input an int
# a.age='a' # please input an integer

五、在运行时创建描述符

我们可以在运行时添加property属性:

class Person(object):
    def addProperty(self, attribute):
        # create local setter and getter with a particular attribute name
        getter = lambda self: self._getProperty(attribute)
        setter = lambda self, value: self._setProperty(attribute, value)
        # construct property attribute and add it to the class
        setattr(self.__class__, attribute, property(fget=getter, \
                                                    fset=setter, \
                                                    doc="Auto-generated method"))
    def _setProperty(self, attribute, value):
        print("Setting: {} = {}".format(attribute, value))
        setattr(self, '_' + attribute, value.title())
    def _getProperty(self, attribute):
        print("Getting: {}".format(attribute))
        return getattr(self, '_' + attribute)
>>> user = Person()
>>> user.addProperty('name')
>>> user.addProperty('phone')
>>> user.name = 'john smith'
Setting: name = john smith
>>> user.phone = '12345'
Setting: phone = 12345
>>> user.name
Getting: name
'John Smith'
>>> user.__dict__
{'_phone': '12345', '_name': 'John Smith'}

六、用描述器类来模拟静态方法@staticmethod和类方法@classmethod

我们可以使用描述符来模拟Python中的@staticmethod@classmethod的实现。我们首先来浏览一下下面这张表:

Transformation

Called from an Object

Called from a Class

function

f(obj, *args)

f(*args)

staticmethod

f(*args)

f(*args)

classmethod

f(type(obj), *args)

f(klass, *args)

(1)静态方法@staticmethod

对于静态方法fc.fC.f是等价的,都是直接查询object.__getattribute__(c, ‘f’)或者object.__getattribute__(C, 'f’)。静态方法一个明显的特征就是没有self变量。

静态方法有什么用呢?假设有一个处理专门数据的容器类,它提供了一些方法来求平均数,中位数等统计数据方式,这些方法都是要依赖于相应的数据的。但是类中可能还有一些方法,并不依赖这些数据,这个时候我们可以将这些方法声明为静态方法,同时这也可以提高代码的可读性。

使用非数据描述符来模拟一下静态方法的实现:

class StaticMethod(object):
    def __init__(self, f):
        self.f = f
    def __get__(self, obj, objtype=None):
        return self.f

我们来应用一下:

class MyClass(object):
    @StaticMethod
    def get_x(x):
        return x
print(MyClass.get_x(100))  # output: 100

(2)类方法@classmethod

@classmethod的作用见我的博客:《python中的@classmethod的作用

Python的@classmethod@staticmethod的用法有些类似,但是还是有些不同,当某些方法只需要得到类的引用而不关心类中的相应的数据的时候就需要使用classmethod了。

使用非数据描述符来模拟一下类方法的实现:

class ClassMethod(object):
    def __init__(self, f):
        self.f = f
    def __get__(self, obj, klass=None):
        if klass is None:
            klass = type(obj)
        def newfunc(*args):
            return self.f(klass, *args)
        return newfunc

附录:其他的魔术方法

首次接触Python魔术方法的时候,我也被__get____getattribute____getattr____getitem__之间的区别困扰到了,它们都是和属性访问相关的魔术方法,其中重写__getattr____getitem__来构造一个自己的集合类非常的常用,下面我们就通过一些例子来看一下它们的应用。

(1)__getattr__

Python默认访问类/实例的某个属性都是通过__getattribute__来调用的,__getattribute__会被无条件调用,没有找到的话就会调用__getattr__。如果我们要定制某个类,通常情况下我们不应该重写__getattribute__,而是应该重写__getattr__,很少看见重写__getattribute__的情况。

从下面的输出可以看出,当一个属性通过__getattribute__无法找到的时候会调用__getattr__

In [1]: class Test(object):
    ...:     def __getattribute__(self, item):
    ...:         print('call __getattribute__')
    ...:         return super(Test, self).__getattribute__(item)
    ...:     def __getattr__(self, item):
    ...:         return 'call __getattr__'
    ...:
In [2]: Test().a
call __getattribute__
Out[2]: 'call __getattr__'

应用

对于默认的字典,Python只支持以obj['foo']形式来访问,不支持obj.foo的形式,我们可以通过重写__getattr__让字典也支持obj['foo']的访问形式,这是一个非常经典常用的用法:

class Storage(dict):
    """
    A Storage object is like a dictionary except `obj.foo` can be used
    in addition to `obj['foo']`.
    """
    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError as k:
            raise AttributeError(k)
    def __setattr__(self, key, value):
        self[key] = value
    def __delattr__(self, key):
        try:
            del self[key]
        except KeyError as k:
            raise AttributeError(k)
    def __repr__(self):
        return '<Storage ' + dict.__repr__(self) + '>'

我们来使用一下我们自定义的加强版字典:

>>> s = Storage(a=1)
>>> s['a']
1
>>> s.a
1
>>> s.a = 2
>>> s['a']
2
>>> del s.a
>>> s.a
...
AttributeError: 'a'

(2)__getitem__

getitem用于通过下标[]的形式来获取对象中的元素,下面我们通过重写__getitem__来实现一个自己的list。

class MyList(object):
    def __init__(self, *args):
        self.numbers = args
    def __getitem__(self, item):
        return self.numbers[item]
my_list = MyList(1, 2, 3, 4, 6, 5, 3)
print my_list[2]

这个实现非常的简陋,不支持slice和step等功能,请读者自行改进,这里我就不重复了。

应用

下面是参考requests库中对于__getitem__的一个使用,我们定制了一个忽略属性大小写的字典类。

程序有些复杂,我稍微解释一下:由于这里比较简单,没有使用描述符的需求,所以使用了@property装饰器来代替,lower_keys的功能是将实例字典中的键全部转换成小写并且存储在字典self._lower_keys中。重写了__getitem__方法,以后我们访问某个属性首先会将键转换为小写的方式,然后并不会直接访问实例字典,而是会访问字典self._lower_keys去查找。赋值/删除操作的时候由于实例字典会进行变更,为了保持self._lower_keys和实例字典同步,首先清除self._lower_keys的内容,以后我们重新查找键的时候再调用__getitem__的时候会重新新建一个self._lower_keys

class CaseInsensitiveDict(dict):
    @property
    def lower_keys(self):
        if not hasattr(self, '_lower_keys') or not self._lower_keys:
            self._lower_keys = dict((k.lower(), k) for k in self.keys())
        return self._lower_keys
    def _clear_lower_keys(self):
        if hasattr(self, '_lower_keys'):
            self._lower_keys.clear()
    def __contains__(self, key):
        return key.lower() in self.lower_keys
    def __getitem__(self, key):
        if key in self:
            return dict.__getitem__(self, self.lower_keys[key.lower()])
    def __setitem__(self, key, value):
        dict.__setitem__(self, key, value)
        self._clear_lower_keys()
    def __delitem__(self, key):
        dict.__delitem__(self, key)
        self._lower_keys.clear()
    def get(self, key, default=None):
        if key in self:
            return self[key]
        else:
            return default

我们来调用一下这个类:

>>> d = CaseInsensitiveDict()
>>> d['ziwenxie'] = 'ziwenxie'
>>> d['ZiWenXie'] = 'ZiWenXie'
>>> print(d)
{'ZiWenXie': 'ziwenxie', 'ziwenxie': 'ziwenxie'}
>>> print(d['ziwenxie'])
ziwenxie
# d['ZiWenXie'] => d['ziwenxie']
>>> print(d['ZiWenXie'])
ziwenxie