【译】PEP584:增加字典合并操作符

注意:字典合并操作符在 Python 3.9 实现,预计在2020年10月5日推出 Final 版。 欢迎关注我的公众号:ReadingPython

0. 摘要

本提案建议为内置字典类增加合并( | )与更新( |= )操作符。


1. 动机

目前,Python 中合并两个字典的方法都存在一些问题。

1.1 dict.update

d1.update(d2) 会就地修改 d1,合并为新字典 e = d1.copy(); e.update(d2) 必须使用一个中间变量。

1.2 {**d1, **d2}

字典拆包看上去很丑陋,而且大家不容易发现这种用法。很少有人能在一眼看懂这个表达式,并把它当做合并字典的“明显方式”。

如 Guido 所说:

关于 PEP448,我很抱歉。虽然大家知道可以通过 **d 对字典进行拆包,但要问一个 Python 用户如何合并两个字典,我相信很少有人会想到 {**d1, **d2}。我自己就忘记过这种用法。

{**d1, **d2} 会忽略字典的具体类型,只是简单地返回一个 dict。当 d1 有自定义的 __init__ 方法时(例如作为defaultdict),表达式 type(d1)({**d1, **d2}) 会报错。

1.3 collections.ChainMap

ChainMap 也是一个少有人知的方法。当遇到重复的键时,它会反直觉地取第一个字典,而不是最后一个字典的值。和字典拆包一样,它返回的也是一个普通字典,当 d1 是某个具体的字典类型时,type(d1)(ChainMap(d2, d1)) 会报错。

另外,ChainMaps 实际上引用了原字典,修改它的值也会影响到原字典:

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

1.4 dict(d1, **d2)

这个 小技巧 同样鲜为人知,并且要求 d2 的键必须为字符串:

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

2. 基本逻辑

新操作符与 dict.update 的关系,正如列表中的 ++= 操作符与 list.extend 的关系。注意,它们与集合中的 ||= 操作符与 set.update 的关系不一致。本提案的作者们认为,就地修改对象的操作符应该支持更广泛的参数类型(正如 list 所做的),而二元运算操作符应该限制参数类型,以免复杂的隐式类型转换可能带来的问题(同样,正如 list 所做的)。

当两个字典的键重复时,将取右边字典的值,与当前的类似操作逻辑保持一致:

{'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()}

以上所有操作均遵循同取最后一个值的规则。本提案认为,这个规则简单、明显、通常也正是用户所需,因此应作为字典的默认行为规则。这也意味着字典合并是不支持交换律的,即 d | e != e | d

类似地,字典键值对的 迭代顺序 也遵循同样的规则,新增的键值对在迭代时会出现在原有键值对之后。(译者注:Python 3.7 之后的字典是有顺序的,3.8 版本还添加了 __reversed__() 方法,从此 OrderDict 就没什么存在的意义了。)


3. 具体说明

字典合并会返回两个字典组合而成的新字典,操作符两边的参数都必须是字典(或其子类)的实例。如果两个字典包含同一个键,则使用最后出现的值(即右边字典的值)。

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

合并赋值符会就地修改对象:

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

合并赋值符的行为与 update 方法保持一致,因此,它可以用于任何实现了 Maping 接口(具体地说,即实现了 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
{'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel', 'spam': 999}

4. 实现方式

本提案的一位作者写了一个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

5. 主要反对意见

5.1 不支持交换律

并集操作是支持交换律的,而字典合并不支持( d | e != e | d )。

回应:

Python 中已有不支持交换律的合并操作的先例:

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

很明显,a | bb | a不一致。

5.2 效率很低

合并操作符会鼓励一些不易扩展的代码。连续的字典合并是低效的:d | e | f | g | h 会创建并销毁 3 个临时字典。

回应:

对效率的担心也适用于序列合并。

序列合并的复杂度随序列中的元素总量增长,是 O(N**2),而字典由于重复键的存在,增长还会慢一些。

正如大家很少连续大量合并列表或元组,本提案的作者们相信,也很少会有连续大量合并字典的场景。collections.Counter 是一个字典的子类,支持很多操作符,但目前还没有出现因大量合并 Counters 导致的性能问题。另外,作者们检查标准库之后发现,并没有合并超过两个字典的例子。因此,这个担心在实践中是不存在的……“只要 N 足够小,任何操作都是很快的”。

如果有人需要合并多个字典并控制性能,或许可以考虑使用就地合并:

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

5.3 会丢失数据

合并后,原字典的值可能会丢失,而一般的合并不会丢失数据。

回应:

不能理解为什么有这条反对意见。dict.update() 可能会更新某个键的值,但不会丢失键,这是预期的行为——不论我们用 update() 还是 |

其它类型的合并也可能会有丢失问题,或者说,会产生不可逆的改变。

5.4 违背仅有一种方式的原则

增加字典合并操作符违背了 Python 之禅中“仅有一种方式”(Only One Way)的箴言。

回应:

并不存在这条箴言,“仅有一种方式”的说法来自很早之前 Perl 语言社区对 Python 的嘲讽。

5.5 还是不应该有多种方式来做同一件事情

没错,Python 之禅确实没说值应该有一种方式,但确实禁止多种方式来做同一件事情。

回应:

这条所谓的禁止并不存在。Python 之禅的具体说法是,更 倾向于 只有一种 明显 的方式:

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

这里主要强调的是,做一件事情应该有一种明显的方式。就字典更新而言,需要做的事情至少包括两件:

  • 就地更新字典:明显的方式是调用 update() 方法。如果这个提案被接受, |= 操作符也是一个选项,不过这主要是增量赋值的定义方式造成的副作用,用户可以根据自己的喜好自由选择。
  • 将两个字典合并为一个新的字典:本提案认为,明显的方式就是调用 | 操作符。

在实践中,“只有一种方式”的倾向经常被违背。比如说,所有 for 循环都可以改写为 while 循环,所有 if 语句块都可以重写为 if/ else 语句块,所有的列表、集合、字典推导式也都可以用生成器表达式替代。就列表合并操作来说,实现的方式超过 5 种:

  • 使用加号:a + b
  • 增量赋值:a += b
  • 切片赋值:a[len(a):] = b
  • 序列拆包:[*a, *b]
  • 调用方法:a.extend(b)

因此,不应该以违背“仅有一种方式”的说法来拒绝有用的功能。

5.6 让代码更难理解

字典合并操作符使代码更难看懂,比如说,在知道 spameggs 之前,我们无法判断 spam | eggs的含义。

回应:

确实如此。但要说难看懂,现在就已经很难看懂了,因为 | 操作符有多种可能的含义:

  • 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.

5.7 会影响到完整的集合 API 的实现

字典是类似于集合的,应该支持完整的集合操作符:|&^-

回应:

本提案不对字典是否应该支持完整的集合操作下结论,而更愿意将这个问题留给之后的提案(其中一位作者对起草这个新提案很感兴趣)。为方便之后的提案,这里提供一个简要说明:

集合的对称差操作在字典中也是很明显、很自然的,例如有以下两个字典:

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

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

字典的差也很明显,之前已经有提案提到过了。依然以上面两个字典为例,d1 - d2 的结果应该是 {"spam": 1} ,而 d2 - d1 则为 {"ham": 3}

字典的交集(&)比较麻烦,应该保留哪些键是确定的,但要保留哪些值则是个问题。就上面两个字典来说,很明显,d1 & d2 的键为 "eggs",至于值,则似乎采用最后(右边)的值更容易与字典的其它操作保持一致。


6. 被拒的相关意见

6.1 被拒的语法提案

关于键的冲突处理,至少有过 4 种提案,这些就留待字典的子类去实现吧。

6.1.1 抛出错误

这种处理方式并不常用,并且每次字典合并都要加一个 try/except 未免太过麻烦。

6.1.2 相同键的值相加

这种处理方式比较特殊,不适合作为默认行为。

6.1.3 采用最左边的值

这种处理方式也比较少用,事实上,有需要的用户直接调换一下写法即可:

d2 | d1  # 调换后,键相同时取 d1 的值

6.1.4 将相同键的值保存到列表中

这种处理方式也不常见,不适合作为默认行为。并且当值本身就是列表时,合并行为也不好定义:

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

应该返回 {'a': [1, 2, 3, 4]} 还是 {'a': [[1, 2], [3, 4]]}

6.2 被拒的其它选择

6.2.1 使用加号

本提案最初提出的就是使用 ++= 操作符,但引起很大争议。详情可以参考 之前的版本邮件列表

6.2.2 使用左移操作符

Python 用户对 <<好像没什么感觉,当然,也不怎么反对。或许最强烈的反对意见来自 Chris Angelico 的评论:

在 C++ 之后,滥用左移操作符来表示信息流动已经过时了。

6.2.3 使用新的左箭头操作符

有一个建议是创建一个新的操作符 <-,但这个操作符存在歧义,d <- e 可能表示 d 合并 e,也可能表示 d 小于 负e

6.2.4 使用方法

使用 dict.merged() 方法,可以完全抛开操作符。不过其实现方式在作为绑定方法和非绑定方法时可能有些细微差别。

作为非绑定方法时,其行为类似于:

def merged(cls, *mappings, **kw):
    new = cls()  # 这样写在 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) 键值对;
  • 作为方法,有需要时可以方便地在子类重载;

劣势:

  • 可能需要创建一种新的,结合类方法与实例方法的装饰器,并且必须是公开的(当然,不一定是内置的),以方便用户重载。这里有一个 概念说明
  • 它不是操作符。Guido 曾讨论过 为什么操作符很有用,另一方面,也可以参考 Nick Coghlan 的博客

6.2.5 使用函数

除了方法,也可以使用一个内置函数 merged(),可能的实现类似于:

def merged(*mappings, **kw):
    if mappings and isinstance(mappings[0], dict):
        # 如果第一个参数是字典,则类型与它保持一致
        new = mappings[0].copy()
        mappings = mappings[1:]
    else:
        # 如果没有占位参数,或者第一个参数是键值对序列,创建新字典
        new = dict()
    for m in mappings:
        new.update(m)
    new.update(kw)
    return new

或者也可以放弃任意关键字参数,通过一个关键字参数指定键冲突时的行为。

def merged(*mappings, on_collision=lambda k, v1, v2: v2):
    # 具体实现留给读者作为练习

优势:

  • 具有之前所说的使用方法的优势;
  • 不需要通过子类来实现键冲突时的不同处理,提供一个方法即可;

劣势:

  • 字典合并的重要性还不足以特意为之新增一个内置方法;
  • 很难在支持任意关键字参数的同时,支持键冲突时的行为重载;

7. 使用示例

本提案的作者们检索过一些第三方库,以寻找可能的字典合并的使用示例。

以下示例来自正好装在作者们的电脑上的第三方库,并不反映任何包的当前状态,同时只是出于比较的目的,改动了和字典合并相关的部分。

7.1 IPython/zmq/ipkernel.py

修改前:

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

修改后:

aliases = kernel_aliases | shell_aliases

7.2 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"),
}

7.3 matplotlib/backends/backend_svg.py

修改前:

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

修改后:

attrib = (attrib | extra).items()

7.4 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])

7.5 matplotlib/legend.py

修改前:

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

修改后:

return default_handler_map | self._handler_map

7.6 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__', {})

7.7 praw/internal.py

修改前:

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

修改后:

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

7.8 pygments/lexer.py

修改前:

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

修改后:

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

7.9 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)

7.10 sphinx/domains/init.py

修改前:

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

修改后:

self.attrs = self.known_attrs | attrs

7.11 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

7.12 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

7.13 sphinx/highlighting.py

修改前:

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

修改后:

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

7.14 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

7.15sympy/abc.py

修改前:

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

修改后

return clash1, clash2, clash1 | clash2

7.16sympy/parsing/maxima.py

修改前:

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

修改后:

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

7.17 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', {})

7.18 sympy/utilities/runtests.py

修改前:

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

修改后:

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

以上示例表明,有时,| 操作符可以明显地提高代码可读性,减少代码行数,表明代码用意。但也有时,| 操作符会导致冗长复杂的单个表达式,甚至已经超出 PEP8 所限制的 80 个字符。正如任何语言特性一样,程序员应根据自己的判断来通过 | 操作符改进自己的代码。


公众号:ReadingPython


发表评论

评论列表,共 0 条评论