Python 描述符(descriptor) 杂记

Python 引入的“描述符”(descriptor)语法特性真的很黄很暴力,我觉得这算是 Python 对象模型的核心成员之一。Python 语言设计的紧凑很大程度上得益于它。所以写一篇笔记文记录关于描述符我知道的一切。

低层 - 纯纯的描述符

纯纯的描述符很纯,基于类中定义的 __get____set____delete__ 三个特殊的方法。实现了这三个中方法的任意一个,这个类的实例就拥有了一些特殊特性。

假设现在有这么一个类 MyDescriptor,它拥有描述符的实现。把 MyDescriptor 实例化(my_descriptor),然后将实例作为另一个类 Spam 的类属性。

1

2

3

4

5

6

7

8

9

10

11

12

13

14


class MyDescriptor(object):

def __get__(self, subject_instance, subject_class):

return (self, subject_instance, subject_class)

 

def __set__(self, subject_instance, value):

print "%r %r %r" (self, subject_instance, value)

 

my_descriptor = MyDescriptor()

 

class Spam(object):

my = my_descriptor

 

spam = Spam()


吧 MyDescriptor 的实例作为 Spam 一个名为 my 的类属性之后,Spam 类中属性 my 的访问就被重载了。Spam.my, spam.myspam.my = 123 都不再直接在 spam.__dict__ 上获取和添加值,而是调用 my_descriptor.__get__(None, Spam), my_descriptor.__get__(spam, Spam)my_descriptor.__set__(spam, 123)。如果 MyDescriptor 实现了 __delete__,那么 del spam.my 也会被重载为 my_descriptor.__delete__(spam)

所以我理解为对应“元类”(meta-class),描述符事实上是实现了“元属性”(meta-attribute)。通过这种重载方式,可以定义许多具有特殊行为的对象属性规格,而 Python 许多内置的对象模型成员也是通过这种方式实现的。

高层#0 - 从“函数”到“方法”

描述符在 Python 对象模型中的一个重要作用是实现“对象方法”(method)。我们都知道 Python 中方法有这些特性:

  • 方法本身是一个至少要有一个参数的普通函数,第 0 个参数位 self 指向实例,类似于 C++ 的 const T* this指针。
  • 作为方法的函数在类的命名空间还是以普通函数的形式存在的,比如 Spam 类中的 egg 方法的原始形态是 Spam.__dict__['egg']这个函数。
  • 通过实例访问这个函数时,也就是类似 Spam().egg 的方式,得到的不再是 Spam.__dict__['egg'] 中保存的那个原始函数,而是一个包装过的 “bound method”。这个对象仍然有 __call__ 可以调用,但相比它所包装的原函数,这个对象接受调用时参数列表已经少了一个参数。这是一种类似 functools.partial(raw_function, self)的效果,原函数已经被做了偏函数化处理,默认绑定了实例对象到第 0 个参数位(通常 self 所在的位置);
  • 奇怪的是,如果自己定义一个实现了 __call__ 的函数对象,把它放到类属性中,它并不会表现出“绑定 self”的特性。

我此前一直对此很疑惑,以为这是一个语法级别的行为。直到看了 Flask、Jinja 2 的作者 Armin Ronacher 的一个 Presentation 我才知道,原来 Method 根本不是语法级别特性,而是通过描述符来实现的。

我此前一直忽略了的是:一个 def 定义的 Python 函数或一个 lambda 表达式,除了拥有 __call__ 实现外,还拥有 __get__ 实现。也就是说,所有 Python 函数都默认是一个描述符。这个描述符的特性在“类”这一对象上下文之外没有任何意义,但只要到了类中,就会和其他描述符所表现的一样,将 my_instance.my_method 重载为 MyClass.__dict__['my_method'].__get__(my_instance, MyClass)。“绑定 self 参数” 这个过程正是 __get__ 的行为,导致了 spam.egg(1, 2, 3)Spam.egg(spam, 1, 2, 3) 等价。

明白了这一点,我的思路就清晰多了。“方法”不就是一种特殊的类属性吗,而定义特殊类属性的行为在 Python 对象模型中的实现正是描述符。而对这点的理解也非常有用,Armin Ronacher 的演示稿中提到了可以利用这一点实现只对对象方法有效的装饰器。

高层#1 - Property 属性

不明白为啥属性这个词有 Property 和 Attribute 两种翻译,但是在 Python 中二者是有区别的。后者指的是真正的对象属性,就是保存在 obj.__dict__ 中的成员(我一般更喜欢用 vars(obj) 来取这个字典);而前者指的是类似 C# 中“方法属性”或 Java 中 Getter、Setter 方法的重载属性。

1

2

3

4

5

6

7

8

9

10

11


class Spam(object):

 

@property

def egg(self):

return "some-value"

 

@egg.setter

def egg(self, value):

self._egg = "value = %s" % value

 

spam = Spam()


说起来也很清晰,就是 spam.egg 返回的是第一个 egg 方法调用的返回值,spam.egg = "abc" 调用的是对于第二个 egg 方法的 egg(self=spam, value="abc")。此外还可以继续定义删除属性操作的重载。这个比对象方法要容易看出来得多,property 正是一个描述符。

高层#n - 不受限制的用户自定义描述符

除去语言内置的这些描述符用法,还有许多用户自定义的描述符用法。这些用法让开发者可以更加灵活地扩展对象行为,是非常有用的元编程工具。相比起元类对整个类行为的重载,描述符的重载粒度非常细,它只关注一个属性。所以使用上我们也可以比使用元类减少更多的顾忌,因为我们能非常容易看到它能量释放的边界。

用户定义描述符的例子,我觉得最经典的要属 SQLAlchemy (当然,Django Model 定义也是同理)。SQLAlchemy 的 Column 类就是描述符的实现者。定义一个模型对象的时候,以声明风格写出这个模型对应的数据表所拥有的属性:

1

2

3

4

5


class User(Jsonizable, LowerIdMixin, db.Model):

"""The account of the user."""

 

email = db.Column(db.String(64), unique=True, nullable=False)

nickname = db.Column(db.Unicode(20))


emailnickname 描述符将托管今后所有 User 的实例中,对于这两个同名属性的访问。SQLAlchemy 在映射对象关系的时候,可以用这个特性实现许多特性,比如延迟加载——直到访问 user.roles 的时候才执行 SQL 语句查询 role 表并构造 Role 对象集返回。

 

 

 

表现良好的修饰器

当有一个修饰器显示在函数上头时,就表示原始函数与修饰之后的函数有了明显的不同:

>>> def bar():
... ''' function bar() '''
... pass>>> bar.__name__, bar.__doc__, bar.__module__
('bar','function bar()','__main__')>>> import inspect
>>> inspect.getargspec(bar)
([], None, None, None)>>> bar2=show_call(bar)
>>> bar2.__name__, bar2.__doc__, bar2.__module__
('shown', None, '__main__')>>> inspect.getargspec(bar2)
([],'args','kwargs',None)

从上面的例子可以看到,函数属性并没有拷贝给包装之后的函数,原函数的属性可以通过拷贝的方法,保留给包装后的函数。下面是一个更好的show_call():

def show_call(f):
def shown(*args, **kwds):
print 'Calling', f.__name__
return f(*args, **kwds)
shown.__name__ = f.__name__
shown.__doc__ = f.__doc__
shown.__module__ = f.__module__
shown.__dict__.update(f.__dict__)
return shown

改进后的版本,尽管属性正确了,但是其署名还是错误:

>>> bar2=show_call(bar)
>>> bar2.__name__, bar2.__doc__, bar2.__module__
('bar', ' Function bar() ', '__main__')

python2.5中的functools模块避免了你写上面冗长而无聊的代码,它提供了一个修饰器的修饰器,名叫wrap()。上面的例子可以写成如下形式:

>>> def show_call(f):
... @wraps(f)
... def shown(*args, **kwds):
... print 'Calling', f.__name__
... return f(*args, **kwds)
... return shown>>> bar2=show_call(bar)
>>> bar2.__name__, bar2.__doc__, bar2.__module__
('bar', ' Function bar() ', '__main__')

带参数的修饰器

你可能注意到上面那个所谓的修饰器的修饰器很奇怪,因为它居然接受一个参数。那它是怎么工作的呢?

想像我们有这样一段代码:

@wraps(f)
def do_nothing(*args, **kwds):
return f(*args, **kwds)

它实际等价于:

def do_nothing(*args, **kwds):
return f(*args, **kwds)
do_nothing = wraps(f)(do_nothing)

为了使上面的属性保留有意义,wraps()必须是一个修饰器工厂,这个工厂返回的值本身就是一个修饰器。很绕吧!总之,wraps()是一个返回值为函数的函数,返回的函数是一个以函数为参数并返回函数的函数。呵呵。

举个简单的例子吧。我们希望一个修饰器反复调用它所修饰的函数。对于一个固定的调用次数,可以写成如下:

>>> def repeat3(f):
... def inner(*args, **kwds):
... f(*args, **kwds)
... f(*args, **kwds)
... return f(*args, **kwds)
... return inner
>>> f3=repeat3(foo)
>>> f3()
foo here
foo here
foo here

但是我们想传递一个参数控制反复调用的次数。这样我们需一个函数它的返回值是一个修饰器。这个返回的修饰器将与上面的repeat3()很相似。它需要另一层包装:

>>> def repeat(n):
... def repeatn(f):
... def inner(*args, **kwds):
... for i in range(n):
... ret = f(*args, **kwds)
... return ret
... return inner
... return repeatn

这里的repeat()就是一个修饰器工厂,repeatn()是实际上是一个修饰器,而inner()是被调用的包装函数。下面是使用这个修饰器的语法:

>>> @repeat(4)
... def bar():
... print 'bar here'
>>> bar()
bar here
bar here
bar here
bar here