__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
name と age が同じなら等しい、というルールを定義した。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
なぜこうなるのか。ハッシュと等価性には重要なルールがあるからです。
ハッシュの不変条件
等しいオブジェクトは同じハッシュ値を持たなければならない。a == b なら hash(a) == hash(b)。
ハッシュ値はオブジェクトの生存期間中に変わってはいけない。
デフォルトの __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__ が消える。辞書のキーや集合の要素として使いたいなら __hash__ も定義する。
属性が変わりうるなら __hash__ = None と明示する。または frozen=True でイミュータブルにする。
手動で書くより @dataclass を使う方が安全で簡潔。
__eq__ と __hash__ の関係は、Python のオブジェクトモデルを理解する上で重要なポイントです。両者のルールを守らないと、辞書や集合で予期しないバグが起きる。