我在 Python 里想用一个列表当字典的 key 来做分组、又想把一批列表丢进集合去重,结果两处都甩给我一个 TypeError unhashable type list,我一开始以为是 Python 小题大做后来才真正想明白可变的东西天生就没法做哈希定位的依据的深度复盘

我有两个需求:一是想按一组坐标或一组标签给数据分组、自然想到用那组值(一个列表)当字典 key 比如 groups

我在 Python 里想用一个列表当字典的 key 来做分组、又想把一批列表丢进集合去重,结果两处都甩给我一个 TypeError unhashable type list,我一开始以为是 Python 小题大做,后来才真正想明白可变的东西天生就没法做哈希定位的依据

这是一次让我把 Python 里"unhashable type"这件事,从"Python 小题大做、不让我用列表当 key",重新理解成"可变的东西天生没有稳定身份、不能做哈希定位的依据"的事故。我想用一个列表当字典的 key 来做分组、又想把一批列表丢进集合去重,结果两处都甩给我一个 TypeError: unhashable type: 'list'。我一开始觉得 Python 太死板,后来才真正想明白:可变的东西,天生就没法做哈希定位的依据。这篇就把这次"列表不能当 key/集合元素"的事故,从头到尾复盘一遍。

故障现场:列表当 key、列表进 set,双双 TypeError

我有两个需求。其一:我想按"一组坐标"或"一组标签"给数据分组,自然想到用那组值(一个列表)当字典的 key,比如 groups[[1, 2]] = ...。其二:我有一批列表,想去重,自然想到丢进一个 set。我觉得这俩都天经地义。

结果一运行,两处都报错:TypeError: unhashable type: 'list'。我当时有点恼火:不就是拿个列表当 key、放个列表进集合吗,至于报错? 我先以为是语法写错了,检查没问题;又以为是某个版本的限制,查了发现各版本都这样。直到我去翻 dict 和 set 的实现原理,才真正明白根因——Python 的 dictset 都是基于哈希表实现的:它们靠对 key/元素调用 hash() 算出一个哈希值、据此决定把它放到哈希表的哪个桶里,之后查找、去重也都靠这个哈希值定位。这套机制要正确工作,有一个铁的前提:一个对象在它作为 key/元素期间,它的哈希值必须始终不变(否则存的时候按一个哈希放、找的时候按另一个哈希找,就找不到了)。而列表是可变的——它的内容随时可能改变,内容变了"它该有的哈希"也就变了,这与"哈希必须不变"的前提根本冲突。所以 Python 干脆从设计上让所有可变类型(list、dict、set)都不可哈希(没有 __hash__),你一拿它当 key 或放进 set,就直接 TypeError 拦下——它不是死板,而是在源头上阻止你埋下一个"哈希会变、东西会丢"的雷。

# 需求1: 用列表当字典 key
groups = {}
groups[[1, 2]] = "a"        # ✗ TypeError: unhashable type: 'list'

# 需求2: 把列表放进集合去重
seen = set()
seen.add([1, 2])            # ✗ TypeError: unhashable type: 'list'

# 为什么? dict / set 基于哈希表:
#   存取靠 hash(key) 定位桶; 要求 key 的哈希在其作为 key 期间【始终不变】。
#   而 list 可变, 内容一变它"该有的哈希"就变 → 违背"哈希不变"的前提。
#   所以 Python 让所有可变类型(list/dict/set)都【不可哈希】, 直接拦下。

# 对比: 不可变类型可哈希, 能当 key / 进 set
hash((1, 2))                # OK, tuple 不可变 → 可哈希
hash("hello")              # OK, str 不可变 → 可哈希
hash(frozenset({1, 2}))    # OK, frozenset 不可变 → 可哈希
# d = {[1,2]: "x"}  ✗   但   d = {(1,2): "x"}  ✓

问题被钉死在这个认知错位上:我以为"任何东西都能当字典 key、放进集合",但 dict/set 是靠哈希值定位的,它要求 key/元素有一个"稳定不变的哈希身份";而列表是可变的,它的内容会变、因而没有稳定的哈希身份,根本担不起"定位依据"这个角色。Python 不是不让我图方便,而是在我即将用一个"身份会变"的东西去做"需要身份稳定"的事时,提前把我拦住了。我嫌它死板,其实它是在替我挡掉一类极其隐蔽的 bug——如果它放我过去、让我用可变列表当 key,那么一旦我之后改了这个列表,它在 dict 里就会变成一个"存进去了却再也查不到"的幽灵 key。Python 选择"从源头禁止",正是为了让我连犯这个错的机会都没有。我以为它是在为难我,其实它是在我把会变的东西交给一个要求不变的位置时,果断地说了"不"。

第一件事:想明白哈希定位为什么要求"身份稳定"

把这次事故彻底想清楚,关键是理解dict 和 set 是哈希表结构:它们不是逐个遍历去找 key,而是先用 hash(key) 算出一个值、直接定位到对应的桶、再在桶内比对。这种"按哈希定位"之所以快(平均 O(1)),正是因为它信任"同一个 key 每次算出的哈希都一样"。所以,凡是要充当 dict 的 key 或 set 的元素的对象,都必须满足两个条件:可哈希(有 __hash__、能算出哈希值),且这个哈希值在它作为 key/元素期间保持不变(通常还要求 __eq__ 一致:相等的对象哈希也相等)。

而"可变 vs 不可变"恰好对应"哈希会不会变":不可变类型(int、str、tuple、frozenset)的内容创建后不能改,所以哈希天然稳定,它们都可哈希、能当 key;可变类型(list、dict、set)的内容能改,哈希会跟着变,所以 Python 让它们不可哈希(__hash__ = None),从根上禁止它们充当 key/元素。这是 Python 一个深思熟虑的设计选择:与其允许你用可变对象当 key、然后在你改了它之后给你一个"查不到的幽灵 key"这种极难排查的 bug,不如在你放进去的那一刻就直接拒绝。要把"一组值"当 key,就把它转成不可变的等价物:列表转 tuple、集合转 frozenset、字典转 frozenset(d.items()) 或排序后的 tuple。关键认知是:任何"按内容/身份来定位、去重、索引"的机制,都要求那个被用作"身份依据"的东西本身是稳定的;一个会变的东西,没有资格充当稳定的身份。

# 正解: 把可变的"一组值"转成不可变的等价物再当 key / 进 set

# 列表 → 元组(tuple), 不可变、可哈希
groups = {}
groups[tuple([1, 2])] = "a"        # ✓  或直接 groups[(1, 2)] = "a"

# 一批列表去重 → 先转成元组再进 set
lists = [[1, 2], [1, 2], [3, 4]]
unique = {tuple(x) for x in lists}  # ✓ {(1,2), (3,4)}
# 如需还原成列表: [list(t) for t in unique]

# 集合做 key → frozenset(不可变集合)
perms = {}
perms[frozenset({"read", "write"})] = "rw"   # ✓

# 字典做 key → 转成不可变形式(注意顺序无关用 frozenset of items)
config_key = frozenset({"a": 1, "b": 2}.items())   # ✓ 可哈希

# 自定义对象想当 key: 让它不可变 + 正确实现 __hash__/__eq__
from dataclasses import dataclass
@dataclass(frozen=True)             # frozen=True → 不可变, 自动生成 __hash__
class Point:
    x: int
    y: int
d = {Point(1, 2): "origin-ish"}     # ✓ frozen dataclass 可哈希

想通这一层,我才明白自己错在哪:我把"能当 dict key / set 元素"当成了所有对象都有的能力,而没意识到这个能力有个硬性前提——"有稳定不变的哈希身份",而可变的列表恰恰没有。Python 报 unhashable type,不是在刁难我,而是在告诉我:"你想用一个身份会变的东西,去做一件要求身份不变的事,这行不通。" 正确的做法,是把这"一组值"凝固成一个不可变的快照(tuple/frozenset),让它拥有稳定的身份,再去当 key。不是和 Python 的限制对抗,而是理解"按哈希定位需要稳定身份"这个道理,主动给要当 key 的东西一个不可变的、稳定的身份。

第二件事:正解——可变值转不可变等价物,自定义对象用 frozen 并实现 hash/eq

找到根因,正解就清晰了:要把"一组值"当 dict 的 key 或放进 set,就把可变容器转成不可变的等价物——列表转 tuple、集合转 frozenset、字典转 frozenset(d.items()) 或排序后的 tuple;自定义对象想当 key,就把它做成不可变(@dataclass(frozen=True) 或只读属性)并保证 __hash__/__eq__ 一致,且参与哈希的字段在它作为 key 期间绝不修改。

# 错误: 直接拿可变容器当 key / 进 set
counts = {}
counts[[user.role, user.dept]] = 0          # ✗ unhashable type: 'list'

# 正解1: 列表 → 元组(最常用)
counts = {}
counts[(user.role, user.dept)] = 0          # ✓ 元组不可变、可哈希

# 正解2: 按"无序的一组"分组/去重 → frozenset
tag_groups = {}
tag_groups[frozenset(item.tags)] = item     # ✓ 标签集合无序, 用 frozenset

# 正解3: 自定义对象当 key —— frozen dataclass(不可变 + 自动 hash/eq)
from dataclasses import dataclass
@dataclass(frozen=True)
class CacheKey:
    user_id: int
    resource: str
cache = {CacheKey(1, "profile"): data}      # ✓

# 正解4: 嵌套结构当 key —— 递归转成不可变(注意深层也要不可变)
def freeze(obj):
    if isinstance(obj, dict):
        return frozenset((k, freeze(v)) for k, v in obj.items())
    if isinstance(obj, (list, tuple)):
        return tuple(freeze(x) for x in obj)
    if isinstance(obj, set):
        return frozenset(freeze(x) for x in obj)
    return obj                               # 不可变标量原样返回
key = freeze({"filters": ["a", "b"], "page": 1})   # 可哈希的快照

这套做法的精髓,是给"要充当身份依据的东西"一个不可变的、稳定的快照:把当下这组值"凝固"成一个永不改变的 tuple/frozenset,让它从此拥有稳定的哈希身份,才有资格当 key。列表转元组是最常用的一招;无序的一组用 frozenset;自定义对象用 frozen dataclass 一举拿到不可变性和正确的 __hash__/__eq__;嵌套结构则递归地冻结。核心都是同一件事:用不可变的等价物,替代那个会变的、没资格当 key 的可变对象不是绕过 Python 的限制,而是顺着它的设计,给 key 一个名副其实的稳定身份。

【用 dict key / set 元素, 我现在认死的几条】

1. dict/set 基于哈希表, 要求 key/元素可哈希且哈希在作为 key 期间不变

2. 可变类型(list/dict/set)不可哈希, 当 key/进 set 直接 TypeError

3. 不可变类型(int/str/tuple/frozenset)可哈希, 能当 key

4. 列表当 key → 转 tuple; 无序一组 → frozenset; 字典 → frozenset(items)

5. 自定义对象当 key → @dataclass(frozen=True), 自动不可变 + hash/eq

6. 参与哈希的字段, 在它作为 key 期间绝不修改(否则和 Java 幽灵键同坑)

7. unhashable 报错不是死板, 是在源头拦下"哈希会变、东西会丢"的雷

第三件事:其他"用会变的东西做需要稳定身份的事"的同类坑

顺着"用一个会变的东西,去充当需要稳定身份/依据的角色"这条线,我把同类的坑都排查了一遍:

第一个,可变对象当 key 放进去后又改它(其他语言)。Java/C# 允许可变对象当 HashMap key,但改了参与 hashCode 的字段后就成"查不到的幽灵 key"——Python 干脆禁止,是更彻底的防护。

第二个,用会变的字段做唯一标识/主键。拿一个会被修改的业务字段当主键,改了之后所有引用它的地方就全断了;主键应当稳定不变。

第三个,用浮点数当 key 比较相等。浮点有精度误差,0.1+0.2 算出的 key 和 0.3 不相等,导致取不到;需要的话用定点/字符串/整数化的 key。

第四个,缓存 key 里塞入会变的内容。缓存 key 包含了某个时刻就会变的值,导致每次都不命中、或命中到错的;缓存 key 要由稳定的、决定性的输入构成。

第四件事:可哈希 vs 不可哈希——一张对照表

我把常见类型按"可不可哈希、能不能当 key"归了类,用前先对一眼:

类型 可变? 可哈希? 能当 key/进 set?
int / float / str / bool 不可变
tuple(元素都可哈希) 不可变
frozenset 不可变
list 可变 不能 → 转 tuple
dict / set 可变 不能 → 转 frozenset
含 list 的 tuple 内部可变 不能(深层有可变)

看清这张表,规律就清楚了:不可变(且内部也都不可变)= 可哈希 = 能当 key;只要可变(或内部含可变),就不可哈希、不能当 key,得先转成不可变等价物。注意 tuple 虽不可变,但若它装着 list((1, [2, 3]))就仍不可哈希——因为深层有可变成分。我这次踩坑,正是直接拿 list 当 key/进 set。把可变值凝固成 tuple/frozenset,是拿到稳定哈希身份的标准做法。

第五件事:我曾经对哈希定位想当然的几个误区

这次事故也把我对 dict/set 的一堆"想当然"照了个底朝天:

我以为 实际上
任何东西都能当字典 key / 放进 set 只有可哈希的(不可变的)才能
unhashable type 是 Python 死板 是在源头拦下"哈希会变、东西会丢"的雷
列表和元组差不多, 当 key 应该都行 list 可变不可哈希, tuple 不可变可哈希
tuple 一定能当 key 若它内部装了 list 等可变物, 仍不可哈希
自定义对象默认就能正确当 key 去重 得不可变 + 正确实现 __hash__/__eq__ 才行

这些误区的根子是同一个:我没意识到"当 dict key / set 元素"这件事,本质是"充当一个按哈希定位的身份依据",而这要求那个东西有稳定不变的身份;可变的列表内容会变、身份不稳,因而没资格。Python 用"可变即不可哈希"这条清晰的规则,把"能不能当身份依据"和"身份稳不稳定"绑在了一起,从语言层面替我守住了"定位依据必须稳定"这条底线。把"我想用它当 key"当成"它就能当 key",而忽略了当 key 需要稳定身份这个前提,是这类 unhashable 错误的共同根源。

第六件事:用 dict/set、遇到 unhashable 时,我现在的自检习惯

现在每当我用 dict/set、或遇到 TypeError: unhashable type,我都会先按这张图问自己:

这张图的精髓,是"dict/set 按哈希定位、要求 key 有稳定不变的哈希身份;可变的东西没资格, 要先转成不可变等价物"设计就把要当 key 的可变值转成 tuple/frozenset、自定义对象用 frozen dataclass、参与哈希的字段绝不改、排查就看报 unhashable 的那个东西是不是 list/dict 等可变类型这套习惯,让我从"随手拿列表当 key"变成了"先确认它有没有稳定哈希身份"——核心始终是:dict 和 set 都是基于哈希表实现的:它们靠对 key/元素调用 hash() 算出哈希值、据此决定放到哈希表的哪个桶、之后查找去重也靠这个哈希值定位,这种按哈希定位之所以平均 O(1) 快正是因为它信任同一个 key 每次算出的哈希都一样;所以充当 dict key 或 set 元素的对象必须满足两个条件——可哈希(有 __hash__ 能算出哈希值)且这个哈希值在它作为 key/元素期间保持不变(通常还要求 __eq__ 一致、相等的对象哈希也相等);而可变与不可变恰好对应哈希会不会变:不可变类型(int、str、tuple、frozenset)内容创建后不能改所以哈希天然稳定都可哈希能当 key,可变类型(list、dict、set)内容能改哈希会跟着变所以 Python 让它们不可哈希(__hash__=None)从根上禁止它们充当 key/元素;这是 Python 深思熟虑的设计选择——与其允许你用可变对象当 key 然后在你改了它之后给你一个查不到的幽灵 key 这种极难排查的 bug,不如在你放进去的那一刻就直接拒绝;要把一组值当 key 就把它转成不可变的等价物(列表转 tuple、集合转 frozenset、字典转 frozenset(items)、自定义对象用 @dataclass(frozen=True)),注意装着 list 的 tuple 因深层含可变仍不可哈希;一句话,任何按内容/身份来定位去重索引的机制都要求那个被用作身份依据的东西本身是稳定的、一个会变的东西没有资格充当稳定的身份。

我立下的几条规矩

这场"列表不能当 key"的事故,换来了我用 dict/set 时,刻进骨子里的几条铁律:

  1. dict/set 基于哈希表,要求 key/元素可哈希且哈希在作为 key 期间不变。
  2. 可变类型(list/dict/set)不可哈希,当 key/进 set 直接 TypeError。
  3. 不可变类型(int/str/tuple/frozenset)可哈希,能当 key。
  4. 列表当 key→转 tuple;无序一组→frozenset;字典→frozenset(items)。
  5. 自定义对象当 key→@dataclass(frozen=True),自动不可变 + hash/eq。
  6. 装着 list 的 tuple 仍不可哈希(深层含可变);要递归冻结。
  7. 参与哈希的字段在它作为 key 期间绝不修改(否则同 Java 幽灵键)。

附:我现在把"一组值"安全当 key 的"冻结成不可变快照"工具

这是我现在处理"用一组值当 dict key / set 元素"固定套的工具——把这次踩坑的教训(可变值先冻结成不可变快照、自定义对象用 frozen、深层递归冻结)固化成几个函数,让"unhashable type"那种坑再不会埋进代码:

from dataclasses import dataclass

# 工具1: 递归冻结任意嵌套结构为可哈希的不可变快照
def freeze(obj):
    if isinstance(obj, dict):
        # 字典 → 按 key 排序的 items 元组(顺序无关也可用 frozenset)
        return tuple(sorted((k, freeze(v)) for k, v in obj.items()))
    if isinstance(obj, (list, tuple)):
        return tuple(freeze(x) for x in obj)
    if isinstance(obj, set):
        return frozenset(freeze(x) for x in obj)
    return obj                         # 不可变标量原样返回

# 用法: 任何"会变的一组值"当 key 前先 freeze 一下
cache = {}
params = {"filters": ["a", "b"], "page": 1, "tags": {"x", "y"}}
cache[freeze(params)] = result        # ✓ 不再 unhashable

# 工具2: 自定义实体当 key —— frozen dataclass(不可变 + 自动 hash/eq)
@dataclass(frozen=True)
class CacheKey:
    user_id: int
    resource: str
    version: int
d = {CacheKey(1, "profile", 2): data} # ✓ 三字段共同构成稳定身份

# 工具3: 分组/去重的通用写法 —— 用 freeze 出来的不可变 key
from collections import defaultdict
groups = defaultdict(list)
for item in items:
    key = freeze([item.role, item.dept])   # 列表 → 元组快照
    groups[key].append(item)

# 自检: 断言冻结后的 key 可哈希、且相同内容冻结出相同 key(去重才正确)
assert hash(freeze([1, [2, 3]]))                  # 可哈希
assert freeze({"a": 1, "b": 2}) == freeze({"b": 2, "a": 1})  # 顺序无关一致

这套工具把我这次的教训钉死在了代码里:任何"会变的一组值"当 key/进 set 前,都先用 freeze 递归冻结成不可变快照(list→tuple、set→frozenset、dict→排序 items 元组、深层也冻);自定义实体当 key 用 frozen dataclass 一举拿到不可变性和正确的 hash/eq;并用断言验证冻结后可哈希、相同内容冻结出相同 key(保证去重正确)。这样,要当 key 的东西永远有一个稳定不变的哈希身份,而不再是当初那个"拿会变的列表当 key、被 Python 当场拦下"的局面。把"身份必须稳定、用不可变之物做定位依据"这个道理,沉淀成处理 key 的固定工具,这是我对这次"unhashable type"最实在的交代——毕竟,要让一个东西可靠地指代另一个东西,它自己得先稳稳地、不变地待在那儿。

写在最后

回头看,这场由"unhashable type"引发的"列表不能当 key"事故,真正教给我的,远不止"把列表转成元组"这一个技巧。它让我对"当我们要用某个东西去充当一个'身份'——一个用来标识、定位、去重、索引的依据时,这个东西本身必须是'稳定的、不变的';因为'身份'的全部意义,就在于它能可靠地、一致地指代同一个东西,而一个会变的东西,根本无法承担这个'始终指代同一个'的职责",有了一次刻骨的体会。我栽跟头,是因为我想用一个"会变的东西(可变列表)",去充当一个"需要稳定不变(哈希定位依据)"的角色——我只想着"用这组值当 key 多方便",却没想过"这组值它会变吗?变了之后还能不能指代同一个东西?";而 Python 替我想到了:它知道哈希定位的全部前提是"身份不变",知道列表是会变的、配不上这个前提,于是它没有放我过去、没有让我先尝到方便、再在某个深夜被"查不到的幽灵 key"狠狠教训,而是当场、明确地把我拦了下来;我当时只觉得它死板,后来才懂,那声 TypeError 是一道把"会变的东西"挡在"需要不变的位置"之外的、深思熟虑的防线这让我领悟到一个关于"身份与稳定性"的深刻认知:凡是要充当"身份/标识/定位依据"的东西(哈希 key、主键、唯一标识、缓存 key、索引依据),都必须具备"稳定不变"这一根本属性——因为身份的本质是"在变化中保持对同一个对象的可靠指代",一旦这个依据自身会变,它就无法再可靠地指代任何东西,基于它建立的一切定位、查找、去重、关联都会随之瓦解;所以选择"什么来充当身份"时,第一要问的不是"它方不方便、信息全不全",而是"它稳不稳定、会不会变":宁可用一个朴素但不变的东西(一个 id、一个不可变快照)当身份,也不要用一个丰富但会变的东西;而一个好的系统(像 Python 这样),会在你试图用会变之物充当身份时主动报错、把问题挡在源头,而不是放任你埋下一个要到很久以后才会爆、且极难排查的雷这给了我一种看待"一切'选取某物作为标识/定位依据'之事"时的清醒:每当我要用某个东西去当 key、当标识、当定位/去重的依据时,要追问"这个东西稳定吗?它在我用它当依据的整个期间会不会变?如果会变,我是不是该换一个不变的、或先把它凝固成一个不可变的快照"——永远用稳定不变之物充当身份依据,会变的东西要么别用、要么先冻结成不可变的快照;"身份必须稳定、用不可变之物做定位依据",是用对 Python 的 dict/set、也是设计一切按身份定位之系统的关键认清 dict/set 按哈希定位要稳定身份、可变类型不可哈希、要转成不可变等价物当 key——这,是我用一次"列表当 key 报 unhashable"的事故,换来的、关于 Python、也关于身份为何必须稳定的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想拿一个列表当字典 key、或丢进 set 时,先停一秒想想"它会变吗?要不要先转成元组?",那我对着那个"不就是拿列表当 key 吗、至于报错"的 TypeError 较劲的那阵子,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我的 AI Agent 多步任务跑着跑着就再也不动了、既不报错也不返回结果,用户那边一直转圈等到天荒地老,我盯着日志看了半天发现它卡在某一次调用外部工具的地方一动不动,最后才意识到我给每个工具调用都没设超时一个外部接口不返回就能让整个 Agent 永远等下去的深度复盘

2026-6-3 8:55:09

技术教程

我在 JavaScript 里用 reduce 求和写得简洁又顺手、测试也全过,上线后却突然抛出 Reduce of empty array with no initial value 把页面整个搞崩,我盯着那行用了无数次的 reduce 百思不得其解最后才明白只要传进来的是个空数组而我又没给它一个初始值 reduce 就会因为根本没有起点可用而直接报错的深度复盘

2026-6-3 9:08:44

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索