__eq__ と __hash__:等価性とハッシュ可能性

Python でオブジェクト同士を == で比較したり、辞書のキーや集合の要素として使ったりするとき、裏で動いているのが __eq____hash__ です。この2つは密接に関連していて、片方だけ定義すると問題が起きる。

デフォルトの挙動

何も定義しないと、== は同一オブジェクトかどうか(is と同じ)を判定します。

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

user1 = User("Alice", 30)
user2 = User("Alice", 30)

print(user1 == user2)  # False(別のオブジェクトだから)
print(user1 is user2)  # False
print(user1 == user1)  # True

同じ内容でも別のインスタンスなら False になる。これがデフォルト。

eq で等価性を定義する

__eq__ を定義すれば、「どういう条件で等しいとみなすか」を決められます。

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name and self.age == other.age

user1 = User("Alice", 30)
user2 = User("Alice", 30)
user3 = User("Bob", 25)

print(user1 == user2)  # True
print(user1 == user3)  # False

nameage が同じなら等しい、というルールを定義した。NotImplemented を返すのは、比較できない型のときに Python に処理を委ねるため。

eq だけ定義すると hash が消える

ここで重要な挙動があります。__eq__ を定義すると、デフォルトの __hash__ が無効化されます。

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name and self.age == other.age

user = User("Alice", 30)
print(hash(user))  # TypeError: unhashable type: 'User'

辞書のキーや集合の要素にもできなくなる。

users = {user}  # TypeError: unhashable type: 'User'
cache = {user: "data"}  # TypeError

なぜこうなるのか。ハッシュと等価性には重要なルールがあるからです。

ハッシュの不変条件

ルール1

等しいオブジェクトは同じハッシュ値を持たなければならない。a == b なら hash(a) == hash(b)

ルール2

ハッシュ値はオブジェクトの生存期間中に変わってはいけない。

デフォルトの __hash__id() に基づくため、__eq__ をオーバーライドするとルール1が破られる可能性がある。だから Python は安全のために __hash__ を無効化するのです。

hash も定義する

__eq__ を定義したら、__hash__ も定義するのが基本。

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name and self.age == other.age
    
    def __hash__(self):
        return hash((self.name, self.age))

user1 = User("Alice", 30)
user2 = User("Alice", 30)

print(hash(user1))           # 同じハッシュ値
print(hash(user2))           # 同じハッシュ値
print(hash(user1) == hash(user2))  # True

# 辞書のキーや集合の要素として使える
users = {user1, user2}
print(len(users))  # 1(等しいので1つにまとまる)

__eq__ で使う属性と同じ属性から __hash__ を計算するのがポイント。タプルにまとめて hash() に渡すのが簡単です。

ミュータブルなオブジェクトは要注意

ハッシュ値は変わってはいけない。しかし属性が変更可能だと問題が起きます。

class User:
    def __init__(self, name):
        self.name = name
    
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name
    
    def __hash__(self):
        return hash(self.name)

user = User("Alice")
users = {user}
print(user in users)  # True

user.name = "Bob"     # 属性を変更!
print(user in users)  # False(ハッシュ値が変わったので見つからない)
print(list(users))    # [<User object>](でも中には存在する)

集合の中にいるのに in で見つからない。これはバグの温床になります。

イミュータブルにする

属性を変更不可にする。__setattr__ で書き込みを禁止するか、@dataclass(frozen=True) を使う。

ハッシュを諦める

__hash__ = None と明示して、辞書のキーや集合の要素として使えないことを示す。

意図的にハッシュ不可にする

ミュータブルなオブジェクトでは、明示的に __hash__ = None とするのが安全です。

class MutableUser:
    def __init__(self, name):
        self.name = name
    
    def __eq__(self, other):
        if not isinstance(other, MutableUser):
            return NotImplemented
        return self.name == other.name
    
    __hash__ = None  # 明示的にハッシュ不可

user = MutableUser("Alice")
users = {user}  # TypeError: unhashable type: 'MutableUser'

リストや辞書がハッシュ不可なのも同じ理由。内容が変わりうるから。

!= の挙動

__eq__ を定義すれば、!= は自動的にその否定になります。__ne__ を別途定義する必要はありません。

class User:
    def __init__(self, name):
        self.name = name
    
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name

user1 = User("Alice")
user2 = User("Bob")

print(user1 != user2)  # True(__eq__ の否定が自動で使われる)

Python 3 では __ne__ のデフォルト実装が __eq__ の否定を返すようになっています。

dataclass を使う

@dataclass を使えば、__eq____hash__ を自動生成できます。

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

user1 = User("Alice", 30)
user2 = User("Alice", 30)

print(user1 == user2)  # True
print(hash(user1))     # TypeError(デフォルトではハッシュ不可)

デフォルトでは __eq__ だけ生成され、__hash__None になる。イミュータブルにするには frozen=True を指定します。

from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    name: str
    age: int

user1 = User("Alice", 30)
user2 = User("Alice", 30)

print(user1 == user2)            # True
print(hash(user1) == hash(user2))  # True
print({user1, user2})            # {User(name='Alice', age=30)}

user1.name = "Bob"  # FrozenInstanceError(変更不可)

frozen=True なら属性の変更が禁止され、安全に __hash__ が生成される。

まとめ

__eq__ を定義したら __hash__ も考える

__eq__ だけ定義すると __hash__ が消える。辞書のキーや集合の要素として使いたいなら __hash__ も定義する。

ミュータブルならハッシュ不可にする

属性が変わりうるなら __hash__ = None と明示する。または frozen=True でイミュータブルにする。

dataclass を使うと楽

手動で書くより @dataclass を使う方が安全で簡潔。

__eq____hash__ の関係は、Python のオブジェクトモデルを理解する上で重要なポイントです。両者のルールを守らないと、辞書や集合で予期しないバグが起きる。