Python中的描述符

本文翻译自python descriptor guide

摘要

本文定义了描述符,总结了其中的协议,并且介绍如何调用描述符。在此基础上,本文展示了一个自定义的描述符和几个内置的Python描述符,包括函数,属性,静态方法和类方法。对于这些内置的描述符,本文通过提供编写相同功能的python函数来展示其工作机制。

学习描述符不仅可以学会使用工具,还可以更深入地了解Python的工作原理,并了解其设计的优雅之处。

定义和介绍

一般来讲,描述符是具有“绑定行为”的对象属性,其属性访问已被描述符协议中的方法覆盖。这些方法是__get__(),__set__()__delete__()。如果为一个对象定义了这些方法中的任何一个,那么它将被称为一个描述符。

属性访问的默认行为是从对象的字典中获取,设置或者删除属性。比如,a.x的查找过程是:先找a.__dict__['x'],再找type(a).__dict__['x'],然后遍历type(a)的基类。如果查找的值在定义了描述符方法的对象中,那么Python可以重写默认行为并调用描述符方法。在这种情况下,查找的顺序由定义的描述符方法决定。请注意,描述符仅仅对新式类(继承自object或者type)生效。

描述符是一个强大的通用协议。它们是属性,方法,静态方法,类方法和super()背后的机制。新式类(Python2.2版本引入)的实现使用了描述符。描述符简化了底层的C代码,并为日常的Python程序提供了一套灵活的新工具。

描述符协议

descr.__get__(self, obj, type=None) --> value
descr.__set__(self, obj, value) --> None
descr.__delete__(self, obj) --> None

这就是所有的了。对象如果定义了上面方法中的任何一个,它会被认为是一个描述符,并且可以在属性被查找时覆盖默认行为。

如果一个对象定义了__get__()__set__(),它会被认为是一个数据描述符。仅定义__get__()的描述符被称为非数据描述符(它们通常用于方法,但可能有其他用途)。在计算实例字典如果实例的字典具有与数据描述符相同名称的条目,则数据描述符优先调用。如果实例的字典具有与非数据描述符相同名称的条目时,字典条目优先调用。

要创建只读数据描述符,需要定义__get__()__set__(),并且在__set__()被调用时抛出AttributeError异常。

描述符的调用

描述符可以通过其方法名直接调用,比如d.__get__(obj)

更常见的是在访问属性时自动调用描述符。例如,obj.d会在obj的字典中查找d。如果d中定义了__get__()方法,则根据下面列出的优先级规则调用d.__get__(obg)

调用的细节取决于obj是一个对象还是一个类。

对于对象来说,其中的机制在于object.__getattribute__(),它将b.x转换成type(b).__dict__['x'].__get__(b, type(b))。这个方法实现了一个优先级链,在该链中,数据描述符优先级高于实例变量,实例变量优先级高于非数据描述符,如果定义了__getattr__(),那么它的优先级最低。其中的完整实现见PyObject_GenericGetAttr(),其位于Objects/object.c

对类来说,其中的机制在于type.__getattribute__()B.x转换成B.__dict__['x'].__get__(None, B)。用Python来实现的话,类似下面:

def __getattribute__(self, key):
    "Emulate type_getattro() in Objects/typeobject.c"
    v = object.__getattribute__(self, key)
    if hasattr(v, '__get__'):
        return v.__get__(None, self)
    return v

以下要点要记住:

super()方法返回的对象也有一个自定义的__getattribute__()方法来调用描述符。super(B, obj).m()被调用时,会先在obj.__class__.__mro__中查找与B紧邻的基类A,然后返回A.__dict__['m'].__get__(obj, B)。如果结果不是描述器,返回m。如果m不再实例字典中,则回溯调用object.__getattribute__()继续查找。

注意,在Python2.2中,如果m是数据描述符,super(B, obj).m()只会调用__get__()。在Python2.3中,除非涉及旧式类,非数据描述符也会被调用。具体的实现细节见Objects/typeobject.csuper_getattro()方法。

上面的细节显示,object,typesuper中描述符的实现是在__getattribute__()方法中。如果继承自object或者元类提供了类似的功能,类也会继承这个机制。类似的,可以通过重写__getattribute__()](https://docs.python.org/2/方法来关闭描述符调用

描述器例子

下面的代码创建一个类,其对象是数据描述符,会在每次setget时打印一条信息。

In [1]: class RevealAccess(object):
   ...:     """A data descriptor that sets and returns values normally and prints a message logging their access.
   ...:     """
   ...:     def __init__(self, initval=None, name='var'):
   ...:         self.val = initval
   ...:         self.name = name
   ...:     def __get__(self, obj, objtype):
   ...:         print 'Retrieving', self.name
   ...:         return self.val
   ...:     def __set__(self, obj, val):
   ...:         print 'Updating', self.name
   ...:         self.val = val
   ...:

In [2]: class MyClass(object):
   ...:     x = RevealAccess(10, 'var "x"')
   ...:     y = 5
   ...:

In [3]: m = MyClass()

In [4]: m.x
Retrieving var "x"
Out[4]: 10

In [5]: m.x = 20
Updating var "x"

In [6]: m.x
Retrieving var "x"
Out[6]: 20

In [7]: m.y
Out[7]: 5

这个协议很简单,并提供让人兴奋的可能性。其中有几个用例非常常见,已经被封装成函数调用。属性,绑定和未绑定的方法,静态方法和类方法都是基于描述符协议。

属性

property()是一种简单构建数据描述符的方法,它在访问属性时触发函数调用。其原型如下:

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

下面的代码展示了一了典型应用,定义一个托管属性x

class C(object):
    def getx(self): return self.__x
    def setx(self): self.__x = value
    def delx(self): del self.__x
    x = property(getx, setx, delx, "I'm the 'x' property.")

下面是一个Python版本的property()实现

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 set attribute")
            
    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__)

当用户接口已经被授权访问属性后,需求发生一些变化,需要对属性做进一步处理才能返回给用户时,property()就很有用了。

比如,电子表格类可能通过Cell('b10').value来访问单元格的值。后续的需求需要每次访问时重新计算单元,但是程序员不想修改现有的客户端逻辑。解决方案是用property数据描述符来装饰value属性。

class Cell(object):
    ...
    def getvalue(self, obj):
        "Recalculate cell before returning value"
        self.recalc()
        return obj._value
    value = property(getvalue)

函数和方法

Python的面向对象特性是在函数的基础上构建起来的。使用非数据描述符,两者可以无缝联合。

类的字典会将方法存储为函数。在类的定义中,方法使用def lambda来创建,就像声明函数一样。方法与函数的唯一区别是,方法的第一个参数是为对象实例保留的。Python中,实例引用被约定称为self,实际使用this或者其他变量名称都是可以的。

为了支持方法调用,函数包括了在属性访问期间绑定__get__()的方法。这意味着所有函数都是非数据描述符,根据从对象调用和从类调用,它们返回绑定或者未绑定的方法,如下是Python的一个实现

class Function(object):
    ...
    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() int Objects/funcobject.c"
        return types.MethodType(self, obj, objtype)

运行解释器来实际看一下(译者使用的是ipython)。

In [1]: class D(object):
   ...:     def f(self, x):
   ...:         return x
   ...:

In [2]: d = D()

In [3]: D.__dict__['f']  #在内部以函数的形式存储
Out[3]: <function __main__.f>

In [4]: D.f              #从类访问得到未绑定的方法
Out[4]: <unbound method D.f>

In [5]: d.f              #从对象访问得到一个绑定的方法。
Out[5]: <bound method D.f of <__main__.D object at 0x1101edd10>>

从上面输出表明,绑定和未绑定的方法是两种不同的类型。虽然可以两种类型分开实现,但是在实际的C实现中,是同一个对象通过im_self这个字段来来进行区分标识的,见 Objects/classobject.c中的PyMethod_Type

同样的,im_self字段也影响了调用一个方法对象的效果。如果这个字段被设置(绑定),原始函数会被调用,并且如预期一样将第一个参数设置为实例。如果没有绑定,所有参数将不变地传递到原始函数。instancemethod_call()的实际实现会稍微复杂一些,因为其中涉及到一些类型检查。

静态方法和类方法

非数据描述符提供了用于将绑定函数的通常模式变化为方法的简单机制。

简单的说,函数有一个__get__()方法,以便在作为属性访问时将它们转换为方法。非数据描述符将obj.f(*args)转换为f(obj, *args)。调用kclass.f(*args)会变成f(*args)

下图总结了绑定及两个最有用的变体。

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(kclass, *args)

静态方法会返回没有更改的底层函数。调用c.f或者是C.f等价交换于直接查找object.__getattribute__(c, "f")或者obj.__getattribute__(C, "f")。因此,该函数从对象或者类中可以同等访问。

那些不需要self变量的适合写成静态方法。

比如,统计程序包中可能会使用container类来封装实验数据。该类提供了用于计算平均数,中位数,以及其他描述性统计指标的方法。然而,可能存在部分功能,概念上相关但是不依赖于数据。例如,erf(x)是在统计工作中出现的转换例程,但不直接依赖于特定的数据集。类或者是对象可以调用它:s.erf(1.5) --> .9332或者Sample.erf(1.5) --> .9332

既然静态方法会将底层的函数原样返回,那么下面的代码就不会让人奇怪了。

In [1]: class E(object):
    def f(x):
        print x
    f = staticmethod(f)
   ...:

In [2]: E.f(3)
3

In [3]: E().f(3)
3

使用非数据描述符协议,Python版本的staticmethod()如下:

class StaticMethod(object):
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"
    def __init__(self, f):
        self.f = f
        
    def __get__(self, obj, objtype=None):
        return self.f

和静态方法不同,类方法在调用函数之前会将类引用预置到参数列表。不管调用者是对象还是类,都是相同的。

In [1]: class E(object):
   ...:     def f(klass, x):
   ...:         return klass.__name__, x
   ...:     f = classmethod(f)
   ...:

In [2]: print E.f(3)
('E', 3)

In [3]: print E().f(3)
('E', 3)

在函数只需要关心类引用儿不需要关心任何底层数据时,这个特性是很有用的。类方法的一个用途是创建构造函数。在Python2.3中,类方法dict.fromkeys()会依据一个key列表来创建一个新的字典。等价的Python实现如下。

class Dict(object):
    ...
    def fromkeys(klass, iterable, value=None):
        "Emulate dict_fromkeys() in Object/dictobject.c"
        d = klass()
        for key in iterable:
            d[key] = value
        return d
    fromkeys = classmethod(fromkeys)

现在,可以通过如下方式创建一个字典:

In [1]: dict.fromkeys("abracadabra")
Out[1]: {'a': None, 'b': None, 'c': None, 'd': None, 'r': None}

采用非数据描述符,Python版本的classmethod()实现如下:

class ClassMethod(object):
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"
    
    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

“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意简书平台在接获有关著作权人的通知后,删除文章。”

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容