就在本週,字典合併特性(PEP 584)的提交被合入了 CPython 的主幹分支,並在 2020-02-26 發佈了 Python 3.9.0a4 預覽版本。python
那什麼是字典合併操做符呢?在回答這個問題前,咱們不妨回憶下集合的合併操做。當咱們想要對兩個結合作合併操做時,會怎麼作呢?git
>>> 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
。github
今天就想和你們聊聊這個提案,不只是要了解字典合併操做符的前世此生,更是要學習提案做者以及參與者是如何對引入一個新特性的思考,辯證性地分析利弊,最終肯定引入。最後還想和你們分享下在 CPython 層面是如何實現的。segmentfault
<!--more-->性能
在平時使用 Python 的過程當中,咱們有時會須要合併字典。目前合併字典有多種方式,它們或多或少都有些缺點。學習
d1.update(d2)
確實能合併兩個字典,但它是在修改d1
的基礎上進行。若是咱們想要合併成一個新的字典,沒有一個直接使用表達式的方式,而須要藉助臨時變量進行:spa
e = d1.copy() e.update(d2)
字典解包能夠將兩個字典合併爲一個新的字典,但看起來有些醜陋,而且不能讓人顯而易見地看出這是在合併字典。設計
{**d1, **d2}
還會忽略映射類型,並始終返回字典類型。code
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}
這是一種不爲人知的合併字典的「巧妙方法」,但若是字典的鍵不是字符串,它就不能有效工做了:
>>> 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}
合併是符合交換律的,可是字典聯合卻沒有(d | e != e | d
)。
迴應
Python 中有過不符合交換律的合併先例:
>>> {0} | {False} {0} >>> {False} | {0} {False}
上述結果雖然是相等的,可是本質是不一樣的。一般來講,a | b
和 b | a
並不相同。
相似管道寫法使用屢次字典合併並不高效,好比 d | e | f | g | h
會建立和銷燬三個臨時映射。
迴應
這種問題在序列級聯時一樣會出現。
序列級聯的每一次合併都會使序列中的元素總數增長,最終會帶來 O(N^2) 的性能開銷。而字典合併有可能會有重複鍵,所以臨時映射的大小並不會如此快速地增加。
正如咱們不多將大量的列表或元組鏈接在一塊兒同樣,PEP的做者任務合併大量的字典也是少見狀況。如果確實有這樣的訴求,那麼最好使用顯式的循環和就地合併:
new = {} for d in many_dicts: new |= d
字典合併可能會丟失數據(相同鍵的值可能消失),其餘形式的合併並不會。
迴應
做者並不以爲這種有損是一個問題。此外,dict.update
也會發生這種狀況,但並不會丟棄鍵,這實際上是符合預期的。只不過是如今使用的不是 update
而是 |
。
若是從不可逆的角度考慮,其餘類型的合併也是有損的。假設 a | b
的結果是365,那麼 a
和 b
是多少卻不得而知。
字典合併不符合「Only One Way」的禪宗。
迴應
其實並無這樣的禪宗。「Only One Way」起源於很早以前Perl社區對Python的誹謗。
好吧,禪宗並無說「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)
咱們不能太教條主義,不能由於它違反了「僅一種方式」就很是嚴格的拒絕有用的功能。
字典合併讓人們更難理解代碼的含義。爲了解釋該異議,而不是具體引用任何人的話:「在看到 spam | eggs
,若是不知道 spam
和 eggs
是什麼,根本就不知道這個表達式的做用」。
迴應
這確實如此,即便沒有該提案,|
操做符的現狀也是如此:
int
/bool
是按位或set
/forzenset
是並集添加字典合併看起來並不會讓理解代碼變得更困難。肯定 spam
和 eggs
是映射類型並不比肯定是集合仍是整數要花更多的工做。其實良好的命名約定將會有助於改善狀況:
flags |= WRITEABLE # 可能就是數字的按位或 DO_NOT_RUN = WEEKENDS | HOLIDAYS # 可能就是集合合併 settings = DEFAULT_SETTINGS | user_settings | workspace_settings # 可能就是字典合併
字典和集合很類似,應該要支持集合所支持的操做符:|
、&
、^
和 -
。
迴應
也許後續會有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...。
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__
魔法方法對應於 |=
操做符,將右側操做數更新到自身便可。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... 。