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

Python difference between mutating and re-assigning a list ( _list = and _list[:] = )

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

_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) 突变是否节省内存?据推测,在重新分配后,对 "old" 列表的引用将降为零,并且 "old" 列表将被忽略,但是在此之前是否存在延迟,我可能使用的内存比我需要的多当我使用重新分配而不是改变列表时?

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 写道 "when [reassignment] is used, two new and old _lists with two different identities and values are created. Afterward, old _list is garbage collected. But when a container is mutated, every single value is retrieved, changed in CPU and updated one-by-one. So the list is not duplicated." 这似乎表明重新分配的内存成本很高。

我决定尝试使用 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 秒的间隔执行了基于时间的分析,并绘制了结果图表。我想看看内存使用量是否会出现短暂的峰值,但由于垃圾回收(引用计数)而立即消失。不过可惜,我还没找到这样的秒杀。

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

根据CPython documentation

Some objects contain references to other objects; these are called containers. Examples of containers are tuples, lists and dictionaries. The references are part of a container’s value. In most cases, when we talk about the value of a container, we imply the values, not the identities of the contained objects; however, when we talk about the mutability of a container, only the identities of the immediately contained objects are implied.

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

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]

被使用,创建了两个具有两个不同身份和值的新旧_lists。之后,旧的 _list 被垃圾收集。 但是当容器发生变化时,每个值都会被检索,在 CPU 中更改并更新 one-by-one。所以列表没有重复。

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

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

更新: 可以认为一个列表由两部分组成:对其他对象(的)的引用和引用值。我用一段代码演示了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%

TLDR:如果不自己执行某种循环或使用外部库,则无法修改 Python 中的列表 in-place,但可能不值得尝试 [=99] =] 原因(过早的优化)。可能值得尝试的是使用 Python map 函数和 iterables,它们根本不存储结果,而是按需计算它们。


在 Python 中,有多种方法可以在列表中应用修改函数(即执行 map),每种方法对性能和 side-effects:


新列表

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

[some_function(x) for x in _list]

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

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

最重要的是,这两种方法在时间和内存上都是线性的,因为它们复制了列表,并且有 side-effects 需要考虑。


In-place 替换

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

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

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

从功能上讲,这可能是最危险的选项,因为在调用 some_function 期间列表保持不一致状态。只要 some_function 不引用列表(这无论如何都是非常糟糕的设计),它应该与 new list 各种解决方案一样安全。也和上面_list[:] = ...的方案一样存在危险,因为原来的列表正在被修改。


可迭代对象

Python 3 map 函数作用于可迭代对象而不是列表。列表是可迭代的,但可迭代并不总是列表,当您调用 map(some_function, _list) 时,它根本不会立即 运行 some_function。它仅在您尝试以某种方式消耗 可迭代对象时执行。

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

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

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

在 Python 标准库 itertools.

中有许多用于处理迭代器和生成器的函数

关于并行化的说明

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

很难规范地回答这个问题,因为实际细节是 implementation-dependent 甚至 type-dependent。

例如,在 CPython 中,当一个对象达到 reference-count 零时,它就会被释放,内存会立即被释放。但是,某些类型有一个额外的 "pool" 可以在您不知情的情况下引用实例。例如 CPython 有 "pool" 个未使用的 list 个实例。当 list 的最后一个引用在 Python 代码中被删除时,它 可以 添加到这个 "free list" 而不是释放内存(需要调用某些东西 PyList_ClearFreeList 来回收该内存)。

但是一个列表不仅仅是列表所需要的内存,一个列表包含个对象。即使回收了列表的内存,列表中的对象可能仍然存在,例如,在其他地方仍然有对该对象的引用,或者该类型本身也有一个 "free list".

如果您查看其他实现,例如 PyPy,那么即使没有 "pool",在 no-one 引用时也不会立即处理对象它不再存在,它只被处理掉 "eventually".

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

让我们看看你的例子:

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

在这一行运行之前,有一个列表实例分配给了变量_list。然后使用 list-comprehension 创建一个 新列表 并将其分配给名称 _list。在此分配之前不久,内存中有两个列表。旧列表和理解创建的列表。赋值后,有一个列表被名称 _list 引用(新列表)和一个引用计数减 1 的列表。引用计数为 0,它可能会返回到池中,可能会被处理掉,也可能最终被处理掉。旧列表的内容相同。

另一个例子呢:

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

在该行运行之前,再次将一个列表分配给名称 _list。当该行执行时,它还会通过列表理解创建一个新列表。但不是将新列表分配给名称 _list,而是将旧列表的内容替换为新列表的内容。然而,当它清除旧列表时,它将有 两个 列表保存在内存中。在这个分配之后,旧列表仍然可以通过名称 _list 使用,但是由 list-comprehension 创建的列表不再被引用,它的引用计数达到 0,它会发生什么取决于它。可以放在free lists的"pool"中,可以立即销毁,也可以在以后某个未知的时间点销毁。旧列表的原始内容已被清除。

那么区别在哪里:

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

然而,更快地释放引用并不能保证它实际上会导致 "less memory",因为它可能会返回到池中,或者实现只会在将来的某个(未知)点释放内存。

内存成本较低的替代方案

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

所以不要这样做:

_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)根本不会创建任何列表。生成器是一个 state-machine,它在请求时一次处理一个元素。