浅析 python 属性描述符(上)
转载自我自己的 github 博客 ——> 半天钟的博客
元编程相关博文的目录及链接
这篇博文是元编程系列博文中的其中一篇、这个系列中其他博文的目录和连接见下:
- 使用 python 特性管理实例属性
- 浅析 python 属性描述符(上)
- 浅析 python 属性描述符(下)
- python 导入时与运行时
- python 元编程之动态属性
- python 元编程之类元编程
Review
在上一篇博文中、我们使用 python 特性(property)管理了实例属性,最大的好处是:在使用 property 装饰器后,我们能够在通过使用 " . " 这种方便的方式(obj.attr)来访问实例属性的同时,为其设置存储规则。
并且,因为处理存储的函数都有 @property
或者 @特性名.setter
这种明显的装饰器标志,我们可以很容易的找到处理业务逻辑的核心函数。
然而、当大量的属性都需要相同的存取逻辑做控制时,例如:水果类的 weight 和 price 的值均不能小于零。单纯的使用 property 依然无法避免代码的重复。
当重复为 weight 和 price 编写几乎相同的代码时,重构代码的时机到了!!!
运用和 property 同宗的属性描述符可以很好的避免代码段的重复。
博文的编写思路
首先、我会直接亮出使用属性描述符重构的代码(基于上一篇博文的 Fruits 类),用以给你属性描述符,以及其如何减少重复代码的直观印象。
然后、我会仔细的说明实现属性描述符需要注意的细节、属性描述符的性质、及其如何与托管类实例交互。
接着、我会说明为什么 python 特性也是一种属性描述符
最后、我会举例说明属性描述符比之 python 特性和优势在哪里
使用属性描述符进行高效管理
下面这段代码是基于上一篇博文的 Fruit 类进行的重构:
class Quantity:
def __init__(self, attrname):
self.attrname = attrname
def __get__(self, instance, owner):
print("get:"+str(instance.description)+"的" + str(self.attrname))
return instance.__dict__[self.attrname]
def __set__(self, instance, value):
print("set:"+str(instance.description)+"的" + str(self.attrname))
if value >0:
instance.__dict__[self.attrname] = value
else:
raise ValueError("想干嘛呢?")
class Fruits:
weight = Quantity("weight")
price = Quantity("price")
def __init__(self, price, weight, description):
# 水果的描述
self.description = description
# 水果的价格
self.price = price
# 水果的重量
self.weight = weight
def subtotal(self):
# 小记
return self.price * self.weight
>>> apple = Fruits(10, 2, "apple") # 1
set:apple的price
set:apple的weight
>>> pear = Fruits(11, 3, "pear")
set:pear的price
set:pear的weight
>>> vars(apple) # 2
{'description': 'apple', 'price': 10, 'weight': 2}
>>> vars(pear)
{'description': 'pear', 'price': 11, 'weight': 3}
>>> apple.weight # 3
get:apple的weight
2
>>> apple.price
get:apple的price
10
>>> apple.weight = 10 # 4
set:apple的weight
>>> apple.weight # 5
get:apple的weight
10
>>> vars(pear) # 6
{'description': 'pear', 'price': 11, 'weight': 3}
>>> apple.price = -1 # 7
Traceback (most recent call last):
...
ValueError: 想干嘛呢?
上例使用属性描述符类 Quantity 类对 Fruits 类进行了重构,使用了 Quantity 实例作为 Fruits 类的 weight 和 price 的类属性值。
可以看到、重构后的 Fruits 类实例有如下行为:
- 在初始化 Fruits 类实例时,会调用描述符实例的
__set__
magic 方法。 - 查看实例 apple 的全部属性、发现其有 price 和 weight 实例属性。这是由于
__set__
magic 方法使用instance.__dict__[self.attrname] = value
语句为实例属性设了值 -
可以通过 apple.weight 访问实例属性,但是其是通过描述符实例的
__get__
magic 方法来访问的。 -
可以通过 apple.weight = 10 这种方式为实例属性设置值,但是其实通过描述符实例的
__set__
magic 方法来访问的。 - 能够访问到刚刚为 apple 实例设置的值。
- 查看 pear 实例的全部属性、发现刚才对 apple 实例的所有操作对 pear 实例毫无影响。
- 若设置 value 为负值,那么会抛出异常。
如果你不明白这些行为的原理,没关系,我会在下一小节解释属性描述符的原理,现在你只需要知道,重构后的代码有着这样的行为。
重构后的好处
好了,在使用了 Quantity 属性描述重构了 Fruits 类之后、我们依然可以使用 " . " 方便的访问实例属性,并同时做存储逻辑的验证。
而且、**我仅用了 30 行代码就实现了 weight 和 price 两个实例属性的管理。**要知道、上一篇博文中,仅实现了 weight 属性的管理就写了 27 行代码。
不仅是代码量减少了,如果水果店老板想为所有水果都增加一个折扣属性(discount)、其也不能为负值。那么我们只需要在 Fruit 类中增加一行代码 discount = Quantity("discount")
这大大减少了重复代码,提高了代码的可重用性。
属性描述符原理
为了说明白属性描述符的原理,我将先说明一些专有名词。
专有名词
描述符类
-
实现了描述符协议的类、比如上例中的 Quantity 类、它实现了描述符类的一些协议(
__get__
、__set__
)。
实现了
__get__
、__set__
、__delete__
方法的类是描述符,只要实现了其中一个就是。
托管类
- 将描述符实例作为类属性的类,比如上例中的 Fruits 类,他有 weight、price 两个类属性,且都被赋予了描述符类的实例。
描述符实例
-
描述符类的实例、比如上例中 Fruits 类中就用
Quantity("weight")
创建了一个描述符实例,通常来讲,描述符类的实例会被赋给托管类的类属性。
托管实例
- 托管类的实例、比如上例中的 apple 、 pear。
托管属性
- 托管类中由描述符实例处理的公开属性、比如上例中 Fruits 类的类属性 weight、price
存储属性
-
可以粗略的理解为、托管实例的属性、在上例中使用
vars(apple)
得到的结果中 price 和 weight 实例属性就是存储属性,它们实际存储着*实例的*属性值
你可能不理解存储属性和托管属性的区别、因为在上例中托管属性与存储属性同名。
那么,你只需要记住:托管属性是类(Fruits)属性、存储属性是实例(apple)的属性。
描述符类与托管类的关系
首先,描述符类与托管类都是类,可以将他们想象成类的工厂、下面两张图很好的展示了他们之间的关系:
以下两张图修改自《流畅的 python》 第 20 章
如上图、Quantity 作为描述符实例的工厂、产出了两个实例并绑定至 Fruits 类的类属性 weight、price。
Fruit 作为托管类实例的工厂、可以产出多个实例、每一个实例都有两个存储属性 weight、price
上图将描述符的两个实例抽象成了两个小机器人、手上拿着一个放大镜和一个手抓,放大镜用于获取托管类实例的值(__get__
)、手抓用于设置托管类实例的值(__set__
)。
值得注意的是、Fruits 工厂不管生产多少实例,都只能拥有两个描述符小机器人,因为它们是类属性。
描述符实例如何工作
如果你看过上一篇博文、你可能还记得上一篇博文中的特性的工作流程图。实际上特性就是一种属性描述符、所以在这里 Quantity 属性描述符的工作流程与特性几乎一致,例如:apple.weight = 10
语句的执行过程如下面的流程图:
以上流程、对于 Quantity 这个类型的描述符而言,
apple.weight
这样的代码的执行流程与上图几乎没有差别,无非是在搜索到有 weight 描述符实例时,调用__get__
magic 方法;在搜索到有 weight 实例属性时获取该属性的值;都搜索不到则抛出异常。
property 是一种属性描述符
为什么说 python 特性也是一种属性描述符呢?让我们看 python 2.2 之前是如何使用 property的:
property 类实现了完整的描述符协议
def get_weight(instance):
print("get weight")
return instance.__dict__["weight"]
def set_weight(instance, value):
print("set weight")
if value > 0:
instance.__dict__["weight"] = value
else:
raise ValueError("想干嘛呢?")
class Fruits:
weight = property(get_weight, set_weight)
def __init__(self, price, weight, description):
# 水果的描述
self.description = description
# 水果的价格
self.price = price
# 水果的重量
self.weight = weight
def subtotal(self):
# 小记
return self.price * self.weight
>>> apple = Fruits(10, 2, "apple")
set weight
>>> vars(apple)
{'description': 'apple', 'price': 10, 'weight': 2}
>>> apple.weight
get weight
2
>>> apple.weight = 10
set weight
>>> apple.weight
get weight
10
>>> apple.weight = -1
Traceback (most recent call last):
...
ValueError: 想干嘛呢?
你看出来了吗?上例中的我们写了一对 set/get 方法,并用他们生成了一个 property 对象,赋予了 Fruits 类的 weight 类属性。这和属性描述符类 Quantity 有太多的相似之处。
实际上 property 类的构造方法返回一个描述符实例,该实例的 __get__
magic方法即是get_weight、__set__
magic 方法既是 set_weight。
甚至这样做以后、也能很好的管理 Fruits 实例的 weight 属性,行为与使用 Quantity 一致。
肯定有人会说、既然这样,那我完全没有必要使用 Quantity 了,直接写一个特性工厂函数即可!!!
比如:
代码来自《流畅的 python》第19章
def quantity(storage_name):
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)
这样一来、weight = Quantity("weight")
就可以转变为 weight = quantity('weight')
。甚至比创建 Quantity 类的代码还要短。
但是,对于描述符类来说、依旧有着得天独厚的优势,即面向对象的方式
描述符类能够继承
现在,项目的产品经理小姐姐提出了一个合理的需求 —— **Fruits 的描述不能为空!!**这很合理,因为描述为空时,顾客在系统中根本看不到自己买的是哪种水果。
轮到程序猿头疼了,难道再增加一个描述符类,或者特性工厂函数吗?如果这样做了,那小姐姐以后又提出一种新属性的存取逻辑怎么办?
我们注意到、不管是值不能小于零、还是描述不能为空,二者的存取逻辑都在 set 方法上,对于 get 方法几乎没有逻辑验证。
那么我们为什么不写一个描述符类、其实现了通用的 __get__
方法,再设置一个抽象的验证方法、新来的描述符类只需要继承该抽象类,再覆盖该验证方法即可。这样能够极大的节省代码冗余。
这种思想通常被称为模板方法设计模式
实现代码如下:
import abc
class AutoStorage:
def __init__(self, attrname):
self.attrname = attrname
def __get__(self, instance, owner): # __get__ 方法除了必要的判断 instance 是否真实存在以外,操作与之前几乎一致
if instance is None:
return self
else:
return instance.__dict__[self.attrname]
def __set__(self, instance, value): # 1
instance.__dict__[self.attrname] = value
class Validated(abc.ABC, AutoStorage): # 2
def __set__(self, instance, value):
value = self.validate(instance, value) # 3
super().__set__(instance, value) # 4
@abc.abstractmethod
def validate(self, instance, value): # 5
"""返回经过验证的值或抛出异常"""
class Quantity(Validated):
"""验证值是否大于等于零"""
def validate(self, instance, value): # 6
if value < 0:
raise ValueError('value must be > 0')
return value
class NonBlank(Validated):
"""验证字符串是否不为空"""
def validate(self, instance, value): # 7
value = value.strip()
if len(value) == 0:
raise ValueError('value cannot be empty or blank')
return value
在上述代码中:
- AutoStorage 描述符类的 set 方法不做任何验证。
- Validated 不但继承了 AutoStorage 描述符类、而且还是一个抽象类。
-
重写了
__set__
magic 方法,并将 value 设为经过 validate 方法验证过的值。 - 经过验证后的 value 可以直接委托给父类 AutoStorage 描述符类直接存储了。
- 设置 validate 方法为抽象方法、其由子类来覆盖它。
- Quantity 描述符类重写了 validate 方法,验证值是否大于零。
- NonBlank 描述分类重写了 validate 方法,验证值是否为空。
如此一来 Fruits 类的方法体中,只需要增加一行代码即可:
class Fruits:
description = NonBlank("description")
weight = Quantity("weight")
price = Quantity("price")
def __init__(self, price, weight, description):
# 水果的描述
self.description = description
# 水果的价格
self.price = price
# 水果的重量
self.weight = weight
def subtotal(self):
# 小记
return self.price * self.weight
上述的诸多描述符类通常放在单独的 model 模块中,以供多个模块共同使用。
例如,产品经理小姐姐有一天和你说,甲方也想卖酸奶,需要给酸奶写一个类;那么此时 model 模块中的 NonBlank 和 Quantity 也能够提供给酸奶类使用了。
这就是设计模式的魔力,其能够减少大量的代码冗余
若我们将诸多描述符类放在单独的 model 模块中、那么 Fruits 代码看起来会是这样:
import de_model as model
class Fruits:
description = model.NonBlank("description")
weight = model.Quantity("weight")
price = model.Quantity("price")
... 以下省略 ...
如果你学过 Django,那么你会意识到这和 Django ORM 中的 models.TextField() 用法一致。其实 Django 的 models.TextField() 就是通过属性描述符来实现的。
models.TextField() 不需要传入托管属性名。其原理是类装饰器,我会在后几篇博文中提到。
总结
使用 python 特性能够很好的管理需要特殊存储逻辑的实例属性。
但是、当大量的属性都需要同样的存储逻辑时、单纯的使用 property 依旧会引起代码冗余。
此时、应该考虑是使用属性描述符还是实现特性工厂函数来解决这个问题。
我给出的建议是:在这种情况下,尽量使用属性描述符、因为你不知道后续会不会有类似但又不同的属性存取逻辑。(例如本博文中的 description 和 weight)
使用属性描述符比之特性工厂有着很大的优势、因为其是类,可以实现众多的面向对象的设计模式。
你可能已经注意到,在本博文中,对于 property 的描述,从来都是一种属性描述符。那么,**除了本博文描述的属性描述符,还有其他类型的属性描述符吗?它们又拥有怎样的特性?**下一篇博文将会解答此问题。