内置的dict.get()有递归版本吗?

37

我有一个嵌套的字典对象,我想要能够检索任意深度键的值。我可以通过继承dict来实现这一点:

>>> class MyDict(dict):
...     def recursive_get(self, *args, **kwargs):
...         default = kwargs.get('default')
...         cursor = self
...         for a in args:
...             if cursor is default: break
...             cursor = cursor.get(a, default)
...         return cursor
... 
>>> d = MyDict(foo={'bar': 'baz'})
>>> d
{'foo': {'bar': 'baz'}}
>>> d.get('foo')
{'bar': 'baz'}
>>> d.recursive_get('foo')
{'bar': 'baz'}
>>> d.recursive_get('foo', 'bar')
'baz'
>>> d.recursive_get('bogus key', default='nonexistent key')
'nonexistent key'

然而,我不想通过子类化 dict 来实现这种行为。是否有一些内置方法具有等效或类似的行为?如果没有,是否有任何标准或外部模块提供此行为?

目前我正在使用 Python 2.7,但我也很想听听针对 3.x 的解决方案。


d.get('foo').get('bar')? - Foon
听起来你对使用你在问题中发布的代码实现的功能相当满意。你不想继承dict有什么特别的原因吗? - John Y
@Foon,它不能嵌套到任意深度,并且如果链中的某个键不存在,则会抛出异常(而不是返回默认值)。 - jayhendren
1
@JohnY - 只有一些原因 - 我希望有一种方法可以在字典对象上执行此操作,而无需将它们强制转换为MyDict对象,并且我很好奇是否可以在不使用子类化字典的情况下实现这一点。否则,子类化可以正常工作。 - jayhendren
9个回答

51

一个非常常见的模式是将一个空字典作为默认值:

d.get('foo', {}).get('bar')

如果您有超过几个键,您可以使用reduce函数(请注意,在Python 3中,reduce必须被导入: from functools import reduce)多次应用操作。

reduce(lambda c, k: c.get(k, {}), ['foo', 'bar'], d)

当然,您应该考虑将此包装为一个函数(或方法):

def recursive_get(d, *keys):
    return reduce(lambda c, k: c.get(k, {}), keys, d)

谢谢!我在想是否有一种Python惯用的方法来做到这一点;使用空字典作为get()的默认值并使用匿名函数似乎都是不错的惯用法。 - jayhendren
尽管这个回答解决了 OP 的问题,但我认为 jpp 的回答更加简洁。在某些情况下,引发一个 KeyError 错误比返回一个空字典更自然。此外,jpp 的答案更通用,因为它适用于嵌套字典、嵌套列表和两者混合的情况。 - normanius
这涵盖了大多数情况,但缺点是通常的 if d.get(k) is None 测试不再起作用,因为此实现不区分指向空字典的键和无法找到的键。 - Addison Klinke

28

@ThomasOrozco的解决方案是正确的,但使用了lambda函数,这只有在中间键不存在时才需要避免TypeError。如果这不是问题,可以直接使用dict.get

from functools import reduce

def get_from_dict(dataDict, mapList):
    """Iterate nested dictionary"""
    return reduce(dict.get, mapList, dataDict)

这是一个演示:

a = {'Alice': {'Car': {'Color': 'Blue'}}}  
path = ['Alice', 'Car', 'Color']
get_from_dict(a, path)  # 'Blue'
如果您希望比使用 lambda 更明确,同时避免引发 TypeError,则可以在 try / except 子句中进行包装:
def get_from_dict(dataDict, mapList):
    """Iterate nested dictionary"""
    try:
        return reduce(dict.get, mapList, dataDict)
    except TypeError:
        return None  # or some other default value

最后,如果您希望在任何级别上不存在键时引发KeyError,请使用operator.getitemdict.__getitem__

from functools import reduce
from operator import getitem

def getitem_from_dict(dataDict, mapList):
    """Iterate nested dictionary"""
    return reduce(getitem, mapList, dataDict)
    # or reduce(dict.__getitem__, mapList, dataDict)
请注意,[]__getitem__ 方法的语法糖。因此,这正是您通常访问字典值的方式。 operator 模块只是提供了一种更可读的访问该方法的方式。

1
请注意,这也适用于嵌套列表。如果任何索引超出范围,则使用getitem的变体将引发IndexError - normanius
1
更好的是,建议的答案可以用于包含嵌套列表和嵌套字典的字典,这在处理例如json数据时非常有用。 - normanius
1
我忘了提到元组和任何实现__getitem__方法的对象。 - normanius
这似乎也适用于pandas,尽管对于切片数据框的确切允许语法我不太清楚... - undefined

4

在Python 3中,由于其默认关键字参数和元组分解的处理方式,你可以非常简洁地实现这一点:

In [1]: def recursive_get(d, *args, default=None):
   ...:     if not args:
   ...:         return d
   ...:     key, *args = args
   ...:     return recursive_get(d.get(key, default), *args, default=default)
   ...: 

类似的代码在Python 2中也可以工作,但你需要退回到使用**kwargs,就像你在例子中所做的那样。你还需要使用索引来分解*args

无论如何,如果您打算将函数递归,就没有必要使用循环。

您可以看到以上代码展示了与您现有方法相同的功能:

In [2]: d = {'foo': {'bar': 'baz'}}

In [3]: recursive_get(d, 'foo')
Out[3]: {'bar': 'baz'}

In [4]: recursive_get(d, 'foo', 'bar')
Out[4]: 'baz'

In [5]: recursive_get(d, 'bogus key', default='nonexistent key')
Out[5]: 'nonexistent key'

1
你可以使用defaultdict在缺少键时提供一个空字典:
from collections import defaultdict
mydict = defaultdict(dict)

这只能往下一层 - mydict[missingkey] 是一个空字典,mydict[missingkey][missing key] 会引发 KeyError。你可以通过多个嵌套的 defaultdict 来添加所需的层数,例如 defaultdict(defaultdict(dict))。对于内部最深层,你也可以使用带有合适工厂函数的另一个 defaultdict ,以便符合你的使用场景,例如

mydict = defaultdict(defaultdict(lambda: 'big summer blowout'))

如果您需要任意深度的内容,则可以像这样进行:

def insanity():
    return defaultdict(insanity)

print(insanity()[0][0][0][0])

你会如何在 recursive_get() 函数中使用这个概念? - Addison Klinke

0

我不知道有没有这样的东西。然而,你根本不需要子类化字典,你可以编写一个函数,它接受一个字典、args和kwargs,并执行相同的操作:

 def recursive_get(d, *args, **kwargs):
     default = kwargs.get('default')
     cursor = d
     for a in args:
         if cursor is default: break
         cursor = recursive_get(cursor, a, default)
     return cursor 

使用方法如下

recursive_get(d, 'foo', 'bar')

你的示例引发了“RecursionError”。 - Addison Klinke

0

楼主要求以下行为

>>> d.recursive_get('bogus key', default='nonexistent key')
'nonexistent key'

截至2022年6月15日,没有任何一个被点赞的答案能够实现这一点,所以我修改了@ThomasOrozco的解决方案来解决这个问题。
from functools import reduce

def rget(d, *keys, default=None):
    """Use a sentinel to handle both missing keys AND alternate default values"""
    sentinel = {}
    v = reduce(lambda c, k: c.get(k, sentinel), keys, d)
    if v is sentinel:
        return default
    return v

以下是一个完整的、类似于单元测试的演示,展示了其他答案存在问题的地方。我已经根据每种方法的作者进行了命名。请注意,这个答案是唯一通过所有4个测试用例的答案,即:

  1. 当键树存在时进行基本检索
  2. 不存在的键树返回None
  3. None之外,还可以指定默认值
  4. 值为空字典的情况应该返回它们自己,而不是默认值
from functools import reduce


def thomas_orozco(d, *keys):
    return reduce(lambda c, k: c.get(k, {}), keys, d)


def jpp(dataDict, *mapList):
    """Same logic as thomas_orozco but exits at the first missing key instead of last"""
    try:
        return reduce(dict.get, *mapList, dataDict)
    except TypeError:
        return None


def sapi(d, *args, default=None):
    if not args:
        return d
    key, *args = args
    return sapi(d.get(key, default), *args, default=default)


def rget(d, *keys, default=None):
    sentinel = {}
    v = reduce(lambda c, k: c.get(k, sentinel), keys, d)
    if v is sentinel:
        return default
    return v


def assert_rget_behavior(func):
    """Unit tests for desired behavior of recursive dict.get()"""
    fail_count = 0

    # Basic retrieval when key-tree exists
    d = {'foo': {'bar': 'baz', 'empty': {}}}
    try:
        v = func(d, 'foo', 'bar')
        assert v == 'baz', f'Unexpected value {v} retrieved'
    except Exception as e:
        print(f'Case 1: Failed basic retrieval with {repr(e)}')
        fail_count += 1

    # Non-existent key-tree returns None
    try:
        v = func(d, 'bogus', 'key')
        assert v is None, f'Missing key retrieved as {v} instead of None'
    except Exception as e:
        print(f'Case 2: Failed missing retrieval with {repr(e)}')
        fail_count += 1

    # Option to specify a default aside from None
    default = 'alternate'
    try:
        v = func(d, 'bogus', 'key', default=default)
        assert v == default, f'Missing key retrieved as {v} instead of {default}'
    except Exception as e:
        print(f'Case 3: Failed default retrieval with {repr(e)}')
        fail_count += 1

    # Values which are an empty dict should return as themselves rather than the default
    try:
        v = func(d, 'foo', 'empty')
        assert v == {}, f'Empty dict value retrieved as {v} instead of {{}}'
    except Exception as e:
        print(f'Case 4: Failed retrieval of empty dict value with {repr(e)}')
        fail_count += 1

    # Success only if all pass
    if fail_count == 0:
        print('Passed all tests!')


if __name__ == '__main__':

    assert_rget_behavior(thomas_orozco)  # Fails cases 2 and 3
    assert_rget_behavior(jpp)  # Fails cases 1, 3, and 4
    assert_rget_behavior(sapi)  # Fails cases 2 and 3

    assert_rget_behavior(rget)  # Only one to pass all 3

我的意思是,严格来说,我问题中的实际请求是是否有一个模块大致做了我要求的事情:“是否有一些内置方法具有等效或类似的行为?如果没有,是否有任何标准或外部模块提供此行为?”因此,其他答案中的代码没有完全匹配问题中的示例用法并不是我关心的问题。 - jayhendren

0
softy为此提供了一个易读的界面。
import softy
d = softy.soften({'foo': {'bar': 'baz'}})

if d.foo.bar is not softy.null:
    print(f'd.foo.bar is {d.foo.bar}')
else:
    print('Nope, not there')

as_dict = softy.harden(d)

https://pypi.org/project/softy/

免责声明:我是softy的作者。

-1

迭代解决方案

def deep_get(d:dict, keys, default=None, create=True):
    if not keys:
        return default
    
    for key in keys[:-1]:
        if key in d:
            d = d[key]
        elif create:
            d[key] = {}
            d = d[key]
        else:
            return default
    
    key = keys[-1]
    
    if key in d:
        return d[key]
    elif create:
        d[key] = default
    
    return default


def deep_set(d:dict, keys, value, create=True):
    assert(keys)
    
    for key in keys[:-1]:
        if key in d:
            d = d[key]
        elif create:
            d[key] = {}
            d = d[key]
    
    d[keys[-1]] = value 
    return value

我正要在一个 Django 项目中测试它,使用以下代码:

keys = ('options', 'style', 'body', 'name')

val = deep_set(d, keys, deep_get(s, keys, 'dotted'))

-1

1
我想不出一种使它递归的方法。 - Mark Ransom
2
所以dict.get()也是如此。这不是我关心的行为。 - jayhendren
@jayhendren 看一下我的回答。 我已经调试了那些函数,现在它们正在生产中使用。回答在这里:https://dev59.com/814c5IYBdhLWcg3wLXrI#65842260 - D Left Adjoint to U

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接