Python类与对象学习心得-9:使用延迟计算属性

延迟计算属性是一种只读属性,只在访问的时候才会计算结果。但是一旦被访问后,结果值就被缓存起来,不用每次都去计算了。

 

 

定义一个延迟属性的一种高效方法是使用一种特殊的描述符类(descriptor),如下所示:

class lazyproperty:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            value = self.func(instance) # 通过读取 c.area,执行 area(c)
            setattr(instance, self.func.__name__, value) # c.__dict__[area] = value
            return value

你需要像下面这样在一个类中使用它:

import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @lazyproperty
    def area(self):  # 此处相当于执行了 area = lazyproperty(area),类属性 area 指向一个描述符实例
        print('Computing area')
        return math.pi * self.radius ** 2

    @lazyproperty
    def perimeter(self): # 此处相当于执行了 perimeter = lazyproperty(perimeter),类属性 perimeter 指向一个描述符实例
        print('Computing perimeter')
        return 2 * math.pi * self.radius

下面在一个交互环境中演示它的使用:

>>> c = Circle(4.0)
>>> c.radius
4.0
>>> c.area
Computing area
50.26548245743669
>>> c.area
50.26548245743669
>>> c.perimeter
Computing perimeter
25.132741228718345
>>> c.perimeter
25.132741228718345
>>>

仔细观察你会发现消息 Computing area 和 Computing perimeter 仅仅出现一次。

这是一种描述符的比较精妙的用法,需要特别解释一下。

当一个描述符实例被放入一个类的定义时(即用一个类属性变量指向它),每次访问该属性时它的 __get__() 、__set__() 和 __delete__() 方法就会被触发。不过,如果一个描述符仅仅只定义了一个 __get__() 方法的话,只有当该属性不在实例底层的字典中时 __get__() 方法才会被触发。

lazyproperty 类利用这一点,使用 __get__() 方法在实例中存储计算出来的值,这个实例使用相同的名字作为它的 property。这样一来,结果值被存储在实例字典中并且以后就不需要再去计算这个 property 了。你可以尝试更深入的例子来观察结果:

>>> c = Circle(4.0)
>>> vars(c) # Get instance variables,即 c.__dict__
{'radius': 4.0}
>>> # Compute area and observe variables afterward
>>> c.area
Computing area
50.26548245743669
>>> vars(c)
{'area': 50.26548245743669, 'radius': 4.0}
>>> # Notice access doesn't invoke property anymore
>>> c.area
50.26548245743669
>>> # Delete the variable and see property trigger again
>>> del c.area
>>> vars(c)
{'radius': 4.0}
>>> c.area
Computing area
50.26548245743669
>>>

这种方案有一个小缺陷,就是计算出的值被创建后是可以被修改的。例如:

>>> c.area
Computing area
50.26548245743669
>>> c.area = 25
>>> c.area
25
>>>

如果你担心这个问题,那么可以使用一种稍微没那么高效的实现(利用函数闭包),就像下面这样:

def lazyproperty(func):
    name = '_lazy_' + func.__name__
    @property
    def lazy(self):
        if hasattr(self, name): # 此时实例存储属性与类托管属性不相同,可以放心使用这些内置函数
            return getattr(self, name)
        else:
            value = func(self)
            setattr(self, name, value)
        return value
    return lazy

 

如果你使用这个版本,就会发现现在修改操作已经不被允许了:

>>> c = Circle(4.0)
>>> c.area
Computing area
50.26548245743669
>>> c.area
50.26548245743669
>>> c.area = 25
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>>

然而,这种方案有一个缺点就是所有 get 操作都必须被定向到特性(property)的 getter 函数上去。这个跟之前简单地在实例字典中查找值的方案相比效率要低一点。

为了让大家充分彻底地理解上面不同的实现方式,需要在这里对描述符类(包括特性)做一个完整的总结。

覆盖型与非覆盖型描述符对比

之前讲过,Python 存取属性的方式特别不对等。通过实例读取属性时,通常返回的是实例中定义的属性;但是,如果实例中没有指定的属性,那么会获取类属 性。而为实例中的属性赋值时,通常会在实例中创建属性,根本不影响类。

这种不对等的处理方式对描述符也有影响。其实,根据是否定义 __set__ 方法,描述符可分为两大类。若想观察这两类描述符的行为差异,则需要使用如下示例中的几个类:

### 辅助函数,仅用于显示 ###
def cls_name(obj_or_cls):
    cls = type(obj_or_cls) #  等同于 obj_or_cls.__class__
    if cls is type: # 即传入的参数 obj_or_cls 本身是一个 class
        cls = obj_or_cls
    return cls.__name__.split('.')[-1]

def display(obj):
    cls = type(obj)
    if cls is type: # 即传入的参数 obj 本身是一个 class
        return '<class {}>'.format(obj.__name__)
    elif cls in [type(None), int]: # 传入的参数 obj 是 None 或一个整数对象
        return repr(obj)
    else:
        return '<{} object>'.format(cls_name(obj))

def print_args(name, *args):
    pseudo_args = ', '.join(display(x) for x in args)
    print('-> {}.__{}__({})'.format(cls_name(args[0]), name, pseudo_args))

### 对这个示例重要的类 ###
class Overriding: # 有 __get__ 和 __set__ 方法的典型覆盖型描述符
    """也称数据描述符或强制描述符"""
    def __get__(self, instance, owner):
        print_args('get', self, instance, owner) # 在这个示例中,各个描述符的每个方法都调用 print_args 函数

class OverridingNoGet: # 没有 __get__ 方法的覆盖型描述符
    """没有'__get__'方法的覆盖型描述符"""
    def __set__(self, instance, value):
        print_args('set', self, instance, value)

class NonOverriding: # 没有 __set__ 方法,所以这是非覆盖型描述符
"""也称非数据描述符或遮盖型描述符"""
    def __get__(self, instance, owner):
        print_args('get', self, instance, owner)

class Managed: # 托管类,使用每种描述符类的一个实例
    over = Overriding()
    over_no_get = OverridingNoGet()
    non_over = NonOverriding()

    def spam(self): # spam 方法放在这里是为了对比,因为方法也是描述符
        print('-> Managed.spam({})'.format(display(self)))

1. 覆盖型描述符

实现 __set__ 方法的描述符属于覆盖型描述符,因为虽然描述符是类属性,但是实现 __set__ 方法的话,会覆盖对实例属性的赋值操作。特性(property)也是覆盖型描述符:如果没提供设值函数,property 类中的 __set__ 方法会抛出 AttributeError 异常,指明那个属性是只读的。如下示例测试覆盖型描述符的行为:

>>> obj = Managed()
>>> obj.over # obj.over 触发描述符的 __get__ 方法,第二个参数的值是托管实例 obj
-> Overriding.__get__(<Overriding object>, <Managed object>,
<class Managed>)
>>> Managed.over # Managed.over 触发描述符的 __get__ 方法,第二个参数(instance)的值是 None
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
>>> obj.over = 7 # 为 obj.over 赋值,触发描述符的 __set__ 方法,最后一个参数的值是 7
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
>>> obj.over # 读取 obj.over,仍会触发描述符的 __get__ 方法
-> Overriding.__get__(<Overriding object>, <Managed object>,
<class Managed>)
>>> obj.__dict__['over'] = 8 # 跳过描述符,直接通过 obj.__dict__ 属性设值
>>> vars(obj) # 确认值在 obj.__dict__ 属性中,在 over 键名下
{'over': 8}
>>> obj.over # 然而,即使有名为 over 的实例属性,Managed.over 描述符仍会覆盖读取 obj.over 这个操作。
-> Overriding.__get__(<Overriding object>, <Managed object>,
<class Managed>)

2. 没有 __get__ 方法的覆盖型描述符

通常,覆盖型描述符既会实现 __set__ 方法,也会实现 __get__ 方法,不过也可以只实现 __set__ 方法。此时,只有写操作由描述符处理。通过实例读取描述符会返回描述符对象本身,因为没有处理读操作的 __get__ 方法。如果直接通过实例的 __dict__ 属性创建同名实例属性,以后再设置那个属性时,仍会由 __set__ 方法插手接管,但是读取那个属性的话,就会直接从实例中返回新赋予的值,而不会返回描述符对象。也就是说,实例属性会遮盖描述符,不过只有读操作是如此。示例如下:

>>> obj.over_no_get # 这个覆盖型描述符没有 __get__ 方法,因此,obj.over_no_get 从类中获取描述符实例
<__main__.OverridingNoGet object at 0x665bcc>
>>> Managed.over_no_get # 直接从托管类中读取描述符实例也是如此
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.over_no_get = 7 # 为 obj.over_no_get 赋值会触发描述符的 __set__ 方法
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get # 因为 __set__ 方法没有修改属性,所以在此读取 obj.over_no_get 获取的仍是托管类中的描述符实例
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.__dict__['over_no_get'] = 9 # 通过实例的 __dict__ 属性设置名为 over_no_get 的实例属性
>>> obj.over_no_get # 现在,over_no_get 实例属性会遮盖描述符,但是只有读操作是如此
9
>>> obj.over_no_get = 7 # 为 obj.over_no_get 赋值,仍然经过描述符的 __set__ 方法处理
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get # 但是读取时,只要有同名的实例属性,描述符就会被遮盖
9

3. 非覆盖型描述符

没有实现 __set__ 方法的描述符是非覆盖型描述符。如果已设置了同名的实例属性,描述符会被遮盖,致使描述符无法处理该实例的那个属性。方法(method)是以非覆盖型描述符实现的。如下示例展示了对一个非覆盖型描述符的操作:

>>> obj = Managed()
>>> obj.non_over # obj.non_over 触发描述符的 __get__ 方法,第二个参数的值是 obj
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>,
<class Managed>)
>>> obj.non_over = 7 # Managed.non_over 是非覆盖型描述符,因此没有干涉赋值操作的 __set__ 方法
>>> obj.non_over # 现在,obj 有个名为 non_over 的实例属性,把 Managed 类的同名描述符属性遮盖掉了
7
>>> Managed.non_over # Managed.non_over 描述符依然存在,会通过类截获这次访问
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
>>> del obj.non_over # 如果把 non_over 实例属性删除了……
>>> obj.non_over # 那么,读取 obj.non_over 时,会触发类中描述符的 __get__ 方法
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>,
<class Managed>)

Python 贡献者和作者讨论这些概念时会使用不同的术语。覆盖型描述符也叫数据描述符或强制描述符。非覆盖型描述符也叫非数据描述符或遮盖型描述符。

在上述几个示例中,我们为几个与描述符同名的实例属性赋了值,结果依描述符中是否有 __set__ 方法而有所不同。

依附在类上的描述符无法控制为类属性赋值的操作。其实,这意味着为类属性赋值能覆盖描述符属性,正如下一节所述的。

4. 在类中覆盖描述符

不管描述符是不是覆盖型,为类属性赋值都能覆盖描述符。这是一种猴子补丁技术,不过在如下示例中,我们把描述符替换成了整数,这其实会导致依赖描述符的类不能正确地执行操作。

>>> obj = Managed()
>>> Managed.over = 1 # 覆盖类中的描述符属性
>>> Managed.over_no_get = 2
>>> Managed.non_over = 3
>>> obj.over, obj.over_no_get, obj.non_over # 描述符真的不见了
(1, 2, 3)

上面的示例揭示了读写类属性的另一种不对等:读取类属性的操作可以由依附在托管类上定义有 __get__ 方法的描述符处理,但是为类属性赋值的操作不会由依附在托管类上定义有 __set__ 方法的描述符处理。

若想控制设置类属性的操作,要把描述符依附在类的类上,即依附在元类(metaclass)上。默认情况下,对用户定义的类来说,其元类是 type,而我们不能为 type 添加属性。

方法(method)是描述符

在类中定义的函数属于绑定方法(bound method),因为用户定义的函数都有 __get__ 方法,所以依附到类上时,就相当于描述符。如下示例展示了方法是一种非覆盖型描述符:

>>> obj = Managed()
>>> obj.spam # obj.spam 获取的是绑定方法(bound method)对象
<bound method Managed.spam of <descriptorkinds.Managed object at 0x74c80c>>
>>> Managed.spam # 但是 Managed.spam 获取的是函数
<function Managed.spam at 0x734734>
>>> obj.spam = 7 # 如果为 obj.spam 赋值,会遮盖类属性,导致无法通过 obj 实例访问 spam 方法
>>> obj.spam
7

函数没有实现 __set__ 方法,因此是非覆盖型描述符。

从上面示例中还可以看出一个重要信息:obj.spam 和 Managed.spam 获取的是不同的对象。与描述符一样,通过托管类访问时,函数的 __get__ 方法会返回自身的引用。但是,通过实例访问时,函数的 __get__ 方法返回的是绑定方法对象:一种可调用的对象,里面包装着函数,并把托管实例(例如 obj)绑定给函数的第一个参数(即 self)。这与 functools.partial 函数的行为一致。

为了深入理解这种机制,请看如下示例:

import collections

class Text(collections.UserString):
    def __repr__(self):
        return 'Text({!r})'.format(self.data)

    def reverse(self):
        return self[::-1]

下面来分析 Text.reverse 方法:

>>> word = Text('forward')
>>> word # Text 实例的 repr 方法返回一个类似 Text 构造方法调用的字符串,可用于创建相同的实例
Text('forward')
>>> word.reverse() # reverse 方法返回反向拼写的单词
Text('drawrof')
>>> Text.reverse(Text('backward')) # 在类上调用方法相当于调用函数
Text('drawkcab')
>>> type(Text.reverse), type(word.reverse) # 注意类型是不同的,一个是 function,一个是 method
(<class 'function'>, <class 'method'>)
>>> list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])) # Text.reverse 相当于普通的函数
['diaper', (30, 20, 10), Text('desserts')]
>>> Text.reverse.__get__(word) # 函数都是非覆盖型描述符。在函数上调用 __get__ 方法时传入实例,得到的是绑定到实例上的方法
<bound method Text.reverse of Text('forward')>
>>> Text.reverse.__get__(None, Text) # 调用函数的 __get__ 方法时,如果 instance 参数的值 None,那么得到的是函数本身
<function Text.reverse at 0x101244e18>
>>> word.reverse # word.reverse 表达式其实会调用 Text.reverse.__get__(word),返回对应的绑定方法
<bound method Text.reverse of Text('forward')>
>>> word.reverse.__self__ # 绑定方法对象有个 __self__ 属性,其值是调用这个方法的实例引用
Text('forward')
>>> word.reverse.__func__ is Text.reverse # 绑定方法的 __func__ 属性是依附在托管类上那个原始函数的引用
True

绑定方法对象还有个 __call__ 方法,用于处理真正的调用过程。这个方法会调用 __func__ 属性引用的原始函数,把函数的第一个参数设为绑定方法的 __self__ 属性。这就是形参 self 的隐式绑定方式。

类函数会变成绑定方法,这是 Python 语言底层使用描述符的最好例证。

描述符用法建议

下面根据刚刚论述的描述符特征给出一些实用的结论。

使用特性(property)以保持简单

内置的 property 类创建的其实是覆盖型描述符,__set__ 方法和 __get__ 方法都实现了,即便不定义设值方法也是如此。特性的 __set__ 方法默认抛出 AttributeError: can't set attribute,因此创建只读属性最简单的方式是使用特性(properrty),这能避免下一条所述的问题。

只读描述符必须定义 __set__ 方法

如果使用描述符类实现只读属性,要记住,__get__ 和 __set__ 两个方法必须都定义,否则,实例的同名属性会遮盖描述符。只读属性的 __set__ 方法只需抛出 AttributeError 异常,并提供合适的错误消息。

用于验证的描述符可以只定义 __set__ 方法

对仅用于验证的描述符来说,__set__ 方法应该检查 value 参数获得的值,如果有效,使用描述符实例的名称为键,直接在实例的 __dict__ 属性中设置值。这样,从实例中读取同名属性的速度很快,因为不用经过 __get__ 方法处理。

仅定义 __get__ 方法的描述符可以实现高效缓存

如果只编写了 __get__ 方法,那么创建的是非覆盖型描述符。这种描述符可用于执行某些耗费资源的计算,然后为实例设置同名属性,缓存结果。同名实例属性会遮盖描述符,因此后续访问会直接从实例的 __dict__ 属性中获取值,而不会再触发描述符的
__get__ 方法。

非特殊的方法(method)可以被实例属性遮盖

由于函数和方法只实现了 __get__ 方法,它们不会处理同名实例属性的赋值操作。因此,像 my_obj.the_method = 7 这样简单赋值之后,后续通过该实例访问 the_method 得到的是数字 7——但是不影响类或其他实例。然而,特殊方法不受这个问题的影响。解释器只会在类中寻找特殊的方法,也就是说,repr(x) 执行的其实是 x.__class__.__repr__(x),因此 x 的 __repr__ 属性对 repr(x) 方法调用没有影响。

出于同样的原因,实例的 __getattr__ 属性不会破坏常规的属性访问规则。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值