【Python】frozendict 深掘り — Python 3.15 の不変辞書の制約とエッジケースを実測

frozendict 深掘りを表したアイキャッチ画像
目次

frozendict は MappingProxyType と何が違うのか

  • Python 3.15 で追加された frozendict って、MappingProxyType と何が違うの?
  • dict の代わりに使える? 型ヒントや lru_cache ではどう書くの?
  • いざ使おうとしたら動かないケース(eval、copy など)を先に知りたい

対象読者: Python 3.14 までの知識で、3.15 の新機能を追っている方

前提知識: dict の基本操作と collections.abc.Mapping の存在が分かれば読めます

結論

作ったあとに変更できない辞書が、ビルトインの frozendict として使えます。キーと値が全部ハッシュ可能なら、インスタンス自体もハッシュ化されて dict のキーや lru_cache の引数に置けますよ。

cfg = frozendict(host="db.local", port=5432)

cfg["port"] = 5433
# TypeError: 'frozendict' object does not support item assignment

merged = cfg | frozendict(user="app")
print(merged)
# frozendict({'host': 'db.local', 'port': 5432, 'user': 'app'})

dict と何が違うのか

一番大きな違いは継承関係です。frozendict は dict のサブクラスではなく、object の直接の子孫です。実機で確認した結果がこちらです。

import json, pickle, copy
from collections.abc import Mapping

a = frozendict(x=1, y=2)
print("repr:", repr(a))

try:
    a["z"] = 3
except TypeError as e:
    print("assign:", e)

b = frozendict(y=2, x=1)
print("hash eq:", hash(a) == hash(b), "| == :", a == b)
print("is dict subclass:", issubclass(frozendict, dict))
print("isinstance dict:", isinstance(a, dict), "| Mapping:", isinstance(a, Mapping))

print("json:", json.dumps(a))
print("pickle roundtrip:", pickle.loads(pickle.dumps(a)))
print("copy.copy type:", type(copy.copy(a)).__name__)
print("dict(a):", dict(a))
Python 3.15.1 で frozendict の基本操作を確認した実行ログ
Python 3.15.1 で frozendict の基本操作を確認した実行ログ

つまり isinstance(f, dict) は False になります。既存コードで受け取り側を isinstance(arg, dict) と書いている箇所は、collections.abc.Mapping に置き換えるか (dict, frozendict) のタプルにします。等値比較は dict と互換性があって frozendict(x=1) == {"x": 1} は True、順序も等値比較には影響しません。

ハッシュ化と lru_cache

値が全部ハッシュ可能なら hash(frozendict) が動いて、ハッシュ値は項目の順序に依存しません。これが一番うれしい点で、PEP 814 の Rationale にも「dict を lru_cache に渡すとエラーになる」ことが動機として書かれています。

import functools

u = frozendict(x=1) | frozendict(y=2)
print("union:", u)

d = frozendict(x=1)
d |= frozendict(y=2)
print(d)

@functools.lru_cache
def f(cfg):
    return sum(cfg.values())

print("lru_cache fd:", f(frozendict(a=1, b=2)))

try:
    f({"a": 1, "b": 2})
except TypeError as e:
    print("lru_cache dict:", e)

# 実行結果(Python 3.15.1)
# union: frozendict({'x': 1, 'y': 2})
# frozendict({'x': 1, 'y': 2})
# lru_cache fd: 3
# lru_cache dict: unhashable type: 'dict'

| と |= も使えます。ただし |= は in-place 更新ではなく新しい frozendict を作るので、元のオブジェクトは変わりません。また CPython では copy() が自分自身を返す実装になっています(PEP 814 の仕様どおり)。

union 演算子と lru_cache での動作を確認した実行ログ
union 演算子と lru_cache での動作を確認した実行ログ

つまずきポイント

実測で1つ引っかかった点があります。eval() や exec() の globals に frozendict を渡すと、Python が __builtins__ を注入しようとして TypeError になります。What’s New には「eval は frozendict の globals を受け付ける」と書いてあるのですが、builtins の注入ができないので実質そのままでは使い物になりません。

import builtins

try:
    eval("x + y", frozendict(x=1, y=2))
except TypeError as e:
    print(e)

g = frozendict(x=1, y=2, __builtins__=builtins)
print("eval with __builtins__:", eval("x + y", g))

# 実行結果(Python 3.15.1)
# cannot assign __builtins__ to frozendict globals
# eval with __builtins__: 3

回避するには、自分で __builtins__ をキーに持たせてから渡す必要があります。設定を渡して式を評価する用途なら、これで動きますね。

eval の globals に frozendict を渡したときの挙動
eval の globals に frozendict を渡したときの挙動

もう1点は浅いコピーです。値にリストのような可変オブジェクトを入れた場合、不変なのはあくまで「辞書の構造」だけで、中身のオブジェクトまでは守りません。設定オブジェクトとして共有するなら、値もタプルなど不変の型に揃えておくのが安全です。

検証環境

  • Python 3.15.1(python.org 公式の Windows 64ビット embeddable 配布を一時ディレクトリに展開して実行)
  • OS: Windows 11
  • 実行したスクリプトとログは、各セクションの実行ログ画像にそのまま記載しています

【広告】XServerドメインならドメイン取得も運用もお得。

まとめ

frozendict は「dict と同じ操作感で、あとから変えられないし、状況によってはハッシュ化できる」型です。設定や定数の受け渡しでうっかり書き換えを防ぎたい場面で、MappingProxyType より素直に使えます。

  • dict のサブクラスではないので isinstance(arg, dict) を使っている既存コードには要注意
  • キーと値が全部ハッシュ可能なら dict のキーや lru_cache の引数にできる
  • eval / exec の globals に渡すときは __builtins__ を自分で入れる

参考

あわせて読みたい

【広告】このサイトはConoHa WINGで運営しています。安定した高速サーバーで快適にブログを書けています。いつもありがとう!!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次