
小王那天写了一个缓存装饰器,想把函数的参数作为键,缓存计算结果。参数里有列表,列表不可哈希,他就先把列表转成了集合,因为集合可以去重,而且看起来“差不多”。代码大概长这样:
cache = {}
def cached_func(items):
key = set(items) # 把列表转成集合作为键
if key in cache:
return cache[key]
result = sum(items)
cache[key] = result
return result
print(cached_func([1, 2, 3]))结果第一行就崩了:
TypeError: unhashable type: 'set'他盯着报错愣了半天:“集合不是可以哈希吗?为什么不能当字典的键?”
答案很简单:set 是可变的,可变对象不可哈希。 要想把集合当键,必须用 frozenset。
小王把 set(items) 改成 frozenset(items),问题瞬间解决。他叹了口气:“原来集合还分两种,我一直以为 set 就是集合的全部。”
这个坑很典型。很多人用 set 用得很顺手,却不知道还有一个 frozenset,更不知道两者的区别会在哪些场景下突然咬你一口。
s = set([1, 2, 3])
fs = frozenset([1, 2, 3])
print(s) # {1, 2, 3}
print(fs) # frozenset({1, 2, 3})
s.add(4) # 正常
print(s) # {1, 2, 3, 4}
fs.add(4) # 报错AttributeError: 'frozenset' object has no attribute 'add'set 有一堆修改方法:add、remove、discard、pop、clear、update。frozenset 一个都没有。它一旦创建,内容就固定了,不能增、不能删、不能改。
用一个生活场景来理解。你有一个标签盒,里面放着各种标签。
set 是一个可以随时打开、往里加标签、往外拿标签的盒子。你可以今天放“重要”,明天放“紧急”,后天把“紧急”撕掉。
frozenset 是一个封死的透明盒子。里面的标签在封装的那一刻就定死了,你只能看,不能动。
区别一目了然:一个可变,一个不可变。
要理解 set 为什么不能当字典键,得先知道哈希是怎么回事。
Python 的字典和集合底层都是哈希表。哈希表靠“哈希值”来快速定位元素。一个对象要能放进哈希表,它的哈希值必须在整个生命周期内保持不变。如果哈希值会变,那么你存进去之后,就再也找不到了。
列表、字典、set 都是可变的。今天里面是 [1, 2, 3],明天可能变成 [1, 2, 3, 4]。如果允许它们哈希,那么内容一变,哈希值就变了,之前存的位置就失效了。所以 Python 干脆规定:可变对象不可哈希。
列表不可哈希,字典不可哈希,set 也不可哈希。
d = {}
d[[1, 2]] = "value" # TypeError: unhashable type: 'list'
d[{1, 2}] = "value" # TypeError: unhashable type: 'set'
d[{"a": 1}] = "value" # TypeError: unhashable type: 'dict'而 frozenset 是不可变的,它的内容永远不会变,哈希值可以稳定计算。所以它可以哈希,可以当字典键,也可以作为另一个集合的元素。
d = {}
d[frozenset([1, 2])] = "value"
print(d) # {frozenset({1, 2}): 'value'}
s = set()
s.add(frozenset([1, 2]))
print(s) # {frozenset({1, 2})}集合的元素必须是可哈希的。所以你不能把一个 set 放进另一个 set 里:
s = set()
s.add({1, 2}) # TypeError: unhashable type: 'set'但你可以把 frozenset 放进去:
s = set()
s.add(frozenset([1, 2]))
s.add(frozenset([3, 4]))
print(s) # {frozenset({1, 2}), frozenset({3, 4})}这就是“集合的集合”的正确写法:外层用 set(因为你要往里加东西),内层用 frozenset(因为要可哈希)。
如果你需要一个不可变的“集合的集合”,那就外层也用 frozenset:
fs = frozenset([frozenset([1, 2]), frozenset([3, 4])])这样整个结构都是不可变的,可以继续当字典键,或者嵌入更深的结构。
set 和 frozenset 都支持标准的集合运算:并集、交集、差集、对称差集。
a = set([1, 2, 3])
b = set([2, 3, 4])
print(a | b) # {1, 2, 3, 4} 并集
print(a & b) # {2, 3} 交集
print(a - b) # {1} 差集
print(a ^ b) # {1, 4} 对称差集frozenset 也一样:
fa = frozenset([1, 2, 3])
fb = frozenset([2, 3, 4])
print(fa | fb) # frozenset({1, 2, 3, 4})
print(fa & fb) # frozenset({2, 3})
print(fa - fb) # frozenset({1})
print(fa ^ fb) # frozenset({1, 4})区别在于原地修改运算符。
set 支持 |=、&=、-=、^=,这些运算符会直接修改左边的集合:
a = set([1, 2, 3])
a |= {4, 5}
print(a) # {1, 2, 3, 4, 5},a 被修改了frozenset 不支持这些原地运算符:
fa = frozenset([1, 2, 3])
fa |= {4, 5} # 报错AttributeError: 'frozenset' object has no attribute '...'但你可以用普通运算符生成新集合:
fa = frozenset([1, 2, 3])
fa = fa | {4, 5}
print(fa) # frozenset({1, 2, 3, 4, 5})注意这里 fa 被重新赋值了,原来的 frozenset 没变,只是变量指向了一个新的 frozenset。
这个区别在函数传参时特别重要。如果你把一个 set 传进函数,函数内部用 |= 修改了它,外部的 set 也会跟着变。而 frozenset 永远不会被修改,传进去什么样,出来还是什么样。
这是一个经典坑,和之前讨论过的可变默认参数是同一个问题。
def add_tag(tag, tags=set()):
tags.add(tag)
return tags
print(add_tag("Python"))
print(add_tag("Java"))输出:
{'Python'}
{'Python', 'Java'}因为默认参数 set() 在函数定义时创建了一次,之后所有调用共享同一个集合。和 []、{} 一样,可变的默认参数会累积。
解决方案还是用 None:
def add_tag(tag, tags=None):
if tags is None:
tags = set()
tags.add(tag)
return tags如果你希望默认值是不可变的,可以用 frozenset:
def add_tag(tag, tags=frozenset()):
return tags | {tag}但这样每次返回的是新集合,不会累积,语义不同。大多数时候还是用 None 更清晰。
这就是小王踩的坑。
cache = {}
key = {1, 2, 3}
cache[key] = "value" # TypeError: unhashable type: 'set'改成 frozenset 就行:
cache[frozenset({1, 2, 3})] = "value"但要注意:set 是无序的,frozenset 也是无序的。frozenset([1, 2, 3]) 和 frozenset([3, 2, 1]) 是相等的,哈希值也一样。所以它们会命中同一个缓存条目。
print(frozenset([1, 2, 3]) == frozenset([3, 2, 1])) # True
print(hash(frozenset([1, 2, 3])) == hash(frozenset([3, 2, 1]))) # True这是好事,因为集合本身就代表“无序的一组元素”。如果你需要有序,应该用元组。
set 是无序的。虽然在小数据量下,遍历顺序看起来可能“稳定”,但这是实现细节,不能依赖。
s = {3, 1, 2}
for x in s:
print(x)输出可能是 1 2 3,也可能是 3 1 2,取决于哈希值和内部桶的顺序。不同 Python 版本、不同运行环境都可能不同。
frozenset 也是无序的。两者在遍历顺序上没有区别。
如果你需要有序的集合,可以用 dict.fromkeys() 去重并保持插入顺序:
items = [3, 1, 2, 1, 3]
unique_ordered = list(dict.fromkeys(items))
print(unique_ordered) # [3, 1, 2]或者用 sorted() 对集合排序:
for x in sorted(s):
print(x)这个坑在函数式编程或者多线程环境下特别危险。
def process(data):
data -= {0} # 原地修改
return data
original = {1, 2, 3, 0}
result = process(original)
print(original) # {1, 2, 3},被改了!如果你不想修改原集合,应该用 - 而不是 -=:
def process(data):
return data - {0}frozenset 没有原地运算符,所以永远不会意外修改原对象。这也是 frozenset 更安全的地方。
判断标准很清晰:
需要修改集合内容(增删元素):用 set。
需要把集合当字典键,或者放进另一个集合:用 frozenset。
需要保证集合不被意外修改:用 frozenset。
做集合运算但不想影响原集合:两者都可以,但 frozenset 更安全。
需要集合的集合:外层 set,内层 frozenset。
函数参数默认值:避免用 set() 和 frozenset(),用 None。
set 和 frozenset 可以互相转换:
s = {1, 2, 3}
fs = frozenset(s) # set -> frozenset
s2 = set(fs) # frozenset -> set转换会创建一个新对象。原来的对象不受影响。
s = {1, 2, 3}
fs = frozenset(s)
s.add(4)
print(s) # {1, 2, 3, 4}
print(fs) # frozenset({1, 2, 3}),没变frozenset 因为不可变,在某些操作上可能比 set 稍快,比如哈希计算可以缓存。但差距通常很小,不应该作为选择的主要依据。
import timeit
s = set(range(1000))
fs = frozenset(range(1000))
t1 = timeit.timeit(lambda: 500 in s, number=1000000)
t2 = timeit.timeit(lambda: 500 in fs, number=1000000)
print(f"set 成员检查: {t1:.4f}")
print(f"frozenset 成员检查: {t2:.4f}")在我的机器上,两者几乎一样快。所以选择 set 还是 frozenset,应该基于语义(是否需要可变、是否可哈希),而不是性能。
集合运算在去重、找共同元素、找差异时非常方便。
比如,有两个用户列表,想找共同关注的人:
user1_follows = {"Alice", "Bob", "Charlie"}
user2_follows = {"Bob", "David", "Charlie"}
common = user1_follows & user2_follows
print(common) # {'Bob', 'Charlie'}想找 user1 关注但 user2 没关注的人:
only_user1 = user1_follows - user2_follows
print(only_user1) # {'Alice'}想找两个人关注的所有人:
all_follows = user1_follows | user2_follows
print(all_follows) # {'Alice', 'Bob', 'Charlie', 'David'}这些运算对 set 和 frozenset 都适用。如果这些集合需要被缓存或者作为字典键,就用 frozenset。
小王最终把缓存装饰器改成了:
cache = {}
def cached_func(items):
key = frozenset(items) # 用 frozenset 作为键
if key in cache:
return cache[key]
result = sum(items)
cache[key] = result
return result
print(cached_func([1, 2, 3]))
print(cached_func([3, 2, 1])) # 命中同一个缓存注意第二次调用 [3, 2, 1] 会命中第一次的缓存,因为 frozenset([1, 2, 3]) == frozenset([3, 2, 1])。这正是他想要的:参数顺序不同但元素相同,结果应该一样。
他后来在团队文档里写了一条:“set 是可变的,不能哈希。要当字典键或者放进另一个集合,用 frozenset。需要修改集合内容,用 set。两者都支持集合运算,但 set 有原地运算符,会改原对象,用的时候小心。”
这个区分看起来简单,但真到写代码的时候,很多人会下意识用 set,然后在需要哈希的地方突然报错。或者用 set 做集合运算,不小心用 -= 改了原集合,导致下游数据出错。
记住那个标签盒的比喻:set 是可以随时打开的盒子,frozenset 是封死的透明盒子。想清楚你要的是哪种,再决定用哪个。
集合运算本身不难,难的是想清楚“这个集合会不会被修改”以及“它需不需要被哈希”。想清楚了,set 和 frozenset 就不再是坑,而是两个各有所长的工具。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。