【问题标题】:Order of operations in a dictionary comprehension字典理解中的操作顺序
【发布时间】:2017-07-01 07:31:01
【问题描述】:

我遇到了以下有趣的结构:

假设您有如下列表:

my_list = [['captain1', 'foo1', 'bar1', 'foobar1'], ['captain2', 'foo2', 'bar2', 'foobar2'], ...]

并且你想用0-index 元素作为键创建一个字典。一个方便的方法是:

my_dict = {x.pop(0): x for x in my_list}
# {'captain1': ['foo1', 'bar1', 'foobar1'], ...}

看起来,pop 在分配列表 x 作为值之前,这就是为什么 'captain' 没有出现在值中(它已经弹出)

现在让我们更进一步,尝试得到如下结构:

# {'captain1': {'column1': 'foo1', 'column2': 'bar1', 'column3': 'foobar1'}, ...}

对于这个任务,我写了以下内容:

my_headers = ['column1', 'column2', 'column3']
my_dict = {x.pop(0): {k: v for k, v in zip(my_headers, x)} for x in my_list}

但这会返回:

# {'captain1': {'col3': 'bar1', 'col1': 'captain1', 'col2': 'foo1'}, 'captain2': {'col3': 'bar2', 'col1': 'captain2', 'col2': 'foo2'}}

所以在这种情况下,pop 发生在构造内部字典之后(或至少在 zip 之后)。

怎么可能?这是如何工作的?

问题不在于如何去做,而在于为什么会出现这种行为。

我使用的是 Python 版本 3.5.1。

【问题讨论】:

  • 我认为这是因为值首先得到评估。
  • @Kasramvd 你有什么支持这个说法的吗?
  • @skyking 据我所知,这就是理解的工作方式。在这种情况下,它会首先评估嵌套的理解。
  • @skyking 似乎即使对于非理解值也是如此。试试{x.pop(0): x.pop(0) for x in [[1, 2]]}。但请注意,当您从中弹出的列表是预定义列表等内部对象时,它不会像这样执行。
  • 请避免将不属于问题的材料放在问题上。问题应仅包含问题和重现问题所需的信息,与解决问题相关的任何内容都属于答案或 cmets。不要把答案的“摘要”放在问题上:那属于答案(如果答案的作者认为合适的话)。

标签: python python-3.x dictionary


【解决方案1】:

注意:从 Python 3.8 和 PEP 572 开始,这已更改并且首先评估键。


tl;dr 直到 Python 3.7:尽管 Python 确实首先评估值(表达式的右侧)根据the reference manualthe grammarPEP on dict comprehensions,这似乎是(C)Python 中的一个错误

虽然这以前是 fixed for dictionary displays,在键之前再次评估值,补丁没有修改以包含 dict-comprehensions。 This requirement was also mentioned by one of the core-devs in a mailing list thread discussing this same subject.

根据参考手册,Python 计算表达式从左到右赋值从右到左; dict-comprehension 实际上是一个包含表达式的表达式,不是赋值*

{expr1: expr2 for ...}

根据相应的rule of the grammar,人们会期望expr1: expr2 的评估类似于它在显示中的作用。因此,两个表达式都应该遵循定义的顺序,expr1 应该在 expr2 之前被评估(并且,如果 expr2 包含它自己的表达式,它们也应该从左到右进行评估。)

dict-comps 上的 PEP 还指出以下内容在语义上应该是等效的:

dict 推导式的语义实际上可以在 库存 Python 2.2,通过将列表推导传递给内置 字典构造函数:

>>> dict([(i, chr(65+i)) for i in range(4)])

在语义上等价于:

>>> {i : chr(65+i) for i in range(4)}

元组 (i, chr(65+i)) 是否按预期从左到右进行评估。

当然,将其更改为根据表达式规则运行会在创建 dicts 时产生不一致。字典推导和带有赋值的 for 循环会导致不同的评估顺序,但这很好,因为它只是遵循规则。

虽然这不是主要问题,但应该修复它(评估规则或文档)以消除歧义。

*在内部,这确实会导致对字典对象的赋值,但这不应该破坏表达式应该具有的行为。用户对表达式的行为方式有期望,如参考手册中所述。


正如其他回答者指出的那样,由于您在其中一个表达式中执行了变异操作,因此您丢弃了有关首先评估什么的任何信息;正如邓肯所做的那样,使用print 调用可以阐明所做的事情。

帮助显示差异的功能:

def printer(val):
    print(val, end=' ')
    return val

(固定)字典显示:

>>> d = {printer(0): printer(1), printer(2): printer(3)}
0 1 2 3

(奇数)字典理解:

>>> t = (0, 1), (2, 3)
>>> d = {printer(i):printer(j) for i,j in t}
1 0 3 2

是的,这特别适用于CPython。我不知道其他实现如何评估这种特定情况(尽管它们都应该符合 Python 参考手册。)

挖掘源代码总是不错的(而且您还可以找到描述行为的隐藏 cmets),所以让我们看看文件 compile.ccompiler_sync_comprehension_generator

case COMP_DICTCOMP:
    /* With 'd[k] = v', v is evaluated before k, so we do
       the same. */
    VISIT(c, expr, val);
    VISIT(c, expr, elt);
    ADDOP_I(c, MAP_ADD, gen_index + 1);
    break;

这似乎是一个足够好的理由,如果这样判断,则应将其归类为文档错误。

在我做的快速测试中,切换这些语句(VISIT(c, expr, elt); 首先被访问)同时切换相应的order in MAP_ADD(用于 dict-comps):

TARGET(MAP_ADD) {
    PyObject *value = TOP();   # was key 
    PyObject *key = SECOND();  # was value
    PyObject *map;
    int err;

基于文档的评估结果,键在值之前评估。 (不适用于他们的异步版本,这是另一个需要的开关。)


我会就此问题发表评论,并在有人回复我时更新。

在跟踪器上创建 Issue 29652 -- Fix evaluation order of keys/values in dict comprehensions。将在问题取得进展时更新问题。

【讨论】:

  • odd dict-comp 绝对是奇怪的。当涉及到 dict-comprehensions 中的操作顺序时,Python 的操作几乎是随机的,这简直太奇怪了。最后,如果理解不使用改变其对象的方法但 pop 使用的方法,结果是相同的。对我来说,这是 pop 的唯一原因是 bad 实践。我发现这个结构在其他方面相当有用..
  • @Ev.Kounis 确实如此。虽然是一个错误,但如果修复它所需的努力大于实际修复它的好处,我不会感到惊讶(这是一个真正的极端情况,除非你处理古怪的事情,否则不应该是问题)。您可以对解决此问题以用于 dict 显示的问题发表评论,并查看开发人员的想法。
  • dict-comprehension 实际上是一个包含表达式的表达式,而不是一个赋值。我不同意。字典推导是一​​个表达式,但是 python 会将它翻译成一个简单的赋值,因此它会遵循赋值规则!
  • 恐怕我不知道该怎么做。如果您认为值得,请随意自己做。如果您这样做,请发布链接,以便我可以跟进。谢谢!
  • @JimFasarakis-Hilliard:实现没有文档化,但是声明与某些示例实现等效的语义只会记录语义,而不是真正的实现。
【解决方案2】:

看起来,pop 先于 list x 作为值的赋值 这就是为什么“船长”没有出现在值中(它已经 弹出)

不,它发生的顺序无关紧要。您正在改变列表,因此无论您使用它,您都会在弹出后看到修改后的列表。请注意,通常您可能不想这样做,因为您会破坏原始列表。即使这一次无关紧要,这也是未来粗心的人的陷阱。

在这两种情况下,首先计算值方,然后计算相应的键。只是在第一种情况下无关紧要,而在第二种情况下则无关紧要。

你可以很容易地看到这一点:

>>> def foo(a): print("foo", a)
... 
>>> def bar(a): print("bar", a)
... 
>>> { foo(a):bar(a) for a in (1, 2, 3) }
('bar', 1)
('foo', 1)
('bar', 2)
('foo', 2)
('bar', 3)
('foo', 3)
{None: None}
>>> 

请注意,您不应该编写依赖于首先评估的值的代码:行为可能会在未来版本中发生变化(在某些地方据说在 Python 3.5 及更高版本中发生了变化,尽管事实上这似乎不是案例)。

一种更简单的方法,避免改变原始数据结构:

my_dict = {x[0]: x[1:] for x in my_list}

或者你的第二个例子:

my_headers = ['column1', 'column2', 'column3']
my_dict = {x[0]: {k: v for k, v in zip(my_headers, x[1:])} for x in my_list}

回答 cmets:zip 使用原始的 x,因为它在 pop 之前被评估,但它使用列表的内容来构造一个新列表,因此以后对列表的任何更改都不会被看到结果。第一个理解也使用原始的x 作为值,但它随后改变了列表,因此该值仍然看到原始列表,因此也看到了突变。

【讨论】:

  • 但是为什么zip(header, x) 使用未经修改的x
  • 因为在这两种情况下,字典理解的值部分都在键端之前执行。
  • 切片方法的唯一问题是实际上是 captain 的元素不是第一个,所以我必须合并两个切片。顺便说一句,我没有 dv。
  • 我已经扩展了我的答案,以展示如何通过一些打印来判断评估顺序。 dv 上没问题,如果有人说他们为什么不喜欢这个答案,那就太好了,但我不介意投反对票。
  • "不,它发生的顺序无关紧要。您正在更改列表,因此无论您使用它,您都会在弹出后看到修改后的列表"。如果是这样,为什么zip 使用原始的x
【解决方案3】:

正如我在评论中所说,这是因为在字典理解中,python 首先评估值。作为一种更 Pythonic 的方法,您可以为此任务使用解包变量,而不是在每次迭代中从列表中弹出:

In [32]: my_list = [['captain1', 'foo1', 'bar1', 'foobar1'], ['captain2', 'foo2', 'bar2', 'foobar2']]

In [33]: {frist: {"column{}".format(i): k for i, k in enumerate(last, 1)} for frist, *last in my_list}
Out[33]: 
{'captain2': {'column3': 'foobar2', 'column1': 'foo2', 'column2': 'bar2'},
 'captain1': {'column3': 'foobar1', 'column1': 'foo1', 'column2': 'bar1'}}

关于python在评估字典理解中的键和值时的奇怪行为,经过一些实验后,我意识到这种行为在某种程度上是合理的,而不是一个错误。

我将在以下部分打破我的印象:

  1. 在赋值表达式中,python 首先计算右侧。 来自文档:

    Python 从左到右计算表达式。请注意,在评估分配时,右侧会先于左侧进行评估。

  2. 字典理解是一个表达式,将从左到右进行评估,但由于在后台有一个赋值,在 python 翻译它之后。 首先计算右边的值。

    例如下面的理解:

    {b.pop(0): b.pop(0) for _ in range(1)} 等价于以下 sn-p:


def dict_comprehension():
    the_dict = {}
    for _ in range(1):
        the_dict[b.pop(0)] = b.pop(0)
    return the_dict

这里有一些例子:

In [12]: b = [4, 0]

# simple rule : Python evaluates expressions from left to right.
In [13]: [[b.pop(0), b.pop(0)] for _ in range(1)]
Out[13]: [[4, 0]]

In [14]: b = [4, 0]
# while evaluating an assignment (aforementioned rule 1), the right-hand side is evaluated before the left-hand side.
In [15]: {b.pop(0): b.pop(0) for _ in range(1)}
Out[15]: {0: 4}

In [16]: b = [4, 0]
# This is not a dictionary comprehension and will be evaluated left to right.
In [17]: {b.pop(0): {b.pop(0) for _ in range(1)}}
Out[17]: {4: {0}}

In [18]: b = [4, 0]
# This is not a dictionary comprehension and will be evaluated left to right.
In [19]: {b.pop(0): b.pop(0) == 0}
Out[19]: {4: True}

In [20]: b = [4, 0]
# dictionary comprehension.
In [21]: {b.pop(0): {b.pop(0) for _ in range(1)} for _ in range(1)}
Out[21]: {0: {4}}

关于字典理解是表达式并且应该从左到右评估(基于python文档)这一事实(或者最好说抽象)之间的差异 通过观察到的行为,我认为这实际上是 python 文档的问题和不成熟,而不是 python 代码中的错误。因为有一致的文档,没有任何例外,所以改变功能根本不合理。

【讨论】:

  • 您使用的是my_list 的哪个定义?
  • @Ev.Kounis 这是一个修改过的列表;)查看更新。
  • 这很好奇,+1,但这是指定的语言功能吗?从一个例子中很难知道这适用的范围有多大。我的印象是应该首先评估键:stackoverflow.com/questions/28156687/…
  • @Chris_Rands 是的,python 从左到右评估表达式,但似乎这是一个例外。
  • 对我来说这几乎像是一个错误。此外,我仍然不清楚 OP 的示例,因为 my_list 在他们的两个示例中都是“外部”
【解决方案4】:

实际上,您的观察不需要对操作进行特殊排序。原因是x.pop(0)修改了对象x。因此,在这种情况下,您是在键 (x.pop(0)) 之前还是之后评估值 (x) 并不重要。

无论如何,我不认为 python 语言规范规定了特定的操作顺序,这意味着您不应该依赖任何特定的顺序。

实际上,标准实现碰巧在评估键之前评估值,但标准中没有任何地方说明这一点。唯一的保证是键值对按迭代顺序进行计算,并按该顺序插入。

【讨论】:

  • 但是为什么zip(header, x) 使用未经修改的x
  • 不是我的队友。
  • @Ev.Kounis 没关系,stackoverflow 上到处都是不加解释就否决答案的人......
猜你喜欢
  • 2018-08-05
  • 1970-01-01
  • 2020-11-02
  • 2021-05-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多