返回介绍

20.1 描述符示例:验证属性

发布于 2024-02-05 21:59:47 字数 16327 浏览 0 评论 0 收藏 0

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

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

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

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

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

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

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

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

描述符类

实现描述符协议的类。在图 20-1 中,是 Quantity 类。

托管类

把描述符实例声明为类属性的类——图 20-1 中的 LineItem 类。

描述符实例

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

托管实例

托管类的实例。在这个示例中,LineItem 实例是托管实例(没在类图中展示)。

储存属性

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

托管属性

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

Quantity 实例是 LineItem 类的类属性,这一点一定要理解。图 20-2 中的机器和小怪兽强调了这个关键点。

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

机器和小怪兽图示法介绍

我以前经常使用 UML 解说描述符,但是后来发现 UML 无法很好地展现类与实例之间的关系,例如托管类与描述符实例之间的关系。2 所以,我自己发明了一门“语言”——机器和小怪兽图示法(Mills & Gizmos Notation,MGN),使用它注解 UML 示意图。

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

图 20-3:MGN 简图表示,LineItem 类生产了三个实例,Quantity 类生产了两个实例。其中一个 Quantity 实例从一个 LineItem 实例中获取存储的值

在这个示例中,我把 LineItem 实例画成表格中的行,各有三个单元格,表示三个属性(description、weight 和 price)。Quantity 实例是描述符,因此有个放大镜,用于获取值(__get__),以及一个手抓,用于设置值(__set__)。讲到元类时,你会感谢我画了这些涂鸦。

2在 UML 类图中,类和实例都画成方框。虽然视觉上有区别,但是因为类图中很少出现实例,所以开发者可能认不出。

先把涂鸦放在一边,来看代码:示例 20-1 是 Quantity 描述符类和新版 LineItem 类,用到两个 Quantity 实例。

示例 20-1 bulkfood_v3.py:使用 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

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

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

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

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

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

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

❼ 类定义体中余下的代码与 bulkfood_v1.py 脚本(见示例 19-15)中的代码一样简洁。

在示例 20-1 中,各个托管属性的名称与储存属性一样,而且读值方法不需要特殊的逻辑,所以 Quantity 类不需要定义 __get__ 方法。

示例 20-1 中的代码会像预期那样运作,禁止以 0 美元销售松露:3

3一磅白松露价值几千美元。留个练习给有进取心的读者:不准以 0.01 美元的价格销售松露。我认识一个人,他以 18 美元买到了价值 1800 美元的统计学百科全书,因为那个网店(不是亚马逊)有漏洞。

>>> truffle = LineItem('White truffle', 100, 0)
Traceback (most recent call last):
  ...
ValueError: value must be > 0

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

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

  instance.__dict__[self.storage_name] = value

而不能试图使用下面这种错误的写法:

  self.__dict__[self.storage_name] = value

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

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

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

  # 余下的方法与之前一样

可问题是,正如第 8 章说过的,赋值语句右手边的表达式先执行,而此时变量还不存在。 Quantity() 表达式计算的结果是创建描述符实例,而此时 Quantity 类中的代码无法猜出要把描述符绑定给哪个变量(例如 weight 或 price)。

因此,示例 20-1 必须明确指明各个 Quantity 实例的名称。这样不仅麻烦,还很危险:如果程序员直接复制粘贴代码而忘了编辑名称,比如写成 price = Quantity('weight'),那么程序的行为会大错特错,设置 price 的值时会覆盖 weight 的值。

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

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

为了避免在描述符声明语句中重复输入属性名,我们将为每个 Quantity 实例的 storage_name 属性生成一个独一无二的字符串。图 20-4 是更新后的 Quantity 和 LineItem 类的 UML 类图。

图 20-4:示例 20-2 的 UML 类图。现在,Quantity 类既有 __get__ 方法,也有 __set__ 方法;LineItem 实例中储存属性的名称是生成的,_Quantity#0_Quantity#1

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

示例 20-2 bulkfood_v4.py:每个 Quantity 描述符都有独一无二的 storage_name

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:
       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 函数不会触发描述符,不会像示例 20-1 那样出现无限递归。

测试 bulkfood_v4.py 脚本之后你会发现,weight 和 price 描述符能按预期使用,而且储存属性也能直接读取——这对调试有帮助:

>>> from bulkfood_v4 import LineItem
>>> coconuts = LineItem('Brazilian coconut', 20, 17.95)
>>> coconuts.weight, coconuts.price
(20, 17.95)
>>> getattr(coconuts, '_Quantity#0'), getattr(coconuts, '_Quantity#1')
(20, 17.95)

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

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

>>> from bulkfood_v4 import LineItem
>>> LineItem.weight
Traceback (most recent call last):
  ...
  File ".../descriptors/bulkfood_v4.py", line 54, 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__ 方法返回描述符实例。示例 20-3 对示例 20-2 做了小幅改动,为 Quantity.__get__ 方法添加了一些逻辑。

示例 20-3 bulkfood_v4b.py(只列出部分代码):通过托管类调用时,__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  ➊
    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')

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

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

测试示例 20-3,会看到如下结果:

>>> from bulkfood_v4b import LineItem
>>> LineItem.price
<bulkfood_v4b.Quantity object at 0x100721be0>
>>> br_nuts = LineItem('Brazil nuts', 10, 34.95)
>>> br_nuts.price
34.95

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

了解这一点之后就可推知,示例 20-4 是描述符的常规用法。

示例 20-4 bulkfood_v4c.py:整洁的 LineItem 类;Quantity 描述符类现在位于导入的 model_v4c 模块中

import model_v4c 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

❶ 导入 model_v4c 模块,指定一个更友好的名称。

❷ 使用 model.Quantity 描述符。

Django 用户会发现,示例 20-4 非常像模型定义。这不是巧合:Django 模型的字段就是描述符。

 就目前的实现来说,Quantity 描述符能出色地完成任务。它唯一的缺点是,储存属性的名称是生成的(如 _Quantity#0),导致用户难以调试。但这是不得已而为之,如果想自动把储存属性的名称设成与托管属性的名称类似,需要用到类装饰器或元类,而这两个话题到第 21 章才会讨论。

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

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

特性工厂函数若想实现示例 20-2 中增强的描述符类并不难,只需在示例 19-24 的基础上添加几行代码。__counter 变量的实现方式是个难点,不过我们可以把它定义成工厂函数对象的属性,以便在多次调用之间持续存在,如示例 20-5 所示。

示例 20-5 bulkfood_v4prop.py:使用特性工厂函数实现与示例 20-2 中的描述符类相同的功能

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)

❶ 没有 storage_name 参数。

❷ 不能依靠类属性在多次调用之间共享 counter,因此把它定义为 quantity 函数自身的属性。

❸ 如果 quantity.counter 属性未定义,把值设为 0。

❹ 我们也没有实例变量,因此创建一个局部变量 storage_name,借助闭包保持它的值,供后面的 qty_getter 和 qty_setter 函数使用。

❺ 余下的代码与示例 19-24 一样,不过这里可以使用内置的 getattr 和 setattr 函数,而不用处理 instance.__dict__ 属性。

那么,你喜欢哪个?示例 20-2 还是示例 20-5 ?

我喜欢描述符类那种方式,主要有下列两个原因。

描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他方法。

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

此外,解说示例 20-5 时,我没有画机器和小怪兽的动力。特性工厂函数的代码不依赖奇怪的对象关系,而描述符的方法中有名为 self 和 instance 的参数,表明里面涉及奇怪的对象关系。

总之,从某种程度上来讲,特性工厂函数模式较简单,可是描述符类方式更易扩展,而且应用也更广泛。

20.1.3 LineItem类第5版:一种新型描述符

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

回想 Quantity 的功能,我们注意到它做了两件不同的事:管理托管实例中的储存属性,以及验证用于设置那两个属性的值。由此可知,我们可以重构,并创建两个基类。

AutoStorage

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

Validated

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

我们稍后会重写 Quantity 类,并实现 NonBlank,让它继承 Validated 类,只编写 validate 方法。类之间的关系见图 20-5。

图 20-5:几个描述符类的层次结构。AutoStorage 基类负责自动存储属性;Validated 类做验证,把职责委托给抽象方法 validateQuantityNonBlankValidated 的具体子类

Validated、Quantity 和 NonBlank 三个类之间的关系体现了模板方法设计模式。具体而言,Validated.__set__ 方法正是 Gamma 等四人所描述的模板方法的例证:

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

4《设计模式:可复用面向对象软件的基础》第 215 页。

这里,抽象的操作是验证。示例 20-6 列出图 20-5 中各个类的实现。

示例 20-6 model_v5.py:重构后的描述符类5

5因为 20.5 节有文档字符串的截图,为了保持一致,所以这里的文档字符串不翻译。——译者注

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):  ➌

  def __set__(self, instance, value):
    value = self.validate(instance, value)  ➍
    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  ➑

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

❷ ……验证除外。

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

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

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

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

❼ Quantity 和 NonBlank 都继承自 Validated 类。

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

model_v5.py 脚本的用户不需要知道全部细节。用户只需知道,他们可以使用 Quantity 和 NonBlank 自动验证实例属性。参见示例 20-7 中的最新版 LineItem 类。

示例 20-7 bulkfood_v5.py:使用 Quantity 和 NonBlank 描述符的 LineItem 类

import model_v5 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

❶ 导入 model_v5 模块,指定一个更友好的名称。

❷ 使用 model.NonBlank 描述符。其余的代码没变。

本章所举的几个 LineItem 示例演示了描述符的典型用途——管理数据属性。这种描述符也叫覆盖型描述符,因为描述符的 __set__ 方法使用托管实例中的同名属性覆盖(即插手接管)了要设置的属性。不过,也有非覆盖型描述符。下一节会详述这两种描述符之间的区别。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文