创建自定义容器

Beginner

This tutorial is from open-source community. Access the source code

简介

在本次实验中,你将学习 Python 容器和内存管理。你将探索 Python 如何为内置数据结构处理内存,并了解如何创建一个节省内存的自定义容器类。

本次实验的目标是研究 Python 列表和字典的内存分配行为,创建一个自定义容器类以优化内存使用,并理解面向列的数据存储的优势。

这是一个实验(Guided Lab),提供逐步指导来帮助你学习和实践。请仔细按照说明完成每个步骤,获得实际操作经验。根据历史数据,这是一个 初级 级别的实验,完成率为 93%。获得了学习者 97% 的好评率。

理解列表的内存分配

在 Python 中,列表是一种非常有用的数据结构,尤其在你需要向其中添加元素时。Python 列表的设计使得追加操作非常高效。Python 不会精确地分配所需的内存量,而是会预先分配额外的内存,以应对未来的元素添加。这种策略可以减少列表增长时所需的内存重新分配次数。

让我们通过使用 sys.getsizeof() 函数来更好地理解这个概念。该函数以字节为单位返回对象的大小,这有助于我们了解列表在不同阶段使用了多少内存。

  1. 首先,你需要在终端中打开一个 Python 交互式 shell。这就像是一个游乐场,你可以立即运行 Python 代码。要打开它,请在终端中输入以下命令并按回车键:
python3
  1. 进入 Python 交互式 shell 后,你需要导入 sys 模块。Python 中的模块就像包含有用函数的工具箱。sys 模块包含我们需要的 getsizeof() 函数。导入模块后,创建一个名为 items 的空列表。以下是实现该操作的代码:
import sys
items = []
  1. 现在,让我们检查空列表的初始大小。我们将使用 sys.getsizeof() 函数,并将 items 列表作为其参数。在 Python 交互式 shell 中输入以下代码并按回车键:
sys.getsizeof(items)

你应该会看到一个类似 64 字节的值。这个值代表空列表的开销。开销是指即使列表没有元素,Python 用于管理列表所需的基本内存量。

  1. 接下来,我们将开始逐个向列表中添加元素,并观察内存分配的变化。我们将使用 append() 方法向列表中添加一个元素,然后再次检查大小。以下是代码:
items.append(1)
sys.getsizeof(items)

你应该会看到一个更大的值,大约为 96 字节。这个大小的增加表明 Python 已经分配了更多的内存来容纳新元素。

  1. 让我们继续向列表中添加更多元素,并在每次添加后检查大小。以下是实现该操作的代码:
items.append(2)
sys.getsizeof(items)  ## Size remains the same

items.append(3)
sys.getsizeof(items)  ## Size still unchanged

items.append(4)
sys.getsizeof(items)  ## Size still unchanged

items.append(5)
sys.getsizeof(items)  ## Size jumps to a larger value

你会注意到,列表的大小并非每次追加操作都会增加。相反,它会周期性地跳跃式增长。这表明 Python 是以块为单位分配内存的,而不是为每个新元素单独分配内存。

这种行为是经过设计的。Python 最初会分配比所需更多的内存,以避免列表增长时频繁进行重新分配。每当列表超出其当前容量时,Python 会分配更大的一块内存。

请记住,列表存储的是对象的引用,而不是对象本身。在 64 位系统上,每个引用通常需要 8 字节的内存。理解这一点很重要,因为它会影响列表在包含不同类型对象时实际使用的内存量。

字典的内存分配

在 Python 中,和列表一样,字典也是一种基础的数据结构。理解字典的一个重要方面是它们如何进行内存分配。内存分配指的是 Python 如何在计算机内存中预留空间来存储字典中的数据。与列表类似,Python 字典也是以块为单位分配内存的。让我们来探究一下字典的内存分配是如何工作的。

  1. 首先,我们需要创建一个字典来进行操作。在同一个 Python shell 中(如果你关闭了它,也可以打开一个新的),我们将创建一个表示数据记录的字典。Python 中的字典是键值对的集合,其中每个键都是唯一的,用于访问其对应的值。
import sys  ## Import sys if you're starting a new session
row = {'route': '22', 'date': '01/01/2001', 'daytype': 'U', 'rides': 7354}

在这里,我们导入了 sys 模块,它提供了对 Python 解释器使用或维护的一些变量的访问,以及与解释器进行强交互的函数。然后,我们创建了一个名为 row 的字典,其中包含四个键值对。

  1. 现在我们有了字典,我们想检查它的初始大小。字典的大小指的是它在计算机中占用的内存量。
sys.getsizeof(row)

sys.getsizeof() 函数以字节为单位返回对象的大小。当你运行这段代码时,你应该会看到一个大约为 240 字节的值。这能让你了解字典最初占用了多少内存。

  1. 接下来,我们将向字典中添加新的键值对,并观察内存分配的变化。向字典中添加项是一个常见的操作,理解它对内存的影响至关重要。
row['a'] = 1
sys.getsizeof(row)  ## Size might remain the same

row['b'] = 2
sys.getsizeof(row)  ## Size may increase

当你添加第一个键值对 ('a': 1) 时,字典的大小可能保持不变。这是因为 Python 已经分配了一定的内存块,并且该内存块中可能有足够的空间来容纳新项。然而,当你添加第二个键值对 ('b': 2) 时,大小可能会增加。你会注意到,在添加了一定数量的项之后,字典的大小会突然增加。这是因为字典和列表一样,以块为单位分配内存以优化性能。以块为单位分配内存可以减少 Python 向系统请求更多内存的次数,从而加快添加新项的过程。

  1. 让我们尝试从字典中删除一个项,看看内存使用量是否会减少。从字典中删除项也是一个常见的操作,观察它对内存的影响很有趣。
del row['b']
sys.getsizeof(row)

有趣的是,删除一个项通常不会减少内存分配。这是因为 Python 会保留已分配的内存,以避免在再次添加项时进行重新分配。从性能角度来看,重新分配内存是一个相对昂贵的操作,因此 Python 会尽量避免这种情况。

内存效率考虑:

当处理需要创建大量记录的大型数据集时,为每个记录使用字典可能不是最节省内存的方法。字典非常灵活且易于使用,但它们可能会消耗大量的内存,尤其是在处理大量记录时。以下是一些内存消耗较少的替代方案:

  • 元组(Tuples):简单的不可变序列。元组是一个值的集合,一旦创建就不能更改。它比字典使用的内存更少,因为它不需要存储键并管理相关的键值映射。
  • 命名元组(Named tuples):带有字段名的元组。命名元组与普通元组类似,但它们允许你通过名称访问值,这可以使代码更具可读性。它们也比字典使用的内存更少。
  • 使用 __slots__ 的类:显式定义属性以避免使用字典来存储实例变量的类。当你在类中使用 __slots__ 时,Python 不会创建字典来存储实例变量,从而减少了内存使用。

在处理大量记录时,这些替代方案可以显著减少内存使用。

使用列向数据优化内存

在传统的数据存储中,我们通常将每条记录存储为一个单独的字典,这种方法称为行向(row-oriented)存储。然而,这种方法会消耗大量的内存。另一种方法是按列存储数据。在列向(column-oriented)存储方法中,我们为每个属性创建单独的列表,每个列表保存该特定属性的所有值。这有助于我们节省内存。

  1. 首先,你需要在项目目录中创建一个新的 Python 文件。这个文件将包含以列向方式读取数据的代码。将文件命名为 readrides.py。你可以在终端中使用以下命令来完成此操作:
cd ~/project
touch readrides.py

cd ~/project 命令将当前目录更改为你的项目目录,touch readrides.py 命令创建一个名为 readrides.py 的新空文件。

  1. 接下来,在 WebIDE 编辑器中打开 readrides.py 文件。然后,将以下 Python 代码添加到文件中。这段代码定义了一个名为 read_rides_as_columns 的函数,该函数从 CSV 文件中读取公交乘车数据,并将其存储在四个单独的列表中,每个列表代表一列数据。
## readrides.py
import csv
import sys
import tracemalloc

def read_rides_as_columns(filename):
    '''
    Read the bus ride data into 4 lists, representing columns
    '''
    routes = []
    dates = []
    daytypes = []
    numrides = []
    with open(filename) as f:
        rows = csv.reader(f)
        headings = next(rows)     ## Skip headers
        for row in rows:
            routes.append(row[0])
            dates.append(row[1])
            daytypes.append(row[2])
            numrides.append(int(row[3]))
    return dict(routes=routes, dates=dates, daytypes=daytypes, numrides=numrides)

在这段代码中,我们首先导入了必要的模块 csvsystracemalloccsv 模块用于读取 CSV 文件,sys 可用于与系统相关的操作(尽管在这个函数中未使用),tracemalloc 用于内存分析。在函数内部,我们初始化了四个空列表来存储不同列的数据。然后,我们打开文件,跳过标题行,并遍历文件中的每一行,将相应的值追加到适当的列表中。最后,我们返回一个包含这四个列表的字典。

  1. 现在,让我们分析一下为什么列向存储方法可以节省内存。我们将在 Python shell 中进行分析。运行以下代码:
import readrides
import tracemalloc

## Estimate memory for row-oriented approach
nrows = 577563     ## Number of rows in original file
dict_overhead = 240  ## Approximate dictionary overhead in bytes
row_memory = nrows * dict_overhead
print(f"Estimated memory for row-oriented data: {row_memory} bytes ({row_memory/1024/1024:.2f} MB)")

## Estimate memory for column-oriented approach
pointer_size = 8   ## Size of a pointer in bytes on 64-bit systems
column_memory = nrows * 4 * pointer_size  ## 4 columns with one pointer per entry
print(f"Estimated memory for column-oriented data: {column_memory} bytes ({column_memory/1024/1024:.2f} MB)")

## Estimate savings
savings = row_memory - column_memory
print(f"Estimated memory savings: {savings} bytes ({savings/1024/1024:.2f} MB)")

在这段代码中,我们首先导入了刚刚创建的 readrides 模块和 tracemalloc 模块。然后,我们估算了行向存储方法的内存使用量。我们假设每个字典的开销约为 240 字节,并将其乘以原始文件中的行数,以得到行向数据的总内存使用量。对于列向存储方法,我们假设在 64 位系统上,每个指针占用 8 字节。由于我们有 4 列,且每个条目有一个指针,因此我们计算出列向数据的总内存使用量。最后,我们通过从行向存储的内存使用量中减去列向存储的内存使用量来计算内存节省量。

这个计算表明,与使用字典的行向存储方法相比,列向存储方法应该可以节省约 120MB 的内存。

  1. 让我们使用 tracemalloc 模块测量实际的内存使用量来验证这一点。运行以下代码:
## Start tracking memory
tracemalloc.start()

## Read the data
columns = readrides.read_rides_as_columns('ctabus.csv')

## Get current and peak memory usage
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")

## Stop tracking memory
tracemalloc.stop()

在这段代码中,我们首先使用 tracemalloc.start() 开始跟踪内存。然后,我们调用 read_rides_as_columns 函数从 ctabus.csv 文件中读取数据。之后,我们使用 tracemalloc.get_traced_memory() 获取当前和峰值内存使用量。最后,我们使用 tracemalloc.stop() 停止跟踪内存。

输出将显示你列向数据结构的实际内存使用量。这应该明显低于我们对行向存储方法的理论估算值。

显著的内存节省来自于消除了数千个字典对象的开销。Python 中的每个字典都有固定的开销,无论它包含多少项。通过使用列向存储,我们只需要几个列表,而不是数千个字典。

创建自定义容器类

在数据处理中,列向存储方法在节省内存方面表现出色。然而,当你现有的代码期望数据以字典列表的形式存在时,这种方法可能会引发问题。为了解决这个问题,我们将创建一个自定义容器类。这个类将呈现行向的接口,这意味着在你的代码看来,它的外观和行为就像一个字典列表。但在内部,它将以列向格式存储数据,从而帮助我们节省内存。

  1. 首先,在 WebIDE 编辑器中打开 readrides.py 文件。我们将向这个文件中添加一个新的类。这个类将成为我们自定义容器的基础。
## Add this to readrides.py
from collections.abc import Sequence

class RideData(Sequence):
    def __init__(self):
        ## Each value is a list with all of the values (a column)
        self.routes = []
        self.dates = []
        self.daytypes = []
        self.numrides = []

    def __len__(self):
        ## All lists assumed to have the same length
        return len(self.routes)

    def __getitem__(self, index):
        return {'route': self.routes[index],
                'date': self.dates[index],
                'daytype': self.daytypes[index],
                'rides': self.numrides[index]}

    def append(self, d):
        self.routes.append(d['route'])
        self.dates.append(d['date'])
        self.daytypes.append(d['daytype'])
        self.numrides.append(d['rides'])

在这段代码中,我们定义了一个名为 RideData 的类,它继承自 Sequence__init__ 方法初始化了四个空列表,每个列表代表一列数据。__len__ 方法返回容器的长度,该长度与 routes 列表的长度相同。__getitem__ 方法允许我们通过索引访问特定的记录,并将其作为字典返回。append 方法通过将值追加到每列的列表中,向容器中添加一条新记录。

  1. 现在,我们需要一个函数将公交乘车数据读取到我们的自定义容器中。将以下函数添加到 readrides.py 文件中。
## Add this to readrides.py
def read_rides_as_dicts(filename):
    '''
    Read the bus ride data as a list of dicts, but use our custom container
    '''
    records = RideData()
    with open(filename) as f:
        rows = csv.reader(f)
        headings = next(rows)     ## Skip headers
        for row in rows:
            route = row[0]
            date = row[1]
            daytype = row[2]
            rides = int(row[3])
            record = {
                'route': route,
                'date': date,
                'daytype': daytype,
                'rides': rides
            }
            records.append(record)
    return records

这个函数创建了一个 RideData 类的实例,并使用 CSV 文件中的数据填充它。它从文件中读取每一行,提取相关信息,为每条记录创建一个字典,然后将其追加到 RideData 容器中。关键在于,它保持了与字典列表相同的接口,但在内部以列的形式存储数据。

  1. 让我们在 Python shell 中测试我们的自定义容器。这将帮助我们验证它是否按预期工作。
import readrides

## Read the data using our custom container
rows = readrides.read_rides_as_dicts('ctabus.csv')

## Check the type of the returned object
type(rows)  ## Should be readrides.RideData

## Check the length
len(rows)   ## Should be 577563

## Access individual records
rows[0]     ## Should return a dictionary for the first record
rows[1]     ## Should return a dictionary for the second record
rows[2]     ## Should return a dictionary for the third record

我们的自定义容器成功实现了 Sequence 接口,这意味着它的行为类似于列表。你可以使用 len() 函数获取容器中记录的数量,也可以使用索引访问单个记录。每条记录看起来都是一个字典,尽管数据在内部是以列的形式存储的。这很棒,因为期望使用字典列表的现有代码可以直接与我们的自定义容器一起使用,而无需进行任何修改。

  1. 最后,让我们测量一下自定义容器的内存使用情况。这将向我们展示与字典列表相比,我们节省了多少内存。
import tracemalloc

tracemalloc.start()
rows = readrides.read_rides_as_dicts('ctabus.csv')
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")
tracemalloc.stop()

当你运行这段代码时,你应该会看到内存使用情况与列向存储方法相似,远低于使用字典列表的情况。这展示了我们的自定义容器在内存效率方面的优势。

增强自定义容器的切片功能

我们的自定义容器在访问单个记录方面表现出色。然而,在进行切片操作时会出现问题。当你尝试对我们的容器进行切片时,结果并非你通常所期望的那样。

让我们来了解一下为什么会出现这种情况。在 Python 中,切片是一种常用的操作,用于提取序列的一部分。但对于我们的自定义容器,Python 不知道如何仅使用切片后的数据创建一个新的 RideData 对象。相反,它会创建一个列表,其中包含对切片中每个索引调用 __getitem__ 方法的结果。

  1. 让我们在 Python shell 中测试切片操作:
import readrides

rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10]  ## Take a slice of the first 10 records
type(r)  ## This will likely be a list, not a RideData object
print(r)  ## This might look like a list of numbers, not dictionaries

在这段代码中,我们首先导入 readrides 模块。然后将 ctabus.csv 文件中的数据读取到变量 rows 中。当我们尝试对前 10 条记录进行切片并检查结果的类型时,会发现它是一个列表,而不是 RideData 对象。打印结果可能会显示一些意外的内容,比如一个数字列表,而不是字典。

  1. 让我们修改 RideData 类,使其能够正确处理切片操作。打开 readrides.py 文件并更新 __getitem__ 方法:
def __getitem__(self, index):
    if isinstance(index, slice):
        ## Handle slice
        result = RideData()
        result.routes = self.routes[index]
        result.dates = self.dates[index]
        result.daytypes = self.daytypes[index]
        result.numrides = self.numrides[index]
        return result
    else:
        ## Handle single index
        return {'route': self.routes[index],
                'date': self.dates[index],
                'daytype': self.daytypes[index],
                'rides': self.numrides[index]}

在这个更新后的 __getitem__ 方法中,我们首先检查 index 是否为切片对象。如果是,我们创建一个名为 result 的新 RideData 对象。然后,我们用原始数据列(routesdatesdaytypesnumrides)的切片来填充这个新对象。这样可以确保当我们对自定义容器进行切片时,得到的是另一个 RideData 对象,而不是一个列表。如果 index 不是切片对象(即它是一个单个索引),我们返回一个包含相关记录的字典。

  1. 让我们测试改进后的切片功能:
import readrides

rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10]  ## Take a slice of the first 10 records
type(r)  ## Should now be readrides.RideData
len(r)   ## Should be 10
r[0]     ## Should be the same as rows[0]
r[1]     ## Should be the same as rows[1]

在更新 __getitem__ 方法后,我们可以再次测试切片操作。当我们对前 10 条记录进行切片时,结果的类型现在应该是 readrides.RideData。切片的长度应该是 10,并且访问切片中的单个元素应该得到与访问原始容器中相应元素相同的结果。

  1. 你还可以使用不同的切片模式进行测试:
## Get every other record from the first 20
r2 = rows[0:20:2]
len(r2)  ## Should be 10

## Get the last 10 records
r3 = rows[-10:]
len(r3)  ## Should be 10

在这里,我们测试了不同的切片模式。第一个切片 rows[0:20:2] 获取前 20 条记录中的每隔一条记录,结果切片的长度应该是 10。第二个切片 rows[-10:] 获取最后 10 条记录,其长度也应该是 10。

通过正确实现切片功能,我们的自定义容器现在的行为更像一个标准的 Python 列表,同时保持了列向存储的内存效率。

这种创建自定义容器类的方法,即模仿 Python 内置容器但具有不同的内部表示,是一种强大的技术,可以在不改变代码向用户呈现的接口的情况下优化内存使用。

总结

在本次实验中,你学到了几项重要技能。首先,你探究了 Python 列表和字典的内存分配行为,并学会了通过从行向数据存储转换为列向数据存储来优化内存使用。其次,你创建了一个自定义容器类,该类在保持原有接口的同时减少了内存使用,并对其进行了增强,使其能够正确处理切片操作。

这些技术在处理大型数据集时非常有价值,因为它们可以在不改变代码接口的情况下显著减少内存使用。创建模仿 Python 内置容器但具有不同内部表示的自定义容器类的能力,是 Python 应用程序的强大优化工具。你可以将这些概念应用到其他对内存要求较高的项目中,特别是那些涉及大型、规则结构数据集的项目。