一、前言
就在本周,字典合并特性(
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 層面是如何實作的。
二、背景
在平時使用 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/peps/pep-0584/#id34七、實作
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/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) {
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/peps/pep-0584/#id50