为什么元组在内存中占用的空间比列表少?

128

Python中元组占用的内存空间较小:

>>> a = (1,2,3)
>>> a.__sizeof__()
48

list 占用更多的内存空间:

>>> b = [1,2,3]
>>> b.__sizeof__()
64

Python的内存管理是如何进行的?

2
我不确定这个内部工作原理,但列表对象至少有更多的功能,如追加,而元组没有。因此,对于元组作为更简单的对象类型来说,更小是有意义的。 - Metareven
我认为这也取决于机器之间的差异......对我来说,当我检查 a = (1,2,3) 时,它占用了 72 的空间,而 b = [1,2,3] 占用了 88 的空间。 - Amrit
8
Python元组是不可变的。可变对象需要额外的开销来处理运行时更改。 - Lee Daniel Crocker
1
@Metareven 类型拥有的方法数量并不影响实例所占用的内存空间。方法列表及其代码由对象原型处理,而实例仅存储数据和内部变量。 - jjmontes
4个回答

182
我假设您正在使用64位的CPython(我在我的CPython 2.7 64位上得到了相同的结果)。其他Python实现或32位Python可能会有所不同。
无论是哪种实现,列表(list)都是可变大小的,而元组(tuple)是固定大小的。
因此,元组可以直接将元素存储在结构体内部,而列表需要一个间接层(它存储指向元素的指针)。这个间接层是一个指针,在64位系统上是64位,因此为8字节。
但是,列表还做了另一件事:它们进行过度分配。否则,list.append始终是O(n)操作,为了使它摊销成O(1)(更快!!!) ,它进行了过度分配。但是现在它必须跟踪已分配大小和已填充大小大小 (元组只需要存储一个大小,因为已分配大小和填充大小总是相同的)。这意味着每个列表都必须存储另一个“大小”,在64位系统上是一个64位整数,再次为8字节。
因此,列表所需的内存比元组多至少16字节。为什么我说“至少”?由于过度分配,它分配的空间比所需的多。然而,过度分配的数量取决于“如何”创建列表和附加/删除历史记录:
>>> l = [1,2,3]
>>> l.__sizeof__()
64
>>> l.append(4)  # triggers re-allocation (with over-allocation), because the original list is full
>>> l.__sizeof__()
96

>>> l = []
>>> l.__sizeof__()
40
>>> l.append(1)  # re-allocation with over-allocation
>>> l.__sizeof__()
72
>>> l.append(2)  # no re-alloc
>>> l.append(3)  # no re-alloc
>>> l.__sizeof__()
72
>>> l.append(4)  # still has room, so no over-allocation needed (yet)
>>> l.__sizeof__()
72

图像

我决定创建一些图像来配合上面的解释。也许这些对您有所帮助。

这是如何(示意性地)在内存中存储您的示例的。我用红色(手绘)循环突出显示了区别:

输入图像描述

那实际上只是一个近似值,因为int对象也是Python对象,而CPython甚至重用小整数,因此对象在内存中的更准确表示(虽然不太可读)可能是:

输入图像描述

有用的链接:

请注意,__sizeof__实际上并没有返回“正确”的大小!它只返回存储值的大小。但是当您使用sys.getsizeof时,结果会有所不同:

>>> import sys
>>> l = [1,2,3]
>>> t = (1, 2, 3)
>>> sys.getsizeof(l)
88
>>> sys.getsizeof(t)
72

有24个“额外”的字节。这些是真实存在的,即垃圾回收器的开销,在`__sizeof__`方法中没有计算在内。这是因为通常不应直接使用魔术方法 - 使用知道如何处理它们的函数,例如:sys.getsizeof(实际上将GC开销添加到从`__sizeof__`返回的值中)。

1
是的,列表有一个额外的“size”(8字节),但也存储了一个指向“PyObject数组”的指针(8字节),而不是直接将它们存储在结构体中(元组所做的)。 8 + 8 = 16。 - MSeifert
3
关于列表内存分配的另一个有用链接:https://dev59.com/ClkS5IYBdhLWcg3wVlXC - vishes_shell
@vishes_shell 这与问题并不相关,因为问题中的代码根本没有超分配。但是如果您想了解在使用list()或列表推导式时超分配的数量,这是有用的。 - MSeifert
“浪费”的内存量通常很小,除非您有大量的列表/元组,否则通常不会有影响。但是,如果许多容器为空,则差异会变得更大。空元组是单例(至少在CPython中),因此它们实际上不占用内存。一个新的空列表总是需要自己的内存分配的新对象。 - Pekka Klärck
2
@user3349993 元组是不可变的,因此您无法向元组添加内容或从元组中删除项目。 - MSeifert
显示剩余2条评论

32
我将深入研究CPython代码库,以便我们可以看到大小实际上是如何计算的。在您的特定示例中,没有进行任何超额分配,因此我不会触及那个问题。
我将在这里使用64位值,就像您一样。

对于list的大小是通过以下函数计算的,list_sizeof

static PyObject *
list_sizeof(PyListObject *self)
{
    Py_ssize_t res;

    res = _PyObject_SIZE(Py_TYPE(self)) + self->allocated * sizeof(void*);
    return PyInt_FromSsize_t(res);
}

这里Py_TYPE(self)是一个宏,它获取了selfob_type(返回PyList_Type),而_PyObject_SIZE是另一个宏,它从该类型中获取tp_basicsizetp_basicsize被计算为sizeof(PyListObject),其中PyListObject是实例结构体。

PyListObject结构体有三个字段:

PyObject_VAR_HEAD     # 24 bytes 
PyObject **ob_item;   #  8 bytes
Py_ssize_t allocated; #  8 bytes

这些都有注释(我已经修剪了)解释它们是什么,请点击上面的链接阅读。PyObject_VAR_HEAD 展开为三个 8 字节字段(ob_refcountob_typeob_size),因此贡献了 24 字节。

因此,现在的 res 是:

sizeof(PyListObject) + self->allocated * sizeof(void*)

或者:

40 + self->allocated * sizeof(void*)

如果列表实例有已分配的元素,则第二部分计算它们的贡献。"self->allocated",顾名思义,保存了已分配元素的数量。
没有任何元素的情况下,列表的大小被计算为:
>>> [].__sizeof__()
40

即实例结构体的大小。

tuple对象没有定义tuple_sizeof函数。相反,它们使用object_sizeof来计算它们的大小:

static PyObject *
object_sizeof(PyObject *self, PyObject *args)
{
    Py_ssize_t res, isize;

    res = 0;
    isize = self->ob_type->tp_itemsize;
    if (isize > 0)
        res = Py_SIZE(self) * isize;
    res += self->ob_type->tp_basicsize;

    return PyInt_FromSsize_t(res);
}

这段话涉及编程相关内容,讲解了关于列表的一些信息。它会获取tp_basicsize,如果对象具有非零tp_itemsize(表示它具有可变长度实例),则会将元组中项目的数量(通过Py_SIZE获得)乘以tp_itemsizetp_basicsize再次使用sizeof(PyTupleObject),其中PyTupleObject结构包含
PyObject_VAR_HEAD       # 24 bytes 
PyObject *ob_item[1];   # 8  bytes

因此,没有任何元素(即Py_SIZE返回0),空元组的大小等于sizeof(PyTupleObject)

>>> ().__sizeof__()
24

咦?好吧,这里有一个我没有找到解释的奇怪现象,tupletp_basicsize 实际上是按照以下方式计算的:

sizeof(PyTupleObject) - sizeof(PyObject *)

为什么从tp_basicsize中删除了额外的8个字节是我无法找到答案的事情。(请参见MSeifert的评论以获取可能的解释)
但是,这基本上是你特定示例中的区别。列表还保留了许多已分配元素的数量,这有助于确定何时再次进行过量分配。
现在,当添加额外的元素时,列表确实执行此过量分配以实现O(1)附加。正如MSeifert在他的答案中很好地解释的那样,这会导致更大的大小。

我认为ob_item [1]大多是一个占位符(因此从basicsize中减去它是有意义的)。tuple是使用PyObject_NewVar分配的。我还没有弄清楚细节,所以这只是一个有根据的猜测... - MSeifert
@MSeifert 很抱歉,已经修复了 :-). 我真的不知道,我记得在过去的某个时候找到过它,但从来没有给它太多关注,也许将来我会问一个问题 :-) - Dimitris Fasarakis Hilliard

30

MSeifert的回答涵盖了广泛内容,简单来说可以这样理解:

元组是不可变的。一旦设定,就无法更改它。因此,您可以预先知道需要为该对象分配多少内存。

列表是可变的。您可以向其中添加或删除项目。它必须知道其当前大小。随着需要,它会调整大小。

“天下没有白吃的午餐”- 这些功能伴随着代价。因此,在内存中使用列表会增加开销。


3

元组的大小是固定的,即在初始化时解释器为其中包含的数据分配足够的空间,因此它是不可变的(无法修改)。而列表是可变对象,因此意味着动态分配内存,因此为了避免每次追加或修改列表时都要分配空间(分配足够的空间来包含更改后的数据并将数据复制到其中),它会为未来的运行时更改(如追加和修改)分配额外的空间。

这基本上就是总结了。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接