【问题标题】:Why does creating a list of lists produce unexpected behavior?为什么创建列表列表会产生意外行为?
【发布时间】:2017-07-04 23:54:56
【问题描述】:

编辑:这个问题是关于为什么行为是什么,而不是如何绕过它,这就是所谓的重复是关于。


我在不同情况下使用了以下符号来创建一定大小的列表。例如:

>>> [None] * 5
[None, None, None, None, None]
>>>

这似乎按预期工作,并且比:

>>> [None for _ in range(5)]
[None, None, None, None, None]
>>>

然后我尝试使用相同的方法创建一个列表列表:

>>> [[]] * 5
[[], [], [], [], []]
>>>

很公平。它似乎按预期工作。

但是,在通过调试器时,我注意到 所有 子列表存储桶具有相同的值,即使我只添加了一个 单个 项。例如:

>>> t = [[]] * 5
>>> t
[[], [], [], [], []]
>>> t[1].append(4)
>>> t
[[4], [4], [4], [4], [4]]
>>> t[0] is t[1]
True
>>>

我没想到所有顶级数组元素都是对单个子列表的引用;我预计有 5 个独立子列表。

为此,我不得不编写如下代码:

>>> t = [[] for _ in range(5)]
>>> t
[[], [], [], [], []]
>>> t[2].append(4)
>>> t
[[], [], [4], [], []]
>>> t[0] is t[1]
False
>>>

我显然遗漏了一些东西,可能是一个历史事实,或者只是一种不同的方式来看待这里的一致性。

谁能解释一下为什么两个不同的代码 sn-ps 可以合理地期望彼此等效,实际上最终会隐式产生不同且不明显 (IMO) 的结果,尤其是考虑到 Python 的 zen总是明确明显

请注意,我已经知道this question,这与我所要求的不同。

我只是在寻找详细的解释/理由。如果此行为存在历史、技术和/或理论原因,请务必提供一两个参考资料。

【问题讨论】:

  • 这肯定有答案....似乎找不到。但这在很多地方都有很好的解释。
  • @idjaw 好吧,老实说,我被这个撕裂了。我最初将它作为那个著名的目标的欺骗目标,但在进一步考虑后重新打开它。欺骗目标问“有人可以解释发生了什么,以及如何绕过它吗?”但是 OP 已经知道发生了什么,以及如何绕过它。他们的问题是为什么这两种方法在语义上不等效
  • “尤其是考虑到 Python 的禅意总是明确而明显”——好吧,问题在于,就 Python 而言,你明确地知道 Python 应该做什么.你只是不明白你告诉它做什么。虽然他们本可以让事情变得更加明确,但将新手可能希望制作副本的所有内容拆分为单独的副本和无副本版本,但一直显式编写 x nc= [1, 2, 3]y nc= [[None] nc* 4] c* 5 会变得非常麻烦,而且这将是一个相当大的设计改变,让复制变得如此重要。
  • [[]] * 4 只是看到它乘以 4 的对象是一个 1 元素列表。它不知道如何构建通用列表元素的副本,它无法重新评估 [] 表达式,因为它看不到该表达式,并且特殊类型的列表元素以制作它们的副本会不一致。在没有不一致或大量语言重新设计的情况下,* 的唯一选择是复制引用而不是对象。
  • @ray 我还注意到您一直在使用术语“数组”。但这些不是数组。它们是列表。是的,在内部深处有一个 Py_Object 指针的 C 数组,但这是一个实现细节。它们是比单纯的数组更庞大的数据结构。它们是具有摊销常数时间.append 和常数时间索引的异构、可调整大小的列表...

标签: python list python-3.x


【解决方案1】:

当您执行以下操作时:

[[]]*n

首先创建一个列表,然后使用* 运算符和int n。这将获取列表中的任何对象,并创建它的 n 次重复。

但由于在 Python 中,显式优于隐式,因此您不会隐式复制这些对象。确实,这与 Python 的语义是一致的。

尝试列举一个 Python 隐式复制的案例。

另外,与榜单上的添加一致:

l = [1, [], 'a']

l2 = l + l + l

l[1].append('foo')

print(l2)

还有输出:

[1, ['foo'], 'a', 1, ['foo'], 'a', 1, ['foo'], 'a']

现在,正如 cmets 中所指出的,来自 C++,上面的内容令人惊讶是有道理的,但如果一个人习惯了 Python,那么上面就是人们所期望的

另一方面:

[[] for _ in range(5)]

是一个列表推导。相当于:

lst = []
for _ in range(5):
    lst.append([])

在这里,很明显,每次您进入循环时,您都会创建一个新列表。这就是字面语法的工作原理。

顺便说一句,我几乎从不在列表中使用* 运算符,除了我喜欢的一个特定习语:

>>> x = list(range(1, 22))
>>> it_by_three = [iter(x)]*3
>>> for a,b,c in zip(*it_by_three):
...    print(a, b, c)
...
1 2 3
4 5 6
7 8 9
10 11 12
13 14 15
16 17 18
19 20 21

【讨论】:

  • 既然您试图解释为什么,我认为最好详细介绍一下list.__mul__ 是如何实现的,以及它背后的原因没有为其他可变结构实现,例如 set 和 list。
  • 这个答案仅仅解释了代码的含义。我已经知道了。不幸的是,这并不能真正解决我的问题。
  • @ray 答案的关键在于斜体文本,但重申一下:在 Python 中,显式优于隐式,您不会隐式复制这些对象。
  • @cᴏʟᴅsᴘᴇᴇᴅ 我实际上并不认为它是如何实现的细节真的很重要。问题是设计选择之一。我的回答归结为“因为 Python 从不隐式复制,而且这种行为与语言的语义一致”。
  • @cᴏʟᴅsᴘᴇᴇᴅ 此外,它是为 sequence types 实现的,只要它有意义,可变或不可变。因此,它适用于不可变的strbytestuple 以及可变的bytearray。它不适用于非序列类型。而且它至少不适用于您无法真正做到的一种序列类型,即range
【解决方案2】:

对于cpython,相关部分源码在listobject.c中的函数list_repeat中。下面重复了一个启发性的 sn-p,并添加了我添加的 cmets:

np = (PyListObject *) PyList_New(size);  // make a new PyListObject

/* some code omitted */

items = np->ob_item;          // grabs the list of pointers of the *new* object
if (Py_SIZE(a) == 1) {        // this is the case for a 1-element list being multiplied
    elem = a->ob_item[0];     // grabs the pointer of the element of the *original* object
    for (i = 0; i < n; i++) {
        items[i] = elem;      // assigns the original pointer to the new list
        Py_INCREF(elem);
    }
    return (PyObject *) np;
}

由于PyListObject 主要是一个包含指向列表元素的指针列表的Vector,因此将这些点作为元素分配给新的PyListObject 很简单。

相反,想象一下如果需要复制位于每个指针处的对象的代码。它会更复杂,并且会对性能造成显着影响。但是,我不会推测这个设计决策的动机。

【讨论】:

  • 这是一个很好的发现。我搜索了 PEP 或其他可以解释为什么做出设计决策的东西。我遇到的是PEP-209,但这与我的问题无关。您是否在其他地方搜索和/或查看过有关此问题的讨论(例如邮件列表等)?你说:“想象一下如果需要复制位于每个指针处的对象的代码”。这似乎是投机性的;毕竟,按照以下思路“想象”某些东西似乎很容易:items[i] = Py_Copy(elem);
  • @ray:从你的标签历史来看,你已经习惯了 C++,其中复制是整个语言中最基本的操作之一。在 Python 的语言设计中,复制与基本操作相去甚远。没有C级复制接口。没有测试对象是否可复制的功能。对象几乎从不尝试复制其他对象,只复制它们自己。赋值不复制,参数传递和返回值不复制,也没有复制初始化的概念。有一个copy 模块,但是[继续]
  • [cont] 它在 stdlib 中的使用不多,主要是在测试代码中,并且几乎总是与已知类型的对象一起已知可复制。在 C++ 中,容器假定它的元素是可复制的并进行隐式复制是合理的和预期的。在 Python 中,这样的假设是不合理的。
  • @user2357112 从您的评论来看(即“没有C级复制接口......没有测试功能......一个对象是可复制的”),它要么出方便(即更容易实现)或有意的设计决策(即更有可能考虑到没有 C 函数来检查这些东西)。 OTOH,存在copy 模块,它允许deepcopying。您的评论似乎暗示 copy.deepcopy 必须 依赖于纯 Python 实现,因为“无法”测试对象是否可以本地复制;你能澄清一下吗?
  • @ray: copy.deepcopy 确实使用了纯 Python 实现,您可以看到 here,但它可以被编写为执行相同操作的 C 扩展模块。在我们讨论的时候,我应该指出它尝试的一些后备方法不是很安全,所以即使是相当简单的自引用结构,它也是 prone to failure
猜你喜欢
  • 1970-01-01
  • 2015-03-14
  • 2016-10-02
  • 1970-01-01
  • 2011-03-02
  • 2018-06-22
  • 2020-01-18
  • 2014-10-03
相关资源
最近更新 更多