第18条 学会利用 __ missing __ 构造依赖键的默认值

内置的 dict 类型提供了 setdefault 方法,在特殊场合可以用这个方法处理缺失的键,这样做要比其他方案少写一些代码(参见 第16条)。然而,对于一般情况来说,还是一个考虑内置的 collections 模块中的defaultdict 类型(参见 第17条)。当然,也有一些任务是 setdefault 和 defaultdict 都是处理不好的。

例如,我们要写一个程序,在文件系统里管理社交网络账号的图片。这个程序应该用字典把这些图片的路径名和相关的文件句柄关联起来,这样我们就能方便地读取图像来。下面,先用普通的 dict 实例实现。我们用 get 方法与赋值表达式(参见 第10条)结合起来,判断字典里面有没有当前操作的这个键。

pictures = {}
path = 'profile_1234.png'

if (handle := pictures.get(path)) is None:
    try:
        handle = open(path, 'a+b')
    except OSError:
        print(f'Failed to open the {path}')
        raise
    else:
        pictures[path] = handle
        
handle.seek(0)
image_data = handle.read()

如果字典里已经有这个 handle 里,那么这种写法只需要进行一次字典访问。如果没有,那么它会通过 get 方法访问一次字典,然后在 try/except/else 结构的 else 分支中做一次赋值(带 finally 到结构也可以那么做,参见 第65条)。读取数据的代码与打开文件并处理异常的代码可以分开写。

这套逻辑也能用 in 表达式或 KeyError 实现,但那两种方案的字典访问次数与代码嵌套层数都比较多。有人可能觉得,既然这套逻辑能用 get、in 与 KeyError 这三种方案实现,那么也应该可以用第四种方案,也就是 setdefault 方法来实现。

try:
    handle = pictures.setdefault(path, open(path, 'a+b'))
except OSError:
    print(f'Failed to open path {path}')
else:
    handle.seek(0)
    image_data = handle.read()

这样写有很多问题。首先,即便图片的路径名已经在字典里面了,程序还是得调用内置的 open 函数创建文件句柄,于是导致这个程序要给眼睛创建过 handle 的那份文件再度创建 handle (两者可能相互冲突)。另外,如果 try 块抛出异常,那我们可能无法判断这个异常是 open 函数导致的,还是 setdefault 方法导致的,因为这两次调用全都写在同一行代码里(其他类似字典的实现方案或许可以做到,参见 第 43 条)。

如果把这套逻辑用作内部状态的管理,会想到 defaultdict 来记录跟踪这些图片。下面用 defaultdict 类实现同样的逻辑,只不过这样需要写一个辅助函数。

from collections import defaultdict

def open_picture(profile_path):
    try:
        return open(profile_path, 'a+b')
    except OSError:
        print(f'Failed to open path{path}')
        raise
    
    
pictures = defaultdict(open_picture)
handle = pictures[path]
handle.seek(0)
image_data = handle.read()

程序出错的原因在于,传给 defaultdict 的那个函数只能是不需要参数的函数,而我们写的辅助函数却要求传入一个参数。defaultdict 并不知道当前要访问的这个键叫什么名字,所以没办法给辅助函数传递这个参数,这也意味我们没办法用这个参数去调用 open。在这种情况下, setdefault 和 defaultdict 都无法满足要求。

这种要求很常见,因此 Python 内置了一种解决方案,可以通过继承 dict 类型并实现 __ missing __ 特殊方法来解决这个问题。我们可以把字典不存在这个键时所要执行的逻辑写在这个方法中。下面定义一个新的类,让它利用刚才写的那个辅助函数来实现 __ missing __方法。

class Pictures(dict):
    def __missing__(self, key):
        value = open_picture(key)
        self[key] = value
        return value

      
pictures = Pictures()
handle = pictures[path]
handle.seek(0)
image_data = handle.read()

访问 pictures[path] 时,如果 picutres 字典里面里没有 path 这个键,那就会调用 __ missing __ 方法。这个方法必须根据 key 参数创建一份新的默认值,系统会把这个默认值插入字典并返回调用方。以后再访问 pictures[path], 就不会调用 __ missing __ 了,因为字典里面已经有了相应的键与值(类似的机制还体现在 __ getattr __ 中,参见 第47条)。