跳转到内容

元组的两副面孔

元组有两个身份,入门教程通常只讲后一个:

用法 元素个数 顺序 元素类型 能排序吗
记录(record) 固定 有意义,位置就是含义 通常各不相同 不能,排序会毁掉含义
不可变列表 任意 可能不重要 通常一致 能

把元组只说成「不可变的列表」是低估了它。

lax_coordinates = (33.9425, -118.408056) # 纬度, 经度
city, year, pop, chg, area = ('Tokyo', 2003, 32_450, 0.66, 8014)
traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'), ('ESP', 'XDA205856')]

判断有没有当记录用,看一件事:把元素重排之后信息还在不在。 (33.9425, -118.408056) 换个顺序就变成另一个地方了,所以它是记录。 [3, 1, 2] 排一下还是那三个数,所以它是列表。

记录用法下有两个技巧很省事:

# % 格式化认识元组,会把它拆成一个个字段
>>> '%s/%s' % ('USA', '31195855')
'USA/31195855'
# for 循环里直接拆,不用下标
>>> for country, _ in traveler_ids:
... print(country)

第二个用法叫解包。_ 只是个普通的变量名,约定俗成表示「这个位置我不关心」。

好处有两个:

  • 清晰:代码里看到元组,你就知道长度不会变。
  • 性能:同样长度的元组比列表省内存,还能让 CPython 做优化。

具体快在哪,书里引了 Raymond Hettinger 的解释,我归纳成四条:

方面 tuple list
字面量求值 编译器一次生成一个元组常量 逐个压栈再构造列表
tuple(t) 直接返回同一个对象 list(l) 必须复制一份
内存分配 长度固定,刚好够用 多留余量,摊薄后续 append 成本
元素引用存放 存在元组自身结构体的数组里 存指针指向别处的数组,多一层间接

最后一条的「多一层间接」不是随便设计的:列表增长时得重新分配那块引用数组, 所以引用数组必须放在外面。

陷阱:不可变的是引用,不是引用的对象

Section titled “陷阱:不可变的是引用,不是引用的对象”

这条是最容易踩的。看这个:

>>> a = (10, 'alpha', [1, 2])
>>> b = (10, 'alpha', [1, 2])
>>> a == b
True
>>> b[-1].append(99)
>>> a == b
False
>>> b
(10, 'alpha', [1, 2, 99])

元组本身没变——b 的三个引用还是指向原来那三个对象。但第三个对象是个 list, 它的内容变了,于是 b 的值变了。

后果很实际:这样的元组不可哈希,不能当 dict 的键,也不能放进 set。

>>> {b: 'value'}
TypeError: unhashable type: 'list'

怎么判断一个元组的值是不是真的固定

Section titled “怎么判断一个元组的值是不是真的固定”

用 hash()。能算出哈希,说明值永远不会变:

def is_fixed_value(obj):
try:
hash(obj)
except TypeError:
return False
return True
>>> is_fixed_value((10, 'alpha', (1, 2))) # 元素全是不可变的
True
>>> is_fixed_value((10, 'alpha', [1, 2])) # 里面有个 list
False

我把它写成 chapters/ch02/records.py 里的 is_fixed_value,测试里连 [1, 2] 本身 也验证了(返回 False)。

书里的原话是「避免把可变元素放进元组」。这条建议的成本很低,收益是省掉一类很难查的 bug: 值莫名其妙变了,但你找不到哪行代码改的它。

  • test_hashability_means_fixed_value —— 哈希判断三种情况
  • test_tuple_immutability_stops_at_the_reference —— 改内层 list 之后两元组不等
  • test_unhashable_tuple_cannot_be_a_dict_key —— 塞进 dict 抛 TypeError