目录

属性描述符

本章内容

  • 描述符协议
  • 描述符与属性覆盖
  • 方法与特性
  • 描述符使用建议
  • 描述符使用示例

1. 描述符与描述符协议

1.1 描述符概述

  • 定义: 是实现了特定协议的类 – 描述符协议
  • 作用: 管理数据属性,是对多个属性运用相同存取逻辑的一种方式
  • 用法: 创建一个描述符类实例,作为另一个类的类属性
  • 应用: property 类,方法, classmethod,staticmethod 装饰器等

1.2 相关名词

/images/fluent_python/descriptor_eg.png

|名词|定义|示例| |: —|: —|: —| |描述符类|实现描述符协议的类|Quantity 类| |托管类|把描述符实例声明为类属性的类|LineItem 类 |描述符实例|描述符类的各个实例,声明为托管类的类属性|| |托管实例|托管类的实例|LineItem 实例是托管实例| |储存属性|托管实例中存储自身托管属性的属性(存储着实际值的属性)
与描述符属性不同,描述符属性都是类属性|| |托管属性|托管类中由描述符实例处理的公开属性
值存储在储存属性中
描述符实例和储存属性为托管属性建立了基础|..|

1.3 描述符协议

__get__(self, instance, owner):

  • 调用: 通过托管类或托管实例获取属性时调用
  • 参数:
    • self: 描述符实例
    • instance: 托管实例,通过托管类调用时为None
    • owner: 托管类
  • 特性:
    • 如果 __set__ 方法同时存在,会覆盖对实例属性的读值操作
    • 如果 __set__ 方法不存在,无法覆盖对实例属性的读值操作
    • 会覆盖对类属性的读值操作

__set__(self, instance, value):

  • 调用: 为托管属性赋值时调用
  • 作用: 把值存储在托管实例中
  • 参数:
    • self: 描述符实例 – 描述符会成为类属性为所有实例共享
    • instance: 托管实例 – 应该把值存储在托管实例中
    • value: 要设定的值
  • 特性:
    • 能覆盖对实例属性的赋值操作
    • 无法覆盖对类属性的赋值操作

__delete__

  • 调用: 删除托管属性时调用

1.4 描述符与属性覆盖

描述符与实例属性

描述符分类:

  • 依据: 是否定义 __set__ 方法,描述符分为非覆盖性描述符和覆盖性描述符
  • 覆盖性描述 - A
  • 没有__get__方法的覆盖型描述符 - B
  • 非覆盖性描述符 - C
  • 附注:
    • 覆盖型描述符也叫数据描述符或强制描述符
    • 非覆盖型描述符也叫非数据描述符或遮盖型描述符

|描述符分类|实现方法|属性覆盖顺序| |: —|: —|: —| |A|__get__
__set__|描述符会同时覆盖实例属性的读值和赋值操作| |B|__set__|会覆盖实例属性的赋值操作
存在同名实例属性时读操作返回实例属性,因为描述符是类属性
不存在同名实例属性时,读值操作返回作为类属性的描述符实例本身
只能直接通过实例的__dict__ 属性创建同名实例属性| |C|__get__|描述符会被同名的实例属性覆盖,属性的读值和赋值操作不会经描述符处理|

描述符与类属性

  1. 读类属性的操作可以由依附在托管类上定义有 __get__ 方法的描述符处理
  2. 写类属性的操作不会由依附在托管类上定义有 __set__ 方法的描述符处理
  3. 类上的描述符无法控制为类属性赋值的操作,为类属性赋值会覆盖描述符
  4. 若想控制设置类属性的操作,要把描述符依附在类的类上,即依附在元类上
  5. 默认情况下,对用户定义的类来说,其元类是 type,不能为 type 添加属性,但可以自定义元类

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

  • 描述符类:
    • 代码复用: 可以使用子类扩展
    • 状态保持: 在类属性和实例属性中保持状态更易于理解
    • 代码逻辑: 描述符涉及了复杂的对象关系,和对象传递,如 self,instance 参数
  • 特性工厂函数:
    • 代码复用: 函数中的代码很难复用
    • 状态保持: 使用函数属性和闭包保持状态,难以理解
    • 代码逻辑: 特性工厂函数的代码不依赖奇怪的对象关系,容易理解
  • 结论: 从某种程度上来讲,特性工厂函数模式较简单,描述符类方式更易扩展,而且应用也更广泛

2. 方法和特性

方法:

  • 原理:
    • 用户定义的函数都有 __get__ 方法,所以依附到类上时,就相当于描述符
    • 函数没有实现 __set__ 方法,因此是非覆盖型描述符
  • 定义: 在类中定义的函数属于绑定方法(bound method)
  • 返回:
    • 通过托管类访问时,函数的 __get__ 方法会返回自身的引用
    • 通过实例访问时,函数的 __get__ 方法返回的是绑定方法对象: 一种可调用的对象
  • self 隐式绑定:
    • 绑定方法的 __self__ 属性是调用这个方法的实例的引用
    • 绑定方法的 __func__ 属性是依附在托管类上那个原始函数的引用
    • 绑定方法对象还有个 __call__ 方法,用于处理真正的调用过程
    • __call__ 会调用 __func__ 引用的原始函数,把函数的第一个参数设为绑定方法的 __self__ 属性
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import collections


class Text(collections.UserString):

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

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

>>> word = Text('forward')
>>> word 
Text('forward')
>>> word.reverse() 
Text('drawrof')
>>> Text.reverse(Text('backward')) 
Text('drawkcab')
>>> type(Text.reverse), type(word.reverse) 
(<class 'function'>, <class 'method'>)
>>> list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])) 
['diaper', (30, 20, 10), Text('desserts')]
>>> Text.reverse.__get__(word) 
<bound method Text.reverse of Text('forward')>
>>> Text.reverse.__get__(None, Text) 
<function Text.reverse at 0x101244e18>
>>> word.reverse 
<bound method Text.reverse of Text('forward')>
>>> word.reverse.__self__ 
Text('forward')
>>> word.reverse.__func__ is Text.reverse 
True

示例分析:

  • ➎ Text.reverse 相当于函数,甚至可以处理 Text 实例之外的其他对象
  • ➏ 函数是非覆盖型描述符,在函数上调用 __get__ 方法时传入实例,得到的是绑定到那个实例上的方法
  • ➐ 调用函数的 __get__ 方法时,如果 instance 参数的值是 None,那么得到的是函数本身

特性:

  • 特性是覆盖型描述符
  • 如果没提供设值函数,property 类的 __set__ 方法会抛出 AttributeError 异常,指明属性是只读的

3. 描述符用法建议

  1. 使用特性以保持简单
  • 内置的 property 类创建的其实是覆盖型描述符
  • __set__ 方法和 __get__ 方法都实现了,即便不定义设值方法也是如此
  • 特性的 __set__ 方法默认抛出 AttributeError: can’t set attribute
  • 因此创建只读属性最简单的方式是使用特性,这能避免下一条所述的问题
  1. 只读描述符必须有 __set__ 方法
  • 如果使用描述符类实现只读属性,要记住, __get__ 和 __set__ 两个方法必须都定义
  • 否则,实例的同名属性会遮盖描述符
  • 只读属性的 __set__ 方法只需抛出 AttributeError 异常,并提供合适的错误消息
  1. 用于验证的描述符可以只有 __set__ 方法
  • 对仅用于验证的描述符来说, __set__ 方法应该检查 value 参数获得的值
  • 如果有效,使用描述符实例的名称为键,直接在实例的 __dict__ 属性中设置
  • 从实例中读取同名属性的速度很快,因为不用经过 __get__ 方法处理
  1. 仅有 __get__ 方法的描述符可以实现高效缓存
  • 如果只编写了 __get__ 方法,那么创建的是非覆盖型描述符
  • 这种描述符可用于执行某些耗费资源的计算,然后为实例设置同名属性,缓存结果
  • 同名实例属性会遮盖描述符,因此后续访问会直接从实例的 __dict__ 属性中获取值, 而不会再触发描述符的 __get__ 方法
  1. 非特殊的方法可以被实例属性遮盖
  • 由于函数和方法只实现了 __get__ 方法,它们不会处理同名实例属性的赋值操作
  • 同名实例属性会遮盖函数和方法,然而,特殊方法不受这个问题的影响
  • 解释器只会在类中寻找特殊的方法
  1. 实例的非特殊方法可以被轻松地覆盖,如果要创建大量动态属性,属性名称从不受自己控制的数据中获取, 那么应该实现某种机制,过滤或转义动态属性的名称,以维持数据的健全性

4. 描述符使用示例

4.1 基础示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Quantity: # 描述符基于协议实现,无需创建子类

    def __init__(self, storage_name):
        self.storage_name = storage_name  # 托管实例中存储值的属性的名称

    def __set__(self, instance, value): #
        if value > 0:
            # 必须直接处理托管实例的 __dict__ 属性,使用内置的 setattr 函数会递归调用
            instance.__dict__[self.storage_name] = value  # <4>
        else:
            raise ValueError('value must be > 0')


class LineItem:
    weight = Quantity('weight')  # <5>
    price = Quantity('price')  # <6>

    def __init__(self, description, weight, price): # <7>
        self.description = description
        self.weight = weight
        self.price = price

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

示例分析

  • 必须直接处理托管实例的 __dict__ 属性;如果使用内置的 setattr 函数,会再 次触发 __set__ 方法,导致无限递归
  • 各个托管属性的名称与储存属性一样,而且读值方法不需要特殊的逻辑, 所以 Quantity 类不需要定义 __get__ 方法

2.2 自动获取储存属性的名称

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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  
        else:
            return getattr(instance, self.storage_name)  

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

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

示例分析

  1. __counter 是 Quantity 类的类属性,统计 Quantity 实例的数量
  2. 要实现 __get__ 方法,因为托管属性的名称与 storage_name 不同
  3. 这里可以使用内置的高阶函数 getattr 和 setattr 存取值,无需使用 instance.__dict__, 因为托管属性和储存属性的名称不同,所以把储存属性传给 getattr 函数不会触发描述符
  4. 通过类访问托管属性时,最好让 __get__ 方法返回描述符实例

2.3 描述符扩展

/images/fluent_python/descriptor_abc.png

  • AutoStorage: 自动管理储存属性的描述符类。
  • Validated: 扩展 AutoStorage 类的抽象子类,覆盖 __set__ 方法,调用必须由子类实现的 validate方法
  • NonBlank: 继承 Validated 类,只编写 validate 方法
  • 这三个类之间的关系体现了模板方法设计模式
  • 模板方法设计模式: 一个模板方法用一些抽象的操作定义一个算法,而子类将重定义这些操作以提供具体的行为
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import abc

class AutoStorage:
    __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
        else:
            return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        setattr(instance, self.storage_name, value)  # 验证除外


class Validated(abc.ABC, AutoStorage): # 抽象类,不过也继承自 AutoStorage 类

    def __set__(self, instance, value):
        value = self.validate(instance, value)  # 把验证操作委托给 validate 方法
        super().__set__(instance, value)

    @abc.abstractmethod
    def validate(self, instance, value):
        """return validated value or raise ValueError"""


class Quantity(Validated):
    """a number greater than zero"""

    def validate(self, instance, value):
        if value <= 0:
            raise ValueError('value must be > 0')
        return value


class NonBlank(Validated):
    """a string with at least one non-space character"""

    def validate(self, instance, value):
        value = value.strip()
        if len(value) == 0:
            raise ValueError('value cannot be empty or blank')
        return value

class LineItem:
    description = NonBlank()  # 用户只需知道,可以使用 NonBlank 自动验证实例属性
    weight = Quantity()
    price = Quantity()

2.4 特性工厂函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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:

Data model 一章

blog:

Descriptor HowTo Guide

Python 官方文档 HowTo 合集

Python’s Object Model

实用工具

书籍:

《 Python Cookbook(第 3 版)中文版》有很多说明描述符的诀窍

  • 6.12 读取嵌套型和大小可变的二进制结构
  • 8.10 让属性具有惰性求值的能力
  • 8.13 实现一种数据模型或类型系统
  • 9.9 把装饰器定义成类,解决了函数装饰器、描述符和方法之间相互作用的深层次问题, 说明了如何使用有 __call__ 方法的类实现函数装饰器; 如果既想装饰方法又想装饰函数,还要实现 __get__ 方法

附注