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是怎么去查询的
- 如果age是内置的属性,直接被找到
- 去类的
__dict__
中查找,如果查找到了age,并且是数据描述符,直接执行其中的__get__
方法。否则去父类的__dict__
中查找,一直往上 - 去实例的
__dict__
中查找 - 再去类的
__dict__
中查找,如果查找到了age(一定是非数据描述符),执行其__get__
方法。如果找到了普通属性直接返回 - 放弃查找,抛出异常
这是执行取值时候的顺序,赋值时候的优先级也类似。
函数也是描述符
上面说的都是属性,并没有提到方法。
方法就是一个函数,但是区别就在于会自动处理第一个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上关注我,如果有问题欢迎在底下的评论区交流,谢谢。