突变和重新分配列表( list = 和 list[:] = )之间的 Python 区别

Posted

技术标签:

【中文标题】突变和重新分配列表( list = 和 list[:] = )之间的 Python 区别【英文标题】:Python difference between mutating and re-assigning a list ( _list = and _list[:] = ) 【发布时间】:2019-10-11 23:47:10 【问题描述】:

所以我经常按照这样的模式编写代码:

_list = list(range(10)) # Or whatever
_list = [some_function(x) for x in _list]
_list = [some_other_function(x) for x in _list]

我现在在另一个问题上看到了一条评论,该评论解释了这种方法如何每次都创建一个新列表,并且最好改变现有列表,如下所示:

_list[:] = [some_function(x) for x in _list]

这是我第一次看到这个明确的建议,我想知道这意味着什么:

1) 突变会节省内存吗?据推测,在重新分配后,对“旧”列表的引用将降至零,并且“旧”列表被忽略,但在此之前是否存在延迟,因为我可能使用的内存比我使用时需要的多重新分配而不是改变列表?

2) 使用变异是否有计算成本?我怀疑就地更改某些内容比创建新列表并删除旧列表更昂贵?

为了安全,我写了一个脚本来测试一下:

def some_function(number: int):
    return number*10

def main():
    _list1 = list(range(10))
    _list2 = list(range(10))

    a = _list1
    b = _list2 

    _list1 = [some_function(x) for x in _list1]
    _list2[:] = [some_function(x) for x in _list2]

    print(f"list a: a")
    print(f"list b: b")


if __name__=="__main__":
    main()

哪些输出:

list a: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
list b: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]

因此,突变似乎确实具有更容易引起副作用的缺点。尽管这些可能是可取的。是否有任何 PEP 讨论此安全方面或其他最佳实践指南?

谢谢。

编辑:冲突的答案:所以更多的记忆测试 所以到目前为止,我收到了两个相互矛盾的答案。在 cmets 中,jasonharper 写道,等式的右侧不知道左侧,因此内存使用不可能受到左侧出现的内容的影响。然而,在答案中,Masoud 写道:“当使用 [reassignment] 时,会创建两个具有两个不同身份和值的新旧 _list。之后,旧 _list 会被垃圾收集。但是当容器发生变异时,每个单独的值被检索,在 CPU 中更改并一一更新。因此该列表不会重复。”这似乎表明进行重新分配需要很大的内存成本。

我决定尝试使用memory-profiler 进行更深入的挖掘。这是测试脚本:

from memory_profiler import profile


def normalise_number(number: int):
    return number%1000


def change_to_string(number: int):
    return "Number as a string: " + str(number) + "something" * number


def average_word_length(string: str):
    return len(string)/len(string.split())


@profile(precision=8)
def mutate_list(_list):
    _list[:] = [normalise_number(x) for x in _list]
    _list[:] = [change_to_string(x) for x in _list]
    _list[:] = [average_word_length(x) for x in _list]


@profile(precision=8)
def replace_list(_list):
    _list = [normalise_number(x) for x in _list]
    _list = [change_to_string(x) for x in _list]
    _list = [average_word_length(x) for x in _list]
    return _list


def main():
    _list1 = list(range(1000))
    mutate_list(_list1)

    _list2 = list(range(1000))
    _list2 = replace_list(_list2)

if __name__ == "__main__":
    main()

请注意,我知道,例如,查找平均字长函数的编写并不是特别好。只是为了测试。

结果如下:

Line #    Mem usage    Increment   Line Contents
================================================
    16  32.17968750 MiB  32.17968750 MiB   @profile(precision=8)
    17                             def mutate_list(_list):
    18  32.17968750 MiB   0.00000000 MiB       _list[:] = [normalise_number(x) for x in _list]
    19  39.01953125 MiB   0.25781250 MiB       _list[:] = [change_to_string(x) for x in _list]
    20  39.01953125 MiB   0.00000000 MiB       _list[:] = [average_word_length(x) for x in _list]


Filename: temp2.py

Line #    Mem usage    Increment   Line Contents
================================================
    23  32.42187500 MiB  32.42187500 MiB   @profile(precision=8)
    24                             def replace_list(_list):
    25  32.42187500 MiB   0.00000000 MiB       _list = [normalise_number(x) for x in _list]
    26  39.11328125 MiB   0.25781250 MiB       _list = [change_to_string(x) for x in _list]
    27  39.11328125 MiB   0.00000000 MiB       _list = [average_word_length(x) for x in _list]
    28  32.46484375 MiB   0.00000000 MiB       return _list

我发现,即使我将列表大小增加到 100000,重新分配始终会使用更多内存,但可能只增加 1%。这让我觉得额外的内存成本可能只是某个地方的额外指针,而不是整个列表的成本。

为了进一步检验假设,我以 0.00001 秒的间隔执行了基于时间的分析,并绘制了结果图。我想看看是否有可能由于垃圾收集(引用计数)而立即消失的内存使用量的瞬时峰值。但是很可惜,我还没有找到这样的尖峰。

谁能解释这些结果?究竟是什么原因导致内存使用量轻微但持续增加?

【问题讨论】:

如果不需要使用中间产品,可以将其定义为生成器:_list = (some_function(x) for x in _list) 这是一个非常好的观点@PatrickHaugh _list[:] = [some_function(x) for x in _list] 创建一个全新的列表 - 对作业右侧的评估不知道左侧将如何处理它。然后它用新的内容替换现有的列表内容,然后处理新的列表。 _list = ... 具有完全相同的内存要求,但速度更快,因为它跳过了删除/替换步骤。 如果其他内容引用了原始列表,并且您希望更新该引用,您可能仍需要使用 _list[:] = ...。在_list = ... 之后,对旧列表的引用仍然是对旧列表的引用。 分配给列表切片没有什么“不安全”的,这是一个标准操作。我认为您对性能的担忧是过早的优化。如果你想要相同的列表具有相同的内存地址然后使用切片分配,否则不需要 【参考方案1】:

根据CPython documentation:

一些对象包含对其他对象的引用;这些被称为容器。容器的示例是元组、列表和字典。引用是容器值的一部分。在大多数情况下,当我们谈论容器的价值时,我们暗示的是价值,而不是所包含对象的身份;然而,当我们谈论容器的可变性时,只暗示了直接包含的对象的身份。

所以当一个列表发生变异时,列表中包含的引用发生了变异,而对象的身份没有改变。有趣的是,虽然具有相同值的可变对象不允许具有相同的标识,但相同的不可变对象可以具有相似的标识(因为它们是不可变的!)。

a = [1, 'hello world!']
b = [1, 'hello world!']
print([hex(id(_)) for _ in a])
print([hex(id(_)) for _ in b])
print(a is b)

#on my machine, I got:
#['0x55e210833380', '0x7faa5a3c0c70']
#['0x55e210833380', '0x7faa5a3c0c70']
#False

当代码:

_list = [some_function(x) for x in _list]
使用

时,会创建两个具有两个不同身份和值的新旧 _list。之后,旧的 _list 会被垃圾回收。 但是当一个容器发生变异时,每一个值都会被检索,在 CPU 中更改并一个一个地更新。所以列表不会重复。

关于处理效率,很容易比较:

import time

my_list = [_ for _ in range(1000000)]

start = time.time()
my_list[:] = [_ for _ in my_list]
print(time.time()-start)  # on my machine 0.0968618392944336 s


start = time.time()
my_list = [_ for _ in my_list]
print(time.time()-start)  # on my machine 0.05194497108459473 s

更新: 可以认为一个列表由两部分组成:对其他对象(的 ID)的引用和引用值。我用一段代码演示了list object directly占用的内存占总内存消耗的百分比(列表对象+引用对象):

import sys
my_list = [str(_) for _ in range(10000)]

values_mem = 0
for item in my_list:
    values_mem+= sys.getsizeof(item)

list_mem = sys.getsizeof(my_list)

list_to_total = 100 * list_mem/(list_mem+values_mem)
print(list_to_total) #result ~ 14%

【讨论】:

你似乎是在暗示这会影响记忆?【参考方案2】:

TLDR:如果不自己进行某种循环或使用外部库,就无法在 Python 中就地修改列表,但出于节省内存的原因(过早优化),它可能不值得尝试。可能值得尝试的是使用 Python map 函数和 iterables,它们根本不存储结果,而是按需计算它们。


有几种方法可以在 Python 中跨列表应用修改函数(即执行 map),每种方法对性能和副作用都有不同的影响:


新列表

这就是问题中两个选项的实际作用。

[some_function(x) for x in _list]

这将创建一个新列表,通过对 _list 中的相应值运行 some_function 按顺序填充值。然后可以将其分配为旧列表的替换 (_list = ...) 或让其值替换旧值,同时保持对象引用相同 (_list[:] = ...)。前一个分配发生在恒定的时间和内存中(毕竟它只是一个引用替换),其中第二个必须遍历列表来执行分配,这在时间上是线性的。但是,首先创建列表所需的时间和内存都是线性的,所以_list = ..._list[:] = ... 快,但它在时间和内存上仍然是线性的,所以这并不重要。

从功能的角度来看,此选项的两个变体通过副作用具有潜在的危险后果。 _list = ... 留下旧列表,这并不危险,但确实意味着内存可能不会被释放。对_list 的任何其他代码引用将在更改后立即获得新列表,这可能还不错,但如果您不注意,可能会导致细微的错误。 list[:] = ... 更改现有列表,因此引用它的其他任何人都将在其脚下更改值。请记住,如果列表曾经从某个方法返回,或者传递到您正在工作的范围之外,您可能不知道还有谁在使用它。

底线是这两种方法在时间和内存上都是线性的,因为它们复制列表,并且有需要考虑的副作用。


就地替换

问题中暗示的另一种可能性是更改现有值。这将节省列表副本的内存。不幸的是,在 Python 中没有内置函数可以执行此操作,但手动执行此操作并不难(如this question 的各种答案中提供的那样)。

for i in range(len(_list)):
    _list[i] = some_function(_list[i])

在复杂性方面,这仍然具有执行对 some_function 的调用的线性时间成本,但节省了保留两个列表的额外内存。如果没有在其他地方引用,则旧列表中的每个项目都可以在被替换后立即进行垃圾回收。

从功能上讲,这可能是最危险的选择,因为在调用some_function 期间列表处于不一致的状态。只要some_function 不提及列表(无论如何这将是非常糟糕的设计),它应该与新列表 变体解决方案一样安全。它也和上面的_list[:] = ...解决方案有同样的危险,因为原来的列表正在被修改。


迭代

Python 3 map 函数作用于可迭代对象而不是列表。列表是可迭代对象,但可迭代对象并不总是列表,当您调用 map(some_function, _list) 时,它根本不会立即运行 some_function。只有当您尝试以某种方式使用 iterable 时,它​​才会这样做。

list(map(some_other_function, map(some_function, _list)))

上面的代码对_list的元素应用some_function,后跟some_other_function,并将结果放入一个新列表中,但重要的是,它根本不存储中间值。如果您只需要对结果进行迭代,或从中计算最大值,或其他一些 reduce 函数,则无需在此过程中存储任何内容。

这种方法符合 函数式 编程范式,该范式不鼓励副作用(通常是棘手错误的根源)。因为原始列表从未被修改过,即使some_function 确实在它当时正在考虑的项目之外引用了它(顺便说一句,这仍然不是好的做法),它不会受到正在进行的 的影响地图

在 Python 标准库 itertools 中有很多用于处理迭代和生成器的函数。


关于并行化的说明

考虑如何在列表上执行 map 以通过在多个 cpu 之间共享来减少调用 some_function 的线性时间成本是非常诱人的。原则上,所有这些方法都可以并行化,但 Python 很难做到。一种方法是使用multiprocessing 库,它有一个map 函数。 This answer 描述了如何使用它。

【讨论】:

【参考方案3】:

很难规范地回答这个问题,因为实际的细节取决于实现,甚至取决于类型。

例如在 CPython 中,当一个对象的引用计数为零时,它就会被释放并立即释放内存。但是,某些类型有一个额外的“池”,它在您不知情的情况下引用实例。例如,CPython 有一个未使用的 list 实例的“池”。当在 Python 代码中删除 list 的最后一个引用时,它可能被添加到这个“空闲列表”而不是释放内存(需要调用 PyList_ClearFreeList 来回收该内存)。

但列表不仅仅是列表所需的内存,列表包含对象。即使回收列表的内存,列表中的对象也可能保留,例如在其他地方仍然存在对该对象的引用,或者该类型本身也有一个“空闲列表”。

如果你看看像 PyPy 这样的其他实现,那么即使没有“池”,当没有人再引用它时,对象也不会立即被处理掉,它只会“最终被处理掉” ”。

那么这与您可能想知道的示例有何关系。

让我们看看你的例子:

_list = [some_function(x) for x in _list]

在这一行运行之前,有一个列表实例分配给变量_list。然后,您使用列表理解创建一个新列表,并将其分配给名称_list。在此分配之前不久,内存中有两个列表。旧列表和理解创建的列表。赋值后,有一个名为 _list 的列表(新列表)和一个引用计数减 1 的列表。如果旧列表没有在其他任何地方引用,因此达到了引用计数为 0 时,可能会返回池中,可能会被丢弃,也可能最终会被丢弃。旧列表的内容相同。

另一个例子呢:

_list[:] = [some_function(x) for x in _list]

在此行运行之前,再次分配了一个名称为_list 的列表。当该行执行时,它还会通过列表推导创建一个新列表。但不是将新列表分配给名称_list,而是将旧列表的内容替换为新列表的内容。然而,当它清除旧列表时,它将有 两个 列表保存在内存中。在此分配之后,旧列表仍可通过名称 _list 获得,但列表理解创建的列表不再被引用,它的引用计数达到 0,取决于它会发生什么。它可以放入空闲列表的“池”中,可以立即释放,也可以在未来某个未知点释放。旧列表的原始内容也被清除了。

那么区别在哪里:

其实差别不大。在这两种情况下,Python 都必须将两个列表完全保存在内存中。然而,第一种方法释放对旧列表的引用比第二种方法释放对内存中中间列表的引用更快,这仅仅是因为在复制内容时它必须保持活动状态。

但是更快地释放引用并不能保证它实际上会导致“更少的内存”,因为它可能会被返回到池中,或者实现只会在将来的某个(未知)时间释放内存。

内存成本较低的替代方案

您可以链接迭代器/生成器并在需要迭代它们(或者您需要实际列表)时使用它们,而不是创建和丢弃列表。

所以不要这样做:

_list = list(range(10)) # Or whatever
_list = [some_function(x) for x in _list]
_list = [some_other_function(x) for x in _list]

你可以这样做:

def generate_values(it):
    for x in it:
        x = some_function(x)
        x = some_other_function(x)
        yield x

然后简单地消费它:

for item in generate_values(range(10)):
    print(item)

或者用一个列表来消费它:

list(generate_values(range(10)))

这些(除非您将其传递给list)根本不会创建任何列表。生成器是一种状态机,可在请求时一次处理一个元素。

【讨论】:

以上是关于突变和重新分配列表( list = 和 list[:] = )之间的 Python 区别的主要内容,如果未能解决你的问题,请参考以下文章

python中的list和list[:]有啥区别?

『Python』内存分析_list和array的内存增长模式

以列表为输入的石墨烯突变

Linux 访问控制列表(access control list)

PHP代码内存不足分配问题

如果在突变查询中添加列表类型,graphql 不会在 android 中编译