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

Wesley13
• 阅读 466

👆 “Python猫” ,一个值得加星标的公众号

花下猫语: 最新发布的 Python 3.9 预览版合入了一个很小的改动(PEP-584),关于这个特性本身不需要多说,只需要一两个示例,大家就能接受使用。但是,就像我之前介绍过的一些 PEP 一样,关于它的来龙去脉和引起的相关讨论,都是挺有意思的细节。今天分享的文章,对此有详尽的梳理,推荐大家一读。

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

剧照 | 《我的天才女友》

来源: Prodesire@Prodesire公众号

一、前言

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

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 层面是如何实现的。

二、背景

在平时使用 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 | b 和 b | 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,那么 a 和 b 是多少却不得而知。

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

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

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

  • 合并两个现存的字典到新字典中:此提案中显而易见的方法是使用  | 合并操作符。

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

  • 级联操作符: a + b

  • 就地级联操作符: a + = b

  • 切片分配: a[len(a):] = b

  • 序列解压缩: [*a, *b]

  • 扩展方法: a.extend(b)

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

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

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

回应

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

  • 对于  int/ bool 是按位或

  • 对于  set/ forzenset 是并集

  • 还可能是任何其他的重载操作

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

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}

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

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

  • d1 - d2 为  {"spam": 1}

  • d2 - d1 为  {"ham": 3}

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

六、已拒绝的观点

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

七、实现

7.1 纯 Python 实现

def __or__(self, other):    ifnot isinstance(other, dict):        returnNotImplemented    new = dict(self)    new.update(other)    return newdef __ror__(self, other):    ifnot isinstance(other, dict):        returnNotImplemented    new = dict(other)    new.update(self)    return newdef __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/cpython/pull/12088/files

最核心的实现如下:

// 实现字典合并生成新字典的逻辑,对应于 | 操作符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) {        returnNULL;    }    if (dict_update_arg(new, other)) {        Py_DECREF(new); // 减少引用计数        returnNULL;    }    returnnew;}// 实现字典就地合并逻辑,对应于 |= 操作符static PyObject *dict_ior(PyObject *self, PyObject *other){    if (dict_update_arg(self, other)) {        returnNULL;    }    Py_INCREF(self); // 增加引用计数    return self;}

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

八、总结

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

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

参考资料

[1]

PEP584: https://www.python.org/dev/peps/pep-0584/

[2]

Python 3.9.0a4: https://www.python.org/downloads/release/python-390a4/

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

优质文章,推荐阅读:

如何高效地远程部署?自动化运维利器 Fabric 教程

一份可以令 Python 变快的工具清单

Python 设计和历史的 27 个问题

Python 工匠:高效操作文件的三个建议

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

感谢创作者的好文PEP 584:字典合并操作符来了

本文分享自微信公众号 - Python猫(python_cat)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Stella981 Stella981
2年前
Python之time模块的时间戳、时间字符串格式化与转换
Python处理时间和时间戳的内置模块就有time,和datetime两个,本文先说time模块。关于时间戳的几个概念时间戳,根据1970年1月1日00:00:00开始按秒计算的偏移量。时间元组(struct_time),包含9个元素。 time.struct_time(tm_y
Wesley13 Wesley13
2年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
可莉 可莉
2年前
2021年全球公有云终端用户支出将增长18% ;EMNLP 2020最佳论文:无声语音的数字发声
!(https://static001.geekbang.org/infoq/af/af9f6637b50b09be60b00a42f3812d5e.png)开发者社区技术周刊又和大家见面
Stella981 Stella981
2年前
2021年全球公有云终端用户支出将增长18% ;EMNLP 2020最佳论文:无声语音的数字发声
!(https://static001.geekbang.org/infoq/af/af9f6637b50b09be60b00a42f3812d5e.png)开发者社区技术周刊又和大家见面
Stella981 Stella981
2年前
Docker 部署SpringBoot项目不香吗?
  公众号改版后文章乱序推荐,希望你可以点击上方“Java进阶架构师”,点击右上角,将我们设为★“星标”!这样才不会错过每日进阶架构文章呀。  !(http://dingyue.ws.126.net/2020/0920/b00fbfc7j00qgy5xy002kd200qo00hsg00it00cj.jpg)  2
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
3个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这