内容简介:就在本周,字典合并特性(那什么是字典合并操作符呢?在回答这个问题前,我们不妨回忆下集合的合并操作。当我们想要对两个结合做合并操作时,会怎么做呢?
一、前言
就在本周,字典合并特性( PEP 584 )的提交被合入了 CPython 的主干分支,并在 2020-02-26 发布了 Python 3.9.0a4 预览版本。
那什么是字典合并操作符呢?在回答这个问题前,我们不妨回忆下集合的合并操作。当我们想要对两个结合做合并操作时,会怎么做呢?
>>> 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 | 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 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
,如果不知道 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/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:字典合并操作符来了》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- Android RxJava 操作符详解系列:条件 / 布尔操作符
- C语言中点操作符(.)和箭头操作符(->)的不同之处
- JavaScript骚操作之操作符
- JS操作符拾遗
- 浅谈JavaScript位操作符
- rxjs switchMap操作符
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
一只小鸟告诉我的事
[美]比兹·斯通 / 顾雨佳 / 中信出版社 / 2014-11 / 59.00元
比兹•斯通,无疑是自乔布斯后的又一个硅谷奇迹! 70后的他,出身贫苦,一无所有,却又特立独行,充满智慧。从他这本自传中,我们知道他和乔布斯一样,大学都没读完就辍学做了一名图书封面设计师,然后创建了赞架(Xanga)网站,又进了谷歌。在经济上打了翻身仗后,他毅然放弃了安逸的生活,从零开始,和朋友创建了世界最知名的社交平台推特(Twitter)。当推特奇迹般地改变着世界时,他又悄然离去,创建了自......一起来看看 《一只小鸟告诉我的事》 这本书的介绍吧!