PEP 584 – 为 dict 添加联合运算符
- 作者:
- 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 线程
目录
- 摘要
- 动机
- 基本原理
- 规范
- 参考实现
- 主要异议
- 被拒绝的方案
- 示例
- IPython/zmq/ipkernel.py
- IPython/zmq/kernelapp.py
- matplotlib/backends/backend_svg.py
- matplotlib/delaunay/triangulate.py
- matplotlib/legend.py
- numpy/ma/core.py
- praw/internal.py
- pygments/lexer.py
- requests/sessions.py
- sphinx/domains/__init__.py
- sphinx/ext/doctest.py
- sphinx/ext/inheritance_diagram.py
- sphinx/highlighting.py
- sphinx/quickstart.py
- sympy/abc.py
- sympy/parsing/maxima.py
- sympy/printing/ccode.py 和 sympy/printing/fcode.py
- sympy/utilities/runtests.py
- 相关讨论
- 版权
摘要
本 PEP 提案为内置 dict
类添加合并 (|
) 和更新 (|=
) 运算符。
注意
在该 PEP 被接受后,决定也为 其他几个标准库映射 实现新的运算符。
动机
目前合并两个字典的方法存在一些缺点
dict.update
d1.update(d2)
会就地修改 d1
。 e = d1.copy(); e.update(d2)
不是表达式,需要一个临时变量。
{**d1, **d2}
字典解包看起来很丑,也不容易发现。很少有人第一次看到它就能猜出它的含义,或者认为它是合并两个字典的“明显方法”。
对于 PEP 448,我感到抱歉,但即使你熟悉在更简单的上下文中使用**d
,如果你要问一个典型的 Python 用户如何将两个字典合并到一个新的字典中,我怀疑很多人不会想到{**d1, **d2}
。我本人在讨论开始时也忘记了它!
{**d1, **d2}
忽略了映射的类型,并且始终返回一个 dict
。对于像 defaultdict
这样的字典子类,type(d1)({**d1, **d2})
会失败,因为它们具有不兼容的 __init__
方法。
collections.ChainMap
ChainMap
不幸的是知名度不高,也不算“明显”。它还以与预期相反的顺序解决重复键(“先见即得”而不是“后见即得”)。与字典解包一样,要使其遵循所需的子类也很棘手。出于同样的原因,对于某些字典子类,type(d1)(ChainMap(d2, d1))
也会失败。
此外,ChainMap 会包装其底层的字典,因此对 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 | b
与 b | 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
……a
和 b
是什么?
只有一种方法
字典联合将违反禅语中的“只有一种方法”的格言。
回应
没有这样的公案。“只有一种方法”是源于很久以前 Perl 社区对 Python 的诽谤。
不止一种方法
好的,禅宗并没有说应该只有一种方法来做这件事。但它确实禁止允许“不止一种方法来做这件事”。
回应
没有这样的禁令。“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]
- 扩展方法:
a.extend(b)
我们不应该过于严格地拒绝有用的功能,因为它们违反了“只有一种方法”。
字典联合使代码难以理解
字典联合使得更难分辨代码的含义。为了转述反对意见而不是引用任何具体的人: “如果我看到 spam | eggs
,除非我知道 spam
和 eggs
是什么,否则我无法判断它做了什么”。
回应
这是非常正确的。但今天的情况也同样如此,其中使用 |
运算符可能意味着以下任何一种情况
int
/bool
按位或set
/frozenset
并集- 任何其他重载操作
将字典联合添加到可能性集合中似乎并没有使代码更难理解。确定 spam
和 eggs
是映射所需的工作量并不比确定它们是集合或整数所需的工作量更多。良好的命名约定会有所帮助
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"
。“最后看到者获胜” 然而,它具有与其他字典操作(以及提议的联合运算符)一致的优势。
Mapping
和 MutableMapping
怎么办?
collections.abc.Mapping
和 collections.abc.MutableMapping
应该定义 |
和 |=
,以便子类可以继承新运算符而不是必须定义它们。
回应
将新运算符添加到这些类中存在两个主要原因
- 目前,两者都没有定义
copy
方法,而这对于|
创建新实例是必要的。 - 将
|=
添加到MutableMapping
(或将copy
方法添加到Mapping
)将为虚拟子类创建兼容性问题。
被拒绝的方案
被拒绝的语义
至少还有其他四种处理冲突键的提议解决方案。这些替代方案留给字典的子类。
引发异常
目前尚不清楚此行为有多少用例或是否经常有用,但它可能会很烦人,因为任何使用字典联合运算符的操作都必须用 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 merge e
或 d less-than minus 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
优势
- 可以说,方法比运算符更容易发现。
- 该方法可以接受任意数量的位置和关键字参数,避免创建临时字典的低效率。
- 接受
(key, value)
对的序列,就像update
方法一样。 - 作为一个方法,如果需要替代行为(如“先到者获胜”、“唯一键”等),则很容易在子类中覆盖它。
劣势
- 可能需要一种新的方法装饰器,它将常规实例方法和
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 和 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 列最大行长。与任何其他语言特性一样,程序员应根据自己的判断来决定 |
是否能改进其代码。
版权
本文件置于公有领域或根据 CC0-1.0-Universal 许可证,以两者中较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0584.rst
上次修改时间:2023-10-11 12:05:51 GMT