Python の set vs frozenset(使い分けの基準)
set と frozenset はどちらも集合を表すが、可変性(mutability)に違いがある。この違いが使い分けの基準になる。
可変性の違い
set は変更可能で、frozenset は変更不可能だ。
# set は変更できる
s = {1, 2, 3}
s.add(4)
s.remove(1)
print(s) # {2, 3, 4}
# frozenset は変更できない
fs = frozenset([1, 2, 3])
# fs.add(4) # AttributeError
# fs.remove(1) # AttributeError
frozenset には add(), remove(), update() などの変更メソッドが存在しない。
add, remove, update, clear など変更メソッドあり
変更メソッドなし。作成後は要素を変えられない
ハッシュ可能性
frozenset はハッシュ可能なので、辞書のキーや別の集合の要素にできる。
# frozenset を辞書のキーに
permissions = {
frozenset(["read"]): "viewer",
frozenset(["read", "write"]): "editor",
frozenset(["read", "write", "admin"]): "admin"
}
user_perms = frozenset(["read", "write"])
print(permissions[user_perms]) # editor
# set はキーにできない
# {{"read", "write"}: "editor"} # TypeError
「集合の集合」も frozenset なら作れる。
# 冪集合(すべての部分集合の集合)
power_set = {
frozenset(),
frozenset([1]),
frozenset([2]),
frozenset([1, 2])
}
print(power_set)
set はハッシュ不可能なので、これはできない。
集合演算の結果
両者とも集合演算ができるが、結果の型は左オペランドに依存する。
s = {1, 2, 3}
fs = frozenset([3, 4, 5])
# set が左だと set が返る
print(type(s | fs)) # <class 'set'>
print(type(s & fs)) # <class 'set'>
# frozenset が左だと frozenset が返る
print(type(fs | s)) # <class 'frozenset'>
print(type(fs & s)) # <class 'frozenset'>
メソッドの場合も同様に、呼び出し元の型が維持される。
s = {1, 2, 3}
fs = frozenset([3, 4, 5])
print(type(s.union(fs))) # <class 'set'>
print(type(fs.union(s))) # <class 'frozenset'>
変更が必要な場合の対処
frozenset の内容を変更したい場合は、一度 set に変換する。
fs = frozenset([1, 2, 3])
# 変更したい場合
temp = set(fs)
temp.add(4)
temp.remove(1)
# 再び frozenset に
fs = frozenset(temp)
print(fs) # frozenset({2, 3, 4})
毎回変換するのはコストがかかるため、頻繁に変更するなら最初から set を使うべきだ。
使い分けの基準
要素の追加・削除が必要。動的に内容が変わる。一時的な作業用の集合。
辞書のキーにしたい。集合の集合を作りたい。不変性を保証したい。関数の引数のデフォルト値にしたい。
デフォルト引数としての利用
ミュータブルなデフォルト引数は Python のよくある落とし穴だ。frozenset ならこの問題を回避できる。
# 危険な例
def bad_func(exclude=set()):
exclude.add("default")
return exclude
print(bad_func()) # {'default'}
print(bad_func()) # {'default', 'default'}(意図しない動作)
# 安全な例
def good_func(exclude=frozenset()):
working = set(exclude)
working.add("default")
return working
print(good_func()) # {'default'}
print(good_func()) # {'default'}(期待どおり)
frozenset はイミュータブルなので、デフォルト値として安全に使える。
キャッシュとの相性
functools.lru_cache はハッシュ可能な引数しか受け付けない。set を引数にしたい場合は frozenset に変換する。
from functools import lru_cache
@lru_cache(maxsize=100)
def expensive_calculation(items: frozenset):
return sum(items) ** 2
# frozenset を渡す
result = expensive_calculation(frozenset([1, 2, 3]))
print(result) # 36
# set は直接渡せない
# expensive_calculation({1, 2, 3}) # TypeError
呼び出し側で変換するか、ラッパー関数を作るとよい。
def calculate(items):
return expensive_calculation(frozenset(items))
print(calculate([1, 2, 3])) # 36
print(calculate({1, 2, 3})) # 36
パフォーマンスの違い
メモリ使用量と検索速度は、set と frozenset でほぼ同等だ。
import sys
s = set(range(1000))
fs = frozenset(range(1000))
print(sys.getsizeof(s)) # 32984
print(sys.getsizeof(fs)) # 32984
イミュータブルだからといって追加のオーバーヘッドはない。
意図の明確化
frozenset を使うことで、そのデータが変更されないという意図を明示できる。
# 設定値として不変であることを明示
ALLOWED_METHODS = frozenset(["GET", "POST", "PUT", "DELETE"])
def is_allowed(method):
return method in ALLOWED_METHODS
コードを読む人に「この集合は変更されない」というメッセージを伝えられる。これは型アノテーションでも表現できるが、frozenset を使えば実行時にも変更を防止できる。
型アノテーションでの使い分け
typing モジュールでは、それぞれの型を明示できる。
from typing import Set, FrozenSet
def process_tags(tags: Set[str]) -> FrozenSet[str]:
# 処理後は不変の集合として返す
return frozenset(tag.lower() for tag in tags)
引数として受け取るときは Set(変更の可能性あり)、戻り値として返すときは FrozenSet(変更不可を保証)という使い分けも一つのパターンだ。