【Python 每日一技】使用 ChainMap 合并多个字典或映射及 ChainMap 源码详解

1 问题

你有多个字典或映射,你希望可以先将它们在逻辑上合并为一个映射,然后对其进行值的查找或者检查键是否存在。

2. 解决方案

假设你有下列两个字典:

>>> a = {'x': 1, 'z': 3 }
>>> b = {'y': 2, 'z': 4 }

假定你需要执行对两个字典的查找操作,即先查找字典 a ,如果没找到再查找字典 b 。最简单的方式是使用 collections 模块的 ChainMap 类。例如:

>>> from collections import ChainMap
>>> c = ChainMap(a, b)
>>> c
ChainMap({'x': 1, 'z': 3}, {'y': 2, 'z': 4})
>>> c['x']
1
>>> c['y']
2
>>> c['z']
3

3. 讨论

使用 ChainMap 将多个字典或映射合并得到单个映射的操作是逻辑上的,实际上,使用 ChainMap 创建对象时,其底层实际上创建了一个列表,列表中的每个元素都是“合并”前的字典或映射。除此之外,ChainMap 还重写了字典操作的常见方法,这些方法会扫描列表中的每个字典或映射再做对应查询或修改操作。例如:

>>> len(c)
3
>>> c.keys()
KeysView(ChainMap({'x': 1, 'z': 3}, {'y': 2, 'z': 4}))
>>> list(c.keys())
['y', 'z', 'x']
>>> c.values()
ValuesView(ChainMap({'x': 1, 'z': 3}, {'y': 2, 'z': 4}))
>>> list(c.values())
[2, 3, 1]
>>> c['z']
3

由上述案例可知,如果有多个相同的键,则返回的结果中将仅包含第一个字典或映射中的该键对应的值。因此, c['z'] 返回的结果来自字典 a 而不是字典 b

实际上,对于 ChainMap 对象的修改类操作只会影响其中的第一个字典或映射。例如:

>>> c
ChainMap({'x': 1, 'z': 3}, {'y': 2, 'z': 4})
>>> c['z'] = 10
c
ChainMap({'x': 1, 'z': 10}, {'y': 2, 'z': 4})
>>> c['w'] = 40
>>> c
ChainMap({'x': 1, 'z': 10, 'w': 40}, {'y': 2, 'z': 4})
>>> a
{'x': 1, 'z': 10, 'w': 40}
>>> del c['x']
>>> c
>>> ChainMap({'z': 10, 'w': 40}, {'y': 2, 'z': 4})
>>> a
{'z': 10, 'w': 40}
>del c['y']
Traceback (most recent call last):
  ...
KeyError: "Key not found in the first mapping: 'y'"

对于本文开头提到的需求,你也可以考虑使用 update() 方法对字典进行合并。例如:

>>> a = {'x': 1, 'z': 3 }
>>> b = {'y': 2, 'z': 4 }
>>> merged = dict(b)
>>> merged.update(a)
>>> merged
{'y': 2, 'z': 3, 'x': 1}

上述实现方式也是可行的,只是该方式要么需要你创建一个全新的字典对象或在其中一个字典的基础上进行修改,此时,如果在原始字典上进行修改,则该修改不会反应在合并后的字典上。例如:

>>> a['x'] = 13
>>> merged['x']
1

ChainMap 的实例会使用原始的字典,即其中列表的每个元素引用原始的字典。例如:

>>> a = {'x': 1, 'z': 3 }
>>> b = {'y': 2, 'z': 4 }
>>> merged = ChainMap(a, b)
>>> merged['x']
1
>>> a['x'] = 42
>>> merged['x']
42

4. 源码分析

本文最后的附录是 collections.ChainMap 的源码,通过分析源码,我们除了能深入理解 ChainMap 的实现机制外,还能了解一个 ChainMap 对象的其他几个实用的方法,例如:new_child()parents 等。

4.1 __init__

def __init__(self, *maps):
    '''Initialize a ChainMap by setting *maps* to the given mappings.
    If no mappings are provided, a single empty dictionary is used.

    '''
    self.maps = list(maps) or [{}]  # always at least one map

创建 ChainMap 对象时调用的初始化方法支持接收多个字典或映射,然后会将其打包进实例属性 self.maps 中,如果创建对象时未指定任何字典或映射,此时形参 maps 为空元组,那么 self.maps 将会被初始化为 [{}] ,这一点可以通过下列代码来验证:

>>> list(tuple()) or [{}]
[{}]

4.2 __getitem__

def __getitem__(self, key):
    for mapping in self.maps:
        try:
            return mapping[key]  # can't use 'key in mapping' with defaultdict
        except KeyError:
            pass
    return self.__missing__(key)  # support subclasses that define __missing__

该方法用于支持任意的 ChainMap 对象 c 可以按照 c[key] 的语法查找值,根据源码也可以发现, ChainMap 底层的确会挨个查找多个字典或映射,即使有多个字典或映射中都存在某 key ,该方法也仅返回其中第一个字典或映射中 key 对应的值。

此外,当根据 key 在任何字典或映射中都无法查找成功,那么该方法内部会调用 __missing__ 方法,后者会抛出 KeyError 异常。

4.3 get

def get(self, key, default=None):
    return self[key] if key in self else default

该方法用于根据 key 查找值,而且该方法还接受一个默认为 Nonedefault 参数,如果查找失败则返回 default

4.4 __len__

def __len__(self):
    return len(set().union(*self.maps))  # reuses stored hash values if possible

支持通过 len(c) 的语法返回多个字典或映射中的键所组成集合的长度,对于该方法可以通过下列案例进行理解:

>>> a = {'x': 1, 'z': 3 }
>>> b = {'y': 2, 'z': 4 }
>>> set().union(a, b)
{'y', 'x', 'z'}

4.5 __iter__

def __iter__(self):
    d = {}
    for mapping in reversed(self.maps):
        d.update(mapping)  # reuses stored hash values if possible
    return iter(d)

支持通过 iter(c) 的语法返回一个迭代器。值得注意的是,该方法内部是先将列表 self.maps 进行反转然后依次调用字典的 update 方法,此举可以保证当 self.maps 中多个字典或映射中都包含某键时,最终通过迭代器得到的该键值对是来自第一个字典或映射中的,这样就保证了 ChainMap 对象的行为一致性。

4.6 __contains__

def __contains__(self, key):
    return any(key in m for m in self.maps)

支持通过 key in c 的语法判断键 key 是否存在于 c 中,其中 any 函数接受可迭代对象作为参数,如果迭代参数时任何一个元素 x 的 bool(x) 返回为 True ,那么该函数就返回为 True

4.7 __bool__

def __bool__(self):
    return any(self.maps)

支持根据 bool(c) 语法判断是否为 True 。需要指出的是,当 c = ChainMap() 时,使用 bool(c) 返回为 False,因为此时 self.maps[{}] ,此时 any 函数接受的可迭代对象就是一个列表,列表中仅有一个空字典 {} ,而 bool({}) 返回 False

4.8 __repr__

@_recursive_repr()
def __repr__(self):
    return f'{self.__class__.__name__}({", ".join(map(repr, self.maps))})'

用于返回 ChainMap 对象的无歧义表现形式。

4.9 fromkeys

@classmethod
def fromkeys(cls, iterable, *args):
    'Create a ChainMap with a single dict created from the iterable.'
    return cls(dict.fromkeys(iterable, *args))

用于先根据字典的类方法 dict.fromkeys() 创建一个字典,然后通过该字典创建一个 ChainMap

4.10 new_child

def new_child(self, m=None):  # like Django's Context.push()
    '''New ChainMap with a new map followed by all previous maps.
    If no map is provided, an empty dict is used.
    '''
    if m is None:
        m = {}
    return self.__class__(m, *self.maps)

用于创建一个新的 ChainMap 对象,创建后的对象中,列表 self.maps 的第一个字典或映射由键值对参数 m 指定(如果未指定 m 则默认为空字典 {} ),后续所有字典或映射和之前保持一致。需要注意的是,这里的 *self.maps 表示解包,即传入 __init__ 方法中的参数可以理解为是 [m] + self.maps

4.11 parents

@property
def parents(self):                          # like Django's Context.pop()
    'New ChainMap from maps[1:].'
    return self.__class__(*self.maps[1:])

用于根据 self.maps[1:] 创建一个新的 ChainMap 对象。

4.12 __setitem__

def __setitem__(self, key, value):
    self.maps[0][key] = value

用于根据 c[key] 设置键值对,由源码可知,该操作仅对第一个字典或映射有效。

4.13 __delitem__

def __delitem__(self, key):
    try:
        del self.maps[0][key]
    except KeyError:
        raise KeyError('Key not found in the first mapping: {!r}'.format(key))

用于使用 del c[key] 语法从第一个字典或映射中删除键值对,如删除失败则抛出异常。

4.14 __popitem__

def popitem(self):
    'Remove and return an item pair from maps[0]. Raise KeyError is maps[0] is empty.'
    try:
        return self.maps[0].popitem()
    except KeyError:
        raise KeyError('No keys found in the first mapping.')

用于从第一个字典或映射中弹出一个键值对,如果此时第一个字典或映射为空,则抛出异常。

4.15 pop

def pop(self, key, *args):
    'Remove *key* from maps[0] and return its value. Raise KeyError if *key* not in maps[0].'
    try:
        return self.maps[0].pop(key, *args)
    except KeyError:
        raise KeyError('Key not found in the first mapping: {!r}'.format(key))

用于从第一个字典或映射中删除 key 并返回对应的值。

4.16 clear

def clear(self):
    'Clear maps[0], leaving maps[1:] intact.'
    self.maps[0].clear()

用于清空第一个字典或映射,不改变其他字典或映射。

5. 附录

########################################################################
###  ChainMap
########################################################################

class ChainMap(_collections_abc.MutableMapping):
    ''' A ChainMap groups multiple dicts (or other mappings) together
    to create a single, updateable view.

    The underlying mappings are stored in a list.  That list is public and can
    be accessed or updated using the *maps* attribute.  There is no other
    state.

    Lookups search the underlying mappings successively until a key is found.
    In contrast, writes, updates, and deletions only operate on the first
    mapping.

    '''

    def __init__(self, *maps):
        '''Initialize a ChainMap by setting *maps* to the given mappings.
        If no mappings are provided, a single empty dictionary is used.

        '''
        self.maps = list(maps) or [{}]  # always at least one map

    def __missing__(self, key):
        raise KeyError(key)

    def __getitem__(self, key):
        for mapping in self.maps:
            try:
                return mapping[key]  # can't use 'key in mapping' with defaultdict
            except KeyError:
                pass
        return self.__missing__(key)  # support subclasses that define __missing__

    def get(self, key, default=None):
        return self[key] if key in self else default

    def __len__(self):
        return len(set().union(*self.maps))  # reuses stored hash values if possible

    def __iter__(self):
        d = {}
        for mapping in reversed(self.maps):
            d.update(mapping)  # reuses stored hash values if possible
        return iter(d)

    def __contains__(self, key):
        return any(key in m for m in self.maps)

    def __bool__(self):
        return any(self.maps)

    @_recursive_repr()
    def __repr__(self):
        return f'{self.__class__.__name__}({", ".join(map(repr, self.maps))})'

    @classmethod
    def fromkeys(cls, iterable, *args):
        'Create a ChainMap with a single dict created from the iterable.'
        return cls(dict.fromkeys(iterable, *args))

    def copy(self):
        'New ChainMap or subclass with a new copy of maps[0] and refs to maps[1:]'
        return self.__class__(self.maps[0].copy(), *self.maps[1:])

    __copy__ = copy

    def new_child(self, m=None):  # like Django's Context.push()
        '''New ChainMap with a new map followed by all previous maps.
        If no map is provided, an empty dict is used.
        '''
        if m is None:
            m = {}
        return self.__class__(m, *self.maps)

    @property
    def parents(self):  # like Django's Context.pop()
        'New ChainMap from maps[1:].'
        return self.__class__(*self.maps[1:])

    def __setitem__(self, key, value):
        self.maps[0][key] = value

    def __delitem__(self, key):
        try:
            del self.maps[0][key]
        except KeyError:
            raise KeyError('Key not found in the first mapping: {!r}'.format(key))

    def popitem(self):
        'Remove and return an item pair from maps[0]. Raise KeyError is maps[0] is empty.'
        try:
            return self.maps[0].popitem()
        except KeyError:
            raise KeyError('No keys found in the first mapping.')

    def pop(self, key, *args):
        'Remove *key* from maps[0] and return its value. Raise KeyError if *key* not in maps[0].'
        try:
            return self.maps[0].pop(key, *args)
        except KeyError:
            raise KeyError('Key not found in the first mapping: {!r}'.format(key))

    def clear(self):
        'Clear maps[0], leaving maps[1:] intact.'
        self.maps[0].clear()
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值