Effective Python:编写高质量Python代码的90个有效方法(原书第2版)
上QQ阅读APP看书,第一时间看更新

第16条 用get处理键不在字典中的情况,不要使用in与KeyError

字典有三种基本的交互操作:访问、赋值以及删除键值对。字典的内容经常变动,所以完全有可能(甚至在很大概率上)会出现这样的情况,也就是你想访问或删除的键已经不在字典中了。

例如,我们要给一家三明治店设计菜单,所以想先确定大家喜欢吃哪些类型的面包。于是,我们定义这样一个字典,把每种款式的名字和它当前的得票数关联起来。

075-01

如果要记录新的一票,首先得判断对应的键在不在字典里。如果不在,那就把这个键已经得到的票数默认为0,然后增加得票数。这需要两次访问这个键,第一次是为了判断它在不在字典里,第二次是为了用它来获取对应的值,而且还要做一次赋值。下面,我们就用if语句来实现该逻辑,如果字典里有这个键,那么if语句中的in表达式等于True

075-02

还有个办法也能实现相同的功能,就是利用KeyError异常。如果程序抛出了这个异常,那说明要获取的键不在字典里。这个写法比刚才那种简单,因为只需要访问一次键名就可以了(也就是在试图获取键值的那行语句中)。赋值操作与刚才那种写法相同。

075-03

获取字典中存在的键,或给字典中不存在的键指定默认值,这两种操作非常常见。所以,Python内置的字典(dict)类型提供了get方法,可以通过第一个参数指定自己想查的键,并通过第二个参数指定这个键不存在时应返回的默认值。这种写法也只需要在查询键值时访问一次键名,然后做一次赋值操作,但要比刚才那种通过KeyError实现的方案简单得多。

075-04

对于通过in表达式与KeyError实现的那两种方案来说,确实可以通过各种技巧来简化代码,但不管怎么简化,都没办法完全消除重复赋值。所以,优先考虑用get方法来实现,因为in方案与KeyError方案无论如何都比它复杂难懂。

076-01

如果字典里的数据属于比较简单的类型,那么代码最简短、表达最清晰的方案就是get方案。

提示

如果要像这个例子这样,用字典来计数,那么应该考虑内置的collections模块中的Counter类,你想要的功能这个类差不多都支持。

如果字典里保存的数据比较复杂,例如列表(list),那该怎么办?例如,这次不仅要记录每种面包的得票数,而且要记录投票的人。那可以像下面这样,把面包的名称(也就是键名)跟一份列表关联起来,而那份列表指的就是喜欢这种面包的人。

076-02

在采用in表达式实现的方案里,如果键名已经存在,那么需要访问两次(一次是在if语句里,另一次是在获取投票人列表的那条names = votes[key]语句里);如果键名不存在,那就只需要在if语句中访问一次,然后在else分支中赋一次值。这和上面那个单纯统计得票数的例子不同,这次如果发现键名不存在,那么只需要把空白的列表与这个键关联起来就行了。那条带有两个等号的赋值语句(votes[key] = names = []),既可以把空白列表赋给names变量,又可以把这份列表与key相关联,这两项操作,只需要一行语句即可表达出来。把默认值(也就是空白列表)插入字典后,不需要再用另一条赋值语句给其中的某个元素赋值,因为可以直接在指向这份列表的names变量上调用append方法把投票人的名字添加进去。

对于字典值为列表的情况来说,除了刚才的in方案,还可以像前面那个例子一样,利用KeyError异常来实现。如果键已经在字典中,那么这种方案只需要在try块里访问一次键名;如果不在字典中,那么要先在try块里访问一次键名,然后在except块中做一次赋值。这种写法要比in方案简单。

077-01

同样,这个例子也能用get方法改写。这样的话,如果键存在,那么只需要在调用get方法的时候,访问一次键名就可以了;如果键不存在,那么访问键名之后,还需要在if块中用键名key作为下标赋一次值。

077-02

在这个方案中,无论votes.get(key)的结果是不是None,都要先把这个结果赋给names变量,只不过在结果是None的时候,还需要在if块中做一些处理。这种逻辑用赋值表达式(Python 3.8引入的,参见第10条)改写可以再节省一行代码,而且读起来更清晰。

077-03

dict类型提供了setdefault方法,能够继续简化代码。这个方法会查询字典里有没有这个键,如果有,就返回对应的值;如果没有,就先把用户提供的默认值跟这个键关联起来并插入字典,然后返回这个值。总之,这个方法所返回的值肯定已经跟键关联起来了,无论这个值是字典里本来就有的,还是作为默认值刚添加到字典里的,它都能保证这一点。现在我们就用setdefault方法来实现与上面get方案相同的逻辑。

077-04

这样写是正确的,而且要比采用赋值表达式的get方案少一行。但这种写法不太好懂,因为该方法的名字setdefault(设置默认值)没办法让人立即明白它的作用。如果字典里本身就有这个键,那么这个方法要做的,其实仅仅是返回相关的值而已,这时它并不会set(设置)什么数据。所以为什么不叫get_or_set(获取或设置)呢?当然笔者并不是故意要在这个小问题上纠缠,只是想表达:不是特别熟悉Python的人,在代码中初次看到这种写法时可能会比较困惑代码要完成什么逻辑,因为这个方法的名字并没有清晰地表达出它的功能。

还有个关键的地方要注意:在字典里面没有这个键时,setdefault方法会把默认值直接放到字典里,而不是先给它做副本,然后把副本放到字典中。我们用下面这段代码演示一下默认值为列表时可能出现的问题。

078-01

这意味着每次调用setdefault时都要构造一个新的默认值出来。在本例中,这就相当于每次调用时,不管字典里有没有这个键,都得分配一个list实例,这有可能产生比较大的性能开销。假如我们像刚才一样,试着复用这个表示默认值的对象(可能尝试这样做来提高效率和可读性),那么就有可能出现奇怪的效果和bug(第24条也讲了这样一个问题)。

回到之前那个只记录票数而不记录投票人的例子。那个例子为什么不用setdefault改写呢?比如,可以这样写:

078-02

这样写的问题是,根本就没必要调用setdefault,因为不管字典里有没有这个键,我们都要递增它所对应的值。在字典里没有这个键的情况下,这种写法会先通过setdefault把默认值0赋给这个键,然后再通过counters[key] = count + 1把递增之后的值更新到字典中,这其实完全没有必要。无论字典里有没有这个键,之前那种get方案都只需要一次访问操作与一次赋值操作即可,而目前的setdefault方案(在字典里没有键的情况下)需要一次访问操作与两次赋值操作。

只有在少数几种情况下用setdefault处理缺失的键才是最简短的方式,例如这种情况:与键相关联的默认值构造起来开销很低且可以变化,而且不用担心异常问题(例如list实例)。在这种特殊的场合,或许可以用这个名字有点儿奇怪的setdefault方法取代行数稍微多一些的get方案。即便如此,一般也应该优先考虑用defaultdict(带默认值的字典)取代dict(参见第17条)。

要点

  • 有四种办法可以处理键不在字典中的情况:in表达式、KeyError异常、get方法与setdefault方法。
  • 如果跟键相关联的值是像计数器这样的基本类型,那么get方法就是最好的方案;如果是那种构造起来开销比较大,或是容易出异常的类型,那么可以把这个方法与赋值表达式结合起来使用。
  • 即使看上去最应该使用setdefault方案,也不一定要真的使用setdefault方案,而是可以考虑用defaultdict取代普通的dict