Python学习之路40-属性描述符

《流畅的Python》笔记。

本篇主要讨论Python中的描述符,它是精通Python的关键。

1. 前言

描述符是对多个属性运用相同存取逻辑的一种方式。它是实现了特定协议的类,只要实现了__get____set____delete__三个方法中的任意一个,这个类就是描述符。

特性property类实现了完整的描述符协议,大多数描述符只实现了__get____set__方法,还有很多只实现了其中的一个。

描述符的用法很简单:创建一个实例,作为另一个类的类属性

本篇的内容包括:将上一篇中的特性工厂函数改为描述符类;重构并派生描述符子类;覆盖型描述符和非覆盖型描述符;非覆盖型描述符的典型代表:方法。

2. 描述符

上一篇中,我们用特性工厂函数quantity()实现了特性的抽象,并以此来验证属性。现在我们将quantity()函数改为Quantity描述符类。

2.1 Quantity

Quantity类暂时只实现存值方法,取值方法暂时还用不到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 代码2.1
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 # 注意此处,用的是__dict__
else:
raise ValueError("value must be > 0")

class Food:
weight = Quantity("weight") # 类属性
price = Quantity("price")

def __init__(self, weight, price):
self.weight = weight
self.price = price

这段代码并不复杂,weightprice与上一篇一样,被设置成了类属性。但奇怪的是,__set__的参数列表是(self, instance, value),即,传入了一个实例,而不是预想的(self, value)。并且这个方法中,直接操作实例的字典属性instance.__dict__[self.storage_name]来修改值,而不是操作描述符的字典属性self.__dict__[self.storage_name]。暂时不解释,先来看看Food的行为:

1
2
3
4
5
6
7
8
9
10
# 代码2.2
>>> Food(100,0)
Traceback (most recent call last):
-- snip --
ValueError: value must be > 0
>>> f = Food(1, 1)
>>> f.weight = -1
Traceback (most recent call last):
-- snip --
ValueError: value must be > 0

当要创建一个price值为0的Food实例时,抛出了异常,行为符合要求。当要给属性weight设置负值时,行为也是正确的。Food类的行为符合要求。

2.2 托管

继续研究描述符,突然蹦出来些奇怪的概念:

  • 描述符类,描述符实例:实现了描述符协议的类叫描述符类,它的实例就是描述符实例(废话,这并不奇怪);
  • 托管类,托管实例:把描述符实例声明为类属性的类,也就是上面的Food类;这种类的对象就称为托管实例,也就是上面的f(这也很好理解);
  • 储存属性:托管实例中存储自身托管属性的属性(这看的是天书?说的这么妖娆?);
  • 托管属性:托管类中有描述符实例处理的公开属性,值存储在储存属性中(已经懵逼了)。

Wait a minute! 怎么就扯上“托管”了?我把什么托管给谁了?为了弄清描述符到底是干什么的,就得弄清这些概念。不过在这之前,先来看看之前我们用到的描述符property

1
2
3
4
5
6
7
8
9
10
11
12
13
# 代码2.3
>>> class Test: # 如果按下方注释中的写法,会无限递归,直到强制结束
... @property
... def a(self): return self.__a # 并不是 return self.a
... @a.setter
... def a(self, value): self.__a = value # 也不是 self.a = value
...
>>> t = Test()
>>> vars(t)
{} # 空的,并不是{"a": None}
>>> t.a = 1
>>> vars(t)
{"_Test__a": 1} # 也不是{"a": 1}

当创建Test的实例t时,它的属性列表是空的,可以理解,毕竟没有给它定义实例属性,而a又是类属性,vars函数不会输出它。但当给t.a赋值后,属性列表多了一个属性,值也存到了这个属性中。换句话说,值并没有存到t.a中,而是存到了t.__a(或者说t._Test__a)中。再来看Food的实例f

1
2
3
4
# 代码2.4
>>> f = Food(1, 1)
>>> vars(f)
{"weight": 1, "price": 1}

f居然有两个和类属性同名的实例属性(实例属性和描述符实例同名)!但在定义Food的时候,一个实例属性都没有定义,那这俩实例属性是从哪来的?不难发现:在Quantity__set__方法中,我们直接操作了实例的__dict__instance.__dict__[self.storage_name],在为self.weightself.price赋值时,创建了这两个实例属性。

这和我最初的理解相差有点大呀:描述符不是用来管理属性的存取的吗?不保存这些值怎么管理呢?嗯?难道它是个中介?

之所以有这个疑惑,其实是忽略了一个概念:描述符是类属性。一个类的实例有千千万万个,但类属性是唯一的,被所有实例所共有。要是把每个实例的数据都存到类属性中,这不叫“管理”,这叫“制造混乱”。

也就是说,描述符其实是个管理工具,它不是用来存储实例的数据属性的,而是代为管理实例的这些属性。这也解释了为什么有“托管”一说:所有托管实例将某些共同的属性委托给一个描述符实例管理。没使用描述符时,用户获取属性,比如f.weight,这相当于直接调用f.__dict__["weight"],即用户直接操作了__dict__;使用了描述符后,对__dict__的操作由描述符接管:“你自己操作不安全,告诉我(描述符)你要做什么,我来给你操作”。从这个层面讲,描述符更应该被叫做“接管器”。

现在再回过头来看之前给出的那些奇怪概念:

  • 描述符类描述符实例:我们自定义的,实现了描述符接口的Quantity就是描述符类,Food中的weightprice类属性就是描述符实例;
  • 托管类托管实例Food类使用了描述符实例weightprice作为类属性,所以它是托管类;前面用到的f就是托管实例
  • 托管属性:在使用FoodTest的实例时,如果不知道这两个类的定义,那么在调用f.weight或者t.a时,我们只能判断f有个名为weight的属性,t有个名为a的属性,但这两个属性是一般属性还是特性或者描述符,这就无法直观判断了,只知道这俩属性能公开访问(这类属性也叫公开属性)。如果某个公开属性是由描述符管理的,这个公开属性就是托管属性,否则就是一般的属性。但托管属性并不是指与之同名的用作类属性的描述符实例
  • 储存属性:经上述分析可知,描述符不是用来存储托管实例的属性的,而是用来管理的,但这些值总得有个地方存呀。托管实例真正存这些值的属性就叫做储存属性(如果要说得再准确一点,就是前面给出的那个妖娆的定义)。托管属性t.a真正的值存在t._Test__a中,托管属性f.weight真正的值存在f.__dict__["weight"]中,这两个实例属性就叫做储存属性。或者说,与self.storage_name同名的属性就是储存属性。这里也体现了“描述符”为什么叫“描述符”:把一个属性“描述”成另一个属性。可以看出,储存属性和托管属性是可以同名的,或者说,储存属性和描述符实例是可以同名的!一旦同名,大家也应该明白会牵扯到什么问题:覆盖。

上述这些概念也解释了之前的疑惑:

  • 描述符需要知道从托管实例的哪个属性获取值,或者存到哪个属性中,因此Quantity需要定义一个实例属性storage_name,它的值是储存属性的名称;
  • 描述符其实是管理工具,它要操作实例,所以Quantity__set__的参数列表是(self, instance, value),而不是(self, value)
  • 描述符用作类属性,它不是用来存储托管实例的属性的,真正的值依然存储在托管实例中,所以是instance.__dict__[self.storage_name],而不是self.__dict__[self.storage_name]

2.3 重构Quantity

使用上述Quantity,当在Food中定义描述符实例时,同一个单词重复输入了两次,这看着有点别扭,能不能只输入一次呢?比如像这样:

1
2
3
# 代码2.5
class Food:
weight = Quantity()

实现这种功能最好的办法是使用类装饰器或元类,这将在下一篇文章中介绍。本篇介绍一个略显笨拙的方式:既然Food中不指定储存属性的名称,那就自动生成,为每个Quantity实例的storage_name创建一个唯一的字符串。

我们还要实现之前没有实现的__get__方法,而且还想在Food中添加一个description实例,用于描述Food实例。description不能为空,因此也需要使用描述符。由于验证逻辑和weight相似,从头再写一个描述符类并不值得,因此选择继承。

以下是重构后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 代码2.6
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): # 这个方法有一个owner参数,它是托管类的引用
if instance is None: # 如果实例为空,此时表示通过托管类而不是托管实例实例来访问属性
return self # 返回描述类实例自身
else: # 否则返回托管实例相应的属性
return getattr(instance, self.storage_name) # 并没有直接调用__dict__

def __set__(self, instance, value): # 这里并没有验证,而是直接赋值
setattr(instance, self.storage_name, value)

class Validated(abc.ABC, AutoStorage): # 多重继承,重写了__set__方法,赋值之前进行验证
def __set__(self, instance, value):
value = self.validate(instance, value)
super().__set__(instance, value) # 并没有直接调用__dict__

@abc.abstractmethod # 将验证的过程单独放到一个函数中
def validate(self, instance, value): # 并由子类自行实现验证方法
"""return validated value or raise ValueError"""

class Quantity(Validated): # 值必须大于0
def validate(self, instance, value): # 这个描述符类只需重写验证方法
if value <= 0:
raise ValueError("value must be > 0!")
return value

class NonBlank(Validated): # 值不能是空字符串
def validate(self, instance, value):
value = value.strip()
if len(value) == 0:
raise ValueError("value cannot be empty or blank")
return value

class Food:
description = NonBlank()
weight = Quantity()
price = Quantity()

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

__get__方法的参数列表中有一个owner参数,它存储的是托管类的引用。它之前还有一个instance参数,如果是通过托管实例访问属性,比如f.weightinstance的值则为f的引用;如果是通过托管类访问属性,比如Food.weightinstance的值则为None

__get____set__方法中,我们并没有直接操作__dict__,因为这里的储存属性描述符实例不会重名,所以不会产生无限递归,可以使用内置的getattr()setattr()函数。

为了自动生成storage_name,这里以_Quantity#或者_NonBlank#为前缀,然后在后面接个数字。然而,形如f._Quantity#0的直接访问在Python中是无效的,因为注释也用的是#号,然而内置的getattrsetattr函数可以使用这种“无效的”标识获取和设置属性,此外也可以直接处理实例属性__dict__,因为井号#被放到了字符串中。

2.4 描述符 vs 特性工厂函数

将描述符的实现和前面的特性工厂函数对比,其实差别并不是想象中的那么大。这两者有以下几点差异:

  • 描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他方法;
  • 如果要像代码2.6中重构后的描述符那样自动创建storage_name,那么工厂函数需要用到函数属性和闭包,这让代码显得不够直观。

3. 覆盖型与非覆盖型描述符

Python存取属性的方式并不是对等的:通过实例读取属性时,通常返回的是实例中定义的属性,如果没有这个属性,再到所属的类中去找;但为实例中的属性赋值时,通常会在实例中创建属性,根本不影响类。

这种不对等也影响到了描述符。根据是否定义__set__方法,描述符被分成了两大类:定义了__set__方法的描述符是覆盖型描述符,否则是费覆盖型描述符。可以分为以下三种情况(再次提醒,描述符是类属性):

  • 如果描述符实现了__get____set__方法,描述符覆盖同名实例属性,即属性的存取值过程都会被描述符接管。这说得通,毕竟两个方法都定义了;
  • 如果描述符只实现了__set__方法,描述符“半覆盖”同名实例属性,即存值过程被接管,而取值过程不会被接管。这也说得通,毕竟没有定义__get__方法;
  • 如果描述符只实现了__get__方法,描述符不会覆盖同名实例属性,即存取值过程都不会被接管!这就蹊跷了,明明定义了__get__方法,但它不起作用。

定义三个描述符和一个类用于演示上述情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 代码3.1 所有的__get__,__set__方法都只是输出操作,没有存取值的操作
class Overriding: # 两个方法都实现,覆盖型
def __get__(self, instance, owner):
print(instance, owner)

def __set__(self, instance, value):
print(instance, value)

class OverridingNoGet: # 只实现__set__,覆盖型
def __set__(self, instance, value):
print(instance, value)

class NonOverriding: # 只实现__get__,非覆盖型
def __get__(self, instance, owner):
print(instance, owner)

class Managed:
over = Overriding()
over_no_get = OverridingNoGet()
non_over = NonOverriding()

def spam(self): # 这个方法后面会用到
pass

下面我们通过一些例子来展示覆盖的情况。

3.1 覆盖型描述符

此处展示实现了__get____set__方法的描述符的覆盖情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码3.2
>>> obj = Managed() # 此时没有实例属性
>>> obj.over # 取值过程被接管,注意输出,形参instance指向obj
<a.Managed object at 0x000001A616C13EB8> <class 'a.Managed'>
>>> Managed.over # 通过托管类读值,依然被描述符接管,instance为空
None <class 'a.Managed'>
>>> obj.over = 7 # 存值过程也被接管,值传给了__set__的形参value
<a.Managed object at 0x000001A616C13EB8> 7 # 注意这里的输出
>>> vars(obj) # 由于存值过程被接管,所以依然没有实例属性
{}
>>> obj.__dict__["over"] = 8 # 绕过描述符,直接存值
>>> vars(obj) # 有了和描述符同名的实例属性
{'over': 8}
>>> obj.over # 描述符覆盖了实例属性(被接管),读不到实例属性的值
<a.Managed object at 0x000001A616C13EB8> <class 'a.Managed'>

这里不光验证了前面的说法,还发现,通过类访问描述符(Managed.over),依然会调用__get__方法。而对于普通的类属性,(如果没有定义重写__repr__方法)则会直接返回类属性在内存中的信息,比如<a.OtherClass object at 0x...>。也就是说,通过托管类访问描述符依然会被接管。

3.2 无 __get__ 方法描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码3.3
>>> obj = Managed()
>>> obj.over_no_get
<a.OverridingNoGet object at 0x0000019FF17C7E48>
>>> Managed.over_no_get
<a.OverridingNoGet object at 0x0000019FF17C7E48>
>>> obj.over_no_get = 7
<a.Managed object at 0x0000019FF1F88748> 7
>>> vars(obj)
{}
>>> obj.__dict__["over_no_get"] = 8
>>> vars(obj)
{"over_no_get": 8}
>>> obj.over_no_get
8

可以看到,读值过程没有被接管。在没有实例属性over_no_get之前,obj.over_no_getManaged.over_no_get都返回的是描述符实例over_no_get在内存中的信息。

3.3 非覆盖型描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 代码3.4
>>> obj = Managed()
>>> obj.non_over
<a.Managed object at 0x0000019FF1F88710> <class 'a.Managed'>
>>> Managed.non_over
None <class 'a.Managed'>
>>> vars(obj)
{}
>>> obj.non_over = 7
>>> obj.non_over
7
>>> vars(obj)
{"non_over": 7}
>>> Managed.non_over
None <class 'a.Managed'>
>>> del obj.non_over
>>> obj.non_over
<a.Managed object at 0x0000019FF1F88710> <class 'a.Managed'>

可以看到,未赋值前,obj.non_overManaged.non_over都被描述符接管,此时obj中也没有实例属性。在赋值过后,obj中有了实例属性non_over,并且它覆盖了描述符,读值过程没有被接管。删除了实例属性后,描述符不再被覆盖。非覆盖型描述符可以实现缓存。

4. 方法是描述符

在类中定义的函数属于绑定方法(bound method),简称方法,而用户定义的函数都有__get__方法,所以方法其实是非覆盖型描述符。这也是非覆盖型描述符的一个具体类型,同时,这也说明了,Python语言的底层就用到了描述符类。下面是之前定义的spam方法的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 代码4.1
>>> obj = Managed()
>>> obj.spam
<bound method Managed.spam of <a.Managed object at 0x0000019FF17E1E80>>
>>> Managed.spam
<function Managed.spam at 0x0000019FF1F7D7B8>
>>> obj.spam = 1
>>> obj.spam
1
>>> vars(obj)
{"spam": 1}
>>> del obj.spam
>>> obj.spam
<bound method Managed.spam of <a.Managed object at 0x0000019FF17E1E80>>
>>> obj.spam.__self__
<a.Managed object at 0x0000019FF17E1E80>
>>> obj.spam.__func__ is Managed.spam
True
>>> obj.spam.__get__
<method-wrapper '__get__' of method object at 0x00000261B11B3908>
>>> Managed.spam((1, 2, 3))
(3, 2, 1)

从上面的例子可以看到一个重要的信息:obj.spamManaged.spam获取的是不同的对象,这和前面三种情况的描述符很不一样。Managed.spam得到的是function对象,而obj.spam得到的是bound method对象:

  • 绑定方法对象是一种可调用的对象,里面包装着函数,并把托管实例绑定给函数的第一个参数;
  • 绑定方法对象有一个__self__属性,其值是调用这个方法的实例的引用,比如obj.spam.__self__就是obj自身;
  • 绑定方法对象还有个__func__属性,它的值是依附在托管类上的那个原始函数的引用;通过托管类访问方法也访问的是那个原始函数(Managed.spam),换句话说,如果通过托管类访问方法,这个方法就只是一个普通函数,此时传入的第一个参数会赋值给形参selfself不再自动指向任何类的实例。比如上述的Managed.spam((1, 2, 3))self参数存的是元组(1, 2, 3)的引用。
  • 绑定方法对象还有个__call__方法,用于处理真正的调用过程:它会调用__func__引用的原始函数,并把__self__的引用传给函数的第一个参数,也就是self。这也正是self的隐式绑定过程。

也就是说,function对象只要一个,但bound method对象会随实例的不同而不同;与描述符接管属性的存取过程类似,实例调用方法时也会被接管,由bound method去调用真正的function

5. 总结

本篇首先将讲特性工厂函数换成了描述符类,介绍了描述符的基本用法;然后介绍了众多与描述符相关的概念(“托管”);随后我们将Quantity重构,实现了描述符的派生,以及去掉了之前声明Quantity描述符所需的storage_name参数;接着介绍了覆盖型与非覆盖型描述符;最后介绍了非覆盖型描述符的一个典型类型:方法。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~