紛失届
LOST-012
届出人
遺失物保管所のハンさん
なくしたもの
幽霊かばんBの荷物。先月、遺失物ロッカー(dict)に登録したのに、持ち主が受け取りに来たとき「ない」と出た。ロッカー L7 はまだ鍵がかかったまま。
解決の条件
ロッカーが枠を探すしくみから原因を突き止め、L7 を開けて、変わらないキーで保管し直す。
ハヌルソム国際空港 手荷物センター
昨日、13番コンベヤーの幽霊かばん二つを片付けました。残っているのは、保管台帳の1行です。先月、Bが遺失物ロッカーに登録されたのに、持ち主が受け取りに来たとき、台帳は「ない」と答えました。Bはまたコンベヤーに戻され、その先は昨日までの話のとおりです。
ハンさんが、そのときの台帳をもう一度開いてみせます。一覧を表示するとBは確かにあるのに、in で尋ねると False が出ます。ド・ミラン班長が最後の届けを受け取りながら言います。「ロッカー番号は、かばんが決めるんだ。入れたあとでかばんが変われば、番号も変わる」
== で確かめる (8分)dict と set は、枠がたくさんあるロッカーです。キーを入れるときも探すときも、毎回同じ手順を踏みます。
hash(key) で、キーのハッシュ(ロッカー番号)を求めます。== でも同じキーかを確かめます。違えば、決まった規則で次の枠を見ます。そこで、一つの約束が必要になります。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 BCPythonでは、小さな整数のハッシュはその数自身です。ただし -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は枠を選ぶ順番がもっと複雑ですが、「ハッシュで枠を探し、==で確かめる」という原理は同じです。
ロッカーは、入れるときに計算した番号の枠にキーを置き、その後は番号を計算し直しません。入れたあとでキーの値が変わり、ハッシュが変わったらどうなるでしょう? わざと間違えて作ったキーで確かめます。
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は枠を選ぶ順番がもっと複雑ですが、「ハッシュで枠を探し、==で確かめる」という原理は同じです。
だから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)新しいキーで入れ直しましょう。
何も定義していないクラスは 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__ が比べるのと同じフィールドでハッシュを作る。NotImplemented を返す(8限目)。班長が最後の課題として、センターの古い管理プログラムを出してきます。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)
# True1行ずつ原因を確かめて、点検リストにまとめます。
| 症状 | 原因 | 直し方 | 回 |
|---|---|---|---|
| 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)
# Trueitems=list(items) で受け取ったものをコピーしておくのも、4限目の防御的コピーです。Bag から __eq__ と __hash__ を外したので、かばんそのものは同一性で比べられ、キーの役目は文字列のコードが担います。
前半の二つのバグ(デフォルト引数、クラス属性)を、タグボードで比べてみます。bug では二つのインスタンスの items の矢印が一つのリストに集まり、stickers は規格書(class)の箱にぶら下がっています。
タグボード
ステップ 1 / 0
次に実行する行: 0
実習台を準備中…
いま変わったもの
封印されたかばん(イミュータブル)
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)。キーは入れたあとで変わってはいけない。 |
幽霊かばん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 Trueitems() で一つずつ見ていくのはハッシュを使わないので、Bを見つけ出せます。そのかわり、枠の数だけ時間がかかります。BagNo は frozen=True なので、入れたあとで番号を変えられません。番号が変わるなら、新しい BagNo を作って入れ替えます。ロッカー L7 が開きます。ひと月のあいだ閉じ込められていたBの荷物が出てきて、ハンさんが持ち主に連絡を入れます。ド・ミラン班長は、タグの束を引き出しに戻します。「初日に言ったこと、覚えてる? かばんは一つ、タグはいくつも。もう、タグがどこにぶら下がっているか、誰がかばんをつかんでいるか、かばんがどの枠にあるか、全部見えるでしょう?」ピンが13番コンベヤーを指します。今日は、何も回っていません。
解決報告 — LOST-012。Bは入れたあとでハッシュが変わったキーだったため、ロッカーで見つからなかった。items() でたどって L7 を見つけ、@dataclass(frozen=True) のキーで保管し直した。13番コンベヤーの幽霊かばん事件、すべて解決。
{(1, 2): "A", (2, 1): "B"} では、枠がいくつ使われるでしょう? 二つのタプルの == と hash をそれぞれ比べて予想してから、確かめてください。Tag に @dataclass(frozen=True) を付けて作り直し、key.n = 13 がどこで止められるか確かめてください。班長の業務手帳
dict・set はハッシュで枠を探し、== で確かめる。a == b なら hash(a) == hash(b)。
ハッシュの衝突は正常(hash(-1) == hash(-2))。文字列のハッシュは実行ごとに違う(PYTHONHASHSEED)。
入れたあとでキーが変わると、新しい値でも古い値でも見つからない。キーはイミュータブルに。
list・dict・set はキーになれない。リストの入ったタプルも同じ。
__eq__ だけを定義すると __hash__ は None。@dataclass(frozen=True) か、同じフィールドで __hash__ を書く。
点検リスト:共有されたミュータブルなオブジェクト、デフォルト引数、遅延束縛、クラス属性、循環参照、変わったキー。
かばんは一つ、タグはいくつも。行き詰まったら、名札とかばんを描いてみる。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。