Fluent Python - Part20 属性描述符

学会描述符之后,不仅有更多的工具集可用,还会对 Python 的运作方式有更深入的理解,并由衷赞叹 Python 设计的优雅

--- Raymond Hettinger(Python 核心开发者和专家)

描述符是对多个属性运用相同存取逻辑的一种方式。例如,Django ORM 和 SQL Alchemy等ORM中的字段类型是描述符,把数据库记录中字段里的数据与 Python 对象的属性对应起来。

描述符是实现了特定协议的类,这个协议包括 __get__, __set____delete__ 方法。property 类实现了完整的描述符协议。通常,可以只实现部分协议。其实,我们在真实的代码中见到的大多数描述符只实现了 __get____set__ 方法,还有很多只实现了其中的一个。

描述符是 Python 的独有特征,不仅在应用层中使用,在语言的基础设施中也有用到。除了特性之外,使用描述符的 Python 功能还有方法及 classmethodstaticmethod 装饰器。理解描述符是精通 Python 的关键。本章的话题就是描述符。

描述符示例:验证属性

在19章,特性工厂函数借助函数式编程模式避免重复编写读值方法和设值方法。特性工厂函数是高阶函数,在闭包中存储 storage_name等设置,由参数决定创建哪些存取函数,再使用存取函数构建一个特性实例。解决这种问题的面向对象方式是描述符类。

这里继续19章的 LineItem 系列示例,把 quantity 特性工厂函数重构成 Quantity 描述符类。

LineItem类第3版:一个简单的描述符

实现了__get__, __set__, __delete__ 方法的类是描述符。描述符的用法是,创建一个实例,作为另一个类的类属性。

我们将定义一个 Quantity 描述符, LineItem 类会用到两个 Quantity 实例:一个用于管理 weight 属性,另一个用于管理 price 属性。
在这里插入图片描述

LineItem 类的 UML 示意图,用到了名为 Quantity 的描述符类。UML 示意图中带下划线的属性是类属性。注意,weight 和 price 是依附在 LineItem 类上的 Quantity 类的实例,不过 LineItem 实例也有自己的 weight 和 price 属性,存储着相应的值。注意,上图中,“weight” 这个词出现了两次,因为其实有两个不同的属性都叫 weight:一个是 LineItem 的类属性,另一个是各个 LineItem 对象的实例属性。price 也是如此。

从现在开始,我会使用下述定义。

  • 描述符类:实现描述符协议的类。即 Quantity 类。
  • 托管类:把描述符实例声明为类属性的类,即 LineItem 类。
  • 描述符实例:描述符类的各个实例,声明为托管类的类属性。在上图中,各个描述符实例使用箭头和带下划线的名称表示(在 UML 中,下划线表示类属性)。与黑色菱形接触的 LineItem 类包含描述符实例。
  • 托管实例:托管类的实例。在这个示例中,LineItem实例是托管实例(没在类图中展示)。
  • 储存属性:托管实例中存储自身托管属性的属性。在该实例中,LineItem 实例的 weightprice 属性是储存属性。这种属性与描述符属性不同,描述符属性都是类属性。
  • 托管属性:托管类中由描述符实例处理的公开属性,值存储在储存属性中,也就是说,描述符实例和储存属性为托管属性建立了基础。

Quantity 实例是 LineItem 类的类属性。

class Quantity:
    def __init__(self, storage_name):
        self.storage_name = storage_name
        
    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.storage_name] = value
        else:
            raise ValueError('value must be > 0')

class LineItem:
    weight = Quantity('weight')
    price = Quantity('price')
    
    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price
    
    def subtotal(self):
        return self.weight * self.price

该实例有个缺点,在托管类的定义体中实例化描述符时要重复输入属性的名称。如果 LineItem 类能像下面这样声明就好了:

class LineItem:
    weight = QUantity()
    price = Quantity()

下一节会介绍一个不太优雅但是可行的方案,解决这个重复输入名称的问题。更好的解决方案是使用类装饰器或元类,等下一章再介绍。

LineItem 类第4版:自动获取储存属性的名称

为了避免在描述符声明语句中重复输入属性名,我们将为每个 Quantity 实例的 storage_name 属性生成一个独一无二的字符串。
在这里插入图片描述

为了生成 storage_name, 我们以 '_Quantity#'为前缀,然后在后面拼接一个整数:Quantity.__counter 类属性的当前值,每次把一个新的 Quantity 描述符实例依附到类上,都会递增这个值。在前缀中使用#号能避免 storage_name 与用户使用点号创建的属性冲突,因为 nutmeg._Quantity#0 是无效的Python句法。但是,内置的 getattrsetattr 函数可以使用这种“无效的”标识符获取和设置属性,此外也可以直接处理实例属性__dict__

class Quantity:
    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = '_{}#{}'.format(prefix, index)
        cls.__counter += 1
        
    def __get__(self, instance, owner):
        return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.storage_name] = value
        else:
            raise ValueError('value must be > 0')

class LineItem:
    weight = Quantity()
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price

注意,__get__ 方法有三个参数:self, instanceownerowner 参数是托管类(如LineItem)的引用,通过描述符从托管类中获取属性时用得到。如果使用 LineItem.weight 从类中获取托管属性(以 weight 为例),描述符的 __get__ 方法接收到的 instance 参数值是 None

print (LineItem.weight)
"""
output:
Traceback (most recent call last):
 File "E:/code/python/py_study/test.py", line 32, in <module>
   print (LineItem.weight)
 File "E:/code/python/py_study/test.py", line 12, in __get__
   return getattr(instance, self.storage_name)
AttributeError: 'NoneType' object has no attribute '_Quantity#0'
"""

抛出 AttributeError 异常是实现 __get__方法的方式之一,如果选择这么做,应该修改错误的消息,去掉令人困惑的 NoneType_Quantity#0,这是实现细节。把错误消息改成“‘LineItem’ class has no such attribute” 更好。最好能给出缺少的属性名,但是在这个实例中,描述符不知道托管属性的名称,因此目前只能做到这样。

此外,为了给用户提供内省和其他元编程技术支持,通过类访问托管属性时,最好让 __get__ 方法返回描述符实例。

class Quantity:
   __counter = 0

   def __init__(self):
       cls = self.__class__
       prefix = cls.__name__
       index = cls.__counter
       self.storage_name = '_{}#{}'.format(prefix, index)
       cls.__counter += 1

   def __get__(self, instance, owner):
       if instance is None:
           return self
       return getattr(instance, self.storage_name)

   def __set__(self, instance, value):
       if value > 0:
           instance.__dict__[self.storage_name] = value
       else:
           raise ValueError('value must be > 0')

描述符在类中定义,因此可以利用继承重用部分代码来创建新描述符。下一节会这么做。

特性工厂函数与描述符类比较

特性工厂函数的实现:


def quantity():
    try:
        quantity.counter += 1
    except AttributeError:
        quantity.counter = 0

    storage_name = '_{}:{}'.format('quantity', quantity.counter)
    
    def qty_getter(instance):
        return getattr(instance, storage_name)
    
    def qty_setter(instance, value):
        if value > 0:
            setattr(instance, storage_name, value)
        else:
            raise ValueError('value must be > 0')
    return property(qty_getter, qty_setter)

描述符类的优势

  • 描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他的办法。
  • 与上例使用函数属性和闭包保持状态相比,在类属性和实例属性中保持状态更易于理解。

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

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

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

def cls_name(obj_or_cls):
    cls = type(obj_or_cls)
    if cls is type:
        cls = obj_or_cls
    return cls.__name__.split('.')[-1]

def display(obj):
    cls = type(obj)
    if cls is type:
        return '<class {}>'.format(obj.__name__)
    elif cls in [type(None), int]:
        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:
    """也称数据描述符或强制描述符"""
    
    def __get__(self, instance, owner):
        print_args('get', self, instance, owner)
    
    def __set__(self, instance, value):
        print_args('set', self, instance, value)
        
class OverridingNoGet:
    """没有 __get__ 方法的覆盖型描述符"""
    
    def __set__(self, instance, value):
        print_args('set',self, instance, value)
        
class NonOverriding:
    """也称非数据描述符或遮盖型描述符"""
    
    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):
        print('-> Managed.spam({})'.format(display(self)))


覆盖型描述符

obj = Managed()
print(obj.over)
print(Managed.over)
obj.over = 7
print(obj.over)
obj.__dict__['over'] = 8
print(vars(obj))
print(obj.over)
"""
output:
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
None
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
None
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
None
{'over': 8}
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
None
"""

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

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

obj = Managed()
print(obj.over_no_get)
print(Managed.over_no_get)
obj.over_no_get = 7
print(obj.over_no_get)
obj.__dict__['over_no_get'] = 9
print(obj.over_no_get)
obj.over_no_get = 7
print(obj.over_no_get)
"""
output:
<__main__.OverridingNoGet object at 0x00000257E0920B48>
<__main__.OverridingNoGet object at 0x00000257E0920B48>
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
<__main__.OverridingNoGet object at 0x00000257E0920B48>
9
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
9

"""

非覆盖型描述符

没有实现 __set__ 方法的描述符是非覆盖型描述符。如果设置了同名的实例属性,描述符会被覆盖,致使描述符无法处理那个实例的那个属性。方法是以非覆盖型描述符实现的。

obj = Managed()
print(obj.non_over)
obj.non_over = 8
print(obj.non_over)
print(Managed.non_over)
del obj.non_over
print(obj.non_over)
"""
output:
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
None
8
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
None
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
None


"""

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

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

在类中覆盖描述符

不管描述符是不是覆盖型,为类属性赋值都能覆盖描述符。

obj = Managed()
Managed.over = 1
Managed.over_no_get = 2
Managed.non_over = 3
print(obj.over, obj.over_no_get, obj.non_over)
"""
output:
1 2 3
"""

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

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

下面我们调转话题,分析 Python 是如何使用描述符实现方法的。

方法是描述符

obj = Managed()
print(obj.spam)
print(Managed.spam)
obj.spam = 8
print(obj.spam)
"""
output:
<bound method Managed.spam of <__main__.Managed object at 0x00000283EE931C88>>
<function Managed.spam at 0x00000283EE934AF8>
8
"""

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

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

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

import collections

class Text(collections.UserString):

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

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

word = Text('forward')
print(word)
print(word.reverse())
print(Text.reverse(Text('backward')))
print(type(Text.reverse), type(word.reverse))
print(list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])))
print(Text.reverse.__get__(word)) #函数都是非覆盖型描述符,在函数上调用 __get__ 方法时传入实例,得到的是绑定到那个实例上的方法
print(Text.reverse.__get__(None, Text)) # 如果 instance 参数的值是 None,那么得到的是函数本身
print(word.reverse)
print(word.reverse.__self__)# 绑定方法对象有个 __self__ 属性,其值是调用这个方法的实例的引用
print(word.reverse.__func__ is Text.reverse)

"""
output:
forward
drawrof
drawkcab
<class 'function'> <class 'method'>
['diaper', (30, 20, 10), Text('desserts')]
<bound method Text.reverse of Text('forward')>
<function Text.reverse at 0x0000016226B86DC8>
<bound method Text.reverse of Text('forward')>
forward
True

Process finished with exit code 0

"""

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

深入了解描述符和方法的运作方式之后,下面讨论用法方面的一些建议。

描述符用法建议

  • 使用特性以保持简单

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

  • 只读描述符必须有 __set__ 方法

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

  • 用于验证的描述符可以只有 __set__ 方法

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值