Python 字典表达式,有时候你会突然碰到一段很有深度的代码,仔细琢磨就能从中学到很多关于这门语言的知识。这样的代码片段就像一个禅宗的“公案”修行——从问题或故事中引出疑问再接引禅徒。
本节将要讨论的一小段代码就是这样的一个例子。这段代码乍看起来可能像一个普通的字典表达式,但深入体会就会让你对CPython解释器来一次扩展心智的旅程。
我很看重这行代码,有一次把它印了在我的Python会议徽章上。以这行代码充当话头,我和其他Python参会人员进行了一些受益匪浅的对话。
言归正传,来看代码。花点时间思考下面的字典表达式以及其效果:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
我留点时间给大家……
准备好了吗?
下面是这个字典表达式在CPython解释器会话中得到的结果:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
{True: 'maybe'}
我承认,第一次看到这个结果时我也很惊讶。但逐步深究就会发现这一切都是有道理的,所以来思考一下为什么会得到这个出人意料的结果。
当Python处理这个字典表达式时,首先会构造一个新的空字典对象,然后按照字典表达式中给出的顺序添加键和值。
因此,前面的字典表达式可分解成下面这些依次执行的语句:
>>> xs = dict()
>>> xs[True] = 'yes'
>>> xs[1] = 'no'
>>> xs[1.0] = 'maybe'
说来也怪,Python认为本例中使用的所有字典键都是相等的:
>>> True == 1 == 1.0
True
好吧,但等一下。1.0 == 1
可以接受,但为什么True
也会等于1
呢?第一次看到这个字典表达式时,我也被难住了。
在查阅了Python文档之后,我发现Python将bool
视为int
的子类。Python 2和Python 3都是如此:
布尔类型是整数类型的子类型,布尔值在几乎所有环境中的行为都类似于值0和1,但在转换为字符串时,分别得到的是字符串
False
或True
这意味着从技术上来说,布尔值可以作为Python中列表或元组的索引:
>>> ['no', 'yes'][True]
'yes'
但是为了清楚地表达代码的含义,也为了不要让同事抓狂,请不要以这样的方式使用布尔变量。
不管怎样,现在回到那个字典表达式。
就Python而言,True
、1
和1.0
都表示相同的字典键。当解释器处理字典表达式时,会不断用后续键的值覆盖True
键的值。因此最终产生的字典只包含一个键。
在继续之前,再看看原字典表达式:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
{True: 'maybe'}
为什么得到的键依然是True
?由于重复赋值,最后键不应该修改为1.0
吗?
在研究了一番CPython解释器源码之后,我发现Python的字典在将新值与键关联时不会自动更新键对象:
>>> ys = {1.0: 'no'}
>>> ys[True] = 'yes'
>>> ys
{1.0: 'yes'}
当然,从性能优化的角度上说得通。如果键相同,那为什么要花时间更新原来的键呢?
由于上个例子中永远不会替换第一个作为键的True
对象,因此字典的字符串表示仍然将该键输出为True
(而不是1
或1.0
)。
就目前掌握的信息来看,最后字典中的值被覆盖只是因为各自的键都相等。但事实上,这也不单单是因为键的__eq__
相等。
Python字典本质上是散列表数据结构。在第一次看到这个令人惊讶的字典表达式时,直觉告诉我这种行为与散列冲突有关。
散列表在内部根据每个键的散列值将键存储在不同“桶”中。散列值是根据键生成的固定长度的数值,用来标识这个键。
使用散列值能做到快速查找。查找键对象需要将对象整体与其他键对象逐一比较,而在查找表中查找键对应的数值散列值就要快得多。
然而计算散列值的方法一般做不到十全十美。实际上,不同的键可能会得到相同的散列值,因此这些键最终会落到查找表中相同的桶里。
如果两个键具有相同的散列值,称之为散列冲突。散列表中用于插入和查找元素的算法需要处理这些特殊情况。
根据这些背景,前面那个字典表达式得到的惊人结果可能与散列有些关系,所以下面来验证一下键的散列值是否真的导致了这样的结果。
我定义了下面这个类作为验证工具:
class AlwaysEquals:
def __eq__(self, other):
return True
def __hash__(self):
return id(self)
这个类有两个特殊的地方。
首先,因为其中的__eq__
双下划线方法总是返回True
,所以这个类的所有实例都会假装相互之间相等:
>>> AlwaysEquals() == AlwaysEquals()
True
>>> AlwaysEquals() == 42
True
>>> AlwaysEquals() == 'waaat?'
True
其次,每个AlwaysEquals
实例还将返回由内置id()
函数生成的唯一散列值:
>>> objects = [AlwaysEquals(),
AlwaysEquals(),
AlwaysEquals()]
>>> [hash(obj) for obj in objects]
[4574298968, 4574287912, 4574287072]
在CPython中,id()
返回内存中对象的地址,并且确定是唯一的。
因此这个类可以创建一些假装相互相等的对象,但其各自的散列值不同,以此来验证字典键是否只根据相等性比较结果进行覆盖了。
从下面的例子可以看到,即使键都相等,相互之间也不会覆盖:
>>> {AlwaysEquals(): 'yes', AlwaysEquals(): 'no'}
{ <AlwaysEquals object at 0x110a3c588>: 'yes',
<AlwaysEquals object at 0x110a3cf98>: 'no' }
反过来还可以验证若只是散列值相同是否会覆盖字典的键:
class SameHash:
def __hash__(self):
return 1
这个SameHash
类的实例相互之间不相等,但散列值都为1
:
>>> a = SameHash()
>>> b = SameHash()
>>> a == b
False
>>> hash(a), hash(b)
(1, 1)
现在尝试使用SameHash
类的实例作为字典键,来看看Python字典的处理结果:
>>> {a: 'a', b: 'b'}
{ <SameHash instance at 0x7f7159020cb0>: 'a',
<SameHash instance at 0x7f7159020cf8>: 'b' }
从上可以看出,单单由于散列值相同引起的冲突并不会覆盖字典的键。
只有在两个对象相等,且散列值也相同的情况下,字典才会认为这两个键相同。来试着结合原来的示例总结一下。
{True:'yes',1:'no',1.0:'maybe'}
字典表达式的计算结果为{True:'maybe'}
,因为True
、1
和1.0
的键都相等,并且都具有相同的散列值:
>>> True == 1 == 1.0
True
>>> (hash(True), hash(1), hash(1.0))
(1, 1, 1)
现在这个字典表达式的结果也许就不怎么令人惊讶了:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
{True: 'maybe'}
这里涉及了很多主题,且这个特殊的Python技巧起初可能有点令人难以置信,所以在最初我将其比作“公案”。
如果你觉得本节的内容很难理解,请尝试在Python解释器会话中逐个运行代码示例,从中会掌握更多关于Python内部的知识。
关键要点
-
只有键的
__eq__
比较结果和散列值都相同的情况下,字典才会认为这些是相同的键。 -
某些出乎意料的字典键冲突可能会导致令人惊讶的结果。