我们上一章使用特性工厂函数编程模式避免重复写读值和设值方法,这里继续,把 quantity 特性工厂函数重构为 Quantity 描述符类

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

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

我们将定义一个 Quantity 描述符,LineItem 会用到两个 Quantity 实例,一个管理 weight 属性,一个管理 price 属性。

Quantity 实例是 LineItem 类的属性。

In [50]:
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__ 方法

In [51]:
truffle = LineItem('White truffle', 100, 10)
truffle.weight # 其实是通过 Quantitiy.__get__ 方法返回的
Out[51]:
100
In [52]:
truffle.__dict__['weight'] = 13 # 真实值存在这里,用 Quantitiy 类实例覆盖了它
truffle.weight
Out[52]:
13
In [53]:
truffle = LineItem('White truffle', 100, 0) # 代码正常运行,禁止 0 美元
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-53-c62e2ab4903b> in <module>()
----> 1 truffle = LineItem('White truffle', 100, 0) # 代码正常运行,禁止 0 美元

<ipython-input-50-731d09711b80> in __init__(self, description, weight, price)
     22         self.description = description
     23         self.weight = weight
---> 24         self.price = price
     25 
     26     def subtotal(self):

<ipython-input-50-731d09711b80> in __set__(self, instance, value)
     12             instance.__dict__[self.storage_name] = value
     13         else:
---> 14             raise ValueError('value must be > 0')
     15 
     16 

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

因为 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 就可以了

In [54]:
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,因为托管属性和存储属性名称不同

In [55]:
coconuts = LineItem('Brazilian coconut', 20, 17.95)
coconuts.weight, coconuts.price
Out[55]:
(20, 17.95)
In [56]:
getattr(coconuts, '_Quantity#0'), getattr(coconuts, '_Quantity#1')
Out[56]:
(20, 17.95)

get 方法有 3 个参数,self, instance 和 owner。owner 参数是托管类(如 LineItem)的引用(注意是类而不是实例,instance 是类的实例),通过描述符从托管类中获取属性时用得到。

如果使用 LineItem.weight 从类中获取托管属性,描述符 __get__ instance 参数收到的值是 None,因此会抛出 AttributeError 异常

In [57]:
LineItem.weight
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-57-b9c12b383de2> in <module>()
----> 1 LineItem.weight

<ipython-input-54-b54aeb0c29d6> in __get__(self, instance, owner)
     12     # 稍后说明 owner 参数
     13     def __get__(self, instance, owner):
---> 14         return getattr(instance, self.storage_name) # 使用内置的 getattr 从 instance 获取值
     15 
     16     def __set__(self, instance, value):

AttributeError: 'NoneType' object has no attribute '_Quantity#0'

抛出 AttributeError 异常是实现 __get__ 方法方式之一,如果选择这么做,应该修改错误信息,去掉令人困惑的 NoneType 和 _Quantity#0,改成 'LineItem' class has no such attribute 更好。最好能给出缺少的属性名,但是在这里描述符不知道托管属性的名称,所以只能做到这样

此外,为了个用户提供内省和其它元编程技术支持,通过类访问托管属性时,最高让 __get__ 方法返回描述符实例。下面对 __get__ 做了一些改动

In [61]:
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
In [65]:
coconuts = LineItem('Brazilian coconut', 20, 17.95)
LineItem.weight
Out[65]:
<__main__.Quantity at 0x7fb258ee89e8>
In [64]:
coconuts.price
Out[64]:
17.95

看了上面例子,你可能觉得为了管理几个描述符写这么多代码不值得,但是开发框架的话,描述符会在一个单独的实用工具模块中定义,以便在整个应用中使用,就很值得了

import model_v4c as model

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

就像上面这样,把描述符放到单独模块中。现在来说,Quantity 描述符能出色完成工作,唯一缺点是,出餐属性的名称是生成的(如 _Quantity#0),导致难以调试,如果想自动把出餐属性的名称设为与托管属性的名称类似,需要使用到类装饰器或元类,下章讨论

我们上一章的特性工厂函数其实也很容易实现与描述符同样的功能,如下

In [66]:
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 方法

In [70]:
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
In [71]:
coconuts = LineItem('Brazilian coconut', 20, 17.95)
coconuts.description, coconuts.weight, coconuts.price
Out[71]:
('Brazilian coconut', 20, 17.95)
In [72]:
coconuts = LineItem(' ', 20, 17.95)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-72-30e142068013> in <module>()
----> 1 coconuts = LineItem(' ', 20, 17.95)

<ipython-input-70-34986f272045> in __init__(self, description, weight, price)
     60 
     61     def __init__(self, description, weight, price):
---> 62         self.description = description
     63         self.weight = weight
     64         self.price = price

<ipython-input-70-34986f272045> in __set__(self, instance, value)
     25     def __set__(self, instance, value):
     26         # __set__ 方法把验证委托给 validate 方法
---> 27         value = self.validate(instance, value)
     28         #返回的 value 值返回给超类的 __set__ 方法,存储值
     29         super().__set__(instance, value)

<ipython-input-70-34986f272045> in validate(self, instance, value)
     50         value = value.strip()
     51         if len(value) == 0:
---> 52             raise ValueError('value cannot be empty or blank')
     53         return value
     54 

ValueError: value cannot be empty or blank

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

覆盖型与非覆盖型描述符对比

如前面所说,Python 存取属性方式特别不对等,通过实例读取属性,通常返回是实例中定义的属性名,但是如果实例中没有指定的属性,那么会获取类属性,而为实例中属性赋值时,通常会在实例中创建属性,根本不影响类

这种不对等处理方式对描述符也有影响,其实根据是否定义 __set__ 方法,描述符行为差异,我们需要几个类(下面的 print_args 是为了显示好看,cls_name 和 display 是辅助函数,这几个函数没必要研究):

In [1]:
## 辅助函数,仅用于显示 ##
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 异常,指明那个属性是只读的。我们可以用上面代码测试覆盖型描述符行为:

In [2]:
obj = Managed()
obj.over # get 方法,第二个参数是托管实例 obj
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
In [3]:
Managed.over #第二个参数是 None
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
In [4]:
obj.over = 7 # 触发描述符的 __set__ 方法,最后一个参数是 7
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
In [5]:
obj.over # 仍然触发描述符的 __get__ 方法
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
In [6]:
obj.__dict__['over'] = 8 # 直接通过 obj.__dict__ 属性赋值
vars(obj) #确认值在 obj.__dict__ 下
Out[6]:
{'over': 8}
In [7]:
obj.over # 即使有名为 over 的实例属性,Managed.over 描述符仍然会覆盖读取 obj.over 操作
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)

没有 __get__ 方法的覆盖型描述符

如果描述符只设置 __set__ 方法,那么只有写操作由描述符处理,通过实例读描述符会返回描述符对象本身,因为没有处理操作的 __get__ 方法。如果直接通过实例的 __dict__ 属性创建同名实例属性,以后再设置那个属性时,仍然会由 __set__ 方法插手接管,但是读取那个属性的话,就会直接从实例中返回新赋予的值,而不是返回描述符对象。也就是说,实例属性会遮盖描述符,不过只有读操作如此

In [8]:
obj.over_no_get
Out[8]:
<__main__.OverridingNoGet at 0x7f6f14686518>
In [9]:
Managed.over_no_get
Out[9]:
<__main__.OverridingNoGet at 0x7f6f14686518>
In [10]:
obj.over_no_get = 7
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
In [11]:
obj.over_no_get
Out[11]:
<__main__.OverridingNoGet at 0x7f6f14686518>
In [12]:
obj.__dict__['over_no_get'] = 9
obj.over_no_get
Out[12]:
9
In [13]:
obj.over_no_get = 7
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
In [14]:
obj.over_no_get
Out[14]:
9

非覆盖型描述符

没有实现 __set__ 方法的描述符是非覆盖型描述符。如果设置了同名的实例属性,描述符会被遮盖,致使描述符无法处理那个实例的属性。方法是以非覆盖型描述符实现的。

In [15]:
obj = Managed()
obj.non_over
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
In [17]:
obj.non_over = 7
obj.non_over
Out[17]:
7
In [19]:
Managed.non_over
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
In [20]:
del obj.non_over
obj.non_over
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)

在上面例子中,我们为几个与描述符同名的实例属性赋了值,结果根据描述符有没有 __set__ 方法不同。依附在类上的描述符无法控制为类属性赋值的操作。其实,这意味着类属性赋值能覆盖描述符属性

再类中覆盖描述符

不管描述符是不是覆盖类型,为类属性赋值都能覆盖描述符,这是一种猴子补丁技术:

In [21]:
obj = Managed()
Managed.over = 1 # 覆盖了描述符
Managed.over_no_get = 2
Managed.non_over = 3
obj.over, obj.over_no_get, obj.non_over
Out[21]:
(1, 2, 3)

上面揭示了读写属性的另一种不对等,读类属性的操作可以由依附在托管类上定义有 __get__ 方法的描述符处理,但是写类属性的操作不会由依附在托管类上定义有 __set__ 方法的描述符处理

若想控制类属性的操作,要把描述符依附到类上,即依附到元类上。默认情况,对用户定义的类来说,其元类是 type,而我们不能为 type 添加属性,不过在下一章,我们会自己创建元类

方法是描述符

在类中定义的函数属于绑定方法,因为用户定义的函数都有 __get__ 方法,所以依附到类上时,就相当于描述符。下面演示了从 Managed 类中读取 spam 方法

In [22]:
obj = Managed()
obj.spam # 获取的是绑定方法对象
Out[22]:
<bound method Managed.spam of <__main__.Managed object at 0x7f6f1454d6d8>>
In [23]:
Managed.spam # 获取的是函数
Out[23]:
<function __main__.Managed.spam>
In [24]:
obj.spam = 7 # 遮盖类属性,导致无法通过 obj.spam 访问 spam 方法
obj.spam 
Out[24]:
7

函数没有实现 __set__ 方法,因此是非覆盖型描述符。

从上面能看出一个信息,obj.spam 和 Managed.spam 获取的是不同的对象,与描述符一样,通过托管类访问时,函数的 __get__ 方法会返回自身的引用。但是通过实例访问时,函数的 __get__ 方法返回的是绑定方法对象,一种可调用的对象,里面包装着函数,并把托管实例(如 obj)绑定给函数的第一个参数(即 self),这与 functools.partial 函数行为一致

In [25]:
import collections

class Text(collections.UserString):
    
    def __repr__(self):
        return 'Text({!r})'.format(self.data)
    
    def reverse(self):
        return self[::-1]
In [26]:
word = Text('forward')
word
Out[26]:
Text('forward')
In [27]:
word.reverse()
Out[27]:
Text('drawrof')
In [34]:
Text.reverse(Text('backward')) # 在类上调用方法相当于调用函数
Out[34]:
Text('drawkcab')
In [28]:
type(Text.reverse), type(word.reverse) # 类型不相同,一个 function,一个 method
Out[28]:
(function, method)
In [35]:
list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])) # Text.reverse 相当于函数,甚至可以处理 Text 实例外其它对象
Out[35]:
['diaper', (30, 20, 10), Text('desserts')]
In [30]:
Text.reverse.__get__(word) # 函数都是非覆盖型描述符。在函数上调用 __get__ 方法传入实例,得到的是绑定到那个实例的方法
Out[30]:
<bound method Text.reverse of Text('forward')>
In [31]:
word.reverse # 其实会调用 Text.reverse.__get__(word) 方法,返回对应绑定方法。
Out[31]:
<bound method Text.reverse of Text('forward')>
In [32]:
word.reverse.__self__ # 绑定放方法对象有个 __self__ 属性,其值是调用这个方法的实例引用
Out[32]:
Text('forward')
In [33]:
word.reverse.__func__ is Text.reverse # 绑定方法的 __func__ 是依附在托管类上的原始函数引用
Out[33]:
True

绑定方法对象还有个 __call__ 方法,用于处理真正的调用过程,这个方法会调用 __func__ 属性引用的原始函数,把函数的第一个参数设为绑定方法的 __self__。这就是形参 self 的隐式绑定方式

描述符建议

  • 使用特性以保持简单

  • 只读描述符必须有 __set__ 方法

  • 用于验证的描述符可以只有 __set__ 方法

  • 仅有 __get__ 方法可以实现高效缓存

  • 非特殊方法可以被实例属性覆盖

描述符的删除操作

__delete__ 方法,和 __get__,__set__ 差不多