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