字典(dict)类型在 Python 基础数据类型中有着举足轻重的地位,日常编码中几乎离不开字典的使用。不过字典中有一个看似非常奇怪的现象,常被人忽略。本篇文章就来探索这一奇异现象。

请用 5 秒钟思考一下,下面这个字典表达式会输出什么?

1
>>> {True: 'True', 1: '1', 1.0: '1.0'}

将以上字典表达式复制到 Python 控制台中执行,将会得到如下结果:

1
{True: '1.0'}

初次见到这个结果时我是非常吃惊的,what ?这看起来似乎并不符合常理,但当我们一步一步分析出背后的原因之后,就不会对这个结果感到奇怪了。

我们可以将上面的字典表达式拆解为多个步骤来分析。

1
2
3
4
5
6
>>> d = dict()
>>> d[True] = 'True'
>>> d[1] = '1'
>>> d[1.0] = '1.0'
>>> d
{True: '1.0'}

首先,先创建一个空的字典对象,然后依次给字典赋值,最终得到字典结果为 {True: '1.0'}

从以上代码的执行步骤和结果,我们可以分析出,在 Python 中字典会将作为键的 True11.0 认为是 相等 的。我们可以在 Python 控制台得到验证。

1
2
>>> True == 1 == 1.0
True

果然,这三个对象对 python 来说是 相等 的。实际上,在 Python 中,bool 类型继承自 int 类型,所以这三个对象 相等 也是合理的。

Python 提供了 issubclass 函数可以判断一个类型是否是另一个类型的子类,使用这个函数来验证 bool 类型是 int 类型的子类。

1
2
>>> issubclass(bool, int)
True

还可以使用 isinstance 函数来判断一个对象是否是一个类型的实例,由于 Truebool 类型的实例,那么 True 自然也是 int 类型的实例对象。

1
2
>>> isinstance(True, int)
True

我们证实了 True11.0相等 的对象。但这还不足以说明问题,在 Python 中两个对象的值 相等 并不能说明它们放到字典中时会变为同一个键。这点通过稍后的示例就会明白。

在 Python 中,一个对象是否可以作为字典的键是有要求的,我们知道只有不可变类型才能作为字典的键。实际上 Python 判断一个对象是否可以作为字典的键,其实是判断这个对象是否为 可哈希hashable)对象。

Python 在 官网文档 中对 可哈希 对象进行了说明。大概意思是说:如果一个对象的 hash 值在其生命周期内不会改变(需要实现 __hash__() 方法),并且这个对象可以与其他对象进行比较(需要实现 __eq__() 方法),则该对象是 可哈希 的。由于 Python 内置的常见不可变类型都实现了 __hash__() 方法 和 __eq__() 方法,所以它们都是 可哈希 的。

这里还要强调一点:如果两个 可哈希 对象的值是相等的,那么它们的 hash 值也必然是相等的。这是 Python 的规范,我们自定义的类型也要遵循这个规范。

说的直白一些,Python 字典的键是不可重复的,而 Python 在操作字典的键时,如果两个对象的 hash 值相同,并且这两个对象的值也 相等,那么这两个对象会被当作同一个键。

Python 提供了 hash 函数可以获得一个对象的 hash 值,它会自动调用对象的 __hash__() 方法。至于判断两个对象的值是否相等,实际上就是使用 == 运算符,它会自动调用对象的 __eq__() 方法。

知道了有关字典的键的特性,接下来我们自己实现一个类,通过三个示例分别对三种不同的情况进行探索,从而通过实际的代码来验证字典的键的特性。

(以下示例代码中定义的 __init__ 方法和 __repr__ 方法只用于辅助观察,并不会对结果产生影响。)

示例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> class A(object):
... def __init__(self, name):
... self.name = name
... def __repr__(self):
... return self.name
... def __eq__(self, other):
... return True
... def __hash__(self):
... return id(self)
...
>>> a1 = A('a1')
>>> a2 = A('a2')
>>> a1 == a2
True
>>> hash(a1), hash(a2)
(4332959632, 4332959248)
>>> d = {a1: 1, a2: 2}
>>> d
{a1: 1, a2: 2}

由示例一可知,a1a2 两个对象的值相等,但 hash 值不同,最终得到的字典并不会将这两个对象看作同一个键。

示例二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> class A(object):
... def __init__(self, name):
... self.name = name
... def __repr__(self):
... return self.name
... def __eq__(self, other):
... return False
... def __hash__(self):
... return 1
...
>>> a1 = A('a1')
>>> a2 = A('a2')
>>> a1 == a2
False
>>> hash(a1), hash(a2)
(1, 1)
>>> d = {a1: 1, a2: 2}
>>> d
{a1: 1, a2: 2}

由示例二可知,a1a2 两个对象的 hash 值相同,但值不相等,最终得到的字典也不会将这两个对象看作同一个键。

示例三:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> class A(object):
... def __init__(self, name):
... self.name = name
... def __repr__(self):
... return self.name
... def __eq__(self, other):
... return True
... def __hash__(self):
... return 1
...
>>> a1 = A('a1')
>>> a2 = A('a2')
>>> a1 == a2
True
>>> hash(a1), hash(a2)
(1, 1)
>>> d = {a1: 1, a2: 2}
>>> d
{a1: 2}

由示例三可知,a1a2 两个对象的 hash 值相同,并且值也相等,最终得到的字典会将这两个对象看作同一个键。

通过以上三个示例代码的演示,想必不用我多说,你一定已经猜测到了,实际上在 Python 中,True11.0 这三个对象的 hash 值也是相同的。

1
2
>>> hash(True), hash(1), hash(1.0)
(1, 1, 1)

至此,关于 Python 字典中的 奇异 现象也就解释通了,下次再见到同样的问题就不会觉得奇怪了。