当前位置: 首页 > 科技观察

Python新操作:字典合并操作符来了

时间:2023-03-14 15:29:56 科技观察

1.前言就在本周提交的字典合并功能(PEP584[1])被合并到CPython的主分支并于2020-02-26发布Python3.9.0a4[2]预览版。那么什么是字典合并运算符呢?在回答这个问题之前,我们不妨回忆一下集合的合并操作。当我们想对两个联合进行合并操作时,我们应该怎么做呢?>>>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中的字典可以像集合一样使用,使用|和|=作为合并操作符,解决了我们以往在合并字典时感受到的“痛苦”,于是就有了PEP584。今天我想和大家聊聊这个提议,不仅是为了了解字典合并的前世今生运营商,还要了解提案作者和参与者是如何考虑引入一个新特性的,辩证地分析利弊,最终决定引入。最后跟大家分享一下在CPython层面是如何实现的。二、背景在平时使用Python的过程中,我们有时会需要合并字典。目前有几种合并字典的方法,它们或多或少都有缺点。2.1dict.updated1.update(d2)确实可以合并两个字典,但是是在修改d1的基础上进行的。如果我们要合并到一个新的字典中,没有办法直接使用表达式,而是需要使用临时变量:e=d1.copy()e.update(d2)2.2{**d1,**d2}词典解包可以将两个词典合并成一个新词典,但它看起来很难看,并且不能明显地表明你正在合并词典。{**d1,**d2}也忽略地图类型并始终返回字典类型。2.3collections.ChainMapChainMap鲜为人知,也可以作为合并字典使用。但是与前面的合并方法相反,合并两个字典时,第一个字典的键会覆盖第二个字典的相同键。另外,由于ChainMap是对输入字典的封装,也就是说写入ChainMap会修改原来的字典:>>>fromcollectionsimportChainMap>>>d1={'a':1}>>>d2={'a':2}>>>merged=ChainMap(d1,d2)>>>merged['a']#d1['a']将覆盖d2['a']1>>>merged['a']=3#Actual等价于d1['a']=3>>>d1{'a':3}2.4dict(d1,**d2)这是一种鲜为人知的合并字典的“巧妙方式”,但是如果字典的Keys不是字符串,所以它不会有效地工作:>>>d1={'a':1}>>>d2={2:2}>>>dict(d1,**d2)Traceback(mostrecentcallast):...TypeError:keywordsmustbestrings3.原理new运算符与dict.update方法的关系与列表连接(+)和扩展(+=)运算符与list.extend方法的关系相同。请注意,这与|/|=运算符与集合中的set.update的关系略有不同。作者明确表示,允许就地运算符接受更广泛的类型(如list)是一种更有用的设计,限制二元运算符的操作数类型(如list)将有助于避免由复杂的错误引起的隐式类型转换被吞没了。>>>l1=[1,2]>>>l1+(3,)#限制操作数的类型,如果不是list会报错traceback(mostrecentcalllast)...TypeError:canonlyconcatenatelist(not"tuple")tolist>>>l1+=(3,)#允许就地运算符接受更宽的类型(比如元组)>>>l1[1,2,3]当合并字典出现键冲突时,最右边的值占上风。这与现有的类似字典的操作是一致的,例如:{'a':1,'a':2}#2覆盖1{**d,**e}#e覆盖同一个key对应的值d在d.update(e)#e覆盖d中相同key对应的值d[k]=v#v覆盖原值{k:vforxin(d,e)for(k,v)inx.items()}#e覆盖d中同一个key对应的值。4.CanonicalDictionaryMerge返回通过合并左右操作数形成的新字典。每个操作数必须是字典(或字典子类的实例)。如果一个键出现在两个操作数中,最后出现的值(即来自右操作数的值)将被覆盖:>>>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#Updateetod>>>d{'spam':1,'eggs':2,'cheese':'cheddar','aardvark':'Ethel'}扩展赋值的行为与字典的更新方法完全相同,它也支持映射协议的任何实现(更具体地说,keys和__getitem__方法)或可迭代对象的键值。所以:>>>d|[('spam',999)]#在“原理”一章中提到操作数的类型是有限制的,如果不是字典或者字典的子类,就会报错被报Traceback(mostrecentcalllast):...TypeError:canonlymergedict(not"list")todict>>>d|=[('spam',999)]#《原理》一章提到允许in-place运算符接受范围更广的类型,其行为与update相同,接受键值对Iterationobject>>>d{'eggs':2,'cheese':'cheddar','aardvark':'Ethel','spam':999}5.主流观点5.1字典合并不符合交换律合并符合交换律,但字典并集不符合(d|e!=e|d)。针对Python中不符合交换律的合并先例:>>>{0}|{False}{0}>>>{False}|{0}{False}虽然上面的结果是相等的,本质是不同的。一般来说,a|b和b|a是不一样的。5.2Dictionarymergingisnotefficient使用像pipeline写的多字典合并效率不高,比如d|电子|f|克|h将创建和销毁三个临时映射。响应级联序列时也会出现这种问题。序列级联中的每次合并都会增加序列中元素的总数,最终导致O(N^2)性能开销。并且字典合并可能有重复的键,所以临时映射的大小不会增长得那么快。正如我们很少将大型列表或元组连接在一起一样,PEP的作者也很少合并大型词典。如果有这样的需求,最好使用显式循环和就地合并:new={}fordinmany_dicts:new|=d5.3DictionarymergesarelossyDictionarymergesmaylosedata(valuesforthesamekeymay消失),其他形式的合并则不会。回复作者认为这种损失不是问题。此外,这发生在dict.update中,但不会丢弃密钥,这实际上是预期的。只是现在而不是更新,|用来。如果认为不可逆,其他类型的合并也是有损的。假设|的结果b是365,不知道a和b是什么。5.4只有一种方法可以到达那里字典合并不符合“只有一种方法”的禅宗。响应中其实没有这个Zen。“OnlyOneWay”起源于很久以前Perl社区对Python的诋毁。5.5不止一种方法可以做好,Zen并没有说“只有一种方法可以做到”。但它明确禁止“以不止一种方式达到目的”。响应并没有这种禁止。Python之禅只是表达了对“只有一种明显方式”的偏爱。应该有一种——最好只有一种——显而易见的方法来做到这一点。关键是应该有一种明显的方法可以到达那里。对于字典更新操作,我们可能希望执行至少两种不同的操作:就地更新字典:显而易见的方法是使用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是什么,你就不知道这个表达式的作用”。回应这是真的,即使没有提案,状态也是如此|operator:BitwiseORforint/boolUnionforset/forzenset以及可能的任何其他重载操作添加字典合并看起来不像会使代码更难理解。确定垃圾邮件和鸡蛋是地图类型与确定它们是集合还是整数一样简单。事实上,一个好的命名约定将有助于改善这种情况:flags|=WRITEABLE#maybeabitwisenumberorDO_NOT_RUN=WEEKENDS|HOLIDAYS#maybeacollectionmergesettings=DEFAULT_SETTINGS|user_settings|workspace_settings#maybeadictionarymerge5.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。6.被拒绝的观点PEP584提案列出了很多被拒绝的观点,例如就像使用+合并字典一样;合并字典时,值类型为列表的值也被合并,以此类推。这几点很有意思,拒签的理由也同样有说服力。限于篇幅,我就不展开了。有兴趣的可以阅读https://www.python.org/dev/peps/pep-0584/#id34。7.实现7.1纯Python实现def__or__(self,other):ifnotisinstance(other,dict):returnNotImplementednew=dict(self)new.update(other)returnnewdef__ror__(self,other):ifnotisinstance(other,dict):returnNotImplementednew=dict(other)new.update(self)returnnewdef__ior__(self,other):dict.update(self,other)returnsself纯Python实现并不复杂,我们只需要让dict实现几个魔术方法:__or__和__ror__魔术方法对应即可的|运算符,__or__表示对象在运算符的左边,__ror__表示对象在运算符的右边。实现是根据左操作数生成新字典,然后将右操作数更新为新字典,并返回新字典。__ior__魔术方法对应于|=运算符,它将正确的操作数更新为自身。7.2CPython实现CPython中字典合并的详细实现可以在这个PR中找到:https://github.com/python/cpython/pull/12088/files。核心实现如下://实现合并字典生成新字典的逻辑,对应|运算符staticPyObject*dict_or(PyObject*self,PyObject*oth??er){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;}//实现字典就地合并逻辑,对应|=operatorstaticPyObject*dict_ior(PyObject*self,PyObject*oth??er){if(dict_update_arg(self,other)){returnNULL;}Py_INCREF(self);//增加引用计数returnself;}CPython的实现逻辑和纯Python几乎一样,唯一要注意的是引用计数的问题,这跟对象的垃圾回收有关。8.总结PEP584是一个非常棒的提案。的介绍|and|=operators进行字典合并看似是一个比较简单的功能,但是要考虑的情况有很多。不仅要说明这个提案的背景,目前有哪些方法可以达到目的,有什么痛点;还需要考虑引入运算符对现有类型的各种影响,对开发者提出的问题和顾虑进行思考和讨论。解决。整个提案所涉及的方法论、思维维度、知识点都值得借鉴。合并词典对用户来说会更加方便。在提案的最后,作者给出了很多第三方库在合并词典时用新写法写的例子,可以说是相当简洁了。有关详细信息,请参阅https://www.python.org/dev/peps/pep-0584/#id50。