大家好,又见面了,我是你们的朋友全栈君。
《流畅的Python》笔记。
本篇主要讨论Python中的描述符,它是精通Python的关键。
1. 前言
描述符是对多个属性运用相同存取逻辑的一种方式。它是实现了特定协议的类,只要实现了__get__
,__set__
和__delete__
三个方法中的任意一个,这个类就是描述符。
特性property
类实现了完整的描述符协议,大多数描述符只实现了__get__
和__set__
方法,还有很多只实现了其中的一个。
描述符的用法很简单:创建一个实例,作为另一个类的类属性。
本篇的内容包括:将上一篇中的特性工厂函数改为描述符类;重构并派生描述符子类;覆盖型描述符和非覆盖型描述符;非覆盖型描述符的典型代表:方法。
2. 描述符
上一篇中,我们用特性工厂函数quantity()
实现了特性的抽象,并以此来验证属性。现在我们将quantity()
函数改为Quantity
描述符类。
2.1 Quantity
Quantity
类暂时只实现存值方法,取值方法暂时还用不到:
# 代码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
复制代码
这段代码并不复杂,weight
和price
与上一篇一样,被设置成了类属性。但奇怪的是,__set__
的参数列表是(self, instance, value)
,即,传入了一个实例,而不是预想的(self, value)
。并且这个方法中,直接操作实例的字典属性instance.__dict__[self.storage_name]
来修改值,而不是操作描述符的字典属性self.__dict__[self.storage_name]
。暂时不解释,先来看看Food
的行为:
# 代码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
:
# 代码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
:
# 代码2.4
>>> f = Food(1, 1)
>>> vars(f)
{
"weight": 1, "price": 1}
复制代码
f
居然有两个和类属性同名的实例属性(实例属性和描述符实例同名)!但在定义Food
的时候,一个实例属性都没有定义,那这俩实例属性是从哪来的?不难发现:在Quantity
的 __set__
方法中,我们直接操作了实例的__dict__
,instance.__dict__[self.storage_name]
,在为self.weight
和self.price
赋值时,创建了这两个实例属性。
这和我最初的理解相差有点大呀:描述符不是用来管理属性的存取的吗?不保存这些值怎么管理呢?嗯?难道它是个中介?
之所以有这个疑惑,其实是忽略了一个概念:描述符是类属性。一个类的实例有千千万万个,但类属性是唯一的,被所有实例所共有。要是把每个实例的数据都存到类属性中,这不叫“管理”,这叫“制造混乱”。
也就是说,描述符其实是个管理工具,它不是用来存储实例的数据属性的,而是代为管理实例的这些属性。这也解释了为什么有“托管”一说:所有托管实例将某些共同的属性委托给一个描述符实例管理。没使用描述符时,用户获取属性,比如f.weight
,这相当于直接调用f.__dict__["weight"]
,即用户直接操作了__dict__
;使用了描述符后,对__dict__
的操作由描述符接管:“你自己操作不安全,告诉我(描述符)你要做什么,我来给你操作”。从这个层面讲,描述符更应该被叫做“接管器”。
现在再回过头来看之前给出的那些奇怪概念:
- 描述符类,描述符实例:我们自定义的,实现了描述符接口的
Quantity
就是描述符类,Food
中的weight
和price
类属性就是描述符实例; - 托管类,托管实例:
Food
类使用了描述符实例weight
和price
作为类属性,所以它是托管类;前面用到的f
就是托管实例; - 托管属性:在使用
Food
或Test
的实例时,如果不知道这两个类的定义,那么在调用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
中定义描述符实例时,同一个单词重复输入了两次,这看着有点别扭,能不能只输入一次呢?比如像这样:
# 代码2.5
class Food:
weight = Quantity()
复制代码
实现这种功能最好的办法是使用类装饰器或元类,这将在下一篇文章中介绍。本篇介绍一个略显笨拙的方式:既然Food
中不指定储存属性的名称,那就自动生成,为每个Quantity
实例的storage_name
创建一个唯一的字符串。
我们还要实现之前没有实现的__get__
方法,而且还想在Food
中添加一个description
实例,用于描述Food
实例。description
不能为空,因此也需要使用描述符。由于验证逻辑和weight
相似,从头再写一个描述符类并不值得,因此选择继承。
以下是重构后的代码:
# 代码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.weight
,instance
的值则为f
的引用;如果是通过托管类访问属性,比如Food.weight
,instance
的值则为None
。
在__get__
和__set__
方法中,我们并没有直接操作__dict__
,因为这里的储存属性和描述符实例不会重名,所以不会产生无限递归,可以使用内置的getattr()
和setattr()
函数。
为了自动生成storage_name
,这里以_Quantity#
或者_NonBlank#
为前缀,然后在后面接个数字。然而,形如f._Quantity#0
的直接访问在Python中是无效的,因为注释也用的是#
号,然而内置的getattr
和setattr
函数可以使用这种“无效的”标识获取和设置属性,此外也可以直接处理实例属性__dict__
,因为井号#
被放到了字符串中。
2.4 描述符 vs 特性工厂函数
将描述符的实现和前面的特性工厂函数对比,其实差别并不是想象中的那么大。这两者有以下几点差异:
- 描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他方法;
- 如果要像
代码2.6
中重构后的描述符那样自动创建storage_name
,那么工厂函数需要用到函数属性和闭包,这让代码显得不够直观。
3. 覆盖型与非覆盖型描述符
Python存取属性的方式并不是对等的:通过实例读取属性时,通常返回的是实例中定义的属性,如果没有这个属性,再到所属的类中去找;但为实例中的属性赋值时,通常会在实例中创建属性,根本不影响类。
这种不对等也影响到了描述符。根据是否定义__set__
方法,描述符被分成了两大类:定义了__set__
方法的描述符是覆盖型描述符,否则是费覆盖型描述符。可以分为以下三种情况(再次提醒,描述符是类属性):
- 如果描述符实现了
__get__
和__set__
方法,描述符会覆盖同名实例属性,即属性的存取值过程都会被描述符接管。这说得通,毕竟两个方法都定义了; - 如果描述符只实现了
__set__
方法,描述符“半覆盖”同名实例属性,即存值过程会被接管,而取值过程不会被接管。这也说得通,毕竟没有定义__get__
方法; - 如果描述符只实现了
__get__
方法,描述符不会覆盖同名实例属性,即存取值过程都不会被接管!这就蹊跷了,明明定义了__get__
方法,但它不起作用。
定义三个描述符和一个类用于演示上述情况:
# 代码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__
方法的描述符的覆盖情况:
# 代码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__ 方法描述符
# 代码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_get
和Managed.over_no_get
都返回的是描述符实例over_no_get
在内存中的信息。
3.3 非覆盖型描述符
# 代码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_over
和Managed.non_over
都被描述符接管,此时obj
中也没有实例属性。在赋值过后,obj
中有了实例属性non_over
,并且它覆盖了描述符,读值过程没有被接管。删除了实例属性后,描述符不再被覆盖。非覆盖型描述符可以实现缓存。
4. 方法是描述符
在类中定义的函数属于绑定方法(bound method),简称方法,而用户定义的函数都有__get__
方法,所以方法其实是非覆盖型描述符。这也是非覆盖型描述符的一个具体类型,同时,这也说明了,Python语言的底层就用到了描述符类。下面是之前定义的spam
方法的例子:
# 代码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.spam
和Managed.spam
获取的是不同的对象,这和前面三种情况的描述符很不一样。Managed.spam
得到的是function
对象,而obj.spam
得到的是bound method
对象:
- 绑定方法对象是一种可调用的对象,里面包装着函数,并把托管实例绑定给函数的第一个参数;
- 绑定方法对象有一个
__self__
属性,其值是调用这个方法的实例的引用,比如obj.spam.__self__
就是obj
自身; - 绑定方法对象还有个
__func__
属性,它的值是依附在托管类上的那个原始函数的引用;通过托管类访问方法也访问的是那个原始函数(Managed.spam
),换句话说,如果通过托管类访问方法,这个方法就只是一个普通函数,此时传入的第一个参数会赋值给形参self
,self
不再自动指向任何类的实例。比如上述的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
参数;接着介绍了覆盖型与非覆盖型描述符;最后介绍了非覆盖型描述符的一个典型类型:方法。
迎大家关注我的微信公众号”代码港” & 个人网站 www.vpointer.net ~
转载于:https://juejin.im/post/5b2fac926fb9a00e67149d92
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/107346.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...