【Python】详解 __slots__

目录

一、说明

1.1 限制用户动态修改类成员

1.2 减少内存额外消耗 / 提升属性访问速度

1.3 小结

二、从另一条路线看待 __slots__ 的影响 (选读)


一、说明

1.1 限制用户动态修改类成员

Python 作为一门十分灵活的 动态语言,多处设计为顾及灵活而牺牲效率/性能。例如,Python 作为动态语言,类创建好后仍可动态创建类成员 (字段/方法/属性)。这在静态语言中无法实现,只能调用类中已有属性,而难以甚至无法添加新属性。

>>> class Point:
	def __init__(self, x=0, y=0):
		self.x = x
		self.y = y

>>> p = Point()
>>> p.z
Traceback (most recent call last):
  File "", line 1, in 
    p.z
AttributeError: 'Point' object has no attribute 'z'
>>> p.z = 1
>>> p.z
1

上例构造了一个 Point 类,具有属性 x 和 y。实例化 Point 类对象后,实例 p 虽然本无属性 z,但通过 p.z = 1 的赋值操作却可直接将属性 z 添加至实例 p 中。这虽然很灵活,但从反面看却存在隐患。若用户具有随意添加属性的权限,就可能导致未知问题,特别是面对复杂系统。因此,有时出于严谨,并不希望用户能够随意动态修改。这时,__slots__ 应运而生。

>>> class Point_new:
	__slots__ = ['x', 'y']        # 限制使用
	def __init__(self, x=0, y=0):
		self.x = x
		self.y = y

>>> q = Point_new()
>>> q.z
Traceback (most recent call last):
  File "", line 1, in 
    q.z
AttributeError: 'Point_new' object has no attribute 'z'
>>> q.z = 1
Traceback (most recent call last):
  File "", line 1, in 
    q.z = 1
AttributeError: 'Point_new' object has no attribute 'z'

可见,实例只能使用 关键字 __slots__ 中定义/声明的成员属性 x 和 y,属性 z 原本就不存在,用类 Point_new 的实例 q 也无法通过赋值的方式再创建。换言之,对实例属性而言,类属性是只读的,不可以通过实例对属性进行增删改 (而只能通过类增删改类属性,并随之影响实例属性,详见第二节)。从而,使用 __slots__ 能够 限制用户随意动态修改成员

此外,__slots__ 的另一个功能是 减少内存消耗,提升属性访问速度

在 Python 底层实现中,默认使用一个个 命名空间字典(namespace dictionary) __dict__ 来保存类的实例属性,从而允许在运行时动态创建任意新成员 (字段/方法/属性)。可令实例直接调用 __dict__ 观察 (承接上例):

# 未使用 __slots__
>>> p.__dict__
{'x': 0, 'y': 0, 'z': 1}

# 已使用 __slots__
>>> q.__dict__
Traceback (most recent call last):
  File "", line 1, in 
    q.__dict__
AttributeError: 'Point_new' object has no attribute '__dict__'

可见,使用 __slots__ 前,类实例调用 __dict__ 可查看实例属性,但使用  __slots__ 后就不可以了。

使用 __slots__ 前,Python 无法在创建实例时直接分配⼀个固定量的内存来保存所有的实例属性。因此,对于许多“小”类对象而言,使用 dict 维护实例将额外存储许多数据,占用大量不必要的内存,特别是创建成千上万的实例时。

使用 __slots__ 后,Python 内部的 __new__ 方法将不再创建一个 dict 来保存实例属性,而是以一个 固定大小的数组 取而代之,从而节省空间。

事实上,Python 内置的 dict 本质是一个哈希表 (hashtable),是一种 用空间换时间 的数据结构。为解决冲突问题,当 dict 使用量超过 2/3 时,Python 会根据情况进行 2-4 倍的扩容。由此又佐证了取消 __dict__ 的使用可大幅减少实例的空间消耗。通常,使用 __slots__ 能 降低 40%~50% 的内存占用率,即 牺牲了一定的灵活性来保证性能。这也是 __slots__ 关键字的设计初衷。

>>> class Point_new_son(Point_new):
	pass

>>> g = Point_new_son()
>>> g.z
Traceback (most recent call last):
  File "", line 1, in 
    g.z
AttributeError: 'Point_new_son' object has no attribute 'z'
>>> g.z = 2

注意,__slots__ 定义的属性仅对当前类实例起作用,而对继承的子类无效,如上例所示。除非子类中也定义 __slots__ ,从而令子类实例允许定义的属性为自身的 __slots__  加上父类的 __slots__ 。

1.2 减少内存额外消耗 / 提升属性访问速度

接下来,使用一个例子来定量分析使用  __slots__ 关键字带来的内存性能提升:

首先安装 ipython_memory_usage 模块 用于在 Jupyter Notebook 上精确地展示运行时间与内存占用情况。命令行输入:

$ pip install ipython_memory_usage

或用 conda 安装:

$ conda install -c conda-forge ipython_memory_usage

然后打开 Jupyter Notebook,新建一个 py3 主文件,导入模块并启动内存观测功能:

【Python】详解 __slots___第1张图片

自此,每个 ceil 执行完都将按上述格式返回运行时间与内存占用情况,以便于观察。

接着,另新建一个 without_slots.py 模块,存放一个未使用 __slots__ 关键字的示例:

【Python】详解 __slots___第2张图片

在主文件中导入 without_slots.py 模块,并随之显示运行情况: 

接着,另新建一个 with_slots.py 模块,存放一个使用了 __slots__ 关键字的示例:

【Python】详解 __slots___第3张图片

在主文件中导入 with_slots.py 模块,并随之显示运行情况:

对比可见,使用 __slots__ 关键字减轻了将近 60% 的内存负担,内存占用率几乎至少有 40%~50% 的降低。

1.3 小结

总而言之,__slots__ 关键字的使用能够带来如下好处:

  • 属性声明 (Attribute Declaration),限制动态修改成员
  • 减少内存额外消耗,提升属性访问速度

此外,__slots__ 的相关用法细节和注意事项还有不少,此处暂不赘述。下节将通过示例,从另一条路线看待 __slots__ 带来的变化,原理基于本节。

二、从另一条路线看待 __slots__ 的影响 (选读)

首先,沿用上文,创建一个类 Point,再实例化一个 Point 类对象 p0:

>>> class Point:
	def __init__(self, x=0, y=0):
		self.x = x
		self.y = y

>>> Point.__dict__  # 类的 dict 包含了当前类的类属性
mappingproxy({'__module__': '__main__', '__init__': , '__dict__': , '__weakref__': , '__doc__': None})

>>> p0 = Point()  # 实例的 dict 包含了当前实例的实例属性
>>> p0.__dict__
{'x': 0, 'y': 0}

可见,Point 类的 dict 包含了当前的类属性,而 Point 类实例 p0 的 dict 包含了当前的实例属性二者有所不同

对同一个类而言,理论上可以创建任意数量的实例,但每个实例所携带的 dict 积少成多,将是一笔不小的开销。为此,用于控制 dict 的 slots 应运而生。例如,再创建一个类 Point,并在构造函数 __init__() 前先使用 __slots__ 关键字声明属性:

>>> class Point:
	__slots__ = ('x', 'y')
	def __init__(self, x=0, y=0):
		self.x = x
		self.y = y

>>> dir(Point)  # 类不再维护类属性 __dict__ 了 (__dict__ 不见了)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', 'x', 'y']

>>> p1 = Point()
>>> p1.__dict__  # 实例的 命名空间字典(namespace dictionary) dict 消失了
Traceback (most recent call last):
  File "", line 1, in 
    p1.__dict__
AttributeError: 'Point' object has no attribute '__dict__'

将类 Point 实例化为 p1,可见 p1 的 dict 也消失了。再将类 Point 实例化为 p2,令三者进行对比:

>>> p2 = Point()

>>> Point.__slots__  # 可见类及其实例的 __slots__ 属性完全相同
('x', 'y')
>>> p1.__slots__
('x', 'y')
>>> p2.__slots__
('x', 'y')

>>> id(p1.__slots__)  # 可见类及其实例的 __slots__ 内存地址完全相同
2288061097288
>>> id(p2.__slots__)
2288061097288
>>> id(Point.__slots__)
2288061097288

 可以看到,可见类 Point 及其实例 p1 和 p2 的 __slots__ 属性与内存地址完全相同,这意味着对同一类创建新的实例不会再增加新的 dict 用于保存实例属性,而是使用同一个类的 slots。

>>> p1.x, p1.y
(0, 0)
>>> p2.x, p2.y
(0, 0)

>>> p1.x = 2  # 实例属性不可修改 —— 只读
Traceback (most recent call last):
  File "", line 1, in 
    p1.x = 2
AttributeError: 'Point' object attribute 'x' is read-only

然而,此时无法再通过实例来修改各自的实例属性了。因为对实例属性而言,类的静态数据是只读的、无法修改的,只有通过类属性才能修改 (对于尚未赋值的属性,还是能够通过实例赋值和修改,但不会影响类属性的值)。换言之,类属性此时对实例属性具有单向的决定性作用。例如:

>>> Point.x = 2  # 通过类 Point 修改类属性 x, 将随之影响其实例属性
>>> Point.x
2
>>> p1.x  # p1.x 本为 0
2
>>> p2.x  # p2.x 本为 0
2

可见,修改了类属性,实例属性也随之变化。但反之不可,因为对实例来说,通过类定义的属性都是只读的。

若还要增加实例属性,也只能通过类增加类属性实现,例如:

>>> Point.z = 3  # 通过类 Point 新增类属性 z, 随之影响其实例属性
>>> p1.z  # 实例 p1 本无属性 z
3
>>> p2.z  # 实例 p2 本无属性 z
3

因此,类通过 slots 牢牢控制了其实例的属性, 体现了 slots 限制用户修改 + 优化内存的作用


参考文献:

《Intermediate Python》、《Python Cookbook》

https://www.cnblogs.com/techflow/p/12747480.html

https://www.cnblogs.com/rainfd/p/slots.html

https://www.cnblogs.com/johnyang/p/10463138.html

https://www.liaoxuefeng.com/wiki/1016959663602400/1017501655757856

你可能感兴趣的:(【Python基础】)