R

ReflexKR

Reflex公式ドキュメント
名札の空港:Pythonのオブジェクトモデル
12限目

時間 9:10–10:00

ロッカー番号:ハッシュと等価性、総まとめ

紛失届

LOST-012

ロッカーの中にあるのに取り出せない

届出人

遺失物保管所のハンさん

なくしたもの

幽霊かばんBの荷物。先月、遺失物ロッカー(dict)に登録したのに、持ち主が受け取りに来たとき「ない」と出た。ロッカー L7 はまだ鍵がかかったまま。

解決の条件

ロッカーが枠を探すしくみから原因を突き止め、L7 を開けて、変わらないキーで保管し直す。

ハヌルソム国際空港 手荷物センター

昨日、13番コンベヤーの幽霊かばん二つを片付けました。残っているのは、保管台帳の1行です。先月、Bが遺失物ロッカーに登録されたのに、持ち主が受け取りに来たとき、台帳は「ない」と答えました。Bはまたコンベヤーに戻され、その先は昨日までの話のとおりです。

ハンさんが、そのときの台帳をもう一度開いてみせます。一覧を表示するとBは確かにあるのに、in で尋ねると False が出ます。ド・ミラン班長が最後の届けを受け取りながら言います。「ロッカー番号は、かばんが決めるんだ。入れたあとでかばんが変われば、番号も変わる」

12-1. ロッカー番号:ハッシュで枠を探し、== で確かめる (8分)

dict と set は、枠がたくさんあるロッカーです。キーを入れるときも探すときも、毎回同じ手順を踏みます。

  1. hash(key) で、キーのハッシュ(ロッカー番号)を求めます。
  2. ハッシュから、最初に見る枠を決めます。
  3. 枠が空いていれば、そこで終わりです。入れるときはその枠に入れ、探すときは「ない」です。
  4. 枠が埋まっていれば、ハッシュが同じで、== でも同じキーかを確かめます。違えば、決まった規則で次の枠を見ます。

そこで、一つの約束が必要になります。a == b なら、hash(a) == hash(b) でなければなりません。同じキーが別々の枠から探し始めたら、いつまでも出会えないからです。

print(1 == 1.0 == True, hash(1) == hash(1.0) == hash(True))
# True True
print({1: "int", 1.0: "float", True: "bool"})
# {1: 'bool'}

1、1.0、True は互いに == なので、ハッシュも同じで、dict では同じキーです。キーは最初に入れた 1 が残り、値だけが最後のものに変わりました。

逆は成り立たなくてかまいません。ハッシュが同じなのに値が違う衝突は正常です。手順4の == が見分けてくれます。

print(hash(42), hash(-1), hash(-2))
# 42 -2 -2
print(hash(-1) == hash(-2), -1 == -2)
# True False
lockers = {-1: "A", -2: "B"}
print(lockers[-1], lockers[-2])
# A B

CPythonでは、小さな整数のハッシュはその数自身です。ただし -1 はC内部で「エラー」を意味する値なので、hash(-1) は -2 になります。衝突ですが、dict は問題なく二つを区別します。

文字列のハッシュは、プログラムを実行するたびに変わります。ハッシュ値を予測した攻撃を防ぐための仕組みです。同じ値が必要なら(テストの再現など)、環境変数 PYTHONHASHSEED を固定します。

$ python -c "print(hash('T1'))"
# 例:4646783657884298203
$ python -c "print(hash('T1'))"
# 例:-1769311965495050168
$ PYTHONHASHSEED=0 python -c "print(hash('T1'))"
# 実行するたびに同じ値

ですから、ハッシュ値をファイルに保存したり、ほかのプロセスとやり取りしたりしてはいけません。ハッシュは、1回の実行の中でだけ使う番号です。

下のロッカーは、8枠に単純化してあります。ハッシュを8で割った余りの枠から始め、埋まっていればすぐ次の枠へ進みます。7 と 15、-1 と -2 を順に入れて、衝突を起こしてみてください。

ロッカー保管所

8枠の簡略化したハッシュテーブルです。CPythonのdictは枠を選ぶ順番がもっと複雑ですが、「ハッシュで枠を探し、==で確かめる」という原理は同じです。

7 と 15 はどちらも7番の枠から始まるので、あとから入れたほうが次の枠へ進みます(7番の次は0番)。-1 と -2 はハッシュがどちらも -2 なので、6番の枠から始まります。探すときも同じ道をたどります。

12-2. 入れたあとで変わったキー (10分)

ロッカーは、入れるときに計算した番号の枠にキーを置き、その後は番号を計算し直しません。入れたあとでキーの値が変わり、ハッシュが変わったらどうなるでしょう? わざと間違えて作ったキーで確かめます。

class Tag:
    def __init__(self, n):
        self.n = n
    def __eq__(self, other):
        return isinstance(other, Tag) and self.n == other.n
    def __hash__(self):
        return hash(self.n)   # 変わりうる値でハッシュを作ってしまったミス
    def __repr__(self):
        return f"Tag({self.n})"

key = Tag(3)
lockers = {key: "bag B"}
print(Tag(3) in lockers)
# True
key.n = 13
print(lockers)
# {Tag(13): 'bag B'}
print(Tag(13) in lockers, Tag(3) in lockers, key in lockers)
# False False False

一覧には Tag(13) が見えるのに、どう尋ねても False です。

  • Tag(13) で探すと:ハッシュ13の枠から始めます。かばんはハッシュ3の枠にあるので、出会えません。
  • Tag(3) で探すと:ハッシュ3の枠にはたどり着きます。しかし、その枠のキーはもう Tag(13) なので、== が False です。
  • 入れた key 自身で探しても:いまのハッシュは13なので、一つ目と同じ道をたどります。
# 上の例の続き
key.n = 3
print(Tag(3) in lockers)
# True

値を3に戻すと、また見つかります。かばんは最初から最後まで、入れた枠にずっとありました。枠を探す番号だけが変わったのです。

ロッカー保管所

8枠の簡略化したハッシュテーブルです。CPythonのdictは枠を選ぶ順番がもっと複雑ですが、「ハッシュで枠を探し、==で確かめる」という原理は同じです。

Tag(3) を入れて「入れたキーを変える」を押すと、枠のキーは Tag(13) になりますが、枠に書かれたハッシュは3のままです。これで Tag(13) でも Tag(3) でも見つからなくなります。3 を入れると、ハッシュは同じでも == が違うので次の枠へ進みます。

だからPythonは、値が変わりうる組み込み型を、最初からキーとして受け付けません。list、dict、set にはハッシュがありません。

lockers = {}
lockers[[1, 2]] = "bag"
# TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
print(hash((1, 2)) == hash((1, 2)))
# True
hash((1, [2]))
# TypeError: unhashable type: 'list'

タプルはキーになれますが、中にリストが入っているとハッシュを作れません。2限目で見たとおり、タプルのイミュータブルさは「入れている参照」が変わらないというだけなので、中のリストが変われば、ハッシュも変わらなければならないからです。キーに使うときは、(1, (2,)) や frozenset のように、最後までイミュータブルな値を使います。

ピンの案内

→ dict のキーと set の要素は、「入れたあとで変わらないもの」でなければいけません。変える必要があるなら、取り出して(pop)新しいキーで入れ直しましょう。

12-3. 等しさを決めたら、ハッシュも決める (7分)

何も定義していないクラスは is で等しさを判断し、ハッシュも同一性から作ります。だからインスタンスをキーに使えます。ところが __eq__ だけを定義すると、Pythonは __hash__ を None にします(8限目で予告したとおり)。等しさの基準が変わったのに古いハッシュをそのまま使うと、約束が破られるからです。

class Label:
    def __init__(self, code):
        self.code = code
    def __eq__(self, other):
        return isinstance(other, Label) and self.code == other.code

print(Label("T1") == Label("T1"), Label.__hash__)
# True None
{Label("T1")}
# TypeError: cannot use 'Label' as a set element (unhashable type: 'Label')

ハッシュが必要なら、方法は二つあります。

1. @dataclass(frozen=True):__eq__ と __hash__ をフィールドから作り、フィールドを変えられないようにします。

from dataclasses import dataclass

@dataclass(frozen=True)
class Tag:
    n: int

key = Tag(3)
lockers = {key: "bag B"}
print(lockers[Tag(3)])
# bag B
key.n = 13
# dataclasses.FrozenInstanceError: cannot assign to field 'n'

frozen なしで @dataclass だけを使うと、__eq__ しか作られないので、__hash__ は None です。キーに使うクラスなら frozen=True を付けます。

2. __hash__ を自分で書く:__eq__ で比べているフィールドを、そのままタプルにまとめてハッシュします。

class Route:
    def __init__(self, origin, dest):
        self.origin = origin
        self.dest = dest
    def __eq__(self, other):
        if not isinstance(other, Route):
            return NotImplemented
        return (self.origin, self.dest) == (other.origin, other.dest)
    def __hash__(self):
        return hash((self.origin, self.dest))

seen = {Route("ICN", "NRT")}
print(Route("ICN", "NRT") in seen, len({Route("ICN", "NRT"), Route("ICN", "NRT")}))
# True 1

自分で書くときの規則は三つです。

  • __eq__ が比べるのと同じフィールドでハッシュを作る。
  • そのフィールドは、キーとして使っているあいだ変えない。
  • 比べられない相手には、False ではなく NotImplemented を返す(8限目)。

12-4. 総まとめ:手荷物センター管理プログラムの点検 (15分)

班長が最後の課題として、センターの古い管理プログラムを出してきます。11回の届けで出会ったバグが、一つのファイルに全部入っています。まずは実行結果から見てみましょう。

import gc
import weakref

class Bag:
    stickers = []                          # クラス属性にミュータブルなリスト

    def __init__(self, code, items=[]):    # デフォルト引数のリスト
        self.code = code
        self.items = items
        self.partner = None

    def __eq__(self, other):
        return isinstance(other, Bag) and self.code == other.code

    def __hash__(self):
        return hash(self.code)             # 変わりうる値でハッシュ

a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# ['camera'] ['fragile']

shelves = [[]] * 3                         # 同じリストが三つ
shelves[0].append("A1")
print(shelves)
# [['A1'], ['A1'], ['A1']]

calls = [lambda: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 3', 'gate 3', 'gate 3']

lockers = {b: "L7"}
b.code = "B2"                              # 入れたあとでキーを変えた
print(Bag("B1") in lockers, Bag("B2") in lockers)
# False False

a.partner, b.partner = b, a                # 互いを抱え込んだ二つのかばん
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# False
gc.collect()
print(watch() is None)
# True

1行ずつ原因を確かめて、点検リストにまとめます。

症状原因直し方回
Aに入れたカメラがBにもあるデフォルト引数 items=[] は定義のときに一度だけ作られるitems=None で受け取り、中で新しいリストを作る4
ステッカーがすべてのかばんに貼られたクラス属性 stickers = [] をすべてのインスタンスが共有__init__ でインスタンス属性に7
棚の三つの段が一緒に変わる[[]] * 3 は同じリストへの参照が三つ[[] for _ in range(3)]3
お知らせが全部 gate 3ラムダが値ではなく変数 g を持っている(遅延束縛)lambda g=g: ...6
ロッカーでBが見つからない入れたあとでキーの値を変え、ハッシュが変わった変わらない値をキーに、変えるときは移し替える12
名札を全部外してもかばんが残るpartner 同士の循環参照片方を weakref.ref に11

直したプログラムです。出力がすべて意図どおりに変わりました。

import weakref

class Bag:
    def __init__(self, code, items=None):
        self.code = code
        self.items = [] if items is None else list(items)
        self.stickers = []
        self._partner = None

    @property
    def partner(self):
        return self._partner() if self._partner is not None else None

    @partner.setter
    def partner(self, other):
        self._partner = weakref.ref(other)

a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# [] []

shelves = [[] for _ in range(3)]
shelves[0].append("A1")
print(shelves)
# [['A1'], [], []]

calls = [lambda g=g: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 1', 'gate 2', 'gate 3']

lockers = {b.code: "L7"}                   # キーは変わらない文字列
lockers["B2"] = lockers.pop(b.code)        # コードを変えるときはキーを移し替える
b.code = "B2"
print("B1" in lockers, "B2" in lockers, lockers[b.code])
# False True L7

a.partner, b.partner = b, a
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# True

items=list(items) で受け取ったものをコピーしておくのも、4限目の防御的コピーです。Bag から __eq__ と __hash__ を外したので、かばんそのものは同一性で比べられ、キーの役目は文字列のコードが担います。

前半の二つのバグ(デフォルト引数、クラス属性)を、タグボードで比べてみます。bug では二つのインスタンスの items の矢印が一つのリストに集まり、stickers は規格書(class)の箱にぶら下がっています。

タグボード

ステップ 1 / 0

次に実行する行: 0

実習台を準備中…

いま変わったもの

封印されたかばん(イミュータブル)

bug では a と b の items が同じリストを、stickers はクラスのリスト一つを指しています。fixed ではかばんごとに自分のリストができ、b の二つのリストは空のままです。

12回を1行ずつ振り返ります。

回タイトル一言
1タグはかばんではない:名前とオブジェクト= はオブジェクトに名札をぶら下げる。is は同じオブジェクト、== は同じ値。
2封印されたかばん:ミュータブルとイミュータブルイミュータブルなオブジェクトの「変更」は、新しいオブジェクトと再束縛。+= は型によって二つの顔。
3かばんの中のかばん:コンテナとコピーコンテナは参照を入れる。浅いコピーは1層、deepcopy は最後まで。
4かばんを渡すとき:関数と引数関数は同じオブジェクトにタグをもう一枚付ける。デフォルト引数は一度だけ作られる。
5区域と通行証:スコープ名前は L → E → G → B の順に探し、代入する場所がスコープを決める。
6機内持ち込みかばん:クロージャ内側の関数は、値ではなく変数(セル)を持ち歩く。
7かばんの規格書:クラスとインスタンスクラス属性はすべてのインスタンスで共有される。インスタンス属性は __init__ で。
8空港の標準手順:ダンダーメソッド文法はダンダーメソッドを呼ぶ。len(x) は type(x).__len__(x)。
9規格書の規格書:継承とMRO属性は __mro__ の順に探し、super() はMROの次のクラス。
10検査場:属性アクセスの秘密property とディスクリプタが属性アクセスに割り込む。関数もディスクリプタ。
11持ち主のいないかばん:メモリとGC参照カウントが0なら廃棄、循環はGCが片付ける。弱参照は点線タグ。
12ロッカー番号:ハッシュと等価性、総まとめa == b なら hash(a) == hash(b)。キーは入れたあとで変わってはいけない。

事件解決 (10分)

幽霊かばんBは、番号 no でハッシュを作るかばんでした。ロッカーに入れたあと、分類し直すときに番号を書き換えてしまったため、枠を探す道が変わったのです。ハッシュを使わずに枠を一つずつ見ていけば、Bはちゃんとそこにあります。

from dataclasses import dataclass

class Bag:
    def __init__(self, no):
        self.no = no
    def __eq__(self, other):
        return isinstance(other, Bag) and self.no == other.no
    def __hash__(self):
        return hash(self.no)

ghost = Bag(3)
lockers = {ghost: "L7"}
ghost.no = 13                                         # 分類し直すときに番号を書き換えた
print(ghost in lockers)
# False
print([v for k, v in lockers.items() if k is ghost])  # ハッシュなしで一つずつ見ていけばある
# ['L7']

@dataclass(frozen=True)
class BagNo:
    no: int

lockers = {BagNo(13): "L7"}                           # 変わらないキーで保管し直す
print(lockers[BagNo(13)], BagNo(13) in lockers)
# L7 True
  • items() で一つずつ見ていくのはハッシュを使わないので、Bを見つけ出せます。そのかわり、枠の数だけ時間がかかります。
  • 新しいキー BagNo は frozen=True なので、入れたあとで番号を変えられません。番号が変わるなら、新しい BagNo を作って入れ替えます。

ロッカー L7 が開きます。ひと月のあいだ閉じ込められていたBの荷物が出てきて、ハンさんが持ち主に連絡を入れます。ド・ミラン班長は、タグの束を引き出しに戻します。「初日に言ったこと、覚えてる? かばんは一つ、タグはいくつも。もう、タグがどこにぶら下がっているか、誰がかばんをつかんでいるか、かばんがどの枠にあるか、全部見えるでしょう?」ピンが13番コンベヤーを指します。今日は、何も回っていません。

解決報告 — LOST-012。Bは入れたあとでハッシュが変わったキーだったため、ロッカーで見つからなかった。items() でたどって L7 を見つけ、@dataclass(frozen=True) のキーで保管し直した。13番コンベヤーの幽霊かばん事件、すべて解決。

練習問題

  1. {(1, 2): "A", (2, 1): "B"} では、枠がいくつ使われるでしょう? 二つのタプルの == と hash をそれぞれ比べて予想してから、確かめてください。
  2. 12-2の Tag に @dataclass(frozen=True) を付けて作り直し、key.n = 13 がどこで止められるか確かめてください。
  3. 12-4のバグのあるプログラムを、直した版を見ずに自分で直してみてください。直すたびに、点検リストのどの行に当たるかを書き留めておきましょう。

班長の業務手帳

  • dict・set はハッシュで枠を探し、== で確かめる。a == b なら hash(a) == hash(b)。

  • ハッシュの衝突は正常(hash(-1) == hash(-2))。文字列のハッシュは実行ごとに違う(PYTHONHASHSEED)。

  • 入れたあとでキーが変わると、新しい値でも古い値でも見つからない。キーはイミュータブルに。

  • list・dict・set はキーになれない。リストの入ったタプルも同じ。

  • __eq__ だけを定義すると __hash__ は None。@dataclass(frozen=True) か、同じフィールドで __hash__ を書く。

  • 点検リスト:共有されたミュータブルなオブジェクト、デフォルト引数、遅延束縛、クラス属性、循環参照、変わったキー。

  • かばんは一つ、タグはいくつも。行き詰まったら、名札とかばんを描いてみる。

← 前へ

11限目 · 持ち主のいないかばん:メモリとGC

新しい授業・講座のお知らせを受け取る

この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。

収集項目:メールアドレス・目的:この講座の新しい授業・公開のお知らせ・保有期間:解除のご依頼まで。他の目的には使わず、第三者にも提供しません。お知らせメールに返信いただければすぐに削除します。

質問と回答

この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。

リンクや書式なしで、そのままの文字で表示されます。

R

ReflexKR

触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。

ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する

アフィリエイトと支援について
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex