[译]关于python字典类型最疯狂的表达方式

一篇来自 Dan Bader 的有趣的博文,一起来学习一下,如何去研究一个意外的Python现象。

一个Python字典表达式谜题

让我们探究一下下面这个晦涩的python字典表达式,以找出在python解释器的中未知的内部到底发生了什么。

# 一个python谜题:这是一个秘密
# 这个表达式计算以后会得到什么结果?

>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}

有时候你会碰到一个很有深度的代码示例 --- 哪怕仅仅是一行代码,但是如果你能够有足够的思考,它可以教会你很多关于编程语言的知识。这样一个代码片段,就像是一个Zen kōan:一个在修行的过程中用来质疑和考验学生进步的问题或陈述。

译者注:Zen kōan,大概就是修行的一种方式,详情见wikipedia

我们将在本教程中讨论的小代码片段就是这样一个例子。乍看之下,它可能看起来像一个简单的词典表达式,但是仔细考虑时,通过cpython解释器,它会带你进行一次思维拓展的训练。

我从这个短短的一行代码中得到了一个启发,而且有一次在我参加的一个Python会议上,我还把作为我演讲的内容,并以此开始演讲。这也激发了我的python邮件列表成员间进行了一些积极的交流。

所以不用多说,就是这个代码片。花点时间思考一下下面的字典表达式,以及它计算后将得到的内容:

>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}

在这里,我先等会儿,大家思考一下...

  • 5...
  • 4...
  • 3...
  • 2...
  • 1...

OK, 好了吗?

这是在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

OK,但在这里等一下。我确定你能够接受1.0 == 1,但实际情况是为什么True也会被认为等于1呢?我第一次看到这个字典表达式真的让我难住了。

在python文档中进行一些探索之后,我发现python将bool作为了int类型的一个子类。这是在Python 2和Python 3的片段:

“The Boolean type is a subtype of the integer type, and Boolean values behave like the values 0 and 1, respectively, in almost all contexts, the exception being that when converted to a string, the strings ‘False’ or ‘True’ are returned, respectively.”

“布尔类型是整数类型的一个子类型,在几乎所有的上下文环境中布尔值的行为类似于值0和1,例外的是当转换为字符串时,会分别将字符串”False“或”True“返回。“(原文

是的,这意味着你可以在编程时上使用bool值作为Python中的列表或元组的索引:

>>> ['no', 'yes'][True]
'yes'

但为了代码的可读性起见,您不应该类似这样的来使用布尔变量。(也请建议你的同事别这样做)

Anyway,让我们回过来看我们的字典表达式。

就python而言,True11.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字典类型是由一个哈希表数据结构存储的。当我第一次看到这个令人惊讶的字典表达式时,我的直觉是这个结果与散列冲突有关。

哈希表中键的存储是根据每个键的哈希值的不同,包含在不同的“buckets”中。哈希值是指根据每个字典的键生成的一个固定长度的数字串,用来标识每个不同的键。(哈希函数详情

这可以实现快速查找。在哈希表中搜索键对应的哈希数字串会快很多,而不是将完整的键对象与所有其他键进行比较,来检查互异性。

然而,通常计算哈希值的方式并不完美。并且,实际上会出现不同的两个或更多个键会生成相同的哈希值,并且它们最后会出现在相同的哈希表中。

如果两个键具有相同的哈希值,那就称为哈希冲突(hash collision),这是在哈希表插入和查找元素时需要处理的特殊情况。

基于这个结论,哈希值与我们从字典表达中得到的令人意外的结果有很大关系。所以让我们来看看键的哈希值是否也在这里起作用。

我定义了这样一个类来作为我们的测试工具:

class AlwaysEquals:
     def __eq__(self, other):
         return True

     def __hash__(self):
         return id(self)

这个类有两个特别之处。

第一,因为它的__eq__魔术方法(译者注:双下划线开头双下划线结尾的是一些Python的“魔术”对象)总是返回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)

一起来看看python的字典在我们试图使用SameHash类的实例作为字典键时的结果:

>>> {a: 'a', b: 'b'}
{ <SameHash instance at 0x7f7159020cb0>: 'a',
  <SameHash instance at 0x7f7159020cf8>: 'b' }

如本例所示,“键被覆盖”的结果也并不是单独由哈希冲突引起的。

Umm..好吧,可以得到什么结论呢?

检查python字典对象中两个 key 是否相同的条件是:二者的相等性(__eq__)以及hash值对比(__hash__)是否相等。那我们就来总结一下上述讨论的结果:

{true:'yes',1:'no',1.0:'maybe'} 字典表达式计算结果为 {true:'maybe'},是因为键 true11.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技巧起初可能有点令人难以置信 --- 所以我一开始就把它比作是Zen kōan

如果很难理解本文中的内容,请尝试在Python交互环境中逐个去检验一下代码示例。你会收获一些关于python深处知识。

注:转载请保留下面的内容

原文链接:https://dbader.org/blog/python-mystery-dict-expression

译文链接:http://vimiix.com/post/2017/12/28/python-mystery-dict-expression/

交流

☕️赞助一杯咖啡☕️

取消

感谢支持,一起进步!

扫码支持
你的支持是对我的认可

打开 支付宝 扫一扫

Scroll to top