为啥 p[:] 在这两种情况下的工作方式不同?

Posted

技术标签:

【中文标题】为啥 p[:] 在这两种情况下的工作方式不同?【英文标题】:Why was p[:] designed to work differently in these two situations?为什么 p[:] 在这两种情况下的工作方式不同? 【发布时间】:2019-11-08 22:49:23 【问题描述】:
p = [1,2,3]
print(p) # [1, 2, 3]

q=p[:]  # supposed to do a shallow copy
q[0]=11
print(q) #[11, 2, 3] 
print(p) #[1, 2, 3] 
# above confirms that q is not p, and is a distinct copy 

del p[:] # why is this not creating a copy and deleting that copy ?
print(p) # [] 

以上确认p[:] 在这两种情况下的工作方式不同。不是吗?

考虑到在以下代码中,我希望直接使用 p 而不是 p 的副本,

p[0] = 111
p[1:3] = [222, 333]
print(p) # [111, 222, 333]

感觉

del p[:] 

p[:]一致,均引用原列表 但是

q=p[:] 

在这种情况下,p[:] 会导致一个新列表!

我的新手期望是这样的

q=p[:]

应该和

一样
q=p

为什么创作者允许这种特殊行为产生副本?

【问题讨论】:

因为历史原因。我同意,切片应该返回参考切片,而不是新列表。这就是它在numpy 中的工作方式,也是用户在大多数用例中想要的。事实上,python 添加了list.copy 方法是因为这种奇怪的混淆了人们(因为之前使用[:] 复制列表,而使用.copy() 复制字典和集合)。许多旧的 python 在方法上返回了低效的副本(参见dict.itemszip),这在 python 3 中得到了修复,但列表切片仍然作为副本。我想用[a:b].copy()替换每个[a:b]被认为太麻烦了 @FHTMitchell: list.copy 由于混淆而没有添加,添加它是为了使list 与其他集合类型更兼容(set/dict,必须有@ 987654344@ 因为切片对它们不起作用)。返回引用切片使得推理代码变得更加变得更加困难,并且更加更容易意外地执行诸如意外修改调用者参数之类的事情。 numpy 这样做是出于性能目的;一般来说,Python 更关心的是让编写正确的代码变得容易,而性能是次要的问题。 @FHTMitchell:不,这样做不是为了减少混乱,而是为了增加灵活性。他们的想法是,他们希望通过在收到的任何内容上调用 .copy() 来使函数能够避免类型复制公共集合。如果您需要序列,正确的解决方案仍然是使用seq[:],因为它可以处理可变和不可变序列。 如果你想复制一份列表,为了清晰起见,我推荐q = list(p)而不是q = p[:] @FHTMitchell:dict slice 有一些非常奇怪的行为,这使得它很难用于列表。一方面,如果你改变字典,字典视图将会中断。由于 dict 视图通常仅在迭代期间短期使用,并且由于 dicts 是无序的,因此在这种情况下它的问题要小得多,但是列表切片可以并且将被传递。此外,如果基础列表更改,不可变列表切片可能会意外更改。如果有人做了list.insert(),列表视图应该如何表现?默认情况下,惰性切片有其吸引力,但它会使语言对初学者更难。 【参考方案1】:

del 和 assignments 的设计是一致的,只是它们的设计方式与您期望的方式不同。 del 从不删除对象,它删除名称/引用(对象删除只会间接发生,删除对象的是引用计数/垃圾收集器);同样,赋值运算符从不复制对象,它总是在创建/更新名称/引用。

del 和赋值运算符采用参考规范(类似于 C 中的左值概念,尽管细节有所不同)。此参考规范可以是变量名称(普通标识符)、__setitem__ 键(方括号中的对象)或__setattr__ 名称(点后的标识符)。此左值不会像表达式一样计算,因为这样做会导致无法分配或删除任何内容。

考虑以下之间的对称性:

p[:] = [1, 2, 3]

del p[:]

在这两种情况下,p[:] 的工作方式相同,因为它们都被评估为左值。另一方面,在下面的代码中,p[:] 是一个完全计算为对象的表达式:

q = p[:]

【讨论】:

有趣。所以,我想知道为什么 numpy 没有遵循相同的推理? p[:] 在 numpy 中类似但不同。有关视图,请参阅docs。 “切片数组返回它的视图”,视图是“查看相同数据的新数组对象” @brainOverflow numpy 希望尽可能高效,并且尽可能简洁易读。既然 numpy 代码会被优化,为什么要让最常见的代码更冗长呢? numpy 不是作为一个通用框架诞生的,它是为具有一定专业知识水平的人设计的,因此他们做出的选择与 Guido 在设计 python 语言时做出的选择不同 标准python和numpy在赋值和删除操作上完全一样;它们的区别不在于赋值和删除,而在于在右值上下文中评估时的切片。一个 numpy 数组在切片时返回一个视图,标准 python 返回一个副本。这个微小的差异会对标准 python 和 numpy 的操作行为产生巨大影响;当标准 python 不覆盖时,numpy 将覆盖默认数据。但是,如果您了解 python 对象模型并且 numpy slices 是一个视图,那么这些行为差异是有道理的并且实际上非常简单。【参考方案2】:

迭代器上的del 只是以索引为参数调用__delitem__。就像括号调用 [n] 是对索引为 n 的迭代器实例上的 __getitem__ 方法的调用。

因此,当您调用 p[:] 时,您正在创建一个项目序列,而当您调用 del p[:] 时,您将 del/__delitem__ 映射到该序列中的每个项目。

【讨论】:

del on iterator“似乎是错误的,因为未定义迭代器上的del 他没有说“del on iterator”,他说的是“del on sequence”。即使它不正确的实现(你没有映射任何东西),它也是一种思考方式,通常会得出正确的结论。 @JaccovanDorp 答案字面意思以“del on iterator is ...”开头,所以 ipaleka 确实说/写了。 对不起,我无法重构我之前的思路。【参考方案3】:

正如其他人所说; p[:] 删除p 中的所有项目;但不会影响 q。要更详细地了解列表docs,请参考以下内容:

所有切片操作都返回一个包含请求的新列表 元素。这意味着下面的切片返回一个新的(浅) 列表副本:

>>> squares = [1, 4, 9, 16, 25]
...
>>> squares[:]
[1, 4, 9, 16, 25]

所以q=p[:] 创建了p(浅) 副本作为单独的列表,但进一步检查它确实指向内存中完全独立的位置。

>>> p = [1,2,3]
>>> q=p[:]
>>> id(q)
139646232329032
>>> id(p)
139646232627080

这在copy 模块中有更好的解释:

浅拷贝构造一个新的复合对象,然后(到 尽可能)将引用插入其中找到的对象 原件。

虽然del 语句是在列表/切片上递归执行的:

删除一个目标列表递归删除每个目标,从左到右。

因此,如果我们使用del p[:],我们将通过迭代每个元素来删除p 的内容,而q 并没有如前所述进行更改,它引用了一个单独的列表,尽管具有相同的项目:

>>> del p[:]
>>> p
[]
>>> q
[1, 2, 3]

事实上,列表文档以及list.clear 方法中也引用了这一点:

list.copy()

返回列表的浅表副本。等效于a[:]

list.clear()

从列表中删除所有项目。相当于del a[:]

【讨论】:

del 语句不会在该列表上递归执行。该文档讨论的是目标列表,而不是示例中的一个列表+切片。考虑del parrot, spam, grail——这里的目标列表包含三个从左到右删除的元素,首先是parrot,然后是spam,然后是grail。在del p[:] 中只有一个目标被删除:p[:]【参考方案4】:

基本上,切片语法可以在 3 种不同的上下文中使用:

访问,即x = foo[:] 设置,即foo[:] = x 删除,即del foo[:]

在这些上下文中,放在方括号中的值只是选择项目。这是为了在每种情况下都一致地使用“切片”:

所以x = foo[:] 获取foo 中的所有元素 并将它们分配给x。这基本上是一个浅拷贝。

foo[:] = x 会将foo 中的所有元素 替换为x 中的元素。

并且在删除del foo[:]时会删除foo中的所有元素

但是,正如3.3.7. Emulating container types 所解释的,此行为是可自定义的:

object.__getitem__(self, key)

调用以实现self[key]的评估。对于序列类型,接受的键应该是整数和切片对象。请注意,负索引的特殊解释(如果类希望模拟序列类型)取决于__getitem__() 方法。如果 key 的类型不合适,TypeError 可能会被引发;如果序列的索引集之外的值(在对负值进行任何特殊解释之后),则应引发IndexError。对于映射类型,如果缺少键(不在容器中),则应引发KeyError

注意

for 循环期望为非法索引引发 IndexError,以便正确检测序列的结尾。

object.__setitem__(self, key, value)

调用以实现对self[key]的分配。与__getitem__() 相同的注释。如果对象支持更改键的值,或者可以添加新键,或者如果可以替换元素,则仅应为映射实现此功能。对于不正确的键值,应引发与 __getitem__() 方法相同的异常。

object.__delitem__(self, key)

调用以实现删除self[key]。与__getitem__() 相同的注释。如果对象支持删除键,则仅应为映射实现此功能,如果可以从序列中删除元素,则应为序列实现。对于不正确的键值,应引发与 __getitem__() 方法相同的异常。

(强调我的)

所以理论上任何容器类型都可以实现它,但它想要。然而,许多容器类型遵循列表实现。

【讨论】:

【参考方案5】:

我不确定你是否想要这样的答案。换句话说,对于 p[:],它意味着“遍历 p 的所有元素”。如果你在

中使用它
q=p[:]

那么它可以读作“迭代p的所有元素并将其设置为q”。另一方面,使用

q=p

只是意味着“将 p 的地址分配给 q”或“使 q 成为指向 p 的指针”,如果您来自其他单独处理指针的语言,这会令人困惑。

因此,在del中使用它,就像

del p[:]

只是表示“删除p的所有元素”。

希望这会有所帮助。

【讨论】:

【参考方案6】:

主要是历史原因。

在 Python 的早期版本中,迭代器和生成器并不是真正的东西。大多数处理序列的方法只返回列表:例如,range() 返回包含数字的完整构造列表。

因此,当在表达式的右侧使用切片时,返回一个列表是有意义的。 a[i:j:s] 返回了一个新列表,其中包含来自 a 的选定元素。因此,赋值右侧的a[:] 将返回一个包含a 的所有元素的新列表,即浅拷贝:这在当时是完全一致的。

另一方面,表达式左侧侧的括号总是修改原始列表:这是a[i] = d设置的先例,然后是del a[i],并且然后通过del a[i:j]

时间过去了,到处复制值和实例化新列表被认为是不必要且昂贵的。如今,range() 返回一个生成器,该生成器仅在请求时生成每个数字,并且迭代切片可能会以相同的方式工作 - 但 copy = original[:] 的习语作为历史产物已经根深蒂固。

顺便说一句,在 Numpy 中,情况并非如此:ref = original[:] 将进行引用而不是浅拷贝,这与 del 和分配给数组的工作方式是一致的。

>>> a = np.array([1,2,3,4])
>>> b = a[:]
>>> a[1] = 7
>>> b
array([1, 7, 3, 4])

Python 4,如果发生的话,可能会效仿。正如您所观察到的,它与其他行为更加一致。

【讨论】:

为切片创建副本仍然有意义。我不明白为什么 Python 4 应该效仿。 @BlackJack 迭代其他数据结构通常不会创建副本:例如,如果您想要字典键的单独副本,则需要使用list(d.keys())。如果您必须对切片执行相同操作,这对我来说很有意义:list(a[:]) 或只是 a.copy() 我不明白你的意思。遍历列表也不会创建副本。此外,如果original[:] 将创建一个视图,或者在 Numpy 中没有真正定义一个副本。只是如果你真的需要一份独立的副本,你必须明确要求一份。在 Numpy 中切片有时会给出一个视图,有时会给出一个副本。例如,在某些情况下,切片的切片不能再用偏移量、尺寸和步幅表示为视图。 这个“历史”原因在今天的 python 中仍然和当时一样重要。 numpy 数组之所以能返回视图,是因为 numpy 数组的维度是不可变的(数组不可调整大小)。 Python 的列表是完全可变的,切片返回默认值会使该语言更难正确用于常见用例,并且由于额外的间接性可能会更慢。

以上是关于为啥 p[:] 在这两种情况下的工作方式不同?的主要内容,如果未能解决你的问题,请参考以下文章

c 在这两种情况下如何工作以及有啥区别

arm为啥要分两种工作状态:arm和thumb

为啥门控激活函数(在 Wavenet 中使用)比 ReLU 工作得更好?

为啥这两种轮换方法会给我不同的用户体验?

Google App Engine 和 Django 模板:为啥这两种情况不同?

为啥这两种 Spark RDD 生成方式具有不同的数据局部性?