qtatsuの週報

初心者ですわぁ

【python】深いネストのdictから値を取り出す

前書き

例えば下のようなdictがあるとします。

d = {'category1': {'category2': {'sub_category1': {'sub_category2': {'numbers': [1, 2, 3]}}}}, 'category1b': {'category2b': {'sub_category1': {'sub_category2': {'numbers': [11, 12, 13]}}}}}

このdictから値を取り出すには、keyを辿らなければなりません。

>>> d['category1']['category2']['sub_category1']['sub_category2']['numbers']
[1, 2, 3]

これはかなり面倒ですし、可読性も低いです。 また、途中のkeyがどれかひとつでも存在しなければ、例外が発生します。

本来ならネストが深くならないようデータ構造を見直したり、dictではなくclassを使うなどの対策が適切だと思います。

しかし、既存のjsonを読み込んでdictに変換した...など、実装側ではどうにもならないことがあると思います。そのような場合に、深いネストのdictから値を取り出す方法をまとめました。

環境

バージョン
MacOS Big Sur 11.1
Python3 3.9.0
python-box 5.3.0
jmespath 0.10.0

参考リンク

1.単純なdictで頑張る

具体例

単純なdictで素直に書いた場合はどうなるか考えてみます。

d = {'category1': {'category2': {'sub_category1': {'sub_category2': {'numbers': [1, 2, 3]}}}}, 'category1b': {'category2b': {'sub_category1': {'sub_category2': {'numbers': [11, 12, 13]}}}}}

再掲になりますが、単純にkeyを辿るには、カッコを繋いで以下のように書きます。

>>> d['category1']['category2']['sub_category1']['sub_category2']['numbers']
[1, 2, 3]

ただし、途中のkeyが存在しなければ例外発生することに注意しなければなりません。

>>> d['category1']['category2']['NOT_EXISTING_KEY']['sub_category2']['numbers']

Traceback (most recent call last): .... KeyError: 'NOT_EXISTING_KEY'

これを回避する方法は一応あって、get(, {})を使って値を取り出すことです。 keyが存在しなければ、getの第二引数で指定した空のdict{}が渡され、それ以降は空振りし続けるという方法です。

>>> d.get('category1', {}).get('category2', {}).get('NOT_EXISTING_KEY', {}).get('sub_category2', {}).get('numbers', {})
{}

またはKeyError例外を拾うのもpythonっぽい書き方かなと思います。 こちらの方が意味は伝わりやすいですし、見た目もスッキリしているかなと思います。値が存在していなかった場合に渡す値もはっきりしています。

try:
     val = d['category1']['category2']['NOT_EXISTING_KEY']['sub_category2']['numbers']
except KeyError:
    val = 'NotFound'

print(val)
# OUT: NotFound

メリット

デメリット

  • []を連ねる書き方が非常に書きづらく、可読性も低い。
  • 再利用が難しい。
    • あるパスからあるパスまで...という部品を組み合わせて使うには、dictから順番に取り出す必要がある。
    • パスを変数に入れておくことができない。

(再利用の例)以下のように、大カテゴリだけ変更して、それ以降のサブカテゴリは同じkeyでアクセスしたい時。 サブカテゴリ以降のパスは2回同じ内容をコピペして使うしかない。

>>> temp = d['category1a']['category2a']  # 大カテゴリ
>>> temp['sub_category1']['sub_category2']['numbers']
[1, 2, 3]

>>> temp2 = d['category1b']['category2b']  # CHANGED
>>> temp2['sub_category1']['sub_category2']['numbers']
[5, 22, 31]

2. Box

具体例

割と有名で、2021現在もリリースされているライブラリです。

pipインストール後、以下のようにドットつなぎでkeyを指定できるdictのサブクラスを使うことができます。

>>> from box import Box
>>> mybox = Box(d)
>>> mybox.category1.category2.sub_category1.sub_category2.numbers
<BoxList: [1, 2, 3]>

dictの[]つなぎと比べ、Boxのドットつなぎは見た目以上に書きやすいです。

なぜなら、IDEvimプラグインを入れていれば、予測変換でkeyが表示されるからです。

ネストが深い場合に限らないのですが、基本的にdictが苦しくなってきたら、dataclassなど別のオブジェクトにする方がデータを追いやすいと、職場の先輩に教えていただきました。

存在しないキーへのアクセスは、専用のexceptionがraiseされます。それを利用してハンドルできます。(もっといい方法を後述します。)

>>> import box
>>> try:
...     mybox.category1.category2.NOT_EXISTING_KEY.sub_category2.numbers
... except box.exceptions.BoxKeyError:
...     'NotFound'
...     
... 
'NotFound'

個人的には、このBoxライブラリを使うことでネストされたdictの問題をほぼ全て解決できると思います。 「Box」のググラビリティがあまりに低いこと以外に、大きなデメリットはないと思います。

存在しないKey問題はdefault_boxでカスタムできる。

ドキュメントはこの辺です。

https://github.com/cdgriffith/Box/wiki/Types-of-Boxes

default_box引数をTrueにしてboxを作成します。そうすると、Keyが存在しない場合には空のBoxオブジェクトを返すようになります。

>>> mybox = Box(d, default_box=True)
>>> mybox.category1.category2.NOT_EXISTING_KEY.sub_category2.numbers
<Box: {}>

空のBoxオブジェクトはboolでFalse判定なので、値が取得できなかった場合の分岐も書きやすいです。

先述のBoxKeyErrorを使うより、こちらの方がスッキリ書けるケースも多いと思います。

キーつなぎ(パス)の再利用はbox_dotsで対処できる。

category1.category2とか、sub_category1.sub_category2.numbersなどの一連のキーの連なりを再利用したい場合、通常のdictの[]やBoxデフォルトのドットつなぎだと、基本的にコピペになります。

Boxには、キーの連なりを文字列で扱えるようにするオプションが用意されています。

>>> mybox = Box(d, box_dots=True)
>>> mybox['category1.category2.sub_category1.sub_category2.numbers']
<BoxList: [1, 2, 3]>

これで通常dictの項目で述べた再利用問題は解決します。変数にパスを入れておいて、使うときに文字列連結すれば良いです。

d = {'category1': {'category2': {'sub_category1': {'sub_category2': {'numbers': [1, 2, 3]}}}}, 'category1b': {'category2b': {'sub_category1': {'sub_category2': {'numbers': [11, 12, 13]}}}}}
mybox = Box(d, box_dots=True)

subcategory = 'sub_category1.sub_category2.numbers'

mybox['category1.category2.' + subcategory]
# OUT: <BoxList: [1, 2, 3]>

mybox['category1b.category2b.' + subcategory]
# OUT: <BoxList: [11, 12, 13]>

参考: Boxに類似したライブラリ

(AttrDictとbenedictは会社の同僚から教えていただきました。)

Boxと同じく、ドットアクセスを可能にしたdictのサブクラスを実装したライブラリは複数あります。

ただ、以下の観点から見ると、Boxが最も優れていると思います(個人的感想).

  1. Documentがしっかりしているか。
  2. 頻繁にリリースされているか。
  3. 深いネストに対応する機能が十分か。

3. jmespath

具体例

個人的におすすめなのがjmespathです。

これはdict側を改良したBoxとは異なり、普通のdictから値を取り出す便利な関数jmespath.searchを使うことでネスト問題を楽に解決することができます。

まず、ドットつなぎのアクセスは以下のように実行できます。 keyをつなげたパスは文字列です。そのため、先程のBoxで触れた例と同じく、パスの再利用は容易です。

>>> import jmespath
>>> jmespath.search('category1.category2.sub_category1.sub_category2.numbers', d)
[1, 2, 3]

存在しないkeyが入っていると、デフォルトでNoneが返ります。

>>> jmespath.search('category1.category2.NOT_EXISTING_KEY.sub_category2.numbers', d) is None
True

Boxと同じく、性能的には十分です。Boxと比較すると以下のようなメリット/デメリットがあるかな?と思います。

メリット

  • jmespathの記法を理解していれば、かなり柔軟なデータ操作が可能。(結合やソート、where句のような検索も可.)
  • listなどが入ってきても柔軟に対処可能。
  • pythonコード以外にも、terminalで利用するコマンドとしてパスを再利用できる。

デメリット

  • Pythonっぽくなく、初見の人にとっては可読性が低い。
  • とくにクエリはなんでもできてしまうため、複雑にしてしまいがち。
  • IDEvimプラグインを使っていても、ドットつなぎの予測変換ができない
    • あくまで単なる文字列のクエリを書くことになる。

まとめ

個人的にはjmespathが好きです。個人で何かを作るときは、jmespathを使うと思います。

ただ、複数人が関わるプロジェクトを新しく始めるならBoxを使う方がメンテしやすいかな?と思います。初見でも直感的に使いやすいですし、複雑なクエリが生まれることもないため保守性に優れていると思います。