有时你想给某个实例属性增加除访问与修改之外的其他处理逻辑,比如类型检查或合法性验证。一种简单方法是将它定义为一个特性(property)。例如,下面的代码定义了一个特性,对一个属性做简单的类型检查:
class Person:
def __init__(self, first_name):
self.first_name = first_name
# Getter function
@property
def first_name(self):
return self._first_name
# Setter function
@first_name.setter
def first_name(self, value):
if not isinstance(value, str):
raise TypeError('Expected a string')
self._first_name = value
# Deleter function (optional)
@first_name.deleter
def first_name(self):
raise AttributeError("Can't delete attribute")
上述代码中有三个相互关联的方法,这三个方法的名字都必须一样。第一个方法是一个 getter 函数,它使得 first_name 成为一个特性(property)。其他两个方法给 first_name 特性添加了 setter 和 deleter 函数。需要强调的是只有在 first_name 被 @property 创建后,后面的两个装饰器 @first_name.setter 和 @first_name.deleter 才能被定义。
特性(property)的一个关键特征是它看上去跟普通的属性(attribute) 没什么两样,但是访问它的时候会自动触发它的 getter 、setter 和 deleter 方法。例如:
>>> a = Person('Guido')
>>> a.first_name # Calls the getter
'Guido'
>>> a.first_name = 42 # Calls the setter
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "prop.py", line 14, in first_name
raise TypeError('Expected a string')
TypeError: Expected a string
>>> del a.first_name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't delete attribute
>>>
在实现一个特性(property)的时候,实际的底层(underlying)数据(如果有的话) 仍然需要存储在某个地方。因此,在 get 和 set 方法中,你会看到对 _first_name 属性的操作,这也是实际数据保存的地方。另外,你可能还会问为什么 __init__() 方法中设置了 self.first_name 而不是 self._first_name 。在这个例子中,我们创建一个特性(property)的目的就是在设置属性(attribute) 的时候进行类型检查。因此,你可能想在初始化的时候也进行这种类型检查。通过设置 self.first_name ,自动触发 setter 方法,这个方法里面会进行参数的检查,否则就是直接访问 self._first_name 了。
还能在已存在的 get 和 set 方法基础上定义 property。例如:
class Person:
def __init__(self, first_name):
self.set_first_name(first_name)
# Getter function
def get_first_name(self):
return self._first_name
# Setter function
def set_first_name(self, value):
if not isinstance(value, str):
raise TypeError('Expected a string')
self._first_name = value
# Deleter function (optional)
def del_first_name(self):
raise AttributeError("Can't delete attribute")
# Make a property from existing get/set methods
first_name = property(get_first_name, set_first_name, del_first_name)
虽然内置的 property 经常用作装饰器,但它其实是一个类。在 Python 中,函数和类通常可以互换,因为二者都是可调用的对象,而且没有实例化对象的 new 运算符,所以调用构造方法与调用工厂函数没有区别。此外,只要能返回新的可调用对象,代替被装饰的函数,二者都可以用作装饰器。
property 构造方法的完整签名如下:
property(fget=None, fset=None, fdel=None, doc=None)
所有参数都是可选的,如果没有把函数传给某个参数,那么得到的特性(property)对象就不允许执行相应的操作。
上面的代码在类中构建了一个 property 对象,然后赋值给公开的类属性 first_name。
类中的特性(property)能影响实例属性的寻找方式,而一开始这种方式可能会让人觉得意外。这里需要详细澄清说明一下:
特性(property)都是类属性,但是特性管理的其实是实例属性的存取。
一般而言,如果实例和所属的类有同名的数据属性,那么实例属性会覆盖(或称遮盖)类属性——至少通过那个实例读取属性时是这样,如下面的例子所示:
>>> class Class: # 定义 Class 类,这个类有两个类属性:data 数据属性和 prop 特性
... data = 'the class data attr'
... @property
... def prop(self):
... return 'the prop value'
...
>>> obj = Class()
>>> vars(obj) # vars 函数返回 obj 的 __dict__ 属性,表明此时没有实例属性
{}
>>> obj.data # 读取 obj.data,获取的是 Class.data 的值
'the class data attr'
>>> obj.data = 'bar' # 为 obj.data 赋值,创建一个新的实例属性
>>> vars(obj) # 查看 obj 的实例属性
{'data': 'bar'}
>>> obj.data # 现在读取 obj.data,获取的是实例属性的值。此时实例属性 data 会遮盖类属性 data
'bar'
>>> Class.data # Class.data 属性的值完好无损
'the class data attr'
下面尝试覆盖 obj 实例的 prop 特性。接着前面的控制台会话,如下例所示:
>>> Class.prop # 直接从 Class 中读取 prop 特性,获取的是特性对象本身,不会运行特性的读值方法
<property object at 0x1072b7408>
>>> obj.prop # 读取 obj.prop 会执行特性的读值方法
'the prop value'
>>> obj.prop = 'foo' # 尝试为 obj 设置 prop 实例属性,结果失败
Traceback (most recent call last):
...
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo' # 但是可以直接把 'prop' 存入 obj.__dict__,setattr() 也能达到同样效果
>>> vars(obj) # 可以看到,obj 现在有两个实例属性:data 和 prop
{ 'data': 'bar','prop': 'foo'}
>>> obj.prop # 然而,读取 obj.prop 时仍会运行特性的读值方法。特性没被实例属性遮盖
'the prop value'
>>> Class.prop = 'baz' # 覆盖 Class.prop 特性,销毁特性对象
>>> obj.prop # 现在,obj.prop 获取的是实例属性。Class.prop 不是特性了,因此不会再覆盖 obj.prop
'foo'
为了理解得更深刻,最后再举一个例子,为 Class 类新添一个特性,覆盖实例属性。如下例所示:
>>> obj.data # obj.data 获取的是实例属性 data
'bar'
>>> Class.data # Class.data 获取的是类属性 data
'the class data attr'
>>> Class.data = property(lambda self: 'the "data" prop value') # 创建新特性来覆盖 Class.data
>>> obj.data # 现在,obj.data 被 Class.data 特性遮盖了
'the "data" prop value'
>>> del Class.data # 删除特性
>>> obj.data # 现在恢复原样,obj.data 获取的是实例属性 data
'bar'
这里的主要观点是,obj.attr 这样的表达式不会从 obj 开始寻找 attr,而是从 obj.__class__ 开始,而且,仅当类中没有名为 attr 的特性时,Python 才会在 obj 实例中寻找。这条规则不仅适用于特性,还适用于一整类描述符——覆盖型描述符(overriding descriptor)。稍后的学习心得会进一步讨论描述符(descriptor),那时你会发现,特性其实是覆盖型描述符。
只有当你确实需要对属性(attribute)执行其他额外的操作的时候才应该使用到特性(property)。有时候一些从其他编程语言(比如 Java) 过来的程序员总认为所有访问都应该通过 getter 和 setter,所以他们认为代码应该像下面这样写:
class Person:
def __init__(self, first_name):
self.first_name = first_name
@property
def first_name(self):
return self._first_name
@first_name.setter
def first_name(self, value):
self._first_name = value
不要写这种没有做其他任何额外操作的特性(property)。首先,它会让你的代码变得很臃肿,并且还会迷惑阅读者。其次,它还会让你的程序运行起来变慢很多。最后,这样的设计并没有带来任何好处。特别是当你以后想给普通属性(attribute)访问添加额外的处理逻辑时,你可以将它变成一个特性(property)而无需改变原来的代码。因为所有访问属性(attribute)的代码还是保持原样。
特性(property)还是一种定义动态计算的属性的方法。这种类型的属性并不会被实际的存储,而是在需要的时候计算出来。比如:
import math
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return math.pi * self.radius ** 2
@property
def diameter(self):
return self.radius * 2
@property
def perimeter(self):
return 2 * math.pi * self.radius
在这里,我们通过使用特性(property),将所有的访问接口形式统一起来,对半径、直径、周长和面积的访问都是通过属性访问,就跟访问简单的属性是一样的。如果不这样做的话,那么就要在代码中混合使用简单属性访问和方法调用。下面是使用的实
例:
>>> c = Circle(4.0)
>>> c.radius
4.0
>>> c.area # Notice lack of ()
50.26548245743669
>>> c.perimeter # Notice lack of ()
25.132741228718345
>>>
尽管特性(properties)可以实现优雅的编程接口,但有些时候你还是会想直接使用 getter 和 setter 函数。例如:
>>> p = Person('Guido')
>>> p.get_first_name()
'Guido'
>>> p.set_first_name('Larry')
>>>
这种情况的出现通常是因为 Python 代码被集成到一个大型基础平台架构或程序中。例如,有可能是一个 Python 类准备加入到一个基于远程过程调用的大型分布式系统中。这种情况下,直接使用 get/set 方法(普通方法调用) 而不是特性(property)或许会更容易兼容。
最后一点,不要像下面这样写有大量重复代码的特性(property)定义:
class Person:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
@property
def first_name(self):
return self._first_name
@first_name.setter
def first_name(self, value):
if not isinstance(value, str):
raise TypeError('Expected a string')
self._first_name = value
# Repeated property code, but for a different name (bad!)
@property
def last_name(self):
return self._last_name
@last_name.setter
def last_name(self, value):
if not isinstance(value, str):
raise TypeError('Expected a string')
self._last_name = value
重复代码会导致臃肿、易出错和丑陋的程序。好消息是,在 Python 中通过使用装饰器或闭包,或使用描述符,有很多种更好的方法来完成同样的事情。
下面给一个通过使用闭包来减少特性(property)重复代码的例子。
我们将定义一个名为 quantity 的特性工厂函数,并将它用于如下定义的 LineItem 类中:
class LineItem:
weight = quantity('weight') # 使用工厂函数把第一个自定义的特性 weight 定义为类属性
price = quantity('price') # 第二次调用,构建另一个自定义的特性,price
def __init__(self, description, weight, price):
self.description = description
self.weight = weight # 这里,特性已经激活,确保不能把 weight 设为负数或零
self.price = price # 同样的特性处理逻辑,确保不能把 price 设为负数或零
def subtotal(self):
return self.weight * self.price # 这里也用到了特性,使用特性获取实例中存储的值
下面是 quantity 特性工厂函数的实现。
def quantity(storage_name): # storage_name 参数确定各个特性的数据存储在哪儿;对 weight 特性来说,存储的名称是 'weight'
def qty_getter(instance): # qty_getter 函数的第一个参数用 instance 指代要把属性存储其中的 LineItem 实例
"""qty_getter 引用了闭包变量 storage_name,值直接从 instance.__dict__ 中获取,为的是跳过特性调用,防止无限递归"""
return instance.__dict__[storage_name]
def qty_setter(instance, value): # 定义 qty_setter 函数,第一个参数也是 instance
if value > 0:
instance.__dict__[storage_name] = value # 值直接存到 instance.__dict__ 中,这也是为了跳过特性调用
else:
raise ValueError('value must be > 0')
return property(qty_getter, qty_setter) # 构建一个自定义的特性对象,然后将其返回
下面是一个使用 LineItem 实例的例子:
>>> nutmeg = LineItem('Moluccan nutmeg', 8, 13.95)
>>> nutmeg.weight, nutmeg.price # 通过特性读取 weight 和 price,这会遮盖同名实例属性
(8, 13.95)
>>> sorted(vars(nutmeg).items()) # 使用 vars 函数审查 nutmeg 实例,查看真正用于存储值的实例属性
[('description', 'Moluccan nutmeg'), ('price', 13.95), ('weight', 8)]
注意,:weight 特性覆盖了 weight 实例属性,因此对 self.weight 或 nutmeg.weight 的每个引用都由特性函数处理,只有直接存取实例的 __dict__ 属性才能跳过特性的处理逻辑。