我们上一章使用特性工厂函数编程模式避免重复写读值和设值方法,这里继续,把 quantity 特性工厂函数重构为 Quantity 描述符类
LineItem 类第三版:一个简单的描述符¶
实现了 __get__, __set__ 或 __delete__ 方法的类是描述符。描述符的用法是,创建一个实例,作为另一个类的属性
我们将定义一个 Quantity 描述符,LineItem 会用到两个 Quantity 实例,一个管理 weight 属性,一个管理 price 属性。
Quantity 实例是 LineItem 类的属性。
class Quantity: # 描述符类
def __init__(self, storage_name): # storage_name 是托管实例中存储值的属性的名称
self.storage_name = 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')
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__ 方法
truffle = LineItem('White truffle', 100, 10)
truffle.weight # 其实是通过 Quantitiy.__get__ 方法返回的
truffle.__dict__['weight'] = 13 # 真实值存在这里,用 Quantitiy 类实例覆盖了它
truffle.weight
truffle = LineItem('White truffle', 100, 0) # 代码正常运行,禁止 0 美元
编写 __set__ 方法时,要记住 self 和 instance 参数的意思:self 是描述符实例,instance 是托管实例。管理实例属性的描述符应该把值存到托管实例中,因此,Python 才为描述符中的那个方法提供了 instance 参数
你可能想把各个托管属性的值直接存在描述符,但是这种做法是错误的。也就是说,在 __set__ 方法中,应该这么写:
instance.__dict__[self.storage_name] = value
而不能试图下面这种错误的写法:
self.__dict__[self.storage_name] = value
因为 self 是描述符实例,它其实是托管类(LineItem)的属性,同一时刻,内存中可能有几个 LineItem 实例,不过只会有两个描述符实例:LineItem.weight 和 LineItem.price(因为这是类属性而不是实例属性)。因此,存储在描述符实例中的数据,其实会变成 LineItem 类的类属性,从而由全部 LineItem 实例共享
上面有个缺点,在托管类的定义体中实例化描述符时要重复输入属性的名称。如果 LineItem 类像下面这样声明就好了。
class LineItem:
weight = Quantity()
price = Quantity()
...
但问题是,赋值语句右手边表达式先执行,此时变量还不存在,Quantity() 表达式计算的结果是创建描述符实例,而此时 Quantity 类中的代码无法猜出要把描述符绑定给哪个变量(例如 weight 或 price)
因此必须明确指明各个 Quantity 实例的名称,这么不仅麻烦,而且危险,如果程序员直接复制粘贴而忘记了编辑名称,例如 price = Quantity('weight') 就会出大事
下面我们先介绍一个不太优雅的解决方案,更优雅的下章介绍
LineItem 类第四版:自动获取存储属性的名称¶
我们不用管用户传什么名称,每个 Quantity 描述符有独一无二的 storage_name 就可以了
class Quantity:
__counter = 0 #类变量,为了为不同的实例创建不同的 sorage_name
def __init__(self):
cls = self.__class__ # Quantity 类的引用
prefix = cls.__name__
index = cls.__counter
self.storage_name = '_{}#{}'.format(prefix, index) #独一无二的 storage_name
cls.__counter += 1
# 因为托管属性名与 storage_name 不同,我们要实现 __get__ 方法
# 稍后说明 owner 参数
def __get__(self, instance, owner):
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() # 不用传入托管属性名称
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
这里可以使用 getattr 函数和 setattr 获取值,无需使用 instance.dict,因为托管属性和存储属性名称不同
coconuts = LineItem('Brazilian coconut', 20, 17.95)
coconuts.weight, coconuts.price
getattr(coconuts, '_Quantity#0'), getattr(coconuts, '_Quantity#1')
get 方法有 3 个参数,self, instance 和 owner。owner 参数是托管类(如 LineItem)的引用(注意是类而不是实例,instance 是类的实例),通过描述符从托管类中获取属性时用得到。
如果使用 LineItem.weight 从类中获取托管属性,描述符 __get__ instance 参数收到的值是 None,因此会抛出 AttributeError 异常
LineItem.weight
抛出 AttributeError 异常是实现 __get__ 方法方式之一,如果选择这么做,应该修改错误信息,去掉令人困惑的 NoneType 和 _Quantity#0,改成 'LineItem' class
has no such attribute 更好。最好能给出缺少的属性名,但是在这里描述符不知道托管属性的名称,所以只能做到这样
此外,为了个用户提供内省和其它元编程技术支持,通过类访问托管属性时,最高让 __get__ 方法返回描述符实例。下面对 __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')
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
coconuts = LineItem('Brazilian coconut', 20, 17.95)
LineItem.weight
coconuts.price
看了上面例子,你可能觉得为了管理几个描述符写这么多代码不值得,但是开发框架的话,描述符会在一个单独的实用工具模块中定义,以便在整个应用中使用,就很值得了
import model_v4c as model
class LineItem:
weight = model.Quantity()
price = model.Quantity()
...
就像上面这样,把描述符放到单独模块中。现在来说,Quantity 描述符能出色完成工作,唯一缺点是,出餐属性的名称是生成的(如 _Quantity#0),导致难以调试,如果想自动把出餐属性的名称设为与托管属性的名称类似,需要使用到类装饰器或元类,下章讨论
我们上一章的特性工厂函数其实也很容易实现与描述符同样的功能,如下
def quantity(storage_name):
try:
quantity.counter += 1
except AttributeError:
quantity.counter = 0 # 第一次赋值
# 借助闭包每次创建不同的 storage_name
storage_name = '_{}:{}'.format('quantity', quantity.counter)
def qty_getter(instance):
return instance.__dict__[storage_name]
def qty_setter(instance, value):
if value > 0:
instance.__dict__[storage_name] = value
else:
raise ValueError('value must be > 0')
return property(qty_getter, qty_setter)
LineItem 类第五版:一种新型描述符¶
假如有机食物网站遇到问题,有个食品描述为空,为了解决这个问题,我们要再创建一个描述符 NonBlank,它和 Quantity 很像,只是验证逻辑不同
我们可以重构一下代码,创建两个基类。
AutoStorage: 自动管理存储属性的描述符类
Validated: 扩展 AutoStorage 类的抽象子类,覆盖 __set__ 方法,调用由子类实现的 validate 方法
import abc
class AutoStorage: # 提供了之前 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):
setattr(instance, self.storage_name, value) # 不进行验证
class Validated(abc.ABC, AutoStorage): # 抽象类,也继承自 AutoStorage
def __set__(self, instance, value):
# __set__ 方法把验证委托给 validate 方法
value = self.validate(instance, value)
#返回的 value 值返回给超类的 __set__ 方法,存储值
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'''
# 只需要根据不同的验证规则实现 validate 方法即可
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 not-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: # 托管类
weight = Quantity()
price = Quantity()
description = NonBlank()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
coconuts = LineItem('Brazilian coconut', 20, 17.95)
coconuts.description, coconuts.weight, coconuts.price
coconuts = LineItem(' ', 20, 17.95)
本章所举的几个 LineItem 实例演示了描述符的典型用途 -- 管理数据属性。这种描述符也叫覆盖型描述符,因为描述符的 __set__ 方法使用托管实例中同名属性覆盖(即插手接管)了要设置的属性,不过也有非覆盖型描述符,下节介绍两种区别
覆盖型与非覆盖型描述符对比¶
如前面所说,Python 存取属性方式特别不对等,通过实例读取属性,通常返回是实例中定义的属性名,但是如果实例中没有指定的属性,那么会获取类属性,而为实例中属性赋值时,通常会在实例中创建属性,根本不影响类
这种不对等处理方式对描述符也有影响,其实根据是否定义 __set__ 方法,描述符行为差异,我们需要几个类(下面的 print_args 是为了显示好看,cls_name 和 display 是辅助函数,这几个函数没必要研究):
## 辅助函数,仅用于显示 ##
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, owner):
print_args('set', self, instance, owner)
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)))
覆盖型描述符¶
实现 __set__ 方法的描述符属于覆盖型描述符,因为虽然描述符是类属性,但是实现 __set__ 方法的话,会覆盖对实例属性的赋值操作。特性也是覆盖型描述符,如果没提供设置函数,property 会抛出 AttributeError 异常,指明那个属性是只读的。我们可以用上面代码测试覆盖型描述符行为:
obj = Managed()
obj.over # get 方法,第二个参数是托管实例 obj
Managed.over #第二个参数是 None
obj.over = 7 # 触发描述符的 __set__ 方法,最后一个参数是 7
obj.over # 仍然触发描述符的 __get__ 方法
obj.__dict__['over'] = 8 # 直接通过 obj.__dict__ 属性赋值
vars(obj) #确认值在 obj.__dict__ 下
obj.over # 即使有名为 over 的实例属性,Managed.over 描述符仍然会覆盖读取 obj.over 操作
没有 __get__ 方法的覆盖型描述符¶
如果描述符只设置 __set__ 方法,那么只有写操作由描述符处理,通过实例读描述符会返回描述符对象本身,因为没有处理操作的 __get__ 方法。如果直接通过实例的 __dict__ 属性创建同名实例属性,以后再设置那个属性时,仍然会由 __set__ 方法插手接管,但是读取那个属性的话,就会直接从实例中返回新赋予的值,而不是返回描述符对象。也就是说,实例属性会遮盖描述符,不过只有读操作如此
obj.over_no_get
Managed.over_no_get
obj.over_no_get = 7
obj.over_no_get
obj.__dict__['over_no_get'] = 9
obj.over_no_get
obj.over_no_get = 7
obj.over_no_get
非覆盖型描述符¶
没有实现 __set__ 方法的描述符是非覆盖型描述符。如果设置了同名的实例属性,描述符会被遮盖,致使描述符无法处理那个实例的属性。方法是以非覆盖型描述符实现的。
obj = Managed()
obj.non_over
obj.non_over = 7
obj.non_over
Managed.non_over
del obj.non_over
obj.non_over
在上面例子中,我们为几个与描述符同名的实例属性赋了值,结果根据描述符有没有 __set__ 方法不同。依附在类上的描述符无法控制为类属性赋值的操作。其实,这意味着类属性赋值能覆盖描述符属性
再类中覆盖描述符¶
不管描述符是不是覆盖类型,为类属性赋值都能覆盖描述符,这是一种猴子补丁技术:
obj = Managed()
Managed.over = 1 # 覆盖了描述符
Managed.over_no_get = 2
Managed.non_over = 3
obj.over, obj.over_no_get, obj.non_over
上面揭示了读写属性的另一种不对等,读类属性的操作可以由依附在托管类上定义有 __get__ 方法的描述符处理,但是写类属性的操作不会由依附在托管类上定义有 __set__ 方法的描述符处理
若想控制类属性的操作,要把描述符依附到类上,即依附到元类上。默认情况,对用户定义的类来说,其元类是 type,而我们不能为 type 添加属性,不过在下一章,我们会自己创建元类
方法是描述符¶
在类中定义的函数属于绑定方法,因为用户定义的函数都有 __get__ 方法,所以依附到类上时,就相当于描述符。下面演示了从 Managed 类中读取 spam 方法
obj = Managed()
obj.spam # 获取的是绑定方法对象
Managed.spam # 获取的是函数
obj.spam = 7 # 遮盖类属性,导致无法通过 obj.spam 访问 spam 方法
obj.spam
函数没有实现 __set__ 方法,因此是非覆盖型描述符。
从上面能看出一个信息,obj.spam 和 Managed.spam 获取的是不同的对象,与描述符一样,通过托管类访问时,函数的 __get__ 方法会返回自身的引用。但是通过实例访问时,函数的 __get__ 方法返回的是绑定方法对象,一种可调用的对象,里面包装着函数,并把托管实例(如 obj)绑定给函数的第一个参数(即 self),这与 functools.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')
word
word.reverse()
Text.reverse(Text('backward')) # 在类上调用方法相当于调用函数
type(Text.reverse), type(word.reverse) # 类型不相同,一个 function,一个 method
list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])) # Text.reverse 相当于函数,甚至可以处理 Text 实例外其它对象
Text.reverse.__get__(word) # 函数都是非覆盖型描述符。在函数上调用 __get__ 方法传入实例,得到的是绑定到那个实例的方法
word.reverse # 其实会调用 Text.reverse.__get__(word) 方法,返回对应绑定方法。
word.reverse.__self__ # 绑定放方法对象有个 __self__ 属性,其值是调用这个方法的实例引用
word.reverse.__func__ is Text.reverse # 绑定方法的 __func__ 是依附在托管类上的原始函数引用