码农高天
CPython哈希查找机制在哈希值相同时会优先比对对象指针,只要内存地址一致便跳过__eq__直接判定命中,导致即使对象判等返回False仍会被字典视作同一键覆写。
AI-assisted summary of 【python】自定义类型的对象,可以成为字典的键值么?这里有什么你从没想过的坑呢? — original source. Check the original for full context.
Source published:
Key ideas
- 自定义类默认直接继承 object 的基于内存指针(id)的哈希算法,因此属性相同的新对象作为字典键时不会互相覆盖,而是被作为两个独立键保存。 (0:00)
- 在类中仅仅重写 __eq__ 方法会导致该类被 CPython 自动设置为不可哈希(__hash__ = None),再次作为字典键时会直接抛出 TypeError 异常。 (5:00)
- CPython 字典在哈希匹配后具备指针级别的身份短路优化,若对象指针相同,底层不会调用 __eq__ 便直接判定为同一键,导致即便 __eq__ 恒返回 False 也会覆盖键值。 (10:00)
Detailed notes
## Background 该视频由“码农高天”于 2023 年 9 月 29 日发布在 YouTube 平台。视频围绕如何让 Python 自定义类型对象成为字典可用的键值展开,旨在揭示日常开发看似简单的语法背后鲜为人知的底层原理与技术陷阱。
## TL;DR 视频以二维坐标类作为案例,深入探讨了自定义对象充当字典键时的行为模式。作者首先展示了仅仅重写 `__eq__` 会导致 Python 自动屏蔽 `tp_hash` 并引发不可哈希异常,并通过 CPython 底层源码剖析了哈希表维护相等性不变性的规则。在给出同时实现属性元组哈希与相等性比较的标准解决方案后,视频进一步揭示了反直觉的极端场景:由于 CPython 字典内部存在指针级别的“身份短路”优化,即便对象的 `__eq__` 恒定返回 `False`,相同引用的对象在字典中依然会被判定为同一个键。
## Atmosphere & style 视频兼具硬核技术探究的深度与平易近人的幽默感。主讲人在自研工具意外翻车时淡定自嘲并保留未剪辑片段,展现了极具真实感的开发者日常;而随着从表层现象层层递进到 CPython 源码剖析,再到抛出颠覆直觉的极端测试案例,节奏环环相扣,为观众带来了抽丝剥茧般的智力解谜快感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=0s) 自定义类默认直接继承 object 的基于内存指针(id)的哈希算法,因此属性相同的新对象作为字典键时不会互相覆盖,而是被作为两个独立键保存。 - [05:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=300s) 在类中仅仅重写 __eq__ 方法会导致该类被 CPython 自动设置为不可哈希(__hash__ = None),再次作为字典键时会直接抛出 TypeError 异常。 - [10:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=600s) CPython 字典在哈希匹配后具备指针级别的身份短路优化,若对象指针相同,底层不会调用 __eq__ 便直接判定为同一键,导致即便 __eq__ 恒返回 False 也会覆盖键值。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=0s) 自定义类默认可以作为字典的键,因为其继承了 object 默认提供的哈希与比较行为。 - [00:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=0s) 默认情况下,相同属性的两个自定义类实例由于内存地址不同,在字典中会被视作完全不同的键。 - [02:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=120s) 仅定义 __eq__ 实现值的逻辑相等不仅无法合并字典键,反而会触发 unhashable type 错误使得对象彻底不可哈希。 - [05:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=300s) CPython 的基类 object 默认通过 _Py_HashPointer 计算哈希值,其核心基于对象的内存地址指针(即 id())。 - [06:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=360s) 为了维护“相等对象必须哈希相等”的哈希表不变性约定,CPython 在类型重载 rich comparison (__eq__) 且未定义 __hash__ 时,会自动屏蔽继承自父类的 tp_hash 槽。 - [07:30](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=450s) 即使手动定义 __hash__ 为 hash(id(self)),也无法解决等值键合并问题,因为不同实例计算出的哈希值依然互不相同。 - [08:30](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=510s) 正确将自定义类作为等值键的规范做法,是结合 __eq__ 逻辑,将参与判等的属性组合成元组后调用 hash((self.x, self.y))。 - [09:30](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=570s) 若仅定义 __hash__ 而未定义 __eq__,当哈希发生碰撞时,字典仍会退化为比对对象身份,无法合并逻辑相同的键。 - [10:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=600s) CPython 字典查找键时采用身份短路(Identity Short-circuit)优化:若哈希值相同且两者指针相同,将直接判定命中而跳过 __eq__ 调用。 - [11:30](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=690s) 当类的 __eq__ 恒返回 False 时,对同一对象的多个引用放入字典仍会触发键覆盖,呈现出 Python 代码判断 pos == pos2 为 False 但字典视为同一键的奇特行为。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=0s) 自定义对象作为字典键的初次尝试与 `__eq__` 引发的报错
在 Python 开发中,字典(`dict`)是非常核心且常用的数据结构,本质上保存的是键值对(key-value pair)。通常情况下,开发者习惯使用字符串、数字,甚至是只包含不可变(immutable)对象的元组(tuple)作为字典的键。然而,很多人都会面临一个疑问:我们自定义的数据结构到底能不能直接作为字典的键使用?
码农高天举了一个在二维网格游戏中极其常见的例子:定义一个表示二维坐标的自定义类 `Position`。通过定义这样的类型,开发者可以封装各种向量或坐标运算,简化代码逻辑。为了测试这个自定义类是否能当成字典的键,他编写了一个简单的测试场景:假设字典 `d` 用来记录每个坐标上存在的玩家数量,接着实例化了一个位于坐标 `(0, 1)` 的对象 `pos = Position(0, 1)`,并执行赋值操作 `d[pos] = 1`。代码顺利运行通过,没有任何报错,表面上看字典完美支持了自定义对象作为键。
为了更清晰地观察字典内部的结构,他原本打算使用自己开发的第三方库 `object_print` 打印对象详情。然而在尝试用新坐标 `pos2 = Position(0, 1)` 覆盖字典键(即执行 `d[pos2] = 2`)时,终端却意外触发了 `object_print` 的报错。面对这个突发的直播翻车现场,码农高天半开玩笑地自嘲道:“谢谢大家直接发现了一个 object_print 的 bug,我们不用 object_print 了,等我提个 issue 啊”,并为了保持视频的真实性决定保留这段未剪辑的内容。
```mermaid flowchart TD accTitle: 自定义类在字典中的键匹配演进 accDescr: 展示从默认行为、添加 eq、添加基础 hash 到正确哈希的设计变化流程
subgraph Step1["阶段一:默认继承自 object"] A1["pos = Position(0, 1)<br/>pos2 = Position(0, 1)"] --> B1["默认 hash(ID) 不同<br/>is/== 均为不同对象"] B1 --> C1["字典中存在 2 个不同的 key"] end
subgraph Step2["阶段二:仅重载 __eq__"] A2["仅定义 __eq__(self, other)"] --> B2["Python 自动将 __hash__ 设为 None"] B2 --> C2["抛出 TypeError: unhashable type"] end
subgraph Step3["阶段三:重载 __eq__ 与坐标 tuple 哈希"] A3["同时定义 __eq__ 与<br/>__hash__ = hash((self.x, self.y))"] --> B3["hash 相等且 __eq__ 返回 True"] B3 --> C3["成功覆盖,字典仅保留 1 个 key"] end ```
抛开工具的小插曲后,使用原生打印可以清楚看到字典中竟然同时存在两个键:`Position(0, 1)` 与 `Position(0, 1)`。尽管在人类的直观思维中这两个坐标的值完全一致,但在 Python 底层,`pos` 和 `pos2` 实际上是两个独立的 Python 对象,字典自身无法默认感知两者的逻辑等价性。有些开发者可能会认为只要重载 `__eq__` 方法比较 `self.x == other.x and self.y == other.y` 就能解决问题。然而当为 `Position` 类添加了 `__eq__` 方法后再次运行程序,结果反而变得更糟糕了——程序直接崩溃并抛出异常:“`TypeError: unhashable type: 'Position'`”。
- 默认情况下,没有任何定制的自定义类实例可以作为字典的键放入字典中,但相同的属性值不会被自动判定为同一个键。 - 仅仅为了实现“逻辑相等”而重定义 `__eq__` 函数,不仅不能解决多键覆盖问题,反而会触发 `unhashable type` 错误,导致对象彻底丧失充当字典键的资格。
---
[05:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=300s) CPython 源码剖析:`tp_hash` 的继承与覆盖机制
为了从根源上弄清楚为何加了 `__eq__` 就会导致对象变得不可哈希(unhashable),必须深入 CPython 的底层源码。在 CPython 中,字典设置键值对的核心实现位于 `PyDict_SetItem`。该函数在处理非 Unicode 键时,会调用 `PyObject_Hash` 去获取该对象的哈希值。
在 `PyObject_Hash` 内部,逻辑非常直截了当:检查对象所属类型的类型槽(type slot)`tp_hash`。如果存在,就调用它计算哈希;如果 `tp_hash` 为空或者未实现,系统就会抛出 `PyExc_TypeError`,其报错信息正是大家看到的 `unhashable type`。这引出了一个深层机制问题:为什么最开始没有定义任何哈希逻辑时程序能够正常计算哈希,而一旦定义了 `__eq__`,原本存在的哈希机制却消失了?
在 CPython 类型对象的继承逻辑(`inherit_slots`)中可以找到确凿的证据: 1. **基类默认哈希**:所有的 Python 自定义类都隐式继承自 `object`(即底层的 `PyBaseObject_Type`)。基类的 `tp_hash` 默认指向 `_Py_HashPointer`。该函数直接使用对象自身的内存地址指针(也就是 Python 层面的 `id()`)来进行哈希运算。正因为最初两个 `Position` 对象的内存地址不同,计算出的哈希值自然不同,所以在最开始的代码中字典会保存两个独立的键。 2. **`tp_hash` 的自动屏蔽**:在类型继承填充(`inherit_slots`)以及类构建逻辑中,Python 有一条专门规则:只有当该类型既没有重写比较方法(rich comparison,如 `__eq__`),也没有显式重载 `__hash__` 时,才会从父类继承 `tp_hash`。一旦开发者显式定义了 `__eq__`,Python 就会认为该类型的对象具有了基于值的相等性判定逻辑,因此不能再随意使用基于内存地址指针的默认哈希算法;为了防止打破“相等对象必须具有相同哈希值”的哈希表不变性约定,CPython 会主动将该类型的 `__hash__` 设为 `None`,并将其底层的 `tp_hash` 槽置空。
针对这个机制,码农高天用简单实验验证:如果手动添加一个 `__hash__` 方法并直接返回 `hash(id(self))`,报错确实消失了。但由于不同对象的 `id` 依旧不同,字典里仍然会有两个不同的键。因为哈希表查找元素的第一步是比较键的哈希值,如果哈希值不同,底层根本不会去调用 `__eq__` 判断相等性。“两个相等的对象,它们的哈希值必须相等”,这是哈希表能够正常工作的基石。
解决此问题的正确实践,是将影响对象逻辑相等的属性打包成一个不可变集合(例如 `(self.x, self.y)` 的 tuple),然后利用元组的内置哈希来实现自定义哈希逻辑:
```python def __hash__(self): return hash((self.x, self.y)) ```
当 `Position` 同时正确实现了匹配坐标值的 `__eq__` 和打包坐标的 `__hash__` 后,字典键的覆盖终于如期生效,字典最终只保留了一个键,其对应的值被成功更新为 2。相反,如果只保留 `__hash__` 而删掉 `__eq__`,字典在发生哈希碰撞时由于无法比对内容,默认退化为比对对象身份,依然会被当作两个键处理。
- CPython 的默认哈希算法由 `_Py_HashPointer` 实现,直接对对象的内存地址指针(即 `id()`)取哈希。 - 根据 Python 数据模型规定,一旦一个类重写了 `__eq__` 而没有定义 `__hash__`,该类将被自动视作不可哈希类型(unhashable)。 - 实现哈希表键的核心铁律:只要两个对象通过 `__eq__` 判定为相等,它们的 `__hash__()` 必须返回相同的整数。
---
[10:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=600s) 特殊情况探秘:同一对象判断与字典内部的身份短路
掌握了哈希与相等性的基本规则之后,码农高天抛出了一个极易让人产生认知偏差的极端案例。假设我们故意反常理而行之,让一个类的 `__eq__` 永远返回 `False`:
```python class Position: def __init__(self, x, y): self.x = x self.y = y
def __eq__(self, other): return False
def __hash__(self): return hash((self.x, self.y)) ```
在这个类中,任何实例之间使用 `==` 进行比较,结果都会是 `False`。接着,执行以下测试: 1. 实例化 `pos = Position(0, 1)`。 2. 令 `pos2 = pos`。由于 Python 中自定义对象赋值属于引用传递,`pos` 和 `pos2` 严格指向内存中的同一个对象(`pos is pos2` 为 `True`)。 3. 按照直觉测试 `pos == pos2`,由于 `__eq__` 永远返回 `False`,在 Python 解释器中运行 `pos == pos2` 得到的结果确实是 `False`。
但如果此时将它们作为字典的键进行连续操作: ```python d = {} d[pos] = 1 d[pos2] = 2 ```
此时控制台呈现了令人震惊的结果:字典不仅没有新增键,反而直接把 `d[pos]` 覆盖成了 2!整个字典中自始至终只有一个键。
码农高天指出了这一现象的奇妙与反直觉之处:“Python 认为 pos2 是不等于 pos 的,但是 Dictionary 认为,它们是同一个东西。”之所以发生这种现象,是因为 CPython 在实现字典和集合的哈希查找机制时加入了一层性能优化——身份短路(Identity Short-circuit)。当在哈希槽中比对一个新键和已有键时,底层的流程通常是: 1. 比对两个对象的 `hash` 值,如果不同直接跳过; 2. 如果 `hash` 相同,首先比较两者的内存指针(即 `self is other`); 3. **只有当指针不同时**,CPython 才会真正去调用 Python 层面的 `__eq__` 富比较函数。
因为 `pos` 与 `pos2` 指向的是同一块内存空间(同一个 C 结构体指针),底层在指针判等阶段就直接认定了两者是同一个键,压根不会去调用那个被故意改写成返回 `False` 的 `__eq__` 方法。
自定义类能否成为字典的键,以及在字典中展现出何种匹配行为,远比表面上的语法糖复杂得多。它不仅关乎 `__hash__` 和 `__eq__` 的常规设计模式,更涉及 CPython 为了兼顾安全与查找性能而在底层设定的机制规则。
- 在 Python 层显式比对 `pos == pos2` 时,会严格走方法分发逻辑调用 `__eq__` 并返回 `False`。 - 在 CPython 字典内部查找机制中,若两个键对象的哈希值相同且指针相同(`key1 == key2` 指针级别),字典会直接判定命中,从而跳过 `__eq__` 的调用。 - 设计可哈希自定义类时,必须保持哈希不可变性与相等逻辑的高度一致,否则会由于底层身份比对与哈希逻辑的交叉作用,引发极难排查的逻辑诡异行为。
## Notable quotes > [05:00](https://www.youtube.com/watch?v=d_ZxzjpK_LQ&t=300s) 两个相等的对象,它们的哈希值必须相等 — 码农高天
## Mentioned Python, CPython, 码农高天, object_print
## Video description 这期视频围绕着如何将自定义类型变成字典可用的键值展开,可能你知道这个问题的答案,但是你绝对想象不到这背后有多少内容你从没想过。我可以保证,看完这期视频,你一定会学到一些你不知道的知识!
[Source](https://www.youtube.com/watch?v=d_ZxzjpK_LQ)