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() などの変更メソッドが存在しない。

set

add, remove, update, clear など変更メソッドあり

frozenset

変更メソッドなし。作成後は要素を変えられない

ハッシュ可能性

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 を使うべきだ。

使い分けの基準

set を選ぶ場面

要素の追加・削除が必要。動的に内容が変わる。一時的な作業用の集合。

frozenset を選ぶ場面

辞書のキーにしたい。集合の集合を作りたい。不変性を保証したい。関数の引数のデフォルト値にしたい。

デフォルト引数としての利用

ミュータブルなデフォルト引数は 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(変更不可を保証)という使い分けも一つのパターンだ。