Pythonのdataclassesでhashableなインスタンスを作るクラスを定義する

Pythonでコレクションを扱っていると、ハッシュ可能なオブジェクトの使用を求められることがままある。

例えば、辞書のキーはハッシュ可能なオブジェクトでないといけないし、集合の要素もハッシュ可能でなければならない。 これはコレクション内部のアルゴリズムにおいて、オブジェクトのハッシュ値を使っているからだろう。

時々、自分で定義したクラスのインスタンスを辞書や集合の中で使いたくなるときがある。そういうとき、そのクラスのインスタンスはハッシュ可能である必要がある。

そこで、ハッシュ可能なインスタンスを生成するクラスは、どのように定義するのが適切なのかについて考えたい。  

組み込みのclass

Pythonの組み込みのclassでクラスを定義すると、そのインスタンスはハッシュ可能である。
つまり、__hash__メソッドを持っていて、ハッシュ値を計算できる。  

class Company:
    def __init__(self, cid, name):
        self.cid = cid
        self.name = name

toyota = Company(1, "Toyota")
hash(toyota) # -9223371913295946368

 
じゃあ、組み込みのclass使っておけばOKなのだろうか。 そういうわけにもいかないと思われる。

なぜなら、Pythonの組み込みのclassのミュータブルな性質が、「プログラム中においてハッシュ値はイミュータブルでなければならない」という要件と相容れないからだ。

公式ドキュメントを読むと、辞書や集合の実装が、ハッシュ可能なオブジェクトのハッシュ値がイミュータブルであることを要求していると書いてある。 しかし、Pythonの組み込みのclassインスタンスのフィールドの値は簡単に書き換えることができ、そのインスタンスハッシュ値がイミュータブルであることは全然保証されていない。

要約すると、ハッシュ可能なオブジェクトはイミュータブルでなければならないが、Pythonの組み込みのclassインスタンスはハッシュ可能であるにもかかわらず、ミュータブルになりがちである。 そういうわけで、ユーザー定義クラスをハッシュ可能にしたいならば、組み込みのclassを使わない方がいいような気がする。

dataclasses

次にdataclassesモジュールを使う方法がある。
これはバージョン3.7で新たにリリースされた機能らしく、使えるようになったのは割と最近である。

このモジュールを使って新たにクラスを定義するときには、いくつかのパラメータを指定することができる。 そのパラメータの一つに、frozenがある。 デフォルトではfrozenFalseだが、これをTrueにしてやると、イミュータブルなインスタンスを生成するクラスを定義することができる。

@dataclass(frozen=True)
class Company:
    company_id: int
    name: str

そして、frozenなクラスから生成されるインスタンスは、ハッシュ可能である。
frozen=Trueにすると、適切な__hash__メソッドを自動で生成してくれている。
実際にハッシュ可能であるかを確かめてみよう。

@dataclass(frozen=True)
class Company:
    company_id: int
    name: str

toyota = Company(1, "Toyota")

hash(toyota)  #-124679679567354462
set([toyota]) # 集合の要素にできる
{toyota: 1}    # 辞書のキーにできる

確かに、Companyのインスタンスはハッシュ可能である。

試しに、上記のfrozenパラメータをFalseにしてみると、「toyotaはハッシュ不可能なオブジェクトだ」というエラーが発生する。 つまり、dataclassを使って普通に定義したクラスのインスタンスは、ミュータブルであるがハッシュ可能ではない。

これは、dataclassを使ってクラスを定義しておけば、ミュータブルでハッシュ可能なオブジェクトを作らずに済むということだろう。(変なことをしない限り)

とはいえ、先ほどのfrozenなクラスのインスタンスも完全にイミュータブルであるわけではない。
実はフィールドに辞書がセットされている場合、その辞書の一部の値を書き換えることはできる。

@dataclass(frozen=True)
class Company:
    company_id: int
    name: str
    child: dict

toyota = Company(1, "Toyota", {"company_id": 5, "name": "SUBALU"})
toyota.child["name"] = "SUBARU"

print(toyota)
# Company(company_id=1, name='Toyota', child={'company_id': 5, 'name': 'SUBARU'})

frozenなクラスも「浅く」イミュータブルなだけであるようだ。
しかし、この場合でも、ちゃんとインスタンス自体はハッシュ不可能にしてくれている。

dataclassesは最近追加された機能だが、構文的にも他の言語でいうところのクラスに近くて良いと思う。 組み込みのクラスを使う場合と比較して、オーバーヘッドがどうなのかというところはちょっと気になるところではあるが、特にパフォーマンスが問題にならない場面では積極的に使わない手はないと思っている。