Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 584 – 将合并运算符添加到字典

作者:
Steven D’Aprano <steve at pearwood.info>, Brandt Bucher <brandt at python.org>
BDFL 委托
Guido van Rossum <guido at python.org>
状态:
最终版
类型:
标准跟踪
创建日期:
2019年3月1日
Python 版本:
3.9
发布历史:
2019年3月1日,2019年10月16日,2019年12月2日,2020年2月4日,2020年2月17日
决议:
Python-Dev 帖子

目录

摘要

本 PEP 提议向内置的 dict 类添加合并(|)和更新(|=)运算符。

注意

此 PEP 被接受后,决定也为其他几个标准库映射实现这些新运算符。

动机

目前合并两个字典的方式存在一些缺点

dict.update

d1.update(d2) 会就地修改 d1e = d1.copy(); e.update(d2) 不是一个表达式,需要一个临时变量。

{**d1, **d2}

字典解包看起来不美观,也不容易被发现。很少有人第一次看到它就能猜到它的含义,或者认为它是合并两个字典的“明显方式”。

正如 Guido 所说:

我很抱歉 PEP 448,但即使你知道在更简单的上下文中 **d 的作用,如果你问一个典型的 Python 用户如何将两个字典合并成一个新的字典,我怀疑很多人会想到 {**d1, **d2}。我知道我自己在这个帖子开始时就已经忘记了!

{**d1, **d2} 忽略映射的类型,并且总是返回一个 dicttype(d1)({**d1, **d2}) 对于具有不兼容 __init__ 方法的字典子类(例如 defaultdict)会失败。

collections.ChainMap

ChainMap 不幸地鲜为人知,不符合“明显”的条件。它还以相反的顺序(“首次出现获胜”而不是“最后出现获胜”)解决重复键。与字典解包一样,要让它遵循所需的子类很麻烦。出于同样的原因,type(d1)(ChainMap(d2, d1)) 对于某些字典子类会失败。

此外,ChainMaps 会包装其底层字典,因此对 ChainMap 的写入将修改原始字典

>>> d1 = {'spam': 1}
>>> d2 = {'eggs': 2}
>>> merged = ChainMap(d2, d1)
>>> merged['eggs'] = 999
>>> d2
{'eggs': 999}

dict(d1, **d2)

这个“巧妙的技巧”并不为人所知,并且仅当 d2 完全由字符串键组成时才有效

>>> d1 = {"spam": 1}
>>> d2 = {3665: 2}
>>> dict(d1, **d2)
Traceback (most recent call last):
  ...
TypeError: keywords must be strings

基本原理

新运算符与 dict.update 方法的关系,将类似于列表连接(+)和扩展(+=)运算符与 list.extend 的关系。请注意,这与 |/|=set.update 的关系有所不同;作者已确定允许就地运算符接受更广泛的类型(如 list 所做)是一种更有用的设计,并且限制二元运算符操作数的类型(同样,如 list 所做)将有助于避免由复杂的隐式类型转换在两侧引起的静默错误。

键冲突将通过保留最右边的值来解决。这与现有类似 dict 操作的行为一致,其中最后出现的值总是获胜

{'a': 1, 'a': 2}
{**d, **e}
d.update(e)
d[k] = v
{k: v for x in (d, e) for (k, v) in x.items()}

以上所有内容都遵循相同的规则。本 PEP 认为这种行为是简单、明显、通常是我们想要的,并且应该是字典的默认行为。这意味着字典合并不具有交换性;通常 d | e != e | d

同样,字典中键值对的*迭代顺序*将遵循与上述示例相同的语义,每个新添加的键(及其值)都将被追加到当前序列中。

规范

字典合并将返回一个新 dict,由左操作数与右操作数合并而成,每个操作数都必须是 dict(或 dict 子类的实例)。如果一个键同时出现在两个操作数中,则最后出现的值(即来自右操作数的值)获胜

>>> d = {'spam': 1, 'eggs': 2, 'cheese': 3}
>>> e = {'cheese': 'cheddar', 'aardvark': 'Ethel'}
>>> d | e
{'spam': 1, 'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel'}
>>> e | d
{'cheese': 3, 'aardvark': 'Ethel', 'spam': 1, 'eggs': 2}

增广赋值版本是就地操作的

>>> d |= e
>>> d
{'spam': 1, 'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel'}

增广赋值的行为与使用单个位置参数调用的 update 方法相同,因此它也接受任何实现映射协议(更具体地说,任何具有 keys__getitem__ 方法的类型)或键值对可迭代对象。这类似于 list +=list.extend,它们接受任何可迭代对象,而不仅仅是列表。承接上文

>>> d | [('spam', 999)]
Traceback (most recent call last):
  ...
TypeError: can only merge dict (not "list") to dict

>>> d |= [('spam', 999)]
>>> d
{'spam': 999, 'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel'}

当添加新键时,它们的顺序与它们在右侧映射中的顺序匹配(如果其类型存在的话)。

参考实现

其中一位作者编写了一个 C 实现

一个*近似的*纯 Python 实现是

def __or__(self, other):
    if not isinstance(other, dict):
        return NotImplemented
    new = dict(self)
    new.update(other)
    return new

def __ror__(self, other):
    if not isinstance(other, dict):
        return NotImplemented
    new = dict(other)
    new.update(self)
    return new

def __ior__(self, other):
    dict.update(self, other)
    return self

主要反对意见

字典合并不具有交换性

合并是可交换的,但字典合并不是(d | e != e | d)。

回应

在 Python 中,存在不可交换合并的先例

>>> {0} | {False}
{0}
>>> {False} | {0}
{False}

尽管结果可能相等,但它们明显不同。一般来说,a | bb | a 不是相同的操作。

字典合并将效率低下

为映射提供管道运算符可能会导致编写不易扩展的代码。重复的字典合并效率低下:d | e | f | g | h 会创建并销毁三个临时映射。

回应

同样的论点也适用于序列连接。

序列连接随序列中项目总数的增加而增长,导致 O(N**2)(二次)性能。字典合并可能涉及重复键,因此临时映射不会增长那么快。

就像人们很少连接大量的列表或元组一样,本 PEP 的作者认为人们也很少合并大量的字典。collections.Counter 是一个支持许多运算符的字典子类,目前还没有发现人们因为组合大量 Counter 而出现性能问题的例子。此外,作者对标准库的调查发现没有合并超过两个字典的例子,因此这在实践中不太可能成为性能问题……“对于足够小的 N,一切都很快”。

如果预期要合并大量字典,并且性能是一个问题,那么最好使用显式循环和就地合并

new = {}
for d in many_dicts:
    new |= d

字典合并是“有损的”

字典合并可能会丢失数据(值可能会消失);其他形式的合并都不是“有损的”。

回应

不清楚这个论证的第一部分为什么是个问题。dict.update() 可能会丢弃值,但不会丢弃键;这是预期行为,并且无论它被写成 update() 还是 |,都将保持预期行为。

其他类型的合并也是“有损的”,即不可逆的;仅给出合并结果,你无法找回两个操作数。a | b == 365……那么 ab 是什么?

只有一种方法可以做到

字典合并将违反 Zen 中的“只有一种方式”禅语。

回应

没有这样的禅语。“只有一种方法”是 Perl 社区很久以前对 Python 的诽谤。

不止一种方法可以做到

好吧,Zen 没有说应该“只有一种方法可以做到”。但它确实禁止“允许多于一种方法可以做到”。

回应

没有这样的禁令。《Python 之禅》只是表达了对“只有一种*显而易见*的方法”的*偏好*

There should be one-- and preferably only one --obvious way to do
it.

这里的重点是应该有一种显而易见的方式来完成“它”。对于字典更新操作,我们可能希望执行至少两种不同的操作

  • 就地更新字典:显而易见的方法是使用 update() 方法。如果此提案被接受,|= 增广赋值运算符也将起作用,但这只是增广赋值定义方式的副作用。选择哪一个取决于个人喜好。
  • 将两个现有字典合并成一个新的字典:本 PEP 提议显而易见的方法是使用 | 合并运算符。

在实践中,Python 经常违反这种“只有一种方法”的偏好。例如,每个 for 循环都可以重写为 while 循环;每个 if 块都可以写成 if/ else 块。列表、集合和字典推导式都可以被生成器表达式替换。列表提供了不少于五种实现连接的方法

  • 连接运算符:a + b
  • 就地连接运算符:a += b
  • 切片赋值:a[len(a):] = b
  • 序列解包:[*a, *b]
  • extend 方法:a.extend(b)

我们不应该因为“只有一种方法”而过于严格地拒绝有用的功能。

字典合并使代码更难理解

字典合并使代码含义更难理解。意译一下反对意见,而不是具体引用任何人:“如果我看到 spam | eggs,除非我知道 spameggs 是什么,否则我无法判断它做了什么”。

回应

这非常正确。但今天也是如此,| 运算符的使用可能意味着以下任何一种情况

  • int/bool 按位或
  • set/frozenset 合并
  • 任何其他重载操作

将字典合并添加到可能性集合中似乎并没有让代码更难理解。确定 spameggs 是映射所需的工作量,并不比确定它们是集合或整数所需的工作量大。而且良好的命名约定也会有所帮助

flags |= WRITEABLE  # Probably numeric bitwise-or.
DO_NOT_RUN = WEEKENDS | HOLIDAYS  # Probably set union.
settings = DEFAULT_SETTINGS | user_settings | workspace_settings  # Probably dict union.

set 的完整 API 呢?

字典是“类似集合的”,应该支持完整的集合运算符集合:|, &, ^, 和 -

回应

本 PEP 不对字典是否应支持完整的集合运算符集合发表立场,并希望将其留给后续的 PEP(其中一位作者有兴趣起草这样的 PEP)。为了任何后续 PEP 的利益,以下是简要总结。

集合对称差(^)是显而易见和自然的。例如,给定两个字典

d1 = {"spam": 1, "eggs": 2}
d2 = {"ham": 3, "eggs": 4}

对称差 d1 ^ d2 将是 {"spam": 1, "ham": 3}

集合差(-)也是显而易见和自然的,本 PEP 的早期版本将其包含在提案中。给定上述字典,我们将有 d1 - d2{"spam": 1}d2 - d1{"ham": 3}

集合交集(&)有点问题。虽然很容易确定两个字典中*键*的交集,但如何处理*值*尚不清楚。给定上述两个字典,显然 d1 & d2 的唯一键必须是 "eggs"。“最后出现获胜”的好处是与其他的字典操作(以及提议的合并运算符)保持一致。

MappingMutableMapping 呢?

collections.abc.Mappingcollections.abc.MutableMapping 应该定义 ||=,这样子类就可以直接继承新的运算符,而无需自己定义。

回应

将新运算符添加到这些类中存在两个主要原因

  • 目前,两者都没有定义 copy 方法,而 | 需要该方法来创建新实例。
  • MutableMapping 添加 |=(或向 Mapping 添加 copy 方法)将为虚拟子类带来兼容性问题。

被拒绝的想法

被拒绝的语义

对于处理冲突键,至少还有四种其他提议的解决方案。这些替代方案留给了字典的子类。

抛出异常

这种行为是否有许多用例或是否经常有用尚不清楚,但它可能会令人讨厌,因为任何使用字典合并运算符的操作都必须用 try/except 子句进行保护。

添加值(像 Counter 那样,使用 +

过于专业化,不宜作为默认行为。

最左边的值(首次出现)获胜

这种行为是否有许多用例尚不清楚。事实上,只需反转参数的顺序即可

d2 | d1  # d1 merged with d2, keeping existing values in d1

将值连接成一个列表

这可能过于专业化,不宜作为默认值。如果值已经是列表,则不清楚该怎么做

{'a': [1, 2]} | {'a': [3, 4]}

这应该给出 {'a': [1, 2, 3, 4]} 还是 {'a': [[1, 2], [3, 4]]}

被拒绝的替代方案

使用加法运算符

本 PEP 最初是一个关于字典加法(使用 ++= 运算符)的提案。该选择被证明极具争议,许多人对运算符的选择提出了严重异议。详情请参阅 PEP 的以前版本和邮件列表讨论

使用左移运算符

<< 运算符在 Python-Ideas 上似乎没有得到太多支持,也没有引起重大反对。也许最强烈的反对意见是 Chris Angelico 的评论

滥用运算符来指示信息流的“可爱”之处在 C++ 这样做之后很快就过时了。

使用新的左箭头运算符

另一个建议是创建一个新运算符 <-。不幸的是,这可能会产生歧义,d <- e 可能意味着 d 合并 ed 小于 减去 e

使用方法

一个 dict.merged() 方法将完全避免对运算符的需求。一个微妙之处在于,当作为非绑定方法调用时,它可能需要略有不同的实现,而作为绑定方法调用时则不同。

作为非绑定方法,其行为可能类似于

def merged(cls, *mappings, **kw):
    new = cls()  # Will this work for defaultdict?
    for m in mappings:
        new.update(m)
    new.update(kw)
    return new

作为绑定方法,其行为可能类似于

def merged(self, *mappings, **kw):
    new = self.copy()
    for m in mappings:
        new.update(m)
    new.update(kw)
    return new
优点
  • 可以说,方法比运算符更容易发现。
  • 该方法可以接受任意数量的位置参数和关键字参数,从而避免创建临时字典的低效率。
  • 接受像 update 方法那样的一系列 (key, value) 对。
  • 作为一种方法,如果您需要替代行为,例如“首次获胜”、“唯一键”等,则可以很容易地在子类中覆盖它。
缺点
  • 可能需要一种新型的方法装饰器,它结合了常规实例方法和 classmethod 的行为。对于需要覆盖该方法的用户来说,它需要是公共的(但不一定是内置的)。有一个概念验证
  • 它不是一个运算符。Guido 讨论了为什么运算符有用。对于另一种观点,请参阅Alyssa Coghlan 的博客文章

使用函数

不是方法,而是使用新的内置函数 merged()。一种可能的实现方式可能类似于

def merged(*mappings, **kw):
    if mappings and isinstance(mappings[0], dict):
        # If the first argument is a dict, use its type.
        new = mappings[0].copy()
        mappings = mappings[1:]
    else:
        # No positional arguments, or the first argument is a
        # sequence of (key, value) pairs.
        new = dict()
    for m in mappings:
        new.update(m)
    new.update(kw)
    return new

另一种选择是放弃任意关键字,并接受一个单独的关键字参数来指定冲突时的行为

def merged(*mappings, on_collision=lambda k, v1, v2: v2):
    # implementation left as an exercise to the reader
优点
  • 与上述方法解决方案具有大部分相同的优点。
  • 无需子类实现冲突时的替代行为,只需一个函数。
缺点
  • 可能不够重要,不应成为内置功能。
  • 如果要实现“首次获胜”等行为,而又不想失去处理任意关键字参数的能力,则很难覆盖行为。

示例

本 PEP 的作者对第三方库进行了调查,以寻找可能是字典合并候选的库。

这只是基于作者电脑上安装的任意第三方软件包子集的一个粗略列表,可能无法反映任何软件包的当前状态。另请注意,虽然可能会进行进一步(不相关)的重构,但重写版本仅增加了新运算符的使用,以进行“同类比较”。在效率允许的情况下,它还将结果简化为一个表达式。

IPython/zmq/ipkernel.py

之前

aliases = dict(kernel_aliases)
aliases.update(shell_aliases)

之后

aliases = kernel_aliases | shell_aliases

IPython/zmq/kernelapp.py

之前

kernel_aliases = dict(base_aliases)
kernel_aliases.update({
    'ip' : 'KernelApp.ip',
    'hb' : 'KernelApp.hb_port',
    'shell' : 'KernelApp.shell_port',
    'iopub' : 'KernelApp.iopub_port',
    'stdin' : 'KernelApp.stdin_port',
    'parent': 'KernelApp.parent',
})
if sys.platform.startswith('win'):
    kernel_aliases['interrupt'] = 'KernelApp.interrupt'

kernel_flags = dict(base_flags)
kernel_flags.update({
    'no-stdout' : (
            {'KernelApp' : {'no_stdout' : True}},
            "redirect stdout to the null device"),
    'no-stderr' : (
            {'KernelApp' : {'no_stderr' : True}},
            "redirect stderr to the null device"),
})

之后

kernel_aliases = base_aliases | {
    'ip' : 'KernelApp.ip',
    'hb' : 'KernelApp.hb_port',
    'shell' : 'KernelApp.shell_port',
    'iopub' : 'KernelApp.iopub_port',
    'stdin' : 'KernelApp.stdin_port',
    'parent': 'KernelApp.parent',
}
if sys.platform.startswith('win'):
    kernel_aliases['interrupt'] = 'KernelApp.interrupt'

kernel_flags = base_flags | {
    'no-stdout' : (
            {'KernelApp' : {'no_stdout' : True}},
            "redirect stdout to the null device"),
    'no-stderr' : (
            {'KernelApp' : {'no_stderr' : True}},
            "redirect stderr to the null device"),
}

matplotlib/backends/backend_svg.py

之前

attrib = attrib.copy()
attrib.update(extra)
attrib = attrib.items()

之后

attrib = (attrib | extra).items()

matplotlib/delaunay/triangulate.py

之前

edges = {}
edges.update(dict(zip(self.triangle_nodes[border[:,0]][:,1],
             self.triangle_nodes[border[:,0]][:,2])))
edges.update(dict(zip(self.triangle_nodes[border[:,1]][:,2],
             self.triangle_nodes[border[:,1]][:,0])))
edges.update(dict(zip(self.triangle_nodes[border[:,2]][:,0],
             self.triangle_nodes[border[:,2]][:,1])))

重写为

edges = {}
edges |= zip(self.triangle_nodes[border[:,0]][:,1],
             self.triangle_nodes[border[:,0]][:,2])
edges |= zip(self.triangle_nodes[border[:,1]][:,2],
             self.triangle_nodes[border[:,1]][:,0])
edges |= zip(self.triangle_nodes[border[:,2]][:,0],
             self.triangle_nodes[border[:,2]][:,1])

matplotlib/legend.py

之前

hm = default_handler_map.copy()
hm.update(self._handler_map)
return hm

之后

return default_handler_map | self._handler_map

numpy/ma/core.py

之前

_optinfo = {}
_optinfo.update(getattr(obj, '_optinfo', {}))
_optinfo.update(getattr(obj, '_basedict', {}))
if not isinstance(obj, MaskedArray):
    _optinfo.update(getattr(obj, '__dict__', {}))

之后

_optinfo = {}
_optinfo |= getattr(obj, '_optinfo', {})
_optinfo |= getattr(obj, '_basedict', {})
if not isinstance(obj, MaskedArray):
    _optinfo |= getattr(obj, '__dict__', {})

praw/internal.py

之前

data = {'name': six.text_type(user), 'type': relationship}
data.update(kwargs)

之后

data = {'name': six.text_type(user), 'type': relationship} | kwargs

pygments/lexer.py

之前

kwargs.update(lexer.options)
lx = lexer.__class__(**kwargs)

之后

lx = lexer.__class__(**(kwargs | lexer.options))

requests/sessions.py

之前

merged_setting = dict_class(to_key_val_list(session_setting))
merged_setting.update(to_key_val_list(request_setting))

之后

merged_setting = dict_class(to_key_val_list(session_setting)) | to_key_val_list(request_setting)

sphinx/domains/__init__.py

之前

self.attrs = self.known_attrs.copy()
self.attrs.update(attrs)

之后

self.attrs = self.known_attrs | attrs

sphinx/ext/doctest.py

之前

new_opt = code[0].options.copy()
new_opt.update(example.options)
example.options = new_opt

之后

example.options = code[0].options | example.options

sphinx/ext/inheritance_diagram.py

之前

n_attrs = self.default_node_attrs.copy()
e_attrs = self.default_edge_attrs.copy()
g_attrs.update(graph_attrs)
n_attrs.update(node_attrs)
e_attrs.update(edge_attrs)

之后

g_attrs |= graph_attrs
n_attrs = self.default_node_attrs | node_attrs
e_attrs = self.default_edge_attrs | edge_attrs

sphinx/highlighting.py

之前

kwargs.update(self.formatter_args)
return self.formatter(**kwargs)

之后

return self.formatter(**(kwargs | self.formatter_args))

sphinx/quickstart.py

之前

d2 = DEFAULT_VALUE.copy()
d2.update(dict(("ext_"+ext, False) for ext in EXTENSIONS))
d2.update(d)
d = d2

之后

d = DEFAULT_VALUE | dict(("ext_"+ext, False) for ext in EXTENSIONS) | d

sympy/abc.py

之前

clash = {}
clash.update(clash1)
clash.update(clash2)
return clash1, clash2, clash

之后

return clash1, clash2, clash1 | clash2

sympy/parsing/maxima.py

之前

dct = MaximaHelpers.__dict__.copy()
dct.update(name_dict)
obj = sympify(str, locals=dct)

之后

obj = sympify(str, locals=MaximaHelpers.__dict__|name_dict)

sympy/printing/ccode.py and sympy/printing/fcode.py

之前

self.known_functions = dict(known_functions)
userfuncs = settings.get('user_functions', {})
self.known_functions.update(userfuncs)

之后

self.known_functions = known_functions | settings.get('user_functions', {})

sympy/utilities/runtests.py

之前

globs = globs.copy()
if extraglobs is not None:
    globs.update(extraglobs)

之后

globs = globs | (extraglobs if extraglobs is not None else {})

以上示例表明,有时 | 运算符会明显提高可读性,减少代码行数并提高清晰度。然而,其他使用 | 运算符的示例会导致冗长、复杂的单个表达式,可能远远超过 PEP 8 规定的最大行长 80 列。与任何其他语言特性一样,程序员应自行判断 | 是否能改进其代码。


来源:https://github.com/python/peps/blob/main/peps/pep-0584.rst

最后修改:2025-02-01 08:59:27 GMT