Python中修改列表和重新分配列表的区别(_list = 和 _list[:] =)

9

因此,我经常按照以下模式编写代码:

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

我现在看到了一个不同的问题,有一条评论解释了这种方法每次都会创建一个新列表,更好的方法是改变现有的列表,像这样:

etc

_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讨论这个安全方面或其他最佳实践指南?
谢谢。
编辑:矛盾的答案:因此需要对内存进行更多测试
迄今为止,我已经收到了两个相互矛盾的答案。在评论中,jasonharper写道,等式的右侧不知道左侧的情况,因此内存使用不可能受到左侧出现的影响。然而,在答案中,Masoud写道,“当重新分配时,将创建具有两个不同标识和值的新旧列表。之后,旧的列表被垃圾回收。但是当容器发生变异时,每个单个值都会被检索,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秒的间隔进行了基于时间的分析,并绘制了结果图。我想看看是否有瞬间的内存使用量峰值,由于垃圾回收(引用计数)而立即消失。但遗憾的是,我没有找到这样的峰值。
有人能解释这些结果吗?在这里究竟发生了什么导致内存使用略微但持续地增加?

2
如果您不需要使用中间产品,可以将它们定义为生成器:_list = (some_function(x) for x in _list) - Patrick Haugh
那是一个非常好的观点,@PatrickHaugh。 - Neil
3
_list[:] = [some_function(x) for x in _list]会创建一个全新的列表,赋值语句右侧的求值不会知道左侧的操作。然后它会用新内容替换现有列表的内容,并且新列表将被处理掉。_list = ...的内存需求完全相同,但速度更快,因为它跳过了删除/替换步骤。 - jasonharper
好的@jasonharper,你的意思是说在资源方面,_list = 与 has 相同的内存但更好的 CPU 使用?所以没有任何权衡吗? - Neil
3
如果其他地方还有对原列表的引用,并且你希望这些引用也能得到更新,那么你可能仍需要使用 _list[:] = ...。在 _list = ... 之后,对旧列表的引用仍然是指向旧列表。请注意不改变原意,让翻译通俗易懂。 - jasonharper
2
将列表切片赋值并没有什么“不安全”的,这是一个标准操作。我认为你对性能的担忧是过早的优化。如果你想要相同的列表和相同的内存地址,那么使用切片赋值,否则就没有必要了。 - Chris_Rands
3个回答

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,否则这些代码不会创建任何列表。生成器是一种状态机,当请求时逐个处理元素。


2
TLDR: 在Python中,你不能直接就地修改列表而不进行某种循环或使用外部库,但出于节省内存的原因(过早优化),尝试这样做可能并不值得。值得尝试的是使用Python的map函数和可迭代对象,它们根本不会存储结果,而是按需计算它们。

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


新列表

这正是问题中两个选项实际上正在做的事情。

[some_function(x) for x in _list]

这将创建一个新列表,通过对_list中的相应值运行some_function来按顺序填充值。然后可以将其分配为旧列表的替换项(_list = ...),或者在保持对象引用不变的情况下替换旧值 (_list[:] = ...)。前一种分配在常量时间和内存中发生(毕竟只是引用替换),而第二种需要遍历列表以执行分配,这是线性的。但是,首先创建列表所需的时间和内存都是线性的,因此_list = ..._list[:] = ...严格更快,但它仍然是时间和内存的线性,所以并不重要。
从功能角度来看,这个选项的两个变体可能会通过副作用产生潜在危险的后果。_list = ...留下了旧列表,这并不危险,但意味着内存可能没有被释放。任何其他对_list的代码引用在更改后将立即获得新列表,这也可能是可以接受的,但如果您没有注意可能会导致微妙的错误。list[:] = ...更改现有列表,因此任何其他引用它的人都将在他们的脚下更改值。请记住,如果列表从方法返回或传递到您正在使用的范围之外,则您可能不知道谁还在使用它。
底线是这两种方法都是时间和内存的线性复杂度,因为它们都复制了列表,并且需要考虑副作用。

原地替换

另一种可能性是在原地更改值。这将节省复制列表的内存。不幸的是,Python中没有内置函数来执行此操作,但手动执行此操作并不难(如此问题中提供的各种答案)。
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。只有在以某种方式消耗可迭代对象时才会运行。

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

以上代码将some_functionsome_other_function应用于_list的元素,并将结果放入新列表中,但重要的是,它根本不存储中间值。如果您只需要迭代结果、计算最大值或其他一些reduce函数,则无需在途中存储任何内容。
这种方法符合函数式编程范例,该范例不鼓励副作用(通常是棘手错误的源头)。因为原始列表从未被修改过,即使some_function在考虑当前项时超出了对它的引用(顺便说一下,这仍然不是好的实践),它也不会受到正在进行的map的影响。
Python标准库itertools中有许多用于处理可迭代对象和生成器的函数。

并行化注意事项

非常诱人的想法是考虑如何并行化对列表的map操作,通过在多个CPU之间共享来减少调用some_function的线性时间成本。原则上,所有这些方法都可以并行化,但Python使其变得相当困难。其中一种方法是使用multiprocessing库,该库具有map函数。这个答案描述了如何使用它。

2
根据 CPython文档
一些对象包含对其他对象的引用;这些称为容器。元组、列表和字典都是容器的例子。引用是容器值的一部分。在大多数情况下,当我们谈论一个容器的值时,我们意味着所包含对象的值而不是其身份;然而,当我们谈论容器的可变性时,只隐含了直接包含对象的身份。
因此,当一个列表被改变时,列表中包含的引用会被改变,而对象的身份则保持不变。有趣的是,虽然具有相同值的可变对象不能拥有相同的身份,但相同的不可变对象可以拥有类似的身份(因为它们是不可变的!)。
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]

被使用时,创建了两个具有不同身份和值的新旧列表。之后,旧列表被垃圾回收。 但是当容器发生变化时,每个值都会被检索,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)和引用值。我使用了一段代码来演示直接占用列表对象在总内存消耗中所占的百分比。
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%

你似乎在暗示这会有内存方面的影响? - Neil

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