Python类与对象学习心得-4:创建可管理的属性 - 特性(property)

有时你想给某个实例属性增加除访问与修改之外的其他处理逻辑,比如类型检查或合法性验证。一种简单方法是将它定义为一个特性(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__ 属性才能跳过特性的处理逻辑。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值