学会描述符之后,不仅有更多的工具集可用,还会对 Python 的运作方式有更深入的理解,并由衷赞叹 Python 设计的优雅
--- Raymond Hettinger(Python 核心开发者和专家)
描述符是对多个属性运用相同存取逻辑的一种方式。例如,Django ORM 和 SQL Alchemy等ORM中的字段类型是描述符,把数据库记录中字段里的数据与 Python 对象的属性对应起来。
描述符是实现了特定协议的类,这个协议包括 __get__
, __set__
和 __delete__
方法。property
类实现了完整的描述符协议。通常,可以只实现部分协议。其实,我们在真实的代码中见到的大多数描述符只实现了 __get__
和 __set__
方法,还有很多只实现了其中的一个。
描述符是 Python 的独有特征,不仅在应用层中使用,在语言的基础设施中也有用到。除了特性之外,使用描述符的 Python 功能还有方法及 classmethod
和 staticmethod
装饰器。理解描述符是精通 Python 的关键。本章的话题就是描述符。
描述符示例:验证属性
在19章,特性工厂函数借助函数式编程模式避免重复编写读值方法和设值方法。特性工厂函数是高阶函数,在闭包中存储 storage_name
等设置,由参数决定创建哪些存取函数,再使用存取函数构建一个特性实例。解决这种问题的面向对象方式是描述符类。
这里继续19章的 LineItem
系列示例,把 quantity
特性工厂函数重构成 Quantity
描述符类。
LineItem
类第3版:一个简单的描述符
实现了__get__
, __set__
, __delete__
方法的类是描述符。描述符的用法是,创建一个实例,作为另一个类的类属性。
我们将定义一个 Quantity
描述符, LineItem
类会用到两个 Quantity
实例:一个用于管理 weight
属性,另一个用于管理 price
属性。
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
类的类属性。
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
该实例有个缺点,在托管类的定义体中实例化描述符时要重复输入属性的名称。如果 LineItem
类能像下面这样声明就好了:
class LineItem:
weight = QUantity()
price = Quantity()
下一节会介绍一个不太优雅但是可行的方案,解决这个重复输入名称的问题。更好的解决方案是使用类装饰器或元类,等下一章再介绍。
LineItem
类第4版:自动获取储存属性的名称
为了避免在描述符声明语句中重复输入属性名,我们将为每个 Quantity
实例的 storage_name
属性生成一个独一无二的字符串。
为了生成 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 = '_{}#{}'.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:
instance.__dict__[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
注意,__get__
方法有三个参数:self
, instance
和 owner
。owner
参数是托管类(如LineItem
)的引用,通过描述符从托管类中获取属性时用得到。如果使用 LineItem.weight
从类中获取托管属性(以 weight
为例),描述符的 __get__
方法接收到的 instance
参数值是 None
。
print (LineItem.weight)
"""
output:
Traceback (most recent call last):
File "E:/code/python/py_study/test.py", line 32, in <module>
print (LineItem.weight)
File "E:/code/python/py_study/test.py", line 12, 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__
方法返回描述符实例。
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
return getattr(instance, self.storage_name)
def __set__(self, instance, value):
if value > 0:
instance.__dict__[self.storage_name] = value
else:
raise ValueError('value must be > 0')
描述符在类中定义,因此可以利用继承重用部分代码来创建新描述符。下一节会这么做。
特性工厂函数与描述符类比较
特性工厂函数的实现:
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)
描述符类的优势
- 描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他的办法。
- 与上例使用函数属性和闭包保持状态相比,在类属性和实例属性中保持状态更易于理解。
覆盖型与非覆盖型描述符对比
如前所述,Python存取属性的方式特别不对等。通过实例读取属性时,通常返回的是实例中定义的属性;但是,如果实例中没有指定的属性,那么会获取类属性。而为实例中的属性赋值时,通常会在实例中创建属性,根本不影响类。
这种不对等的处理方式对描述符也有影响。其实,根据是否定义 __set__
方法,描述符可分为两大类。若想观察这两类描述符的行为差异,则需要使用几个类。
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, value):
print_args('set',self, instance, value)
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)))
覆盖型描述符
obj = Managed()
print(obj.over)
print(Managed.over)
obj.over = 7
print(obj.over)
obj.__dict__['over'] = 8
print(vars(obj))
print(obj.over)
"""
output:
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
None
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
None
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
None
{'over': 8}
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
None
"""
没有 __get__
方法的覆盖型描述符
因为没有处理读操作的 __get__
方法。如果直接通过实例的 __dict__
属性创建同名实例属性,以后再设置那个属性时,仍会由 __set__
方法插手接管,但是读取那个属性的话,就会直接从实例中返回新赋予的值,而不会返回描述符对象。也就是说,实例属性会遮盖描述符,不过只有读操作是如此。
obj = Managed()
print(obj.over_no_get)
print(Managed.over_no_get)
obj.over_no_get = 7
print(obj.over_no_get)
obj.__dict__['over_no_get'] = 9
print(obj.over_no_get)
obj.over_no_get = 7
print(obj.over_no_get)
"""
output:
<__main__.OverridingNoGet object at 0x00000257E0920B48>
<__main__.OverridingNoGet object at 0x00000257E0920B48>
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
<__main__.OverridingNoGet object at 0x00000257E0920B48>
9
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
9
"""
非覆盖型描述符
没有实现 __set__
方法的描述符是非覆盖型描述符。如果设置了同名的实例属性,描述符会被覆盖,致使描述符无法处理那个实例的那个属性。方法是以非覆盖型描述符实现的。
obj = Managed()
print(obj.non_over)
obj.non_over = 8
print(obj.non_over)
print(Managed.non_over)
del obj.non_over
print(obj.non_over)
"""
output:
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
None
8
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
None
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
None
"""
在上述几个示例中,我们为几个 与描述符同名的实例属性赋了值,结果依描述符中是否有 __set__
方法而有所不同。
依附在类上的描述符无法控制为类属性赋值的操作。其实,这意味着为类属性赋值能覆盖描述符属性。
在类中覆盖描述符
不管描述符是不是覆盖型,为类属性赋值都能覆盖描述符。
obj = Managed()
Managed.over = 1
Managed.over_no_get = 2
Managed.non_over = 3
print(obj.over, obj.over_no_get, obj.non_over)
"""
output:
1 2 3
"""
上例揭示了读写属性的另一种不对等:读类属性的操作可以由依附在托管类上定义有 __get__
方法的描述符处理,但是写类属性的操作不会由依附在托管类上定义有 __set__
方法的描述符处理。
若想控制设置类属性的操作,要把描述符依附在类的类上,即依附在元类上。默认情况下,对用户定义的类来说,其元类是 type
,而我们不能为 type
添加属性。不过在下一章,我们会自己创建元类。
下面我们调转话题,分析 Python 是如何使用描述符实现方法的。
方法是描述符
obj = Managed()
print(obj.spam)
print(Managed.spam)
obj.spam = 8
print(obj.spam)
"""
output:
<bound method Managed.spam of <__main__.Managed object at 0x00000283EE931C88>>
<function Managed.spam at 0x00000283EE934AF8>
8
"""
函数没有实现 __set__
方法,因此是非覆盖型描述符。
从上例中还可以看出一个重要信息:obj.spam
和 Managed.spam
获取的是不同的对象。与描述符一样,通过托管类访问时,函数的 __get__
方法会返回自身的引用。但是通过实例访问时,函数的 __get__
方法返回的是绑定方法的对象:一种可调用的对象,里面包装着函数,并把托管实例(例如 obj)绑定给函数的第一个参数(即 self),这与 funtools.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')
print(word)
print(word.reverse())
print(Text.reverse(Text('backward')))
print(type(Text.reverse), type(word.reverse))
print(list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])))
print(Text.reverse.__get__(word)) #函数都是非覆盖型描述符,在函数上调用 __get__ 方法时传入实例,得到的是绑定到那个实例上的方法
print(Text.reverse.__get__(None, Text)) # 如果 instance 参数的值是 None,那么得到的是函数本身
print(word.reverse)
print(word.reverse.__self__)# 绑定方法对象有个 __self__ 属性,其值是调用这个方法的实例的引用
print(word.reverse.__func__ is Text.reverse)
"""
output:
forward
drawrof
drawkcab
<class 'function'> <class 'method'>
['diaper', (30, 20, 10), Text('desserts')]
<bound method Text.reverse of Text('forward')>
<function Text.reverse at 0x0000016226B86DC8>
<bound method Text.reverse of Text('forward')>
forward
True
Process finished with exit code 0
"""
绑定方法对象还有个 __call__
方法,用于处理真正的调用过程。这个方法会调用 __func__
属性引用的原始函数,把函数的第一个参数设为绑定办法的 __self__
属性。这就是形参 self
的隐式绑定方式。
深入了解描述符和方法的运作方式之后,下面讨论用法方面的一些建议。
描述符用法建议
-
使用特性以保持简单
内置的
property
类创建的其实都是覆盖型描述符,__set__
方法和__get__
方法都实现了,即便不定义设值方法也是如此。特性的__set__
方法默认抛出AttributeError: can't set attribute
, 因此创建只读属性最简单的方式是使用特性,这能避免下一条所述的问题。 -
只读描述符必须有
__set__
方法如果使用描述符类实现只读属性,要记住,
__get__
和__set__
两个方法必须都定义,否则,实例的同名属性会遮盖描述符。只读属性的__set__
方法只需抛出AttributeError
异常,并提供合适的错误消息。 -
用于验证的描述符可以只有
__set__
方法对仅用于验证的描述符来说,
__set__
方法应该检查value
参数获得的值,如果有效,使用描述符实例的名称为键,直接在实例的__dict__
属性中设置。这样,从实例中读取同名属性的速度很快,因为不用经过__get__
方法处理。 -
仅有
__get__
方法的描述符可以实现高效缓存
如果只编写了__get__
方法,那么创建的是非覆盖型描述符。这种描述符可用于执行某些耗费资源的计算,然后为实例设置同名属性,缓存结果。同名实例属性会遮盖描述符,因此后续访问会直接从实例中__dict__
属性中获取值,而不会再触发描述符的__get__
方法。 -
非特殊的方法可以被实例属性遮盖
由于函数和方法只实现了__get__
方法,它们不会处理同名实例属性的赋值操作。因此,像my_obj.the_method = 7
这样简单赋值之后,后续通过该实例访问the_method
得到的是数字7。然而,特殊方法不受这个问题的影响。解释器只会在类中寻找特殊的方法,也就是说,repr(x)
执行的其实是x.__class__.__repr__(x)
,因此 x 的__repr__
属性对repr(x)
方法调用没有影响,出于同样的原因,实例的__getattr__
属性不会破坏常规的属性访问规则。