python中@property以及描述符descriptor详解

python一直以代码简洁优雅而著称,这篇文章介绍的小技巧,就是如何优雅地对一个类的属性进行赋值和取值。不过不仅仅如此,本文章还为类属性的查找顺序,以及装饰器在类方法的使用打下了基础。

待解决的问题

先看下面的例子

class People:
    def __init__(self):
        self.age = 3

if __name__ == '__main__':
    xiaofu = People()
    print('-' * 10)
    print(xiaofu.age)  # 3
    xiaofu.age=100
    print(xiaofu.age)  # 100
    xiaofu.age=-10
    print(xiaofu.age)  # -10

对于定义的类People有一个实例属性age表示实例的年龄,对于实例xiaofu,我们可以任意设置其年龄,即使有些数字根本就不合理。

这就是我们这篇文章要解决的问题,如何在不改变操作习惯的前提下限制对属性的合理赋值范围,例如年龄age不能为负值。

属性变为方法

首先是最原始的办法,不直接暴露属性,而是暴露出两个方法分别用于赋值和取值

class People:
    def __init__(self):
        self.__age = 3

    def set_age(self, value):
        if value >= 0:
            self.__age = value
        else:
            print('Age can not be negative value')

    def get_age(self):
        return self.__age
        
if __name__ == '__main__':
    xiaofu = People()
    print(xiaofu.get_age())  # 3
    xiaofu.set_age(-10)  # Age can not be negative value
    print(xiaofu.get_age())  # 3
    xiaofu.set_age(10)
    print(xiaofu.get_age())  # 10

这是个看似有效其实很笨的方法。

首先,用户为了操作一个属性,必须记住两个方法,要是方法命名规则规范还好,不规范那简直就是要了亲命。再者如果有多个类似的属性,一下子类就变得重复臃肿了起来,不符合代码DRY的原则;

DRY: Don’t Repeat Yourself

其次,大家也都知道,python根本就没有绝对的内置属性,不让用户直接访问也只是通过改了个名字曲线救国而已,dir(xiaofu)或者xiaofu.__dict__一查就能查到,像刚才的__age就改成了_People__age

dir()用于显示所有的属性,方法也是一种属性所以也会显示;__dict__只是显示用户自定义的属性,并不会显示方法

所以这种方式在别的语言也许可以,在python中是坚决不行的。

@property装饰器

来看看第一种改进措施

class People:
    def __init__(self):
        self.__age = 3

    @property
    def age(self):
        return self.__age

    @age.setter
    def age(self, value):
        if value >= 0:
            self.__age = value
        else:
            print('Age can not be negative value')
            
if __name__ == '__main__':
    xiaofu = People()
    print('-' * 10)
    print(xiaofu.age)  # 3
    xiaofu.age=100
    print(xiaofu.age)  # 100
    xiaofu.age=-10  # Age can not be negative value
    print(xiaofu.age)  # 100

这里通过@property装饰器将其中一个方法变成了属性,方法的名字变成了属性可以被直接访问。还对一个同样名字的方法用装饰器变成了setter,当向该属性赋值的时候会调用该方法。

通过打印的结果看,恢复了原先的属性的调用方式,而不再是两个难记的方法。虽说用户还是可以通过dir()或者__dict__查看到真正的属性,但是只要不是有人故意找事,这种方式已经基本帮我们完成了比较优雅地属性取值范围限制。

但是@property还是不能复用,不满足DRY的原则。通过对两个方法添加装饰器来达到对外地统一接口,使用起来很舒服,但假设有10个这种属性,就得创造20个这种方法,这么臃肿的一个类想想就很头疼。

必须得把这两种方法单独提出来,去耦合。

描述符

再来看看第二种改进措施。

python中规定,只要一个对象包含__get__方法,就是描述符对象(Descriptor)。当然,通常的描述符对象还包含__set____delete__方法。只包含__get__方法的描述符叫做非数据描述符(Non-Data descriptor),只读,而同时包含了另外两个方法的叫做数据描述符(Data descriptor),可读可写。

如果类的属性是描述符对象,在执行取值操作时,会执行描述符对象的__get__方法,在执行赋值操作时,会执行描述符对象的__set__方法,而删除操作时,会执行__delete__方法。

关于删除属性,如果该属性不是描述符,会抛出异常,该属性只读。如果该属性是描述符但是没有实现__delete__方法,也会抛出只读的异常。如果类中定义了__delattr__,其优先级高于属性的__delete__

class MyDescriptor:
    def __init__(self,default):
        self.val=default
        
    def __get__(self, instance, owner):
        pass

    def __set__(self, instance, value):
        pass

    def __delete__(self, instance):
        pass
    
class People:
    age = MyDescriptor(0)

这是一个典型的描述符类的结构以及调用方法,这里方法的参数有点多,我们一个个来看。

  • instance - 这是包含了描述符对象的类实例,这里就是具体的People对象
  • self - 和所有其他类一样,self表示当前类的实例,这里就是具体的MyDescriptor对象
  • owner - 是instance这个实例对应的类名,这里就是People类
  • value - 赋值时候等号右边传递进来的值

下面是具体实现的代码

class MyDescriptor:
    def __init__(self,default):
        self.val=default

    def __get__(self, instance, owner):
        return self.val

    def __set__(self, instance, value):
        if value >=0:
            self.val = value
        else:
            print('Negative value is not allowed')

    def __delete__(self, instance):
        print('delete')


class People:
    age = MyDescriptor(0)
    
if __name__ == '__main__':
    xiaofu = People()
    print('-' * 10)
    print(xiaofu.age)  # 0
    xiaofu.age=100
    del xiaofu.age  # delete
    print(xiaofu.age)  # 100
    xiaofu.age=-10  # Negative value is not allowed
    print(xiaofu.age)  # 100

这样就真正达到了优雅地限制属性取值范围的目的。

需要注意,必须在class级别定义描述符对象,如果像下面这样并不会自动调用描述符的__get__或者__set__方法

class People:
    def __init__(self):
        self.age = MyDescriptor(0)

我承认这是个很让人困惑的设定,同时这也引出了描述符的一个大问题,那就是多个实例共享同一描述符对象

if __name__ == '__main__':
    xiaofu = People()
    zhangsan = People()
    print('-' * 10)
    print(xiaofu.age)  # 0
    print(zhangsan.age)  # 0
    xiaofu.age=100
    print(xiaofu.age)  # 100
    print(zhangsan.age)  # 100

因为实例都会有类属性,所以xiaofu和zhangsan都会有age这个属性。这里即使只操作xiaofu的年龄,zhangsan的年龄也会跟着变,这显然不合逻辑,所以还需要对现有的描述符进行改进。

描述符的改进

既然在赋值和取值的时候都会传递进来instance变量,那么就可以在描述符类中添加一个字典,按照不同instance做为key去存储值应该就可以了。这里可以用原生字典,不过为了减少内存泄漏,我采用了弱引用的字典

python中是按照内存被变量的引用个数来决定是否回收,引用变为0则回收。弱引用就是不占用内存引用个数的变量,当其余的引用都消失后,内存自动被回收。通常用于缓存的数据。

import weakref


class MyDescriptor:
    def __init__(self, default):
        self.val = default
        self.info = weakref.WeakKeyDictionary()

    def __get__(self, instance, owner):
        return self.info.get(instance, self.val)

    def __set__(self, instance, value):
        if value >= 0:
            self.info[instance] = value
        else:
            print('Negative value is not allowed')

    def __delete__(self, instance):
        print('delete')

这里创建了一个弱引用字典self.info,每次赋值的时候会将不同的实例做为key存进去,取值的时候再以实例做为key获取,找不到的就返回默认值。

效果如下

if __name__ == '__main__':
    xiaofu = People()
    zhangsan = People()
    print('-' * 10)
    print(xiaofu.age)  # 0
    print(zhangsan.age)  # 0
    xiaofu.age = 100
    print(xiaofu.age)  # 100
    print(zhangsan.age)  # 0
    zhangsan.age=20
    print(xiaofu.age)  # 100
    print(zhangsan.age)  # 20

这样子基本在绝大多数场合都没问题了,除非遇上了无法被哈希的实例,例如list的之类。为了解决这个问题,python3.6开始又给描述符引入了一个新的方法__set_name__

def __set_name__(self, owner, name):
	pass

其中name就是该描述符对象的名字。

再次修改下描述符类定义

class MyDescriptor:
    def __init__(self, default):
        self.val = default

    def __get__(self, instance, owner):
        return instance.__dict__.get(self.name, self.val)

    def __set__(self, instance, value):
        if value >= 0:
            instance.__dict__[self.name] = value
        else:
            print('Negative value is not allowed')

    def __set_name__(self, owner, name):
        self.name = name

    def __delete__(self, instance):
        print('delete')

通过对每个实例添加一个新的实例属性来达到目的。

扩展知识点

将描述符的基本使用和改进版本弄懂后,下面说几个扩展的知识点。

描述符添加额外方法

既然描述符也是一个类对象,当然也可以往里面添加自定义方法,这就为属性添加了额外接口。例如想要实现一个年龄转出生年份的接口就可以用类似的方法,这里就不演示了。

属性查找顺序

在描述符之前,我们可能只知道类属性和实例属性,并且同名的实例属性会覆盖类属性。但是引入了描述符之后,这个顺序会有点改变。

方法也是属性的一种,查找顺序对方法也有效

首先,不管是类还是类的对象,都会有各自的__dict__属性,里面存放的是用户自定义的属性。同时描述符是在类下面定义的属性,所以只存在于类的__dict__中。下面来看看描述符和实例属性哪个优先级比较高。

先来看非数据描述符

class MyDescriptor:
    def __init__(self, default):
        self.val = default

    def __get__(self, instance, owner):
        return self.val
        
class People:
    age = MyDescriptor(0)
    
if __name__ == '__main__':
    xiaofu = People()
    xiaofu.__dict__['age']=10
    print(xiaofu.age)  # 10

可以看出,非数据描述符的优先级低过实例属性

再来看看数据描述符

class MyDescriptor:
    def __init__(self, default):
        self.val = default

    def __get__(self, instance, owner):
        return self.val
        
    def __set__(self, instance, value):
        pass

    def __set_name__(self, owner, name):
        pass

    def __delete__(self, instance):
        pass
        
class People:
    age = MyDescriptor(0)
    
if __name__ == '__main__':
    xiaofu = People()
    xiaofu.__dict__['age']=10
    print(xiaofu.age)  # 0

可见,数据描述符的优先级要高于实例属性

所以我们可以重新整理一下在进行类似xiaofu.age操作的时候,python是怎么去查询的

  1. 如果age是内置的属性,直接被找到
  2. 去类的__dict__中查找,如果查找到了age,并且是数据描述符,直接执行其中的__get__方法。否则去父类的__dict__中查找,一直往上
  3. 去实例的__dict__中查找
  4. 再去类的__dict__中查找,如果查找到了age(一定是非数据描述符),执行其__get__方法。如果找到了普通属性直接返回
  5. 放弃查找,抛出异常

这是执行取值时候的顺序,赋值时候的优先级也类似。

函数也是描述符

上面说的都是属性,并没有提到方法。

方法就是一个函数,但是区别就在于会自动处理第一个self参数。python中一切皆对象,函数也不例外,如果看一下函数类的定义,会发现也有一个__get__方法

class Function(object):
    . . .
    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        if obj is None:
            return self
        return types.MethodType(self, obj)

这说明函数本身也是描述符,并且当函数是类方法时,还利用types.MethodType()将其绑定到了类的实例上。这也就是为什么类方法能自动处理self的原因。

说这个主要是为后面装饰器在类方法上的使用做铺垫,因为一个类方法被类装饰器装饰以后就变成了对象,失去了函数的描述符特性,变得不能自动处理self参数。此时就需要我们手动在类装饰器中定义__get__方法完成这一过程。

我会在装饰器进阶的博客中详细说明,欢迎大家关注。

我是T型人小付,一位坚持终身学习的互联网从业者。喜欢我的博客欢迎在csdn上关注我,如果有问题欢迎在底下的评论区交流,谢谢。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值