天天看点

python之属性描述符(上)

文章目录

        • 关于属性描述符
          • 属性描述符1:
          • 属性描述符2:自动获取储存属性的名称
          • 特性工厂函数与描述符类比较
        • 一种新型属性描述符

学会描述符之后,不仅有更多的工具集可用,还会对 Python 的运作方式有更深入的理解,并由衷赞叹 Python 设计的优雅。 ——Raymond Hettinger Python 核心开发者和专家

关于属性描述符

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

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

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

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

定义一个 Quantity 描述符,LineItem 类会用到两个 Quantity 实例:一个用于管理 weight 属性,另一个用于管理 price 属性。示意图有助于理解,如图:

python之属性描述符(上)

图解:LineItem 类的 UML 示意图,用到了名为 Quantity 的描述符类。UML 示意图中带下划线的属性是类属性。注意,weight 和 price 是依附在 LineItem 类上的 Quantity 类的实例,不过 LineItem 实例也有自己的 weight 和 price 属性,存储着相应的值、

“weight”这个词出现了两次,因为其实有两个不同的属性都叫 weight:一个是 LineItem 的类属性,另一个是各个 LineItem 对象的实例属性。price 也是如此。

  • 描述符类:实现描述符协议的类。(Quantity类)
  • 托管类:把描述符实例声明为类属性的类。(LineItem类)
  • 描述符实例:描述符实例声明为类属性的类。(例

    各个描述符实例使用箭头和带下划线的名称表示(在 UML 中,下划线表示类属性)。与黑色菱形接触的 LineItem 类包含描述符实例。)

  • 托管实例:托管类的实例。(LineItem 实例是托管实例(没在类图中展示)。)
  • 存储属性:储存属性

    托管实例中存储自身托管属性的属性。(LineItem 实例的 weight 和 price 属性是储存属性。这种属性与描述符实例不同,描述符属性都是类属性。)

  • 托管属性

    托管类中由描述符实例处理的公开属性,值存储在储存属性中。也就是说,描述符实例和储存属性为托管属性建立了基础。

重点: Quantity实例是LineItem类的类属性。

python之属性描述符(上)

带有 MGN(Mills & Gizmos Notation,机器和小怪兽图示法)注解的 UML 类图:类是机器,用于生产小怪兽(实例)。Quantity 机器生产了两个圆头的小怪兽,依附到 LineItem 机器上,即 weight 和 price。LineItem 机器生产方头的小怪兽,有自己的 weight 和 price 属性,存储着相应的值。‘’

在 MGN 中,类画成“机器”,这是一种复杂的设备,用于生产小怪兽。类(机器)都是有操控杆和刻度盘的设备。小怪兽是实例,外观更简洁。小怪兽与生产它的机器具有相同的颜色。

python之属性描述符(上)

LineItem 实例画成表格中的行,各有三个单元格,表示三个属性(description、weight 和 price)。Quantity 实例是描述符,因此有个放大镜,用于获取值(__get__),以及一个手抓,用于设置值(__set__)。

属性描述符1:

使用 Quantity 描述符管理 LineItem 的属性:

class Quantity:

    def __init__(self, storage_name):
        self.storange_name = storage_name

    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.storange_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
           

优缺陷:

描述符基于协议实现,无需创建子类。

Quantity 实例有个 storage_name 属性,这是托管实例中存储值的属性的名称。

尝试为托管属性赋值时,会调用 __set__ 方法。这里,self 是描述符实例(即 LineItem.weight 或 LineItem.price),instance 是托管实例(LineItem 实例),value 是要设定的值。

这里,必须直接处理托管实例的 __dict__ 属性;如果使用内置的 setattr 函数,会再次触发 __set__ 方法,导致无限递归。

第一个描述符实例绑定给 weight 属性。

第二个描述符实例绑定给 price 属性。

各个托管属性的名称与储存属性一样,而且读值方法不需要特殊的逻辑,所以 Quantity 类不需要定义 get 方法。

s = LineItem("辣鸡", 60, 0)
print(s.price)
print(s.subtotal())
           

运行结果:

python之属性描述符(上)

编写__set__ 方法时,要记住 self 和 instance 参数的意思:self 是描述符实例,instance 是托管实例。管理实例属性的描述符应该把值存储在托管实例中。因此,Python 才为描述符中的那个方法提供了 instance 参数。

你可能想把各个托管属性的值直接存在描述符实例中,但是这种做法是错误的。也就是说,在 set 方法中,应该像下面这样写:

instance.__dict__[self.storange_name] = value
           

错误示范:

# 错误示范
self.__dict__[self.storange_name] = value
           

解析:

__set__ 方法前两个参数(self 和 instance)的意思。这里,self 是描述符实例,它其实是托管类的类属性。同一时刻,内存中可能有几千个 LineItem 实例,不过只会有两个描述符实例:LineItem.weight 和 LineItem.price。因此,存储在描述符实例中的数据,其实会变成 LineItem 类的类属性,从而由全部 LineItem 实例共享。

上面例子的缺点:在托管类的定义体中实例化描述符时要重复输入属性的名称。

属性描述符2:自动获取储存属性的名称

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

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

如图:现在,Quantity 类既有 __get__ 方法,也有 __set__ 方法;LineItem 实例中储存属性的名称是生成的,_Quantity#0 和 _Quantity#1

python之属性描述符(上)

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

示例:

class Quantity:
    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = "_{0}#{1}".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:
            setattr(instance, 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
           
  • __counter 是 Quantity 类的类属性,统计 Quantity 实例的数量.
  • cls 是 Quantity 类的引用。
  • 每个描述符实例的 storage_name 属性都是独一无二的,因为其值由描述符类的名称和 __counter 属性的当前值构成(例如,_Quantity#0)。
  • 递增 __counter 属性的值。
  • 我们要实现 get 方法,因为托管属性的名称与 storage_name 不同。稍后会说明 owner 参数。
  • 使用内置的 getattr 函数从 instance 中获取储存属性的值。
  • 使用内置的 setattr 函数把值存储在 instance 中。

重点:不用把托管属性的名称传给 Quantity 构造方法。

这里可以使用内置的高阶函数 getattr 和 setattr 存取值,无需使用 instance.dict,因为托管属性和储存属性的名称不同,所以把储存属性传给 getattr 函数不会触发描述符,不会像上面示例1那样出现无限递归。

如果想使用 Python 矫正名称的约定方式(例如 _LineItem__quantity0),要知道托管类(即 LineItem)的名称,可是,解释器要先运行类的定义体才能构建类,因此创建描述符实例时得不到那个信息。不过,对这个示例来说,为了防止不小心被子类覆盖,不用包含托管类的名称,因为每次实例化新的描述符,描述符类的 __counter 属性都会递增,从而确保每个托管类的每个储存属性的名称都是独一无二的。

输出:

python之属性描述符(上)

结果为:100 20 2000

__get__ 方法有三个参数:self、instance 和 owner。owner 参数是托管类(如 LineItem)的引用,通过描述符从托管类中获取属性时用得到。如果使用 LineItem.weight 从类中获取托管属性(以 weight 为例),描述符的 __get__ 方法接收到的 instance 参数值是 None。因此,下述控制台会话才会抛出 AttributeError 异常:

python之属性描述符(上)
python之属性描述符(上)

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

为了给用户提供内省和其他元编程技术支持,通过类访问托管属性时,最好让 __get__ 方法返回描述符实例。示=做了小幅改动,为 Quantity.__get__ 方法添加了一些逻辑。

示例:new.py:

class Quantity:
    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = "_{0}#{1}".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")
           

如果不是通过实例调用,返回描述符本身。

否则,像之前一样,返回托管属性的值。

new_1.py:

import new as model

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

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

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


print(LineItem.weight)
a = LineItem("test", 25, 4)
print(a.price, a.weight, a.subtotal())
           
  • 导入 model_v4c 模块,指定一个更友好的名称。
  • 使用 model.Quantity 描述符。
  • Django 用户会发现,示例非常像模型定义。这不是巧合:Django 模型的字段就是描述符。就目前的实现来说,Quantity 描述符能出色地完成任务。它唯一的缺点是,储存属性的名称是生成的(如 _Quantity#0),导致用户难以调试。但这是不得已而为之,如果想自动把储存属性的名称设成与托管属性的名称类似,需要用到类装饰器或元类。

    描述符在类中定义,因此可以利用继承重用部分代码来创建新描述符。

你可能觉得就为了管理几个属性而编写这么多代码不值得,但是要知道,描述符逻辑现在被抽象到单独的代码单元(Quantity 类)中了。通常,我们不会在使用描述符的模块中定义描述符,而是在一个单独的实用工具模块中定义,以便在整个应用中使用——如果开发的是框架,甚至会在多个应用中使用。

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

使用特性工厂函数实现与描述符类相同的功能。

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

    storage_name = "_{}#{}".format("quantity", quantity.counter)

    def a_getter(instance):
        return getattr(instance, storage_name)

    def a_setter(instance, value):
        if value > 0:
            setattr(instance, storage_name, value)
        else:
            raise ValueError("value must be > 0")

    return property(a_getter, a_setter)
           

比较:

  • 没有 storage_name 参数。
  • 不能依靠类属性在多次调用之间共享 counter,因此把它定义为 quantity 函数自身的属性。
  • 如果 quantity.counter 属性未定义,把值设为 0。
  • 我们也没有实例变量,因此创建一个局部变量 storage_name,借助闭包保持它的值,供后面的 qty_getter 和 qty_setter 函数使用。
  • 描述符类可以使用子类扩展;但若想重用工厂函数中的代码,除了复制粘贴,很难有其他方法。

    使用函数属性和闭包保持状态相比,在类属性和实例属性中保持状态更易于理解。

一种新型属性描述符

有机食物网店遇到一个问题:不知怎么回事儿,有个商品的描述信息为空,导致无法下订单。为了避免出现这个问题,我们要再创建一个描述符,NonBlank。在设计 NonBlank 的过程中,我们发现,它与 Quantity 描述符很像,只是验证逻辑不同。

创建:

  • AutoStorage

    自动管理储存属性的描述符类。

  • Validated

    -扩展 AutoStorage 类的抽象子类,覆盖 set 方法,调用必须由子类实现的 validate 方法。

会重写 Quantity 类,并实现 NonBlank,让它继承 Validated 类,只编写 validate 方法。类之间的关系见图:

python之属性描述符(上)

一个模板方法用一些抽象的操作定义一个算法,而子类将重定义这些操作以提供具体的行为。

#  重构后的的描述符类

import abc


class AutoStotage:
    __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, AutoStotage):
    def __set__(self, instance, value):
        value = self.validated(instance, value)
        super().__set__(instance, value)

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


class Quantity(Validated):
    """一个大于零的数字"""

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


class NonBlank(Validated):
    """一个非空格字符串"""

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

AutoStorage 类提供了之前 Quantity 描述符的大部分功能……

❷ ……验证除外。

❸ Validated 是抽象类,不过也继承自 AutoStorage 类。

❹ set 方法把验证操作委托给 validate 方法……

❺ ……然后把返回的 value 传给超类的 set 方法,存储值。

❻ 在这个类中,validate 是抽象方法。

❼ Quantity 和 NonBlank 都继承自 Validated 类。

❽ 要求具体的 validate 方法返回验证后的值,借机可以清理、转换或规范化接收的数据。这里,我们把 value 首尾的空白去掉,然后将其返回。

脚本的用户不需要知道全部细节。用户只需知道,他们可以使用 Quantity 和 NonBlank 自动验证实例属性。如下面:

from . import test as model


class LineItem:
    description = model.NonBlank()
    weight = model.Quantity()
    price = model.Quantity()

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

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

示例演示了描述符的典型用途——管理数据属性。这种描述符也叫覆盖型描述符,因为描述符的 __set__ 方法使用托管实例中的同名属性覆盖(即插手接管)了要设置的属性。不过,也有非覆盖型描述符。