Python 3.15では組み込み型として変更不可能な辞書であるfrozendictクラスと、値が見つからないことや使えないことを意味する番兵値を表すsentinelクラスが追加される予定です。これらをどんなふうに使うのか、ちょっと調べてみました。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
どうもHPかわさきです。
Pythonには変更可能な集合を表すsetクラスと変更不可能な集合を表すfrozensetクラスがありました。このfrozensetクラスの辞書版ともいえるのがfrozendictクラスです(ホントかな?)。そして、コンテナオブジェクトから何かを探したり、与えられた引数の値をチェックしたりする際の番兵値を表すための新たな組み込み型としてsentinelクラスが登場します。これらについて、以下ではちょっと試してみることにしましょう。
Python 3.15では次の2つの組み込み型が追加される予定です。
frozendictクラスは要素の変更や追加ができない辞書(マッピング型)です。通常の辞書(dictクラス)は変更可能なオブジェクトであり、ハッシュ不可能でもあります。つまり、辞書を集合の要素にしたり、別の辞書のキーとして使ったりすることはできません。しかし、frozendictオブジェクトなら、集合の要素にしたり、他の辞書のキーにしたりできます(ただし、その値がリストのように変更可能なオブジェクトであれば、そうはいきません)。
sentinelクラスはいわゆる番兵値(sentinel value)を表現するために導入されました。番兵値とは、何らかの値が見つからない、存在しない、使えないといった状況を表すために使われます。PythonではNoneやobjectクラスのインスタンスを番兵値として使うことがよくありますが、それではうまくいかないときもあることから、新しくsentinelクラスが作られました。
以下では、順に見ていきましょう。
上でも述べましたが、frozendictオブジェクトは変更不可能な辞書(マッピング型)です。そのオブジェクトは辞書と同様な作り方をします。ただし、「fd = {}」は空の辞書を作るだけで、空のfrozendictオブジェクトを作るわけではない点には注意してください。
fd0 = frozendict() # 空のfrozendictオブジェクト
fd1 = frozendict({'k0': 'v0', 'k1': 'v1'}) # 辞書からfrozendictを作成
fd2 = frozendict(k0='v0', k1='v1') # キーワード引数を使う
fd3 = frozendict([('k1', 'v1'), ('k0', 'v0')]) # キー/値を要素とするリストを使う
fd4 = frozendict(fd3) # frozendictからfrozendictを作成
辞書と大きく違うのは、frozendictは変更不可能な点にあります。キー/値の組を追加したり、特定のキーの値を変更したりはできません。
fd1['k2'] = 'v2' # TypeError
ただし、値が変更可能なオブジェクトであれば、その内容は変更可能です。これはタプルの要素が変更可能なオブジェクトであれば、その内容を変更できることと同様です。
fd5 = frozendict({'key': ['foo', 'bar']})
print(fd5) # frozendict({'key': ['foo', 'bar']})
fd5['key'][0] = 'FOO'
print(fd5) # frozendict({'key': ['FOO', 'bar']})
frozendictオブジェクトは変更不可能なので、そのハッシュ値を計算できます(値が変更可能なオブジェクトでない場合)。以下では上で作成したfrozendictオブジェクトを使い回しています。
print(fd1) # frozendict({'k0': 'v0', 'k1': 'v1'})
h1 = hash(fd1)
print(h1) # 6894830583868851914など(実行時に変化する)
また、次のような特性がある点にも注意してください。
print(fd2) # frozendict({'k0': 'v0', 'k1': 'v1'})
print(fd3) # frozendict({'k1': 'v1', 'k0': 'v0'})
print(hash(fd2)) # 6894830583868851914など
print(hash(fd3)) # 6894830583868851914など
print(hash(fd2) == hash(fd3)) # True
print(fd2 == fd3) # True
2つのfrozendictオブジェクト、fd2とfd3はキー/値の組の並びが異なります(frozendictは要素の挿入順序を保持します。これはPython 3.7以降の通常の辞書と同様です)。ですが、hash関数の値は同じになり、これら2つのfrozendictを==演算子で比較すると同じ値であると判断されてTrueが返されます。
言い忘れていましたが、frozendictと通常の辞書の比較も可能です。以下に例を示します。
print(frozendict({'foo': 'FOO'}) == {'foo': 'FOO'}) # True
print(frozendict({'foo': 'FOO'}) == {'foo': 'FOO', 'bar': 'BAR'}) # False
このようにfrozendictと辞書は比較が可能ですが、これはfrozendictが辞書(dict)のサブクラスだからというわけではありません。
print(isinstance(fd0, dict)) # False
frozendictと辞書はともにcollections.abc.Mappingを実装しているのであって、frozendictと辞書の間に継承関係はありません。
一方、変更可能なオブジェクトを値とする要素を含んでいるfrozendictはハッシュ関数に渡すと例外が発生します。
print(fd5) # frozendict({'key': ['FOO', 'bar']})
hash(fd5) # TypeError
次にfrozendictオブジェクトを辞書のキーや集合の要素として使ってみましょう。
print(fd0) # frozendict()
print(fd1) # frozendict({'k0': 'v0', 'k1': 'v1'})
print(fd2) # frozendict({'k0': 'v0', 'k1': 'v1'})
print(fd3) # frozendict({'k1': 'v1', 'k0': 'v0'})
print(fd5) # frozendict({'key': ['FOO', 'bar']})
fd0は空のfrozendictです。fd1とfd2、fd3はキーも値も文字列(変更不可能)なfrozendictです(fd1とfd2はキーと値の並びが同じで、fd3は並びだけが違っています)。fd5は'key'の値が変更可能なオブジェクトになっているfrozendictです。これらを使って、まずは辞書のキーとしてみます。
# frozendictオブジェクトを辞書のキーに使う
d1 = {fd2: 'some value'}
print(d1) # {frozendict({'k0': 'v0', 'k1': 'v1'}): 'some value'}
d2 = {fd5: 'some value'} # TypeError
fd2はキーも値も変更不可能な文字列でした。そのため、これを辞書のキーにすることは問題ありませんでした。しかし、値がリストになっている要素を含むfd5を辞書のキーにするとTypeError例外が発生しました。
これは辞書ではなく、frozendictのキーにこれらを使う場合も同様です。
# frozendictオブジェクトをfrozendictオブジェクトのキーとする
fd6 = frozendict(d1)
print(fd6) # frozendict({frozendict({'k0': 'v0', 'k1': 'v1'}): 'some value'})
fd7 = frozendict({fd5: 'some value'}) # TypeError
最後に集合の要素とする場合です。ここでは空のfrozendictやキーと値の並びだけが異なるfd3も含めて、集合を作成しています。
# frozendictオブジェクトを集合の要素とする
s1 = set([fd0, fd1, fd2, fd3])
print(s1) # {frozendict(), frozendict({'k0': 'v0', 'k1': 'v1'})}
s2 = set([fd1, fd2, fd3, fd5]) # TypeError
「s1 = set([fd0, fd1, fd2, fd3])」として作成した集合には空のfrozendict(fd0)の他に要素は1つだけです。fd1とfd2は同じキーと値だけを含み、その並びも同一でしたが、fd3はキーと値は同じで並びは異なっていました。それでもハッシュ値が同じであることは上で確認しましたが、ここでも同一の要素として扱われていることに注意してください。
また、fd5を含めるとこれまでと同様にTypeError例外が発生します。
というのが、frozendictの基本的な性質といえるでしょう。本当は演算などについても触れた方がよいのですが、今回はここまでとしておきます。
どんなところでfrozendictは使えるかがなかなか分からないんですよね。シンプルに「変更できない」という点に注目して、さまざまな設定のデフォルト値を格納するといった使い方はありそうです。
sentinelクラスは番兵値を表すために使います。例えば、以下のコードについて考えてみます。これは値が見つからなかったことを意味する番兵値としてNoneを使おうというコードです。
d = {'foo': 'FOO', 'bar': 'BAR', 'baz': None}
target = 'baz'
result = d.get(target, None)
if result is None:
print(f'key: {target} does not exist')
else:
print(f'key: {target} exist, value: {result}')
ここでは辞書dのキー'baz'の値はNoneになっています(いかにもな例ですね)。このときに「d.get(target, None)」としてキー'baz'の値を探そうとすると、次のようになってしまいます。
これを避けるには、次のようなコードを書けます。
d = {'foo': 'FOO', 'bar': 'BAR', 'baz': None}
MISSING = object()
target = 'baz'
result = d.get(target, MISSING)
if result is MISSING:
print(f'key: {target} does not exist')
else:
print(f'key: {target} exist, value: {result}')
これは番兵値としてobject型のMISSINGというインスタンスを新規に作成して、getメソッドではキーが存在しなかったときに、それを返すように指定するものです。これにより、上のコードではキー'baz'が存在し、その値がNoneであることを判定できるようになります。
しかし、object型の値を番兵値として使うのがうまくないこともあります。
MISSING = object()
def greet(greeting='hello', to=MISSING):
if to is MISSING:
to = 'world'
print(f'{greeting} {to}')
greet() # hello world
greet(to='Python') # hello Python
greet(greeting='goodbye', to=None) # goodbye None
これはパラメーターtoに引数が与えられなかった場合には、その値がMISSINGとなる関数の定義です。そのため、Noneを渡しても、引数が与えられなかったとは見なされません。挙動も思った通りのものです。しかし、これに型ヒントを付けようとすると、少し困ったことになります。
from typing import Any
MISSING: Any = object()
def greet(greeting: str = 'hello', to: str | Any = MISSING) -> None:
if to is MISSING:
to = 'world'
print(f'{greeting} {to}')
greet() # hello world
greet(to='Python') # hello Python
greet(greeting='goodbye', to=None) # goodbye None
typing.Anyは「任意の型がAnyと互換性がある」ことを意味します。つまり、パラメーターtoは「文字列と任意の型を受け取る」ことを意味します。それって型チェックを実質的にはあきらめることになっちゃうということです。ちなみにAnyをobjectとしても結果は似たようなものです。Pythonのオブジェクトはobjectを継承しているので、実質的には全てのオブジェクトが許容されてしまいます。
このように従来のやり方では型ヒントを書く上でも問題が発生します。番兵を表すクラスを定義するのはありですが。
class Sentinel:
pass # 省略
MISSING = Sentinel()
def greet(greeting: str = 'hello', to: str | Sentinel = MISSING) -> None:
pass # 省略
しかし、こうしたクラスを毎回自分で定義するのも面倒です。そこで登場したのが組み込みのsentinelクラスというわけです。何より素晴らしいのは、sentinelクラスのオブジェクトは特別扱いされていて、型表現に含むことが可能な点です(Noneと同様ですね)。以下に例を示します。
MISSING = sentinel('MISSING')
def greet(greeting: str = 'hello', to: str | MISSING | None = MISSING) -> None:
if to is MISSING:
to = 'world'
print(f'{greeting} {to}')
greet() # hello world
greet(to='Python') # hello Python
greet(greeting='goodbye', to=None) # goodbye None
sentinelクラスのオブジェクトを作成するときには、引数としてそのオブジェクトが表す文字列を渡します。また、上でも述べたように「to: str | MISSING | None」のように型表現にsentinelオブジェクトをそのまま記述できます。こうすることで、Anyやobjectよりも精密にパラメーターの型を指定可能になります。
Python 3.15からは、番兵値を扱うときには、自分でSentinelクラスを定義するのではなく、sentinelクラスを使用するのがよいでしょう。
Copyright© Digital Advantage Corp. All Rights Reserved.