Pythonでハッシュ可能なものの整理

Pythonで辞書型のキーにリストを入れてunhashableとエラーを出した経験はないでしょうか? 辞書型のキーや集合型ではその値がhashableである必要があります。 今回は何がハッシュ可能で何ができないのかを簡単に整理しました。
2023.12.30

はじめに

Pythonでは、辞書(dictionary)や集合(set)などのデータ構造で使用するために、ハッシュ可能なオブジェクトが必要です。

ハッシュ可能なオブジェクトは、hash() 関数を用いてユニークなハッシュ値を生成できるものです。 ただ、全てのオブジェクトがハッシュ可能なわけではありません。 この記事では、Pythonにおいてハッシュ可能なオブジェクトについて整理していきます。

Hashable(ハッシュ可能)なオブジェクトとは?

Pythonにおけるhashableとは以下のような意味です。

An object is hashable if it has a hash value which never changes during its lifetime (it needs a hash() method), and can be compared to other objects (it needs an eq() method). Hashable objects which compare equal must have the same hash value.

Hashability makes an object usable as a dictionary key and a set member, because these data structures use the hash value internally.

Most of Python’s immutable built-in objects are hashable; mutable containers (such as lists or dictionaries) are not; immutable containers (such as tuples and frozensets) are only hashable if their elements are hashable. Objects which are instances of user-defined classes are hashable by default. They all compare unequal (except with themselves), and their hash value is derived from their id().

簡単に要約すると以下のような感じです。

  1. オブジェクトがハッシュ可能であるためには、__hash__() メソッドが必要で、対象オブジェクトはライフタイム中に変化しないハッシュ値を持つ必要があります。
  2. ハッシュ可能なオブジェクトは他のオブジェクトと比較できる必要があり、そのためには __eq__() メソッドが必要です。
  3. 同じハッシュ値を持つオブジェクトは等しいと見なされ、これは辞書のキーおよびセットのメンバーとして使用されます。
  4. Pythonの不変なビルトインオブジェクトのほとんどはハッシュ可能です。 一方で可変コンテナ(リストや辞書など)はハッシュ不可能です。
  5. 不変なコンテナ(タプルやfrozensetなど)は、要素がハッシュ可能な場合にのみハッシュ可能です。
  6. ユーザー定義クラスのインスタンスはデフォルトでハッシュ可能であり、比較時には異なるものとして扱われ、ハッシュ値はid()から派生します。

ハッシュ可能なもの

以下に辞書(dictionary)や集合(set)で使いそうなものを集めて整理してみました。

ハッシュ可能

  • 文字列(str)
  • バイト(bytes)
  • 整数(int)
  • 浮動小数点(float)
  • 複素数(complex)
  • ブール(bool)
  • frozenset(setの要素が固定のもの)
  • タプル(tuple)
  • ユーザー定義のクラス(※注意点は後述)
  • NameTuple
  • Enum

ハッシュ不可能

  • 辞書(dict)
  • リスト(list)
  • 集合(set)
  • dataclass

ユーザー定義のクラスのハッシュ値について

ユーザー定義のクラスのハッシュ値を取る場合は注意点があります。 それは、異なるインスタンスであってもハッシュ値が一致してしまうという点です。

person.py

class Person:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age


print(hash(Person('foo', 42)) == hash(Person('foo', 42)))
# => True
print(hash(Person('foo', 42)) == hash(Person('bar', 84)))
# => True

print(Person('foo', 42) == Person('foo', 42))
# => False

上記のスクリプトからもわかる通り、インスタンスのメンバ変数の値が変わってもハッシュ値は一致します。 一方で同じ値でも等号は成り立ちません。

以下のように書き換えれば、インスタンスの中身を見て判定することが可能です。

person_plus.py

class PersonPlus:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

    def __hash__(self):
        return hash((self.name, self.age))
    
    def __eq__(self, other):
        return (self.name, self.age) == (other.name, other.age)

print(hash(PersonPlus('foo', 42)) == hash(PersonPlus('foo', 42)))
# => True
print(hash(PersonPlus('foo', 42)) == hash(PersonPlus('bar', 84)))
# => False

print(PersonPlus('foo', 42) == PersonPlus('foo', 42))
# => True

ただ、この様にするとメンバ変数を書き換えてしまうとハッシュ値が変わってしまいます。 これは以下の部分と相反します。(メンバ変数を書き換えないという制約付きであれば成立しますが...)

対象オブジェクトはライフタイム中に変化しないハッシュ値を持つ必要があります。

結局のところ、インスタンスの代表値として何が相応しいのかという話になってくるので、ケースバイケースで使い分けるのが良いでしょう。

最後に

今までなんとなくでdictのキーやsetに入れてたので今回で整理できてよかったです。

特にクラスの仕様は知らなかったので勉強になりました。

付録(今回使ったスクリプト)

hash.py

print(hash('1234')) #文字列(str)
print(hash(b'1234')) #バイト(bytes)

print(hash(1234)) # 整数(int)
print(hash(1.234)) # 浮動小数点(float)
print(hash(complex('123+4j'))) # 複素数(complex)

print(hash(True)) # ブール(bool)

# print(hash([1, 2, 3, 4])) # リスト(list) ハッシュ不可
# print(hash({1: 2, 3: 4})) # 辞書(dict) ハッシュ不可
# print(hash({1, 2, 3, 4})) # セット(set) ハッシュ不可
print(hash(frozenset({1, 2, 3, 4})))
print(hash((1, 2, 3, 4)))

class Person:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age


print(hash(Person('foo', 42)))
print(hash(Person('foo', 42)) == hash(Person('foo', 42)))
# => True
print(hash(Person('foo', 42)) == hash(Person('bar', 84)))
# => True

print(Person('foo', 42) == Person('foo', 42))
# => False

from dataclasses import dataclass

@dataclass
class PersonDataclass:
    name: str
    age: int

# print(hash(PersonDataclass('foo', 42))) ハッシュ不可
    
from typing import NamedTuple

class PersonNamedTuple(NamedTuple):
    name: str
    age: int

print(hash(PersonNamedTuple('foo', 42)))
print(hash(PersonNamedTuple('foo', 42)) == hash(PersonNamedTuple('bar', 84)))
# => False

from enum import Enum

class Color(Enum):
    red = 0
    blue = 1
    green = 2

print(hash(Color.red))

class PersonPlus:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

    def __hash__(self):
        return hash((self.name, self.age))
    
    def __eq__(self, other):
        return (self.name, self.age) == (other.name, other.age)

print(hash(PersonPlus('foo', 42)) == hash(PersonPlus('foo', 42)))
# => True
print(hash(PersonPlus('foo', 42)) == hash(PersonPlus('bar', 84)))
# => False

print(PersonPlus('foo', 42) == PersonPlus('foo', 42))
# => True