首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python的集合运算把我坑惨了,原来set和frozenset的区别这么大

Python的集合运算把我坑惨了,原来set和frozenset的区别这么大

原创
作者头像
风一样的男子
发布于 2026-10-12 10:50:43
发布于 2026-10-12 10:50:43
250
举报
文章被收录于专栏:编程教程编程教程

小王那天写了一个缓存装饰器,想把函数的参数作为键,缓存计算结果。参数里有列表,列表不可哈希,他就先把列表转成了集合,因为集合可以去重,而且看起来“差不多”。代码大概长这样:

代码语言:javascript
复制
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]))

结果第一行就崩了:

代码语言:javascript
复制
TypeError: unhashable type: 'set'

他盯着报错愣了半天:“集合不是可以哈希吗?为什么不能当字典的键?”

答案很简单:set 是可变的,可变对象不可哈希。 要想把集合当键,必须用 frozenset。

小王把 set(items) 改成 frozenset(items),问题瞬间解决。他叹了口气:“原来集合还分两种,我一直以为 set 就是集合的全部。”

这个坑很典型。很多人用 set 用得很顺手,却不知道还有一个 frozenset,更不知道两者的区别会在哪些场景下突然咬你一口。

先看一个最直观的对比

代码语言:javascript
复制
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)       # 报错
代码语言:javascript
复制
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 也不可哈希。

代码语言:javascript
复制
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 是不可变的,它的内容永远不会变,哈希值可以稳定计算。所以它可以哈希,可以当字典键,也可以作为另一个集合的元素。

代码语言:javascript
复制
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,内层必须是 frozenset

集合的元素必须是可哈希的。所以你不能把一个 set 放进另一个 set 里:

代码语言:javascript
复制
s = set()
s.add({1, 2})  # TypeError: unhashable type: 'set'

但你可以把 frozenset 放进去:

代码语言:javascript
复制
s = set()
s.add(frozenset([1, 2]))
s.add(frozenset([3, 4]))
print(s)  # {frozenset({1, 2}), frozenset({3, 4})}

这就是“集合的集合”的正确写法:外层用 set(因为你要往里加东西),内层用 frozenset(因为要可哈希)。

如果你需要一个不可变的“集合的集合”,那就外层也用 frozenset:

代码语言:javascript
复制
fs = frozenset([frozenset([1, 2]), frozenset([3, 4])])

这样整个结构都是不可变的,可以继续当字典键,或者嵌入更深的结构。

集合运算:两者都支持,但原地运算符有区别

set 和 frozenset 都支持标准的集合运算:并集、交集、差集、对称差集。

代码语言:javascript
复制
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 也一样:

代码语言:javascript
复制
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 支持 |=、&=、-=、^=,这些运算符会直接修改左边的集合:

代码语言:javascript
复制
a = set([1, 2, 3])
a |= {4, 5}
print(a)  # {1, 2, 3, 4, 5},a 被修改了

frozenset 不支持这些原地运算符:

代码语言:javascript
复制
fa = frozenset([1, 2, 3])
fa |= {4, 5}  # 报错
代码语言:javascript
复制
AttributeError: 'frozenset' object has no attribute '...'

但你可以用普通运算符生成新集合:

代码语言:javascript
复制
fa = frozenset([1, 2, 3])
fa = fa | {4, 5}
print(fa)  # frozenset({1, 2, 3, 4, 5})

注意这里 fa 被重新赋值了,原来的 frozenset 没变,只是变量指向了一个新的 frozenset。

这个区别在函数传参时特别重要。如果你把一个 set 传进函数,函数内部用 |= 修改了它,外部的 set 也会跟着变。而 frozenset 永远不会被修改,传进去什么样,出来还是什么样。

坑一:函数默认参数用了 set

这是一个经典坑,和之前讨论过的可变默认参数是同一个问题。

代码语言:javascript
复制
def add_tag(tag, tags=set()):
    tags.add(tag)
    return tags

print(add_tag("Python"))
print(add_tag("Java"))

输出:

代码语言:javascript
复制
{'Python'}
{'Python', 'Java'}

因为默认参数 set() 在函数定义时创建了一次,之后所有调用共享同一个集合。和 []、{} 一样,可变的默认参数会累积。

解决方案还是用 None:

代码语言:javascript
复制
def add_tag(tag, tags=None):
    if tags is None:
        tags = set()
    tags.add(tag)
    return tags

如果你希望默认值是不可变的,可以用 frozenset:

代码语言:javascript
复制
def add_tag(tag, tags=frozenset()):
    return tags | {tag}

但这样每次返回的是新集合,不会累积,语义不同。大多数时候还是用 None 更清晰。

坑二:把 set 当字典键

这就是小王踩的坑。

代码语言:javascript
复制
cache = {}
key = {1, 2, 3}
cache[key] = "value"  # TypeError: unhashable type: 'set'

改成 frozenset 就行:

代码语言:javascript
复制
cache[frozenset({1, 2, 3})] = "value"

但要注意:set 是无序的,frozenset 也是无序的。frozenset([1, 2, 3]) 和 frozenset([3, 2, 1]) 是相等的,哈希值也一样。所以它们会命中同一个缓存条目。

代码语言:javascript
复制
print(frozenset([1, 2, 3]) == frozenset([3, 2, 1]))  # True
print(hash(frozenset([1, 2, 3])) == hash(frozenset([3, 2, 1])))  # True

这是好事,因为集合本身就代表“无序的一组元素”。如果你需要有序,应该用元组。

坑三:以为 set 的遍历顺序是固定的

set 是无序的。虽然在小数据量下,遍历顺序看起来可能“稳定”,但这是实现细节,不能依赖。

代码语言:javascript
复制
s = {3, 1, 2}
for x in s:
    print(x)

输出可能是 1 2 3,也可能是 3 1 2,取决于哈希值和内部桶的顺序。不同 Python 版本、不同运行环境都可能不同。

frozenset 也是无序的。两者在遍历顺序上没有区别。

如果你需要有序的集合,可以用 dict.fromkeys() 去重并保持插入顺序:

代码语言:javascript
复制
items = [3, 1, 2, 1, 3]
unique_ordered = list(dict.fromkeys(items))
print(unique_ordered)  # [3, 1, 2]

或者用 sorted() 对集合排序:

代码语言:javascript
复制
for x in sorted(s):
    print(x)

坑四:集合运算返回新对象,但 set 的原地运算符会改原对象

这个坑在函数式编程或者多线程环境下特别危险。

代码语言:javascript
复制
def process(data):
    data -= {0}  # 原地修改
    return data

original = {1, 2, 3, 0}
result = process(original)
print(original)  # {1, 2, 3},被改了!

如果你不想修改原集合,应该用 - 而不是 -=:

代码语言:javascript
复制
def process(data):
    return data - {0}

frozenset 没有原地运算符,所以永远不会意外修改原对象。这也是 frozenset 更安全的地方。

什么时候用 set,什么时候用 frozenset?

判断标准很清晰:

需要修改集合内容(增删元素):用 set。

需要把集合当字典键,或者放进另一个集合:用 frozenset。

需要保证集合不被意外修改:用 frozenset。

做集合运算但不想影响原集合:两者都可以,但 frozenset 更安全。

需要集合的集合:外层 set,内层 frozenset。

函数参数默认值:避免用 set() 和 frozenset(),用 None。

相互转换

set 和 frozenset 可以互相转换:

代码语言:javascript
复制
s = {1, 2, 3}
fs = frozenset(s)   # set -> frozenset
s2 = set(fs)        # frozenset -> set

转换会创建一个新对象。原来的对象不受影响。

代码语言:javascript
复制
s = {1, 2, 3}
fs = frozenset(s)
s.add(4)
print(s)   # {1, 2, 3, 4}
print(fs)  # frozenset({1, 2, 3}),没变

性能差异

frozenset 因为不可变,在某些操作上可能比 set 稍快,比如哈希计算可以缓存。但差距通常很小,不应该作为选择的主要依据。

代码语言:javascript
复制
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,应该基于语义(是否需要可变、是否可哈希),而不是性能。

集合运算的实际应用

集合运算在去重、找共同元素、找差异时非常方便。

比如,有两个用户列表,想找共同关注的人:

代码语言:javascript
复制
user1_follows = {"Alice", "Bob", "Charlie"}
user2_follows = {"Bob", "David", "Charlie"}

common = user1_follows & user2_follows
print(common)  # {'Bob', 'Charlie'}

想找 user1 关注但 user2 没关注的人:

代码语言:javascript
复制
only_user1 = user1_follows - user2_follows
print(only_user1)  # {'Alice'}

想找两个人关注的所有人:

代码语言:javascript
复制
all_follows = user1_follows | user2_follows
print(all_follows)  # {'Alice', 'Bob', 'Charlie', 'David'}

这些运算对 set 和 frozenset 都适用。如果这些集合需要被缓存或者作为字典键,就用 frozenset。

回到小王的故事

小王最终把缓存装饰器改成了:

代码语言:javascript
复制
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 删除。

目录
  • 先看一个最直观的对比
  • 为什么可变就不能哈希?
  • 嵌套集合:外层必须是 set,内层必须是 frozenset
  • 集合运算:两者都支持,但原地运算符有区别
  • 坑一:函数默认参数用了 set
  • 坑二:把 set 当字典键
  • 坑三:以为 set 的遍历顺序是固定的
  • 坑四:集合运算返回新对象,但 set 的原地运算符会改原对象
  • 什么时候用 set,什么时候用 frozenset?
  • 相互转换
  • 性能差异
  • 集合运算的实际应用
  • 回到小王的故事
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档