Python学习之路39-特性property

《流畅的Python》笔记。

本篇主要讨论Python中的特性property。

1. 前言

上一篇介绍了如何动态创建属性(Attribute),在最后一个例子中我们使用了@property装饰器实现了只读特性。本篇将介绍如何使用特性(Property)来验证属性。我会通过一个Food类来演示property的用法和行为。

2. property基本用法

Food是个食品类,论公斤卖,以下是它的定义和用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码2.1
class Food:
def __init__(self, weight, price):
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price

# 实例
>>> food = Food(10, 10)
>>> food.subtotal()
100
>>> food.weight = -20
>>> food.subtotal()
-200

这个类很简单,但有一个问题:可以将self.weightself.price的值设为负数。解决这个问题很好办,为每个属性设置get/set方法,在设置值之前对传入的值进行验证,Java就是这么做的。但比起直接访问和设置属性来说,通过get/set方法操作属性并不自然。并且,如果这个代码已经上线运行,存取值就是直接操作属性,现在要把它改成用get/set方法操作属性,那要改的地方就太多了。此时,符合Python风格的做法是:将属性替换成特性。

现在我们使用@property装饰器来修改上述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码2.2 subtotal()不变
class Food:
def __init__(self, weight, price):
self.weight = weight # 这里已经在使用特性了,而不是创建一个名为weight的属性
self.price = price

@property # get方法
def weight(self):
return self.__weight

@weight.setter # set方法
def weight(self, value):
if value > 0:
self.__weight = value
else:
raise ValueError("Value must be > 0")

我们将真正的值存储在self.__weight属性中,并且在设置weight的值之前进行了验证,使其必须为正数。

这里留了一个坑:price依然可以设置为负数。之所以没有改price,因为如果要改,也就只是把上面get/set方法再抄一遍:把self.__weight换为self.__price,再把方法名给换了。这不就重复造轮子了吗?要是get/set方法的代码量比较大,那整个文件一大半内容都被存取值方法给占了。如果这个类再多一些属性,这些属性的要求都一样,这得写多少个@property

避免这种情况的方法大家都知道:抽象。对特性进行抽象有两种方式:使用特性工厂函数,或者使用描述符类。后者更灵活,下一篇再介绍。本篇介绍特性工厂函数,不过在此之前,先深入了解一下特性。

3. property解析

虽然内置的property经常被用作装饰器,但它其实是一个类(在Python中,类和函数经常互换,不用纠结)。它的构造方法的完整签名如下:

1
2
# 代码3.1
property(fget=None, fset=None, fdel=None, doc=None)

所有参数都是可选的,比如Food中,特性weight设置了前两个参数,后两个没有设置。

3.1 用法

property有两种用法,将其用作装饰器是现在主流的用法,但它还有一个“经典”的用法:

1
2
3
4
5
6
7
8
9
# 代码3.2 
class Example:
def get_a(self):
return self.__a

def set_a(self, value):
self.__a = value

a = property(get_a, set_a)

某些情况下,这种写法比装饰器写法要好,比如后面用到的特性工厂函数,但装饰器更加明显且常用。

3.2 特性覆盖实例属性

类属性会被实例属性覆盖,特性也是类属性,但特性管理的是实例属性的存取,它不会被实例属性覆盖。下面来看一个例子:

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
# 代码3.3
>>> class Test: # 定义一个测试类
... data = "the class data attr" # 这是个类属性
... @property
... def prop(self): # prop是特性,特性也是类属性!
... return "the prop value"
...
>>> obj = Test() # 新建一个Test实例
>>> vars(obj) # 查看实例属性,没有任何实例属性
{} # 特性prop和类属性data都不在其中
>>> obj.data # 访问的是类属性
'the class data attr'
>>> obj.data = "bar" # 添加实例属性,与类属性同名
>>> obj.data # 覆盖了类属性
'bar'
>>> vars(obj) # 现在有一个实例属性
{'data': 'bar'}
>>> Test.data # 类属性的值并没有被改变
'the class data attr'
>>> Test.prop # 通过类访问特性prop,特性是类属性
<property object at 0x000002BBD1963C78>
>>> obj.prop # 通过实例访问特性prop
'the prop value'
>>> obj.prop = "foo" # 没有定义set方法,所以不能对特性设置值,也不能像上面那样创建同名实例属性
Traceback (most recent call last):
File "<input>", line 1, in <module>
AttributeError: can't set attribute
>>> obj.__dict__["prop"] = "foo" # 创建也特性同名的普通实例属性,上一篇文章中用到了此法
>>> vars(obj) # 现在有两个实例属性
{'data': 'bar', 'prop': 'foo'}
>>> obj.prop # 依然显示的是特性prop的值,而不是刚才设置的值
'the prop value'
>>> Test.prop = "baz" # 这里不是调用特性的set方法,而是把特性给删除了,prop变为了str类型的类属性
>>> obj.prop # 访问普通实例属性prop,它不再被覆盖
'foo'
>>> Test.data = property(lambda self: "the 'data' prop value") # 将之前的类属性data变为特性
>>> obj.data # 之前这个属性覆盖了类属性,现在类属性变为了特性,于是这个实例属性被特性覆盖
"the 'data' prop value"
>>> del Test.data # 删除这个特性
>>> obj.data # 实例属性不再被覆盖
'bar'

上述代码也展示了一个技巧:如果想添加与特性同名的实例属性,可以直接操作__dict__

3.3 特性删除操作

property的签名可以看出,它的第三个参数是fdel,当删除特性时,就会调用它。虽然使用Python编程时不常删除属性,但Python为我们提供了删除方法del。以下是删除特性的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码3.4
>>> class Test:
... @property
... def a(self):
... print("This is a")
... return "a"
... @a.deleter
... def a(self):
... print("Delete a")
...
>>> t = Test()
>>> t.a
This is a
'a'
>>> del t.a
Delete a

3.4 特性的文档

__doc__属相相当于类或方法的使用说明,当用户需要了解某个类或方法时,Python会从这个属性获取值,并返回给用户。

property的签名可以看出,它有一个参数doc,用于设置特性的__doc__属性。如果使用“经典”方法创建特性,我们可以手动传入这个参数。但如果使用的是装饰器方式,则读值方法的文档字符串将作为特性的文档。

4. 特性工厂函数

现在来定义一个特性工厂函数,实现特性的抽象。延续前面Food类的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def quantity(name):  # 工厂函数,这个单词表示正数量。这个函数使用到了闭包
def qty_getter(instance): # 统一的get方法
return instance.__dict__[name] # name是自由变量

def qty_setter(instance, value): # 统一的set方法
if value > 0:
instance.__dict__[name] = value
else:
raise ValueError("value must be > 0")

return property(qty_getter, qty_setter)

class Food:
weight = quantity("weight") # 同一单词重复输入了两次,这是特性工厂方式的一个不足,很难避免
price = quantity("price")

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

def subtotal(self):
return self.weight * self.price

当一个类中有多个属性采用相同的验证方法时(比如100个属性有50个都要求为正数),使用此法可以节省大量代码。

5. 总结

本篇内容并不多。首先我们介绍了特性property的常用方式,并引出了特性工厂的概念,但并没有马上展开这个概念,转而介绍特性本身的相关内容。最后,使用特性工厂函数改写了之前的Food类的代码。

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