防止项目内存溢出的三个技巧
在编写 Python 代码时,循环并不是我们需要注意内存使用的唯一地方。在与数据相关的项目和面向对象的代码开发中,确保类的内存效率非常重要。通常,我们投入大量时间设计和编写复杂的类,却发现它们因为需要携带大量数据而在测试或生产中表现不佳。
通过遵循本文中讨论的技术和方法,你可以创建优化内存使用并提高整体性能的类。这篇博文探讨了创建内存高效的 Python 类的三种技术和推荐方法。
1.使用__slots__
在Python中,__slots__是一个特殊的属性,你可以在类定义中使用它来声明实例属性的固定集合。这样做主要有两个好处:
- 内存优化:当你在类中定义__slots__后,Python就会为实例使用一种更紧凑的内部表示。传统的对象模型是使用动态的字典来存储实例属性。而有了__slots__,实例会固定下来只能有那些在__slots__中定义的属性,不再创建一个字典,从而减少了内存占用。
- 防止动态属性的添加:通常情况下,Python允许你在实例中动态添加新的属性。如果使用了__slots__,这种动态添加属性的能力会被限制。实例只能拥有__slots__中声明的属性。
下面是一个使用__slots__的简单示例:
class Point:
__slots__ = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
# 创建Point的实例
p = Point(1, 2)
print(p.x, p.y) # 输出: 1 2
# 试图添加新的属性将引发错误
# p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
在这个例子中,Point类的实例只能有x和y这两个属性,尝试添加其他属性将会抛出AttributeError。
为什么能减少内存消耗,用旧的方法我不添加新的属性不就行了?
使用__slots__减少内存消耗的原因主要在于Python对象的存储结构。在Python中,通常情况下,每个类的实例都有一个__dict__属性,这是一个字典,用来存储所有实例属性的名字和对应的值。字典由于其动态性(可以随时添加和删除键值对),需要消耗较多的内存。
当使用__slots__声明时,Python就不会为每个实例创建这个字典。相反,它会为实例使用一种更静态、更紧凑的内部结构来存储属性值,通常是一个固定大小的数组,这显著减少了每个实例所需的内存。这样的内部结构不仅内存占用小,而且访问速度比动态字典快,因为它减少了哈希和动态查找的开销。
即使你在没有__slots__的情况下不添加任何新属性,每个实例仍然会创建并维护一个__dict__,这本身就是一个内存开销。而__slots__的使用限定了属性集合,从而避免了这种情况,对于需要大量创建小对象的应用场景,这种内存节省尤其显著。
我们来创建一个“蚁群”(要添加非常多的蚂蚁)来看看实际运行情况
class Ant:
__slots__ = ['worker_id', 'role', 'colony']
def __init__(self, worker_id, role, colony):
self.worker_id = worker_id
self.role = role
self.colony = colony
class Colony:
def __init__(self, name):
self.name = name
self.ants = []
def add_ant(self, worker_id, role):
ant = Ant(worker_id, role, self.name)
self.ants.append(ant)
def distribute_work(self):
# add code to distribute work among the ants
pass
def defend_queen(self):
# add code to defend the queen
pass
现在让我们实例化一个蚁群并运行一个循环并向该实例添加 500,000 只蚂蚁
# Create an instance of Colony
colony_name = "Tinyopolis"
colony = Colony(colony_name)
# Simulate an ant colony of 500,000 worker ants
n_ants = 500_000
for i in range(n_ants):
worker_id = f"W{
i}"
role = "Worker"
colony.add_ant(worker_id, role)
由于我们Ant()一遍又一遍地实例化该类,因此我们肯定会受益于使用__slots__来减少内存占用。你可以通过使用该pympler包分析此循环的内存使用情况来验证这一情况。当我们比较使用类__slots__和未使用类的每次迭代的内存使用量时,我们得到以下结果:
2.使用延迟初始化
延迟初始化是指延迟属性(通常是昂贵的属性)的初始化直到实际需要时才进行。通过实现延迟初始化,可以减少对象的内存占用,因为只有必要的属性才会在运行时初始化。
在Python中,只需使用functools.cached_property装饰器就可以实现延迟初始化。它是一个装饰器,用于将类方法的返回值转换成一个可缓存的属性。当你使用这个装饰器时,方法的返回值会在第一次调用后被缓存,后续的调用将直接返回缓存的值而不再执行方法本身。这个特性特别适用于计算成本较高的属性值,这样可以避免重复的计算,从而提高效率。
下面是个简单的示例:
from functools import cached_property
class Circle:
def __init__(self, radius):
self.radius = radius
@cached_property
def area(self):
print("Calculating area...")
return 3.14159 * self.radius * self.radius
# 创建 Circle 对象
circle = Circle(4)
# 首次访问 area,将计算并缓存结果
print(circle.area) # 输出: Calculating area... 50.26544
# 后续访问 area,将直接使用缓存的值,不会再打印 "Calculating area..."
print(circle.area) # 输出: 50.26544
在这个例子中,计算圆的面积可能是一个计算成本较高的操作,特别是当半径非常大或者需要非常精确的圆周率时。使用 cached_property,无论我们多少次访问 area 属性,计算只会执行一次,之后都将返回缓存的结果。
同样cached_property也可以用来加载数据集,数据集通常是分析和机器学习任务中的重要部分,但它们的加载可能很耗时,尤其是当数据集很大或来自远程源时。使用 cached_property,数据集只需要被加载一次。在首次加载之后,数据集会被存储在内存中,这样每次访问该属性时,都可以直接从内存中快速读取,而不需要重新从硬盘或网络位置加载。
import pandas as pd
from functools import cached_property
class DataAnalyzer:
def __init__(self, data_url):
self.data_url = data_url
@cached_property
def dataset(self):
print("Loading dataset...")
return pd.read_csv(self.data_url)
analyzer = DataAnalyzer("http://example.com/data.csv")
# 首次访问 dataset,数据会被加载并缓存
data = analyzer.dataset # 输出 "Loading dataset..."
# 后续访问 dataset,将直接从缓存中获取,不会重新加载
more_data = analyzer.dataset
这里顺便提一下lru_cache,这两个装饰器主要有以下区别:
- cached_property:
- 用于将一个类方法的返回值转换成一个可缓存的属性。它主要用于类的实例方法,确保该属性的值只计算一次,然后将其结果存储,供后续使用。
- 适用于那些不依赖于输入参数的属性,且值在第一次计算后不再改变的情况。
- lru_cache:
- 用于缓存最近使用过的函数调用结果,通过维护一个最近最少使用(Least Recently Used, LRU)的缓存策略,当缓存满时,会根据访问历史自动丢弃最久未使用的数据。
- 适用于具有昂贵计算成本或I/O成本的函数,特别是那些依赖于参数的函数。当同样的输入多次出现时,可以直接返回缓存的结果。
3. 使用生成器
Python 生成器是一种可迭代类型,类似于列表和元组,但有一个关键区别。生成器不是一次性将所有值存储在内存中,而是根据需要动态生成值。这使得它们在处理大量数据时具有很高的内存效率。
在处理大型数据集时,生成器尤其有用。它们允许你一次生成或加载一块数据,这有助于节省内存。这种方法提供了一种更有效的方式来按需处理和迭代大量数据。
下面是一个ChunkProcessor类的示例,该类以块的形式加载数据,对其进行处理,并将其以块的形式保存到另一个文件中,所有这些都使用生成器:
import pandas as pd
class ChunkProcessor:
def __init__(self, filepath, chunk_size, verbose=True):
self.filepath = filepath
self.chunk_size = chunk_size
self.verbose = verbose
def process_data(self):
for chunk_id, chunk in enumerate(self.load_data()):
processed_chunk = self.process_chunk(chunk)
self.save_chunk(processed_chunk, chunk_id)
def load_data(self):
# load data in chunks
skip_rows = 0
while True:
chunk = pd.read_csv(self.filepath, skiprows=skip_rows, nrows=self.chunk_size)
if chunk.empty:
break
skip_rows += self.chunk_size
yield chunk
def process_chunk(self, chunk):
# process each chunk of data
processed_chunk = processing_function(chunk)
return processed_chunk
def save_chunk(self, chunk, chunk_id):
# save each processed chunk to a parquet file
chunk_filepath = f"./output_chunk_{
chunk_id}.parquet"
chunk.to_parquet(chunk_filepath)
if self.verbose:
print(f"saved {
chunk_filepath}")
load_data类中的方法使用DataProcessor关键字以块的形式读取数据集yield,这使其成为生成器。这允许它以块的形式加载数据,并在加载下一个块时丢弃每个块。该process_data方法迭代此生成器,以块的形式处理数据,并将每个块保存为单独的文件。
感谢各位阅读~ 如果有所收获,欢迎关注。 您的支持是我创作的最大动力