目录
类中的__dict__
每个python类都是隐形的继承自object类,而object类中有一个自带的魔法函数__dict__,也就是说每个python类也都有这个__dict__方法。那么这个方法是干什么用的呢?先一起来试一下!
class TestA(object):
def __init__(self, name):
self.name = name
test_a = TestA("Jaxx")
print(test_a.name)
print(test_a.__dict__)
# 结果
Jaxx
{'name': 'Jaxx'}
可以看到输出的结果是一个字典,key和value分别是TestA
类的属性和值。而我们在调用test_a.name
时,实际上等同于test_a.__dict__["name"]
。也就是说每个类在实例化后都有一个字典用来保存对象的实例属性,我们在继续来看看Python中的字典这一数据结构。
掀起字典的盖头来
字典的底层结果是哈希表(Hash Table),字典中的每个键都占用一个单元(bucket), 一个单元分为两部分, 分别是对键的引用和对值的引用, 使用hash函数获得键的散列值, 散列值对数组长度取余, 取得的值就是存放位置的索引,举个小例子:
- 假如我们要把 "name" = "Jaxx"这对值放入一个字典,首先是需要将"name"进行hash计算,得到一个散列值假如说是10001010010101001000110(这也就说明了为什么字典的键必须可以hash)
- 用散列值的最右边 3 位数字作为偏移量,即“110”,十进制是数字 6。我们查看偏移量 6,对应的 bucket 是否为空。
- 如果为空,则将键值对放进去。
- 如果不为空,则依次取移 3 位作为偏移量,即“000”,十进制是数字0,循环此过程,直到找到为空的 bucket 将键值对放进去。
- python 会根据散列表的拥挤程度扩容。“扩容”指的是:创造更大的数组,将原有内容拷贝到新数组中。接近 2/3 时,数组就会扩容。
- 扩容后,偏移量的数字个数增加,如数组长度扩容到16时,可以用最右边4位数字作为偏移量。同时扩容也就可能会引发散列冲突(散列冲突:由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,因此总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。)
所以说,字典的内存开销实际是很大的,典型的用空间换时间。
说了这么多,其实是想论证,由于Python类使用了__dict__方法,所以在内存空间的消耗上必然也不少,尤其是多属性、创建多个对象的时候。那么有没有什么办法解决这个问题呢?答案就是本文的主题:__slots__
开始主题:__slots__
还是用文章开始的那个例子:
class TestA(object):
__slots__ = "name"
def __init__(self, name):
self.name = name
test_a = TestA("Jaxx")
print(test_a.name)
print(test_a.__dict__)
运行后我们会发现,第一个print可以正常输出结果:Jaxx,而运行到第二个print时却报错了:AttributeError: 'TestA' object has no attribute '__dict__'
,很明显在类中使用了__slots__后,类实例中就没有了__dict__,也就是说此时类并不会去创建一个字典来存储属性了。那么此时对资源的消耗会更小吗?再一起试一下!
这里我用到了一个统计运行内存的包:ipython_memory_usage
注意看底部的输出部分,没有使用__slots__时的内存使用为0.3438MB,而使用了之后的内存使用降低到了0.0156MB,足足3倍,不可谓不大!尤其是在多属性、多实例时我想这个差别应该会更大。
在前面两节的中,我们知道了普通类是使用字典来管理属性,那么__slots__是通过什么方式来节省内存的呢?
为什么__slots__可以
关于这一部分,我能力有限就不献丑了,可以看这位大佬的文章:python slots源码分析,简单来说就是在使用了__slots__之后,slots是直接存储在实例内存上面的,而属性的具体位置的偏移值信息则以member存储在类对象上。后续将会根据这个member创建具体的descriptior,而实际上读写这个属性都是通过descriptior实现的。相对于使用字典的方式来说,少了哈希的这一过程,所以属性的访问速度必然会更快一些了。
总结一下使用__slots__的优点:
- 节省内存。因为不使用字典,所以也就不涉及到字典的扩容问题。
- 更快的属性访问速度,原因上面已经说了。
- 类属性赋值的限制,这个是什么意思呢?先看下面的例子:
class TestC:
def __init__(self, name) -> None:
self.name = name
print(f"TestC -> {self.name}")
c = TestC("Tom")
c.age = 16
c.age
在上面的代码中,构造函数中有一个实例属性name,在实例化类之后我们有增加了一个属性age并且成功赋值,在使用c.age
调用时也就可以成功的访问了。从原理上来看,因为类是使用__dict__来存储属性的,所以c.age相当于c.__dict__['age']=16
。再来试一下使用__slots__会怎样:
class TestC:
__slots__ = 'name'
def __init__(self, name) -> None:
self.name = name
print(f"TestC -> {self.name}")
c = TestC("Tom")
c.age = 16
c.age
代码增加了一行__slots__ = 'name',而运行结果就直接报错了:
原理也很简单,因为__slots__是使用定长列表来存储的类属性,所以也就没办法直接在类外属性赋值了(也更符合封装的意义)。
更详细一点的使用方法
上面的内容在使用__slots__时,因为属性都只用了一个来举例,所以直接__slots__ = 'name'就完成了,那么有多属性的时候呢?答案是使用List或者tuple,严格来说__slots__接受的值应该是可迭代的对象(字符串也属于可迭代的)。
class TestC:
__slots__ = ['name', 'age']
def __init__(self, name, age) -> None:
self.name = name
self.age = age
print(f"TestC -> {self.name}")
print(f"TestC -> {self.age}")
c = TestC("Tom", 19)
print(c.age)
c.age = 16
c.age
# 结果:
TestC -> Tom
TestC -> 19
19
16
需要注意的是,在父子类继承的时候的一些情况:
- 这是第一种情况,父类中有__slots__方法,子类中没有。结果是在子类中可以访问到继承自父类的__slots__属性,同时也有_dict__
class Father:
__slots__ = "name"
def __init__(self, name) -> None:
self.name = name
class Son(Father):
def __init__(self, name) -> None:
super().__init__(name)
son = Son("Jaxx")
print(son.__slots__)
print(son.__dict__)
# 结果:
name
{}
- 第二种情况,父类中没有,子类中有。结果是子类有自己的__slots__,同时也继承了父类的__dict__
class Father:
def __init__(self, name) -> None:
self.name = name
class Son(Father):
__slots__ = "name"
def __init__(self, name) -> None:
super().__init__(name)
son = Son("Jaxx")
print(son.__slots__)
print(son.__dict__)
# 结果
name
{}
- 第三种情况,父类中有,子类中也有。结果是子类的__slots__覆盖了父类的。
class Father:
__slots__ = "name"
def __init__(self, name) -> None:
self.name = name
class Son(Father):
__slots__ = "age"
def __init__(self, name, age) -> None:
super().__init__(name)
self.age = age
son = Son("Jaxx", 14)
print(son.__slots__)
print(son.name, son.age)
# 结果:
age
Jaxx 14
OK,现在已经把__slots__原理、对比、实际使用方法都大概的介绍了一遍,也算是自己的一个学习的过程吧!回过头来想想自己目前在用的接口测试框架,每条用例都会实例化一个requests对象(二次封装后的),这不正好可以进行优化吗?不多说了,我要去改了!!
后续计划
本文的标题是《Python优化之__slots__》,而优化方法显然不止使用__slots__这一种。再加上目前公司的框架已经处于正常的运行维护期,需要考虑的就是各种的优化补充了,所以我的后续计划就是优化+文档输出,希望可以坚持下去!