第16条 用get处理键不在字典中的情况,不要使用in与KeyError
字典有三种基本的交互操作:访问、赋值以及删除键值对。字典的内容经常变动,所以完全有可能(甚至在很大概率上)会出现这样的情况,也就是你想访问或删除的键已经不在字典中了。
例如,我们要给一家三明治店设计菜单,所以想先确定大家喜欢吃哪些类型的面包。于是,我们定义这样一个字典,把每种款式的名字和它当前的得票数关联起来。
如果要记录新的一票,首先得判断对应的键在不在字典里。如果不在,那就把这个键已经得到的票数默认为0,然后增加得票数。这需要两次访问这个键,第一次是为了判断它在不在字典里,第二次是为了用它来获取对应的值,而且还要做一次赋值。下面,我们就用if
语句来实现该逻辑,如果字典里有这个键,那么if
语句中的in
表达式等于True
。
还有个办法也能实现相同的功能,就是利用KeyError
异常。如果程序抛出了这个异常,那说明要获取的键不在字典里。这个写法比刚才那种简单,因为只需要访问一次键名就可以了(也就是在试图获取键值的那行语句中)。赋值操作与刚才那种写法相同。
获取字典中存在的键,或给字典中不存在的键指定默认值,这两种操作非常常见。所以,Python内置的字典(dict
)类型提供了get
方法,可以通过第一个参数指定自己想查的键,并通过第二个参数指定这个键不存在时应返回的默认值。这种写法也只需要在查询键值时访问一次键名,然后做一次赋值操作,但要比刚才那种通过KeyError
实现的方案简单得多。
对于通过in
表达式与KeyError
实现的那两种方案来说,确实可以通过各种技巧来简化代码,但不管怎么简化,都没办法完全消除重复赋值。所以,优先考虑用get
方法来实现,因为in
方案与KeyError
方案无论如何都比它复杂难懂。
如果字典里的数据属于比较简单的类型,那么代码最简短、表达最清晰的方案就是get
方案。
提示
如果要像这个例子这样,用字典来计数,那么应该考虑内置的collections
模块中的Counter
类,你想要的功能这个类差不多都支持。
如果字典里保存的数据比较复杂,例如列表(list
),那该怎么办?例如,这次不仅要记录每种面包的得票数,而且要记录投票的人。那可以像下面这样,把面包的名称(也就是键名)跟一份列表关联起来,而那份列表指的就是喜欢这种面包的人。
在采用in
表达式实现的方案里,如果键名已经存在,那么需要访问两次(一次是在if
语句里,另一次是在获取投票人列表的那条names = votes[key]
语句里);如果键名不存在,那就只需要在if
语句中访问一次,然后在else
分支中赋一次值。这和上面那个单纯统计得票数的例子不同,这次如果发现键名不存在,那么只需要把空白的列表与这个键关联起来就行了。那条带有两个等号的赋值语句(votes[key] = names = []
),既可以把空白列表赋给names
变量,又可以把这份列表与key
相关联,这两项操作,只需要一行语句即可表达出来。把默认值(也就是空白列表)插入字典后,不需要再用另一条赋值语句给其中的某个元素赋值,因为可以直接在指向这份列表的names
变量上调用append
方法把投票人的名字添加进去。
对于字典值为列表的情况来说,除了刚才的in
方案,还可以像前面那个例子一样,利用KeyError
异常来实现。如果键已经在字典中,那么这种方案只需要在try
块里访问一次键名;如果不在字典中,那么要先在try
块里访问一次键名,然后在except
块中做一次赋值。这种写法要比in
方案简单。
同样,这个例子也能用get
方法改写。这样的话,如果键存在,那么只需要在调用get
方法的时候,访问一次键名就可以了;如果键不存在,那么访问键名之后,还需要在if
块中用键名key
作为下标赋一次值。
在这个方案中,无论votes.get(key)
的结果是不是None
,都要先把这个结果赋给names
变量,只不过在结果是None
的时候,还需要在if
块中做一些处理。这种逻辑用赋值表达式(Python 3.8引入的,参见第10条)改写可以再节省一行代码,而且读起来更清晰。
dict
类型提供了setdefault
方法,能够继续简化代码。这个方法会查询字典里有没有这个键,如果有,就返回对应的值;如果没有,就先把用户提供的默认值跟这个键关联起来并插入字典,然后返回这个值。总之,这个方法所返回的值肯定已经跟键关联起来了,无论这个值是字典里本来就有的,还是作为默认值刚添加到字典里的,它都能保证这一点。现在我们就用setdefault
方法来实现与上面get
方案相同的逻辑。
这样写是正确的,而且要比采用赋值表达式的get
方案少一行。但这种写法不太好懂,因为该方法的名字setdefault
(设置默认值)没办法让人立即明白它的作用。如果字典里本身就有这个键,那么这个方法要做的,其实仅仅是返回相关的值而已,这时它并不会set
(设置)什么数据。所以为什么不叫get_or_set
(获取或设置)呢?当然笔者并不是故意要在这个小问题上纠缠,只是想表达:不是特别熟悉Python的人,在代码中初次看到这种写法时可能会比较困惑代码要完成什么逻辑,因为这个方法的名字并没有清晰地表达出它的功能。
还有个关键的地方要注意:在字典里面没有这个键时,setdefault
方法会把默认值直接放到字典里,而不是先给它做副本,然后把副本放到字典中。我们用下面这段代码演示一下默认值为列表时可能出现的问题。
这意味着每次调用setdefault
时都要构造一个新的默认值出来。在本例中,这就相当于每次调用时,不管字典里有没有这个键,都得分配一个list
实例,这有可能产生比较大的性能开销。假如我们像刚才一样,试着复用这个表示默认值的对象(可能尝试这样做来提高效率和可读性),那么就有可能出现奇怪的效果和bug(第24条也讲了这样一个问题)。
回到之前那个只记录票数而不记录投票人的例子。那个例子为什么不用setdefault
改写呢?比如,可以这样写:
这样写的问题是,根本就没必要调用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
。