PEP 584:字典合并操作符来了

栏目: IT技术 · 发布时间: 5年前

内容简介:就在本周,字典合并特性(那什么是字典合并操作符呢?在回答这个问题前,我们不妨回忆下集合的合并操作。当我们想要对两个结合做合并操作时,会怎么做呢?

一、前言

就在本周,字典合并特性( PEP 584 )的提交被合入了 CPython 的主干分支,并在 2020-02-26 发布了 Python 3.9.0a4 预览版本。

PEP 584:字典合并操作符来了

那什么是字典合并操作符呢?在回答这个问题前,我们不妨回忆下集合的合并操作。当我们想要对两个结合做合并操作时,会怎么做呢?

>>> s1 = {1, 2}
>>> s2 = {2, 3}
>>> s1 | s2  # s1 和 s2 取并集,生成新的集合;与 s1.union(s2) 等价
{1, 2, 3}
>>> s1 |= s2 # s1 和 s2 取并集,并更新到 s1 上;与 s1.update(s2) 等价
>>> s1
{1, 2, 3}

类似地,我们希望 Python 中的字典能像集合一样,使用 ||= 作为合并操作符,以解决我们在过去合并字典时感受到的“痛苦”,于是就有了 PEP 584

今天就想和大家聊聊这个提案,不仅是要了解字典合并操作符的前世今生,更是要学习提案作者以及参与者是如何对引入一个新特性的思考,辩证性地分析利弊,最终确定引入。最后还想和大家分享下在 CPython 层面是如何实现的。

<!--more-->

二、背景

在平时使用 Python 的过程中,我们有时会需要合并字典。目前合并字典有多种方式,它们或多或少都有些缺点。

2.1 dict.update

d1.update(d2) 确实能合并两个字典,但它是在修改 d1 的基础上进行。如果我们想要合并成一个新的字典,没有一个直接使用表达式的方式,而需要借助临时变量进行:

e = d1.copy()
e.update(d2)

2.2 {d1,d2}

字典解包可以将两个字典合并为一个新的字典,但看起来有些丑陋,并且不能让人显而易见地看出这是在合并字典。

{**d1, **d2} 还会忽略映射类型,并始终返回字典类型。

2.3 collections.ChainMap

ChainMap 很少有人知道,它也可以用作合并字典。但和前面合并方式相反,在合并两个字典时,第一个字典的键会覆盖第二个字典的相同键。

此外,由于 ChainMap 是对入参字典的封装,这意味着写入 ChainMap 会修改原始字典:

>>> from collections import ChainMap
>>> d1 = {'a':1}
>>> d2 = {'a':2}
>>> merged = ChainMap(d1, d2)
>>> merged['a']     # d1['a'] 会覆盖 d2['a']
1
>>> merged['a'] = 3 # 实际等同于 d1['a'] = 3
>>> d1
{'a': 3}

2.4 dict(d1, **d2)

这是一种鲜为人知的合并字典的“巧妙方法”,但如果字典的键不是字符串,它就不能有效工作了:

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

三、原理

新操作符同 dict.update 方法的关系,就和列表连接( + )、扩展( += )操作符同 list.extend 方法的关系一样。需要注意的是,这和集合中 | / |= 操作符同 set.update 的关系稍有不同。作者明确了允许就地运算符接受更广泛的类型(就像 list 那样)是一种更有用的设计,并且限制二进制操作符的操作数类型(就像 list 那样)将有助于避免由复杂的隐式类型转换引起的错误被吞掉。

>>> l1 = [1, 2]
>>> l1 + (3,) # 限制操作数的类型,不是列表就报错
Traceback (most recent call last)
...
TypeError: can only concatenate list (not "tuple") to list
>>> l1 += (3,) # 允许就地运算符接受更广泛的类型(如元组)
>>> l1
[1, 2, 3]

当合并字典发生键冲突时,以最右边的值为准。这和现存的字典类似操作相符,比如:

{'a': 1, 'a': 2} # 2 覆盖 1
{**d, **e}       # e覆盖d中相同键所对应的值
d.update(e)      # e覆盖d中相同键所对应的值
d[k] = v         # v 覆盖原有值
{k: v for x in (d, e) for (k, v) in x.items()} # e覆盖d中相同键所对应的值

四、规范

字典合并会返回一个新字典,该字典由左操作数与右操作数合并而成,每个操作数必须是 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 # 不符合交换律,左右互换操作数会得到不同的结果
{'aardvark': 'Ethel', 'spam': 1, 'eggs': 2, 'cheese': 3}

扩展赋值版本的就地操作:

>>> d |= e # 将 e 更新到 d 中
>>> d
{'spam': 1, 'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel'}

扩展赋值的行为和字典的 update 方法完全一样,它还支持任何实现了映射协议(更确切地说是实现了 keys__getitem__ 方法)或键值对迭代对象。所以:

>>> d | [('spam', 999)]   # “原理”章节中提到限制操作数的类型,不是字典或字典子类就报错
Traceback (most recent call last):
  ...
TypeError: can only merge dict (not "list") to dict

>>> d |= [('spam', 999)]  # “原理”章节中提到允许就地运算符接受更广泛的类型,其行为和 update 一样,接受键值对迭代对象
>>> d
{'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel', 'spam': 999}

五、主流观点

5.1 字典合并不符合交换律

合并是符合交换律的,但是字典联合却没有( d | e != e | d )。

回应

Python 中有过不符合交换律的合并先例:

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

上述结果虽然是相等的,但是本质是不同的。通常来说, a | bb | a 并不相同。

5.2 字典合并并不高效

类似管道写法使用多次字典合并并不高效,比如 d | e | f | g | h 会创建和销毁三个临时映射。

回应

这种问题在序列级联时同样会出现。

序列级联的每一次合并都会使序列中的元素总数增加,最终会带来 O(N^2) 的性能开销。而字典合并有可能会有重复键,因此临时映射的大小并不会如此快速地增长。

正如我们很少将大量的列表或元组连接在一起一样,PEP的作者任务合并大量的字典也是少见情况。若是确实有这样的诉求,那么最好使用显式的循环和就地合并:

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

5.3 字典合并是有损的

字典合并可能会丢失数据(相同键的值可能消失),其他形式的合并并不会。

回应

作者并不觉得这种有损是一个问题。此外, dict.update 也会发生这种情况,但并不会丢弃键,这其实是符合预期的。只不过是现在使用的不是 update 而是 |

如果从不可逆的角度考虑,其他类型的合并也是有损的。假设 a | b 的结果是365,那么 ab 是多少却不得而知。

5.4 只有一种方法达到目的

字典合并不符合“Only One Way”的禅宗。

回应

其实并没有这样的禅宗。“Only One Way”起源于很早之前 Perl 社区对Python的诽谤。

5.5 超过一种方法达到目的

好吧,禅宗并没有说“Only One Way To Do It”。但是它明确禁止“超过一种方法达到目的”。

回应

并没有这样的禁止。Python 之禅仅表达了对“仅一种显而易见的方式”的偏爱。

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

它的重点是应该有一种明显的方式达到目的。对于字典更新操作来说,我们可能希望至少执行两个不同的操作:

  • 就地更新字典:显而易见的方式是使用 update() 方法。如果此提案被接受, |= 扩展赋值操作符也将等效,但这是扩展赋值如何定义的副作用。选择哪种取决于使用者口味。
  • 合并两个现存的字典到新字典中:此提案中显而易见的方法是使用 | 合并操作符。

实际上,Python 里经常违反对“仅一种方式”的偏爱。例如,每个 for 循环都可以重写为 while 循环;每个 if 块都可以写为 if/else 块。列表、集合和字典推导都可以用生成器表达式代替。列表提供了不少于五种方法来实现级联:

a + b
a + = b
a[len(a):] = b
[*a, *b]
a.extend(b)

我们不能太教条主义,不能因为它违反了“仅一种方式”就非常严格的拒绝有用的功能。

5.6 字典合并让代码更难理解

字典合并让人们更难理解代码的含义。为了解释该异议,而不是具体引用任何人的话:“在看到 spam | eggs ,如果不知道 spameggs 是什么,根本就不知道这个表达式的作用”。

回应

这确实如此,即使没有该提案, | 操作符的现状也是如此:

  • 对于 int / bool 是按位或
  • 对于 set / forzenset 是并集
  • 还可能是任何其他的重载操作

添加字典合并看起来并不会让理解代码变得更困难。确定 spameggs 是映射类型并不比确定是集合还是整数要花更多的工作。其实良好的命名约定将会有助于改善情况:

flags |= WRITEABLE  # 可能就是数字的按位或
DO_NOT_RUN = WEEKENDS | HOLIDAYS  # 可能就是集合合并
settings = DEFAULT_SETTINGS | user_settings | workspace_settings  # 可能就是字典合并

5.7 参考下完整的集合API

字典和集合很相似,应该要支持集合所支持的操作符: |&^-

回应

也许后续会有PEP来专门说明这些操作符如何用于字典。简单来说:

把集合的对称差集(^)操作用在字典上面是显而易见且自然。比如:

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

对于 d1d2 对称差集,我们期望 d1 ^ d2 应该是 {"spam": 1, "ham": 3}

把集合的差集(-)操作用在字典上面也是显而易见和自然的。比如 d1d2 的差集,我们期望:

  • d1 - d2{"spam": 1}
  • d2 - d1{"ham": 3}

把集合的交集(&)操作用在字典上面就有些问题了。虽然很容易确定两个字典中键的交集,但是如何处理键所对应的值就比较模糊。不难看出 d1d2 的共同键是 eggs ,如果我们遵循“后者胜出”的一致性原则,那么值就是 4。

六、已拒绝的观点

PEP 584 提案中罗列了很多已拒绝的观点,比如使用 + 来合并字典;在合并字典时也合并值类型为列表的值等等。这些观点都非常有意思,被拒绝的理由也同样有说服力。限于篇幅的原因不再进一步展开,感兴趣的可以阅读 https://www.python.org/dev/pe...

七、实现

7.1 纯 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

纯 Python 实现并不复杂,我们只需让 dict 实现几个魔法方法:

  • __or____ror__ 魔法方法对应于 | 操作符, __or__ 表示对象在操作符左侧, __ror__ 表示对象在操作符右侧。实现就是根据左侧操作数生成一个新字典,再把右侧操作数更新到新字典中,并返回新字典。
  • __ior__ 魔法方法对应于 |= 操作符,将右侧操作数更新到自身即可。

7.2 CPython 实现

CPython 中字典合并的详细实现可见此 PR: https://github.com/python/cpy...

最核心的实现如下:

// 实现字典合并生成新字典的逻辑,对应于 | 操作符
static PyObject *
dict_or(PyObject *self, PyObject *other)
{
    if (!PyDict_Check(self) || !PyDict_Check(other)) {
        Py_RETURN_NOTIMPLEMENTED;
    }
    PyObject *new = PyDict_Copy(self);
    if (new == NULL) {
        return NULL;
    }
    if (dict_update_arg(new, other)) {
        Py_DECREF(new); // 减少引用计数
        return NULL;
    }
    return new;
}

// 实现字典就地合并逻辑,对应于 |= 操作符
static PyObject *
dict_ior(PyObject *self, PyObject *other)
{
    if (dict_update_arg(self, other)) {
        return NULL;
    }
    Py_INCREF(self); // 增加引用计数
    return self;
}

CPython 的实现逻辑和纯Python实现几乎一样,唯独需要注意的就是引用计数的问题,这关系到对象的垃圾回收。

八、总结

PEP 584 是一个非常精彩的提案,引入 ||= 操作符用作字典合并,看似是一个比较简单的功能,但所要考虑的情况却不少。不仅需要说明这个提案的背景,目前有哪些方式可以达到目的,它们有哪些痛点;还要考虑对既有类型引入操作符所带来的各种影响,对开发者提出的质疑和顾虑进行思考和解决。整个提案所涉及到的方法论、思考维度、知识点都非常值得学习。

对使用者来说,合并字典将会变得更加方便。在提案的最后,作者给出了许多第三方库在合并字典时采用新方式编写的例子,可谓是简洁了不少。详见 https://www.python.org/dev/pe...

PEP 584:字典合并操作符来了


以上所述就是小编给大家介绍的《PEP 584:字典合并操作符来了》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Responsive Web Design

Responsive Web Design

Ethan Marcotte / Happy Cog / 2011-6 / USD 18.00

From mobile browsers to netbooks and tablets, users are visiting your sites from an increasing array of devices and browsers. Are your designs ready? Learn how to think beyond the desktop and craft be......一起来看看 《Responsive Web Design》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换