22  可变性,可哈希对象

22.1 可哈希对象

22.1.1 什么是哈希

根据颜色将书本放入不同的隔间,加快查找速度,是一个直观的哈希应用。

假设你要在图书馆里找一本书。如果所有书都随机堆在一起,你得一本一本翻——运气不好要翻遍整个图书馆。但如果图书馆按颜色把书分到不同隔间(红色的放A区、蓝色的放B区……),你要找红皮书就直接去A区找,快得多。

这个“按某个规则把东西分到不同格子”的过程,就是哈希(hash)。

在计算机中,哈希函数将任意大小的数据(字符串、数字、任何对象)映射到一个固定长度的整数,这个整数称为哈希值(hash value)。

hash('hello')
-4938767993309476271
hash(42)
42
hash((1, 2, 3))
529344067295497451

哈希值有两个重要特点:

  • 确定性:同一个对象、同一个程序运行中,多次调用 hash() 返回相同的整数。
  • 固定长度输出:不管输入多大(一个字符还是一整本书),输出都是一个长度固定的整数1

不同的对象可能产生相同的哈希值,称为哈希碰撞。好的哈希函数会把碰撞概率降到很低,但不能完全避免。

注记哈希值在不同 Python 运行中可能不同

Python 默认启用哈希随机化。同一个字符串在单个进程内的哈希值相同,但在不同进程中的哈希值可能不同。这是为了防止恶意构造哈希碰撞的攻击。

22.1.2 可变性

Python中有的对象是可变的(mutable),可变对象的值可以被改变。

有的对象不可变(immutable)。原则上来说,不可变对象一经创建,其值不可被改变。

对象的可变性,由其类型决定。

  • 可变对象:list, set, dict, bytearray
  • 不可变对象:int, float, bool, complex; str, bytes; tuple

22.1.3 可哈希对象

如果一个对象的(哈希)值在其生命周期中不会改变,这个对象就是可哈希的(hashable)。可哈希对象可以用作字典的键,也可以作为集合的元素。

hash() 函数可以查看一个对象的哈希值:

# 不可变类型通常是可哈希的
hash(42), hash(3.14), hash('hello'), hash((1, 2, 3))
(42, 322818021289917443, -4938767993309476271, 529344067295497451)
# 可变类型是不可哈希的
hash([1, 2, 3])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 2
      1 # 可变类型是不可哈希的
----> 2 hash([1, 2, 3])

TypeError: unhashable type: 'list'

一个常见的误解是「不可变 = 可哈希」。 实际上,不可变容器只有在它的所有元素都可哈希的时候,才是可哈希的: 判断一个对象是否可哈希,最简单的方法是看 hash() 函数是否报错。

# tuple 的元素全部不可变,可哈希
t1 = (1, 2, 3)
hash(t1)
529344067295497451
# tuple 包含可变元素,不可哈希
t2 = (1, 2, [3, 4])
hash(t2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[7], line 3
      1 # tuple 包含可变元素,不可哈希
      2 t2 = (1, 2, [3, 4])
----> 3 hash(t2)

TypeError: unhashable type: 'list'

22.1.4 为什么字典的键必须可哈希

字典的底层实现是哈希表(hash table)。当你执行 d[key] = value 时,Python 做了两件事:

  1. 计算 key 的哈希值,决定这个键值对放在哪个“格子”(bucket)里
  2. 在这个格子里存储键值对

当你查找 d[key] 时,Python 再次计算 key 的哈希值,直接去对应的格子里找,而不需要遍历所有键。这就是字典查找接近 O(1) 的原因。

现在,假设字典的键是可变的:

  1. 你把一个列表 [1, 2] 作为键存入字典,它的哈希值决定了它所在的格子
  2. 你修改了这个列表:[1, 2].append(3)
  3. 列表的值变了,哈希值也变了
  4. 现在你按新哈希值去找,这个键值对已经不在那个格子里了
  5. 按旧哈希值去找,格子里能匹配到这个键,但它的值已经变了——字典的内部结构乱掉了
# 假如用列表做字典键
d = {[1, 2]: 'value'}
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 2
      1 # 假如用列表做字典键
----> 2 d = {[1, 2]: 'value'}

TypeError: unhashable type: 'list'

集合相当于只有键没有值的字典,因此集合元素也必须是可哈希的。

重要总结

可变对象的哈希值会随着自身的变化而改变,所以不能做字典的键或集合的元素。可哈希的本质要求是:对象的身份和值必须终身绑定,不能变。

表 22.1: 常见类型的可变性与可哈希性速查表
类型 可变 可哈希 说明
int, float, bool, complex 不可变值类型
str 不可变文本
tuple 仅当所有元素都可哈希时
frozenset 不可变集合
list 可变容器
dict 键必须可哈希
set 元素必须可哈希
bytearray 可变字节序列

22.2 拷贝

22.2.1 直接赋值

a = b形式的直接赋值,只是给同一个对象多贴了一个标签。通过任何一个标签修改对象,所有标签看到的内容都会变:

a = [1, 2, 3]
b = a       # 直接赋值:b 和 a 指向同一个对象
id(a) == id(b)
True
b.append(4)
a
[1, 2, 3, 4]

22.2.2 浅拷贝

copy模块的copy()方法可进行浅拷贝:创建一个新的容器对象,但容器内部的元素仍然是原来那些对象的引用(id 相同)。

import copy

a = [[1, 2], [3, 4]]
b = copy.copy(a)       # 浅拷贝

a is b          # 不同容器
a[0] is b[0]    # 但内部的子列表是同一个对象
False
True

由于内部元素共享,修改嵌套的可变对象会影响两个容器:

b[0].append(100)
b[0], a[0]    # a[0],b[0]都被改了
a             # a 也变了
([1, 2, 100], [1, 2, 100])
[[1, 2, 100], [3, 4]]

但如果给外层容器的某个位置重新赋值(绑定新对象),则不会影响另一个容器:

b[1] = [300, 400]  # b[1] 绑定了新列表
a, b               # a 不受影响
([[1, 2, 100], [3, 4]], [[1, 2, 100], [300, 400]])

22.2.3 深拷贝

深拷贝创建完全独立的副本——新的容器,新的内部可变对象。

import copy

a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)

id(a) == id(b)       # False 不同容器
id(a[0]) == id(b[0]) # False 内部子列表也是独立对象
False
False
b[0].append(100)
b[0], a[0]   # b[0] 变了,a[0] 不变
([1, 2, 100], [1, 2])

深拷贝更安全(修改副本不会影响原对象),但更费时间和内存,因为需要复制所有嵌套的可变对象。

22.3 实践陷阱

22.3.1 修改 vs 重新绑定

id() 返回对象的身份标识(可理解为对象在内存中的地址)。两个变量如果 id() 相同,说明它们指向同一个对象。

与之对应,is 判断两个变量是否指向同一个对象(即 id() 相同),== 判断两个对象的值是否相等:

a = [1, 2, 3]
b = a
c = [1, 2, 3]
a is b   # True:a 和 b 是同一个对象
a == c   # True:值相等
a is c   # False:不同对象
True
True
False

对可变对象,我们可以修改对象本身,也可以通过赋值让变量绑定一个新对象,这两者的 id 变化截然不同。

# 修改对象本身(id 不变)
lst = [1, 2, 3]
print(f'原列表{lst=}, 原{id(lst)=}')
lst.append(4)
print(f'现列表{lst=}, 现{id(lst)=}')
原列表lst=[1, 2, 3], 原id(lst)=2627856912128
现列表lst=[1, 2, 3, 4], 现id(lst)=2627856912128
# 变量重新绑定(id 改变)
lst = [1, 2, 3]
print(f'原{id(lst)=}')
lst = [4, 5, 6]
print(f'现{id(lst)=}')
原id(lst)=2627857409792
现id(lst)=2627856912128

对不可变对象,你无法修改对象本身。任何看似“修改”的操作,实际上都在创建新对象。

x = 5
print(f'原{x=},原{id(x)=}')
x = x + 1  # 5 + 1 创建了一个新整数 6
print(f'现{x=},现{id(x)=}')
原x=5,原id(x)=140733904696360
现x=6,现id(x)=140733904696392
s = 'hello'
print(f'原{s=},原{id(s)=}')
s = s + ' world'  # 创建了新字符串
print(f'现{s=},现{id(s)=}')
原s='hello',原id(s)=2627730060656
现s='hello world',现id(s)=2627750450736

对于可变对象,+=原地修改;对于不可变对象,+=创建新对象。

# += 原地修改可变对象
a = [1, 2]
print(f'+=之前,{a=}{id(a)=}')
a += [3, 4]
print(f'+=之后,{a=}{id(a)=}')
+=之前,a=[1, 2],id(a)=2627857413504
+=之后,a=[1, 2, 3, 4],id(a)=2627857413504
# += 创建新的不可变对象
a = (1, 2)
print(f'+=之前,{a=}{id(a)=}')
a += (3, 4)
print(f'+=之后,{a=}{id(a)=}')
+=之前,a=(1, 2),id(a)=2627857225920
+=之后,a=(1, 2, 3, 4),id(a)=2627857172496

22.3.2 元组的「假不可变」

元组本身不可变——你不能增删改它的元素引用。但如果元组里的某个元素恰好是可变对象,这个可变对象的值可以被修改。

t = (1, 2, [3, 4])
t[2].append(5)
t  # 元组的值变了!
(1, 2, [3, 4, 5])

严格来说,元组的「不可变」指的是:元组中每个元素的引用(id)不可改变。但如果被引用的对象本身是可变对象,这个可变对象内部的改变不受元组约束。

# 重新绑定元素不被允许
t[2] = [3, 4, 5]
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[24], line 2
      1 # 重新绑定元素不被允许
----> 2 t[2] = [3, 4, 5]

TypeError: 'tuple' object does not support item assignment

理解这一点对理解“可哈希”很重要:如果一个 tuple 包含可变对象,它的哈希值实际上可以改变(因为内部元素变了),因此这样的 tuple 不可哈希——这正是 hash((1, 2, [3, 4])) 会报错的原因。

22.3.3 可变对象作为函数默认参数

函数默认参数只在定义时计算一次,而不是每次调用时重新计算。

def append_to_list(value, lst=[]):
    lst.append(value)
    return lst

append_to_list(1)   # [1]
append_to_list(2)   # [1, 2] !不是 [2]
append_to_list(3)   # [1, 2, 3]
[1]
[1, 2]
[1, 2, 3]

正确的做法是用 None 做默认值,在函数内部创建新列表:

def append_to_list(value, lst=None):
    if lst is None:
        lst = []
    lst.append(value)
    return lst

22.4 参考资料

Python官方文档:参考资料


  1. Python对小整数有特殊优化:小整数的哈希值通常是其自身。↩︎