如果想创建一种全新的实例属性,自带一些额外功能(比如类型检查),你可以通过一个描述符类(descriptor)的形式来定义它的功能。描述符是对多个属性运用相同存取逻辑的一种方式,它是实现了特定协议的类,这个协议包括 __get__,__set__ 和 __delete__ 方法。特性(property)类实现了完整的描述符协议。通常,一个描述符类可以只实现其中部分协议。其实,我们在真实的代码中见到的大多数描述符只实现了 __get__ 和 __set__ 方法,还有很多只实现了其中的一个。
描述符是 Python 的独有特征,不仅在应用层中使用,在语言的基础设施中也有用到。除了特性(property)之外,使用描述符的 Python 功能还包括方法及 classmethod 和 staticmethod 装饰器。理解描述符是精通 Python 的关键。
下面是一个简单的整数描述符类的例子:
# Descriptor attribute for an integer type-checked attribute
class Integer:
def __init__(self, name):
self.name = name
def __get__(self, instance, cls):
if instance is None:
return self
else:
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError('Expected an int')
instance.__dict__[self.name] = value
def __delete__(self, instance):
del instance.__dict__[self.name]
一个描述符就是一个实现了三个核心的属性访问操作(get, set, delete) 的类,分别为 __get__() 、__set__() 和 __delete__() 这三个特殊的方法。这些方法接受一个对象实例(instance)作为输入,然后对实例底层的字典(instance.__dict__)执行相应的操作。
为了使用一个描述符,需将这个描述符的实例作为类属性放到一个类的定义中。例如:
class Point:
x = Integer('x')
y = Integer('y')
def __init__(self, x, y):
self.x = x
self.y = y
当你这样做后,所有对描述符属性(比如 x 或 y) 的访问会被描述符类的 __get__() 、__set__() 和__delete__() 方法捕获到。例如:
>>> p = Point(2, 3)
>>> p.x # Calls Point.x.__get__(p, Point)
2
>>> p.y = 5 # Calls Point.y.__set__(p, 5)
>>> p.x = 2.3 # Calls Point.x.__set__(p, 2.3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "descrip.py", line 12, in __set__
raise TypeError('Expected an int')
TypeError: Expected an int
>>>
作为输入,描述符的每一个方法会接受一个被操作的实例。为了实现请求的操作,会相应地操作实例底层的字典( __dict__ 属性)。描述符的 self.name 属性存储了在实例字典中被实际使用到的 key。
通过定义一个描述符,你可以在底层捕获核心的实例操作(get, set, delete),并且可完全自定义它们的行为。这是一个强大的工具,有了它你可以实现很多高级功能,并且它也是很多高级库和框架中的重要工具之一。
描述符的一个比较令人困惑的地方是它只能在类级别被定义,而不能为每个实例单独定义。因此,下面的代码是无法工作的:
# Does NOT work
class Point:
def __init__(self, x, y):
self.x = Integer('x') # No! Must be a class variable
self.y = Integer('y')
self.x = x
self.y = y
同时,__get__() 方法的实现比看上去要复杂:
# Descriptor attribute for an integer type-checked attribute
class Integer:
...
def __get__(self, instance, cls):
if instance is None:
return self
else:
return instance.__dict__[self.name]
__get__() 看上去有点复杂的原因归结于实例变量和类变量的不同。如果一个描述符被当做一个类变量来访问,那么 instance 参数被设置成 None 。这种情况下,标准做法就是简单地返回这个描述符实例本身即可(尽管你还可以添加其他的自定义操作)。例如:
>>> p = Point(2,3)
>>> p.x # Calls Point.x.__get__(p, Point)
2
>>> Point.x # Calls Point.x.__get__(None, Point)
<__main__.Integer object at 0x100671890>
>>>
描述符通常是那些涉及到装饰器或元类的大型程序框架中的一个组件。同时对它们的使用也被隐藏起来不可见。举个例子,下面是一些更高级的基于描述符的代码,并用到了一个类装饰器(class decorator):
# Descriptor for a type-checked attribute
class Typed:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, cls):
if instance is None:
return self
else:
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError('Expected ' + str(self.expected_type))
instance.__dict__[self.name] = value
def __delete__(self, instance):
del instance.__dict__[self.name]
# Class decorator that applies it to selected attributes
def typeassert(**kwargs):
def decorate(cls):
for name, expected_type in kwargs.items():
# Attach a Typed descriptor to the class
setattr(cls, name, Typed(name, expected_type))
return cls
return decorate
# Example use
@typeassert(name=str, shares=int, price=float)
class Stock:
def __init__(self, name, shares, price):
self.name = name
self.shares = shares
self.price = price
在我的“Python类与对象学习心得-4”中介绍了一种利用闭包来实现 quantity 特性工厂函数的例子。下面介绍另一种利用面向对象的方式即描述符来实现 quantity 特性工厂的例子:
class Quantity:
def __init__(self, storage_name):
self.storage_name = storage_name # Quantity 实例有个 storage_name 属性,这是托管实例中存储值的属性名称
# 尝试为托管属性赋值时,会调用 __set__ 方法。
# 这里,self 是描述符实例(即 LineItem.weight 或 LineItem.price),
# instance 是托管实例(LineItem 实例),
# value 是要设定的值。
def __set__(self, instance, value):
if value > 0:
# 这里必须直接处理托管实例的 __dict__ 属性;
# 如果使用内置的 setattr 函数,会再次触发 __set__ 方法,导致无限递归
instance.__dict__[self.storage_name] = value
else:
raise ValueError('value must be > 0')
class LineItem:
weight = Quantity('weight') # 第一个描述符实例绑定给 weight 属性
price = Quantity('price') # 第二个描述符实例绑定给 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 类不需要定义 __get__ 方法。
这个示例有个缺点,在托管类的定义体中实例化描述符时要重复输入属性的名称。如果 LineItem 类能像下面这样声明就好了:
class LineItem:
weight = Quantity()
price = Quantity()
# 余下的方法与之前一样
可问题是,Python 中赋值语句右手边的表达式先执行,而此时变量还不存在。Quantity() 表达式计算的结果是创建描述符实例,而此时 Quantity 类中的代码无法猜出要把描述符绑定给哪个变量(例如 weight 或 price)。
因此,上面的示例不得不明确指明各个 Quantity 实例的名称。这样不仅麻烦,还很危险:如果程序员直接复制粘贴代码而忘了编辑名称,比如写成 price = Quantity('weight'),那么程序的行为会大错特错,设置 price 的值时会覆盖 weight 的值。
下面会介绍一个不太优雅但是可行的方案,解决这个重复输入名称的问题。
为了避免在描述符声明语句中重复输入属性名,我们将为每个 Quantity 实例的 storage_name 属性生成一个独一无二的字符串。
class Quantity:
__counter = 0 # __counter 是 Quantity 类的类属性,统计 Quantity 实例的数量
def __init__(self):
cls = self.__class__ # cls 是 Quantity 类的引用
prefix = cls.__name__
index = cls.__counter
# 每个描述符实例的 storage_name 属性值由描述符类的名称和 __counter 属性的当前值构成(例如,_Quantity#0)
self.storage_name = '_{}#{}'.format(prefix, index)
cls.__counter += 1 # 递增 __counter 属性的值以保证独一无二。
def __get__(self, instance, owner): # owner 参数是托管类(如 LineItem)的引用,通过描述符从托管类中获取属性时用得到
if instance is None:
return self # 如果不是通过实例调用,返回描述符实例自身
else:
return getattr(instance, self.storage_name) # 使用内置的 getattr 函数从 instance 中获取储存属性的值
def __set__(self, instance, value):
if value > 0:
setattr(instance, self.storage_name, value) # 使用内置的 setattr 函数把值存储在 instance 中
else:
raise ValueError('value must be > 0')
class LineItem:
weight = Quantity() # 现在,不用把托管属性的名称传给 Quantity 构造方法。这是这一版的目标
price = Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
这里可以使用内置的高阶函数 getattr 和 setattr 存取值,无需使用 instance.__dict__,因为托管属性和储存属性的名称不同,所以把储存属性传给 getattr 函数不会触发描述符,不会像上一个示例那样出现无限递归。
测试示例如下:
>>> LineItem.price
<bulkfood_v4b.Quantity object at 0x100721be0>
>>> br_nuts = LineItem('Brazil nuts', 10, 34.95)
>>> br_nuts.price
34.95
看着这个示例,你可能觉得就为了管理几个属性而编写这么多代码不值得,但是要知道,描述符逻辑现在被抽象到单独的代码单元(Quantity 类)中了。通常,我们不会在使用描述符的模块中定义描述符,而是在一个单独的实用工具模块中定义描述符,以便在整个应用中使用——如果开发的是框架,甚至会在多个应用中使用。
就目前的实现来说,Quantity 描述符能出色地完成任务。它唯一的缺点是,储存属性的名称是生成的(如 _Quantity#0),导致用户难以调试。但这是不得已而为之,如果想自动把储存属性的名称设成与托管属性的名称类似,需要用到类装饰器或元类,而这两个话题到会在后面的学习心得中讨论。
特性工厂函数若想实现上面示例中增强的描述符类也不难,只需在原有的基础上添加几行代码。__counter 变量的实现方式是个难点,不过我们可以把它定义成工厂函数对象的属性,以便在多次调用之间持续存在。实现代码如下:
def quantity(): # 没有 storage_name 参数
try:
quantity.counter += 1 # 不能依靠类属性在多次调用之间共享 counter,因此把它定义 quantity 函数自身的属性
except AttributeError:
quantity.counter = 0 # 如果 quantity.counter 属性未定义,把值设为 0
storage_name = '_{}:{}'.format('quantity', quantity.counter) # 创建一个局部变量 storage_name,借助闭包保持它的值
# 余下的代码与之前一样,不过这里可以使用内置的 getattr 和 setattr 函数,而不用处理 instance.__dict__ 属性
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)
那么你喜欢哪种实现方式?描述符类还是函数闭包?
我喜欢描述符类那种方式,主要有下列两个原因:
- 描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他方法。
- 与上面实现代码中使用函数属性和闭包保持状态相比,在类属性和实例属性中保持状态更易于理解。
总之,从某种程度上来讲,特性工厂函数模式较简单,可是描述符类方式更易扩展,而且应用也更广泛。