news 2026/11/5 2:00:56

Python 的哈希契约:为什么 list 不能当字典的键

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python 的哈希契约:为什么 list 不能当字典的键

原文链接:Python 的哈希契约:为什么 list 不能当字典的键 - 码日新 · 每日文章


Python 的哈希契约:为什么 list 不能当字典的键

上周帮同事看一个缓存问题:他用列表拼了个 key,运行时直接抛出TypeError: unhashable type: 'list'。改成元组就好了。

但另一个更隐蔽的 bug 是:对象明明还在字典里,却怎么也取不出来。这两个问题其实指向同一条规则——能当字典键的东西,必须遵守哈希契约。

一次d[key]到底发生了什么

Python 的dict本质是哈希表。执行d[key]时,解释器大致做两件事:

  1. 计算hash(key),用结果定位到某个桶;
  2. 在桶里用==比较候选键。

为了效率,比较之前还会先看是不是同一个对象;身份相同就直接命中。

classKey:def__init__(self,value):self.value=valuedef__hash__(self):print(f"hash({self.value})")returnhash(self.value)def__eq__(self,other):print(f"eq({self.value},{getattr(other,'value',other)})")returnisinstance(other,Key)andself.value==other.value d={Key(1):"a"}print("--- 查找 ---")print(d[Key(1)])

输出:

hash(1) --- 查找 --- hash(1) eq(1, 1) a

这也解释了字典的两条硬性要求:

  • 相等的键必须有相同的哈希;
  • 键的哈希在它待在字典里的时候不能变。

违反任何一条,字典就可能找不到东西。

list 不行,tuple 也不一定行

list是可变的,所以 Python 直接把它的__hash__设成了None:

print(list.__hash__)# Noneprint(hash((1,2)))# 正常print(hash((1,[2])))# TypeError: unhashable type: 'list'

元组本身不可变,但它的哈希是由元素组合出来的。只要里面有一个元素不可哈希,整个元组就不可哈希。这个规则会递归传递:

hash((1,(2,3)))# 可以hash((1,(2,[3])))# TypeError

所以“把 list 改成 tuple”并不是万能药。如果列表里还藏着列表或字典,照样会失败。

写了__eq__,__hash__就没了

自定义类默认按身份哈希,也就是基于id()。两个内容相同的对象,默认就是两个不同的键。

一旦你实现了__eq__,Python 会把__hash__置为None,除非你也显式实现它:

classUser:def__init__(self,uid,name):self.uid=uid self.name=namedef__eq__(self,other):returnisinstance(other,User)andself.uid==other.uidprint(User.__hash__)# None# hash(User(1, "a")) # TypeError: unhashable type: 'User'

补上__hash__时,字段必须和__eq__保持一致:

classUser:def__init__(self,uid,name):self.uid=uid self.name=namedef__eq__(self,other):returnisinstance(other,User)andself.uid==other.uiddef__hash__(self):returnhash(self.uid)

这里name可变,但不参与相等判断,也不参与哈希,所以修改它不会破坏字典。

如果整个对象都该不可变,可以用冻结的 dataclass:

fromdataclassesimportdataclass@dataclass(frozen=True)classUser:uid:intname:str

默认的@dataclass(frozen=True)会生成__eq__和__hash__,并阻止属性赋值。注意它的哈希会用到所有参与比较的字段;如果只想按uid判等,还是得手写。

最隐蔽的坑:键被修改了

下面这段代码不会报错,但会让键“消失”:

classMutableKey:def__init__(self,value):self.value=valuedef__hash__(self):returnhash(self.value)def__eq__(self,other):returnisinstance(other,MutableKey)andself.value==other.value k=MutableKey(1)d={k:"one"}k.value=2print(kind)# Falseprint(d.get(k))# Noneprint(list(d))# 键还在字典里print(d[k])# KeyError

字典并没有坏。它只是按照新的哈希去 2 号桶找,而当初存进去的是 1 号桶。旧条目还在,但已经无法通过这个键访问。

解决办法不是“改完再 rehash”,而是别让键变:

  • 用不可变对象当键;
  • 只把不会变的字段放进__hash__;
  • 真需要可变键,就先删除旧键,改完再插回去。

相等但哈希不同,也会出事

还有一种更隐蔽的写法:__eq__按值比较,__hash__却按身份生成。

classBadKey:def__init__(self,value):self.value=valuedef__eq__(self,other):returnisinstance(other,BadKey)andself.value==other.valuedef__hash__(self):returnid(self)a,b=BadKey(1),BadKey(1)print(a==b)# Trueprint(hash(a)==hash(b))# Falsed={a:"a"}print(d[b])# KeyErrorprint(len({a,b}))# 2

两个对象相等,却被放进了不同的桶。字典既无法保证“一个键只出现一次”,也无法通过等价的键找到值。

顺带一提:1、1.0和True

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

三者相等,哈希也相同,所以它们是同一个键,后写入的覆盖先写入的。这不是 bug,而是哈希契约的正常结果。

NaN 则是另一个极端:

nan=float("nan")d={nan:"x"}print(d[nan])# x,身份相同,直接命中print(d[float("nan")])# KeyError,新对象既不等于自己,哈希也不同

所以别拿 NaN 当键。如果数据里可能出现 NaN,先清洗再入字典。

实践清单

想用的键能否直接用建议
int/str/bytes是不可变,哈希稳定
tuple视元素而定元素必须全部可哈希
list/dict/set否可变,__hash__为None
frozenset是元素必须可哈希
普通自定义对象是默认按id(),身份语义
实现了__eq__但没实现__hash__否__hash__会被置为None

几条可以直接照做的建议:

  • 组合键优先用元组;嵌套的 list 转成 tuple,dict 转成tuple(sorted(d.items())),set 转成frozenset。
  • 自定义值对象要同时实现__eq__和__hash__,并保证两者使用同一组字段。
  • 对象放进字典后,不要修改参与哈希的字段。
  • 需要身份语义时,就用默认的object.__hash__和object.__eq__,不要多写。
  • 调试时可以直接打印Cls.__hash__,或者在修改前后对比hash(k)。

回到开头那个缓存:把 list 换成 tuple 只是第一步,真正要确认的是这个 key 在缓存生命周期里不会变。字典不会帮你检查这件事,它只负责按照你给的哈希去找。哈希契约是调用方和维护者之间的约定,Python 把签字笔递给了你。


参考文档
  • Primers 编程伙伴/Python/内建函数/type
  • Primers 编程伙伴/Python/内建函数/print
  • Primers 编程伙伴/Python/内建函数/hash
  • Primers 编程伙伴/Python/内建函数/getattr
  • Primers 编程伙伴/Python/内建函数/isinstance
  • Primers 编程伙伴/Python/内建函数/list
  • Primers 编程伙伴/Python/内建函数/int
  • Primers 编程伙伴/Python/内建函数/str
  • Primers 编程伙伴/Python/内建函数/id
  • Primers 编程伙伴/Python/内建函数/len
  • Primers 编程伙伴/Python/内建函数/float
  • Primers 编程伙伴/Python/内建函数/bool
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 2:00:53

5.用DockerCompose启动FastAPIPostgreSQL和Redis

用 Docker Compose 启动 FastAPI、PostgreSQL 和 Redis 码海寻道 AI 算法工程化与系统落地实战第 5 篇 一个算法服务真正接入业务后,通常至少需要保存三类信息:请求和任务状态、算法结果、短期缓存或限流数据。FastAPI 负责接口,PostgreSQL …

作者头像 李华
网站建设 2026/11/5 1:59:55

AgentScope Java v2 第9课,Harness 总架构:13 个中间件从哪来

摘要:一个什么都没配置的 HarnessAgent,默认挂了 13 个中间件、23 个工具,还把账本落到了家目录。本文从一个解剖端点出发,拆这份清单:哪些是固定的,哪些随配置变;12 项能力为什么不是 12 个中间…

作者头像 李华
网站建设 2026/11/5 1:58:27

10-02-互操作-Unsafe代码与手动内存管理

Unsafe 代码与手动内存管理篇章:10-核心技术-托管互操作篇 | 阅读时间:约 35 分钟一、引言C# 的 unsafe 代码允许使用指针直接操作内存,完全绕开 GC。这是 Unity 中实现零分配性能热路径的关键技术。代价是失去了 C# 的类型安全和 GC 的内存安…

作者头像 李华
网站建设 2026/11/5 1:58:21

链上MEV原理与实战:从0到1理解最大可提取价值与Flashbots

引言:区块空间的价值,不在于交易本身,而在于交易的位置 以太坊的公开内存池(mempool)中,每一笔待确认交易在被打包之前都是可见的。这个设计原本是为了让节点验证交易有效性、防止双花,但它附带了一个深远的经济后果:交易的执行顺序本身具有价值。一笔大额买单可以在D…

作者头像 李华
网站建设 2026/11/5 1:56:17

移动硬盘和网盘哪个更安全?2026年数据存储可靠性深度对比

文章目录一、「移动硬盘 vs 网盘」:一个被问烂了但很少被正确回答的问题二、从四个维度真实对比2.1 物理损坏:硬盘的致命弱点2.2 误操作恢复:云端的核心优势2.3 灾难场景:谁更扛得住2.4 多设备访问:云端的天然优势三、…

作者头像 李华