90
130

Python初心者がマスターしておきたい辞書の活用テクニック10選!

Posted at

はじめに

みずほリサーチ&テクノロジーズの @fujine です。

本記事では、Pythonの辞書をより便利に使うためのテクニック10個を解説していきます。基本的な操作も多く含まれていますが、

  • パフォーマンスを意識して実装する
  • キーの数に依存せず、短い行数で簡潔に実装する
  • 辞書の可読性を高める

ことに重点を置きました。Pythonの辞書に一通り慣れた初心者が、チーム開発やより複雑なプログラム開発へステップアップする際に活用いただけたらと思います。

  1. 辞書を作成する
  2. 複数の辞書を1つに結合する
  3. 初期値を持つ辞書を作成する
  4. 辞書の要素を取得・更新・追加・削除する
  5. 要素の追加と取得を一度に実行する
  6. 辞書から複数の要素を同時に取得する
  7. 辞書をキーや値でソートする
  8. 辞書を読み取り専用にする
  9. カスタム辞書を作成する
  10. 型ヒントを導入する

実行環境はWindows10(64bit) 22H2、Pythonバージョンは以下の通りです。

$ python -V
Python 3.12.0

辞書を作成する

波括弧{}、もしくは組み込み関数dict()で辞書を作成できます。

d = {"a": 1, "b": 3.14, "c": []}
d = dict(a=1, b=3.14, c=[])

辞書の作成時間は {}の方が3倍近く高速 です。大量の辞書を作成する場合やパフォーマンス重視のアプリケーションでは、{}を使用しましょう。

>python -m timeit "{}"
1000000 loops, best of 5: 183 nsec per loop
>python -m timeit "dict()"
500000 loops, best of 5: 617 nsec per loop

辞書内包表記でも辞書を作成できます。リスト等のシーケンスオブジェクトを辞書に変換する時に便利です。

keys = ["a", "b", "c"]
values = [1, 3.14, None]

{k: v for k, v in zip(keys, values)}  # {'a': 1, 'b': 3.14, 'c': None}

辞書と集合(set)はどちらも同じ波括弧({})を使用するため、辞書内包表記にしたつもりが集合内包表記とならないよう注意しましょう。

d = {k for k, v in zip(keys, values)}  # {k: v for ...}のタイプミス例
d["a"]  # TypeError: 'set' object is not subscriptable

複数の辞書を1つに結合する

複数の辞書を結合して、新たな辞書を生成できます。
説明にあたり、結合前の辞書を3つ用意します。

d1 = {"a": 1, "b": 3.14}
d2 = {"b": 2.714, "c": None}
d3 = {"d": []}

|演算子で辞書同士を結合した新たな辞書オブジェクトを生成できます。キーが重複する場合、後から結合した値で上書きされます。3つ以上の結合も可能です。

d1 | d2  # {'a': 1, 'b': 2.714, 'c': None}

d2 | d1  # {'b': 3.14, 'c': None, 'a': 1}

d1 | d2 | d3  # {'a': 1, 'b': 2.714, 'c': None, 'd': []}

|演算子による結合はPython3.9から利用可能です。それ以前のバージョンでは、以下の実装でも同じ結果を得ることができます。

{**d1, **d2}  # {'a': 1, 'b': 2.714, 'c': None}

updateメソッドでも結合可能です。|演算子との違いとして、updateはインプレース操作です。また、キーが重複した場合はTypeErrorが送出されます。

d = {}
d.update(**d1, **d2)  # TypeError: dict.update() got multiple values for keyword argument 'b'
  • 予期しない上書きを検知したい場合はupdate
  • 上書きを許容する場合は|演算子

という使い分けが良さそうです。

初期値を持つ辞書を作成する

よくある操作の1つに、「辞書に特定のキーが存在しない場合、そのキーと値(初期値)を設定する」があります。

以下のように愚直に代入することもできますが、

  • キーの数が多いとコードが長くなり保守しづらい
  • 処理結果の辞書はキー順序が不定になる

という問題があります。

d_input = {"b": 2.714, "d": []}

if "a" not in d_input:
    d_input["a"] = 1


if "b" not in d_input:
    d_input["b"] = 3.14


if "c" not in d_input:
    d_input["c"] = None

# 本当はa, b, c, dのキー順にしたい
d_input  # {'b': 2.714, 'd': [], 'a': 1, 'c': None}

代わりに、初期値を持ったd_initを定義して入力データd_inputとマージしましょう。これなら、初期値が複数ある場合も一括設定できる上、キーの順序もコントロールできます

d_init = {"a": 1, "b": 3.14, "c": None}

d_init | d_input  # {'a': 1, 'b': 2.714, 'c': None, 'd': []}

全てのキーで初期値が同じ場合は、fromkeysクラスメソッドを使いましょう。第1引数にキーのシーケンスオブジェクト、第2引数に初期値の値(省略時はNone)を設定することで、全てのキーの同じ値の辞書を生成できます。

keys = ("a", "b", "c")
{}.fromkeys(keys)     # {'a': None, 'b': None, 'c': None}
{}.fromkeys(keys, 0)  # {'a': 0, 'b': 0, 'c': 0}

パフォーマンスは、辞書内包表記とfromkeysのどちらも同程度です。

>python -m timeit "{k: None for k in range(100000)}"
5 loops, best of 5: 79.6 msec per loop
>python -m timeit "{}.fromkeys(range(100000))"     
5 loops, best of 5: 81.5 msec per loop

fromkeysの初期値はイミュータブルオブジェクトのみを指定して下さい。リストや辞書のようなミュータブルオブジェクトを指定すると、辞書の値は全て同じオブジェクトを参照するようになります。

d = {}.fromkeys(("a", "b", "c"), [])   # 初期値にリストを指定
d["a"] is d["b"] is d["c"]             # True

ミュータブルオブジェクトを初期値にしたい場合は、代わりに辞書内包表記を使用しましょう。

d = {k: [] for k in ("a", "b", "c")}
d["a"] is d["b"] is d["c"]            # False

辞書の要素を取得・更新・追加・削除する

辞書の基本的な取得、更新、追加、削除の操作は以下の通りです。

d = {"a": 1}

# 取得
d["a"]  # 1

# 更新
d["a"] *= 2
d["a"]  # 2

# 追加
d["b"] = 3.14

# 削除
del d["a"]
"a" in d  # False

存在しないキーを取得しようとするとKeyErrorが送出されます。
getメソッドを使えば、キーが存在しない場合はデフォルト値(省略時はNone)が応答され、例外は発生しません。

d["a"]              # KeyError: 'a'
d.get("a") is None  # True
d.get("a", 1)       # 1

存在しないキーを削除しようとしてもKeyErrorが送出されます。
popメソッドを使うと、キーが存在しない場合は代わりにデフォルト値が応答されます。

del d["a"]     # KeyError: 'a'
d.pop("a", 1)  # 1

getと異なり、popではキーが存在せずデフォルト値も指定されない場合はNoneではなくKeyErrorが送出されます。popではデフォルト値を指定しましょう。

d.pop("a")  # KeyError: 'a'

要素の追加と取得を一度に実行する

よくある操作の1つに、「特定のキーがあれば値を取得し、なければキーと初期値を追加してから値を取得する」というのもあります。

以下にキー名と初期値を定義します。

d = {}
key = "a"
default = 1

よくある実装例は以下です。if文でキーの有無を判定して値を追加しています。

if key not in d:
    d[key] = default

d[key]  # 1

setdefaultメソッドを使えば、同じ処理を1行でシンプルに実装できます。if文は必要ありません。

d.setdefault(key, default)  # 1

setdefaultは便利ですが、setなのにgetもしており、命名があまり直感的ではありません。私はsetdefault → set default (and get)という独自の覚え方をしています。

辞書から複数の要素を同時に取得する

通常、辞書に指定可能なキーは一度に1つのみです。d[key]では複数の値を同時に指定・取得することはできません。

d = {"a": 1, "b": 3.14, "c": []}

d["a"]       # 1
d["a", "b"]  # KeyError: ('a', 'b')

operator.itemgetterを使えば、複数の値を同時に取得することが可能です。itemgetterの引数にキーを複数指定すると、指定したキーの順番で値を取得できます。

from operator import itemgetter

itemgetter("a")(d)            # 1
itemgetter("a", "b")(d)       # (1, 3.14)
itemgetter("c", "b", "a")(d)  # ([], 3.14, 1)

これを応用すれば、辞書から特定のキーのみをフィルタリングすることも簡単にできます。

from string import ascii_lowercase

# キーが英小文字、値が英大文字の辞書(長さ26)を作成
d = {k: k.upper() for k in ascii_lowercase}  

# 検索キーをa, j, eとし、検索キーの順序を保持しつつフィルタリング
keys = ("a", "j", "e")
values = itemgetter(*keys)(d)
dict(zip(keys, values))  # {'a': 'A', 'j': 'J', 'e': 'E'}

辞書をキーや値でソートする

辞書dに対し、sorted(d)では昇順ソートされたキー名だけが出力されます(これはsorted(d.keys())と同じです)。
sorted(d.items())とすれば、キーと値の両方が出力されます。この場合、d.items()によりキー・値のペア(key, value)が展開され、その0番目であるkeyがソート対象となります。

d = {"a": 1, "c": 0, "b": -1}
sorted(d)          # ['a', 'b', 'c']
sorted(d.items())  # [('a', 1), ('b', -1), ('c', 0)]

この仕組みを利用し、辞書内包表記とdict()の2通りでキー順によるソートが可能です。

# キーで昇順ソート
{k: d[k] for k in sorted(d)}  # {'a': 1, 'b': -1, 'c': 0}
dict(sorted(d.items()))       # {'a': 1, 'b': -1, 'c': 0}

# キーで降順ソート
{k: d[k] for k in sorted(d, reverse=True)}  # {'c': 0, 'b': -1, 'a': 1}
dict(sorted(d.items(), reverse=True))       # {'c': 0, 'b': -1, 'a': 1}

値でソートする場合は、sortedkey引数を用います。lambda関数かitemgetter(1)を使用して、値をソート対象とします。

# 値で昇順ソート
{k: d[k] for k in sorted(d, key=lambda x: d[x])}  # {'b': -1, 'c': 0, 'a': 1}
dict(sorted(d.items(), key=lambda x: x[1]))       # {'b': -1, 'c': 0, 'a': 1}
dict(sorted(d.items(), key=itemgetter(1)))        # {'b': -1, 'c': 0, 'a': 1}

# 値で降順ソート
{k: d[k] for k in sorted(d, key=lambda x: d[x], reverse=True)}  # {'a': 1, 'c': 0, 'b': -1}
dict(sorted(d.items(), key=lambda x: x[1], reverse=True))       # {'a': 1, 'c': 0, 'b': -1}
dict(sorted(d.items(), key=itemgetter(1), reverse=True))        # {'a': 1, 'c': 0, 'b': -1}

パフォーマンスを比較すると、キーと値のどちらでソートする場合も、辞書内包表記の方が速い結果となりました。これは、d.items()でキーと値のペアを生成する処理コストの方が大きいためと考えられます。

>python -m timeit -s "d = {i: i for i in range(100000, 0, -1)}" "{k: d[k] for k in sorted(d)}"
20 loops, best of 5: 12.3 msec per loop
>python -m timeit -s "d = {i: i for i in range(100000, 0, -1)}" "dict(sorted(d.items()))"
5 loops, best of 5: 94.2 msec per loop

>python -m timeit -s "d = {i: i for i in range(100000, 0, -1)}" "{k: d[k] for k in sorted(d, key=lambda x: d[x])}"
10 loops, best of 5: 20.6 msec per loop
>python -m timeit -s "from operator import itemgetter; d = {i: i for i in range(100000, 0, -1)}" "dict(sorted(d.items(), key=itemgetter(1)))"
2 loops, best of 5: 99.3 msec per loop

辞書を読み取り専用にする

types.MappingProxyTypeを使うことで、辞書の読み取り専用ビューを作成することができます。ビューに対しては参照操作のみが可能です。

from types import MappingProxyType

d = {"a": 1}
view = MappingProxyType(d)

# 参照操作は通常の辞書と同じ
"a" in view          # True
view["a"]            # 1
view.get("b", 3.14)  # 3.14
view.items()         # dict_items([('a', 1)])

# ビューに対する更新・追加・削除はTypeError
view["a"] = 2     # TypeError: 'mappingproxy' object does not support item assignment
view["b"] = 3.14  # TypeError: 'mappingproxy' object does not support item assignment
del view["a"]     # TypeError: 'mappingproxy' object does not support item deletion

# ビューに対する和集合演算は、新たな辞書オブジェクトを生成する
view | {"b": 3.14}  # {'a': 2, 'b': 3.14}

# マッピング元オブジェクトの変更はビューにも反映される
d["a"] = 2
view["a"]   # 2

更新・追加・削除操作はTypeErrorとなるため、「辞書オブジェクトへの予期しない変更」をガードしたい場面で効果的です。

カスタム辞書を作成する

辞書に独自の処理をカスタムしたい場合は、collections.UserDictを使用します。

例えば、KeyError発生時に現在のキー情報を例外メッセージに加えたFriendlyDictクラスを定義してみます。UserDictを継承し、キーが存在しない場合に呼び出される__missing__()特殊メソッド内の例外メッセージをオーバーライドします。

from collections import UserDict

class FriendlyDict(UserDict):
    def __missing__(self, key):
        raise KeyError(f"'{key}' not found, keys are {tuple(self.keys())}")

存在しないキーでアクセスすると、期待通りの例外メッセージを得ることができました。既存のキー情報を出力することで、デバッグの効率化にもつながりそうです。

fd = FriendlyDict(a=1, b=3.14)
fd["c"]  # KeyError: "'c' not found, keys are ('a', 'b')"

UserDictの代わりに、組み込みクラスであるdictを継承する方法もあります。UserDictを使用したのは、特殊メソッドのオーバーライドによる他特殊メソッドへの意図しない影響を最小化するためです。

型ヒントを導入する

辞書に型ヒントを定義すると、想定する型や値を明示することができ、プログラムの可読性が向上します。また、型ヒントチェッカーにより想定外データの混入が検知できるため、バグや不良を早期発見しやすくなります。

pythonの型ヒントは静的解析時のみ検査されるため、プログラム実行時には検査されず、エラーも発生しません。
実行時にも型を検査したい場合は、pydanticなどの外部パッケージをご使用下さい。

型ヒントチェッカーとして、mypyをインストールします。

$ pip install mypy

まずはシンプルに、変数: dictとしてみましょう。この変数に辞書以外のオブジェクトを代入するとチェックエラーになります。

d: dict = {}  # OK
d: dict = 1   # error: Incompatible types in assignment

続いて、辞書のキーと値のデータ型を指定します。任意のデータ型はAny、複数のデータ型を許容する場合は|で連結します。

from typing import Any

d: dict[str, Any] = {"a": 1}  # OK
d: dict[str, Any] = {1: 1}    # error: Dict entry 0 has incompatible type "int": "int"; expected "str": "Any"

d: dict[str, int | float] = {"a": 1, "b": 3.14}  # OK
d: dict[str, int | None] = {"a": 1, "c": None}   # OK
d: dict[str, int | float] = {"c": None}          # error: Dict entry 0 has incompatible type "str": "None"; expected "str": "int | float"

キーや値に特定のリテラル値のみを許容するにはLiteralを使用します。指定したリテラル値以外の代入はチェックエラーになります。LiteralfloatAny、変数や式を指定することはできません。
なおAllowedKeysAllowedValuesは辞書の全要素に適用されるため、特定のキーに特定のリテラル値を制約付けたい場合は後述するTypedDictを使用します。

from typing import Literal

AllowedKeys = Literal["a", "b"]    # OK
AValues = Literal[1, 2, 3]         # OK
#BValues = Literal[3.14]           # error: Parameter 2 of Literal[...] cannot be of type "float"
CValues = Literal[None]            # OK
AllowedValues = AValues | CValues

d: dict[AllowedKeys, AllowedValues] = {"a": 1, "b": None}  # OK

TypedDictを継承したクラスを定義することで、キー毎にデータ型を指定することができます。デフォルトでは全てのキーは必須となります。

from typing import TypedDict

class Sample(TypedDict):
    a: int
    b: float

d: Sample = {"a": 1, "b": 3.14}  # OK
d: Sample = {"a": 1}             # error: Missing key "b" for TypedDict "Sample"
d: Sample = {"a": []}            # error: Incompatible types (expression has type "None", TypedDict item "b" has type "float")

クラス継承の引数にtotal=Falseを追加すると、全てのキーは任意となります。

class Sample2(TypedDict, total=False):
    a: int
    b: float

d: Sample2 = {"a": 1, "b": 3.14}  # OK
d: Sample2 = {"a": 1}             # OK
d: Sample2 = {}                   # OK

必須と任意を混在させるには、RequiredNotRequiredを使用します。abを必須とし、cのみ任意とする場合は以下のように定義します。

from typing import NotRequired, Required, TypedDict

class Sample3(TypedDict):
    a: int
    b: float
    c: NotRequired[list]

d: Sample3 = {"a": 1, "b": 3.14, "c": []}  # OK
d: Sample3 = {"a": 1, "b": 3.14}           # OK
d: Sample3 = {"a": 1}                      # error: Missing key "b" for TypedDict "Sample3"

このSample3クラスは、以下のように定義しても同じです。
「必須キーと任意キーのどちらが多いか」によって使い分けても良いでしょう。

class Sample3(TypedDict, total=False):
    a: Required[int]
    b: Required[float]
    c: list

まとめ

本記事では、辞書を活用するためのテクニックを10個紹介させていただきました。

初心者のみならず、辞書を使い慣れた中級者の方にとっても、新たな発見が1つでもありましたら幸いです。

90
130
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
90
130