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

时间:2019-05-25 20:32:56

标签: 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]

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

_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在回答中写道:“使用[重新分配]时,将创建两个具有相同标识和值的新旧_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秒的间隔执行了基于时间的性能分析,并对结果进行了绘图。我想看看是否可能由于垃圾收集(引用计数)而导致内存使用量的瞬时峰值突然消失了。但是a,我还没有发现这样的峰值。

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

3 个答案:

答案 0 :(得分:2)

根据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被垃圾收集。 但是,当容器发生突变时,每个单独的值都将被检索,在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

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

答案 1 :(得分:2)

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

例如,在 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来使用,但是不再由list-comprehension创建的列表被引用,它的引用计数为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 :(得分:1)

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


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


新列表

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

[some_function(x) for x in _list]

这将创建一个新列表,通过对some_function中的相应值运行_list来按顺序填充值。然后,可以将其分配为旧列表的替换(_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不引用列表(无论如何,这都是非常糟糕的设计),它就应该与 new list 各种解决方案一样安全。它也具有与上述_list[:] = ...解决方案相同的危险,因为原始列表已被修改。


Iterables

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

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

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

此方法适合 functional 编程范例,该范例可防止副作用(通常是棘手的bug的来源)。因为原始列表从未修改过,所以即使some_function确实在当时正在考虑的项目之外对其进行了引用(顺便说,这仍然不是一种好习惯),它也不会受到正在进行的< em>地图。

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


关于并行化的说明

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