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

Nei*_*eil 8 python list mutation

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

_list = list(range(10)) # Or whatever
_list = [some_function(x) for x in _list]
_list = [some_other_function(x) for x in _list]
Run Code Online (Sandbox Code Playgroud)

等等

我现在在另一个问题上看到一条评论,它解释了这种方法每次如何创建一个新列表,最好对现有列表进行变异,如下所示:

_list[:] = [some_function(x) for x in _list]
Run Code Online (Sandbox Code Playgroud)

这是我第一次看到此明确建议,我想知道其中的含义是什么:

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()
Run Code Online (Sandbox Code Playgroud)

哪个输出:

list a: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
list b: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
Run Code Online (Sandbox Code Playgroud)

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

谢谢。

编辑:冲突的答案:如此更多的内存测试 所以到目前为止,我已经收到两个冲突的答案。贾森哈珀(Jasonharper)在评论中写道,方程的右手边不知道左手边,因此,内存使用情况可能不会受到左侧出现的内容的影响。但是,Masoud在回答中写道:“使用[重新分配]时,会创建两个具有相同标识和值的新旧_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()
Run Code Online (Sandbox Code Playgroud)

请注意,我知道,例如,“查找平均字长”函数的编写不是特别好。仅出于测试目的。

结果如下:

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
Run Code Online (Sandbox Code Playgroud)

我发现,即使我将列表大小增加到100000,重新分配始终使用更多的内存,但是,大概只增加了1%。这使我认为,额外的内存开销可能只是某个地方的额外指针,而不是整个列表的开销。

为了进一步检验该假设,我以0.00001秒的间隔执行了基于时间的性能分析,并对结果进行了绘图。我想看看是否存在由于垃圾收集(引用计数)而导致瞬间消失的内存使用情况的瞬间高峰。但是a,我还没有发现这样的峰值。

谁能解释这些结果?到底发生了什么,导致内存使用量出现了这种微小但持续的增长?

MSe*_*ert 3

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

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

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

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

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

让我们看一下您的示例:

_list = [some_function(x) for x in _list]
Run Code Online (Sandbox Code Playgroud)

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

那另一个例子呢:

_list[:] = [some_function(x) for x in _list]
Run Code Online (Sandbox Code Playgroud)

在这一行运行之前,会再次分配一个列表给该名称_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]
Run Code Online (Sandbox Code Playgroud)

你可以这样做:

def generate_values(it):
    for x in it:
        x = some_function(x)
        x = some_other_function(x)
        yield x
Run Code Online (Sandbox Code Playgroud)

然后简单地消费它:

for item in generate_values(range(10)):
    print(item)
Run Code Online (Sandbox Code Playgroud)

或者用列表来使用它:

list(generate_values(range(10)))
Run Code Online (Sandbox Code Playgroud)

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