浅谈 Python 中为何无法使用列表作为字典键值

背景

简单的说,当我们在Python中尝试使用列表作为键值,会提示有“不可哈希(unhashable)”错误。

In [1]: la=[1,2]

In [2]: d={}

In [3]: d[la]=3
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-3-79b9de59ffa1> in <module>
----> 1 d[la]=3

TypeError: unhashable type: 'list'
复制代码

更广泛的说,对于Python中的所有内置可变类型(列表、字典、集合),我们都不能将其用作字典的键值。为了解答这是为什么,我们要从列表和字典的大概工作方式入手,再探讨究竟为什么不能这样。

可变类型和不可变类型

我们首先需要了解关于可变类型和不可变类型的概念。Python中的可变和不可变类型,其实就是对应着其他语言中的“值类型”、“引用类型”。

Python中的数据类型,分为可变和不可变两种。不可变类型为数字(intfloat)、字符串(str)、布尔(bool)、元组(tuple),可变类型则有列表(list)、字典(dict)、集合(set),当然,还有用户自定义的类。

Python中的不可变类型,语义和实现上都是不可变的。比如当我们给一个整数对象a赋予或修改数值时,我们会发现,aid变化了,因为数字是不可变类型,当我们修改变量a的值时,我们并不会在原先存储值88888的内存空间上修改。而是将原先内存中的88888销毁,然后开辟出新的内存空间写入9999并赋予对象a

In [40]: a=88888

In [41]: id(a)
Out[41]: 140691892091152

In [42]: a=9999

In [43]: id(a)
Out[43]: 140691892092048
复制代码

对于可变类型,如果对其修改,会在原来的内存空间上修改。因为列表la对象是一个可变对象,也就是其他语言说的引用对象。存储对象la的内存空间中,并不直接存储la的内容,而是包含一个指针指向真实存放la内容的地址。

In [44]: la=[1,2]

In [45]: id(la)
Out[45]: 140691892029824

In [46]: la[1]=3

In [47]: id(la)
Out[47]: 140691892029824

In [48]: la.append(4)

In [49]: id(la)
Out[49]: 140691892029824
复制代码

可变类型有一个显而易见的特性,如果直接将其传递给其他对象,不管是一个新的变量、函数内部还是其他容器或类的成员。被传递的对象都能对其值(也就是内容)进行修改,并影响最初的那个可变对象。换句话说,将可变对象直接传递给其他对象后,这些对象会共同使用同一份值,对其修改会互相影响彼此。下面的例子能展现这一点。

In [72]: la=[1,2]

In [73]: lb=la

In [74]: lc=[4,5,6,la]

In [75]: def change_la(la):
    ...:     la.append(999)
    ...: 

In [76]: change_la(la)

In [77]: la
Out[77]: [1, 2, 999]

In [78]: lb
Out[78]: [1, 2, 999]

In [79]: lc
Out[79]: [4, 5, 6, [1, 2, 999]]
复制代码

字典的工作方式

我们会简单说一下关于哈希函数和字典的工作方式。

哈希函数的意义

哈希函数在数学上的定义,是把任意长度的输入数据,通过哈希算法变换成固定长度的输出,该输出就是哈希值。如果同一个哈希函数输出了两个不同的哈希值,那么它们的输入也将会是不同的。但是两个不同的输入,有可能产生相同的输出,这种情况被成为哈希碰撞。

具体到软件工程中,哈希函数往往作为判断两个对象是否相同的一种方法,将一个对象的内的某些重要属性(值,内存地址等)进行哈希计算并获得一个固定长度的值,该值可被当作该对象的一种类似“指纹”、“摘要”的身份信息。因为哈希值数据结构和长度固定的原因,很容易进行比对。若是有两个对象哈希值相同,我们可以暂且认为它们是相同的对象,但是因为哈希冲突的存在,我们需要进一步判断两个对象的重要属性是否相同。

字典的作用和实现

字典(dict)即哈希表、散列表。是利用了哈希函数(hash function)特性的一种数据结构,可以实现最快时间复杂度为O(1)的查询操作。

一种常见的实现方式是,哈希表首先会开辟一个桶数组,对插入的键值对,对键进行取哈希值操作,再通过进一步操作,比如将哈希值和桶数组长度进行取余操作,最终决定将键值对放入哪个桶,桶可以是链表或数组等容器。因为哈希碰撞的存在,桶通常可以装下不只一个键值对。在进行查询操作时,我们对键执行插入时相同的计算,可以立刻在多个桶中找到存放目标数据的桶的下标(这也是哈希表高效的主要原因),再在桶中寻找键对应的值。

我们再把查询的过程细化,参考Python官方wiki并翻译,字典的查找过程可以用下面的lookup函数展示。当然,实际上字典的设计比这个复杂得多,我们在这主要了解概念就行。

def lookup(d, key):
    '''字典的查找分三步完成
       1. 使用哈希函数计算键的哈希值。

       2. 通过哈希值定位到字典桶数组(d.data)中某个位置,我们应该能
          获得一个被称作桶或冲突列表的数组。里面包含了一个个键值对
          ((key,value))。

       3. 遍历这个被称作桶或冲突列表的数组,如果发现桶中存在键和传入参数键
          (key)相等的键值对,则将该值(value)作为返回值返回。
    '''
    h = hash(key)                  # 步骤 1
    cl = d.data[h]                 # 步骤 2
    for pair in cl:                # 步骤 3
        if key == pair[0]:
            return pair[1]
    else:
        raise KeyError, "没有找到键 %s 。" % key
复制代码

可以留意到,对一个键进行查询,重点需要获取它的哈希值,并通过哈希值在桶数组中寻址,再比对桶中是否存在和传入的键值相同的键值对,最后返回键值对的值。

我们再讨论回Python本身,参考Python官方的说法:

To be used as a dictionary key, an object must support the hash function (e.g. through hash), equality comparison (e.g. through eq or cmp), and must satisfy the correctness condition above.

一个数据类型要用作字典键,对象必须同时支持取哈希值(例如通过__hash__方法)和判断是否相等(例如通过__eq____cmp__方法)这两个操作。

In [80]: hash(la)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-80-e4e0d0f14f47> in <module>
----> 1 hash(la)

TypeError: unhashable type: 'list'
复制代码

而对于文章标题的问题,其实我们这里已经可以回答了。即列表类型没有实现__hash__方法,因此无法取哈希值,进而无法作为字典键。但是更加深入一点的话,我们想讨论为什么不实现__hash__方法。

允许列表取哈希值带来的问题

对于字典,如果我们将它的作用再抽象一下,可以描述为。将一个键值对插入字典后,我们希望能通过“同样的”键能将该值取出来。而如何定义两个键(或者说对象)相同,则是哈希函数的事情。

问题就出于这,因为列表和哈希函数的特性,我们很难对列表的“相同”作出一个符合直觉、不让人困惑的定义。

你可能想说,两个列表的内容,即长度和元素顺序相同,则说明两个列表相同,这种定义不是很容易能给出吗?实际上,列表确实是支持判断相等操作的(实现了__eq__方法),但是对于作为哈希表键值来说,仍然具有很多问题。

我们直接一点,考虑设计一个这样的列表,它支持和原生列表一样的判断相等操作,而它的取哈希方式,也是对内容进行计算的。相同内容的两个列表将拥有相同的哈希,就像我们想像中的那样。

class MyList(object):
    def __init__(self, value):
        self.value = value

    def __repr__(self):
        return str(self.value)

    def __str__(self):
        return str(self.value)

    def __eq__(self, other):
        return self.value == other.value

    def __hash__(self):
		# 将列表转化为字符串,再将字符串每个字符转为数字
		# 例如:[1,2] -> "[1, 2]" -> 914944325093
        hashValue = ""
        for c in str(self.value):
            hashValue += str(ord(c))
        return int(hashValue)
复制代码

使用情况如下

In [47]: ml1=mylist.MyList([1,2])

In [48]: ml1
Out[48]: [1, 2]

In [49]: d1={}

In [50]: d1[ml1]=999

In [51]: print(d1)
{[1, 2]: 999}

In [52]: ml2=mylist.MyList([1,2])

In [53]: d1[ml2]
Out[53]: 999
复制代码

看起来这非常符合我们想象中的样子,但是假如我们修改一下ml1呢?

In [54]: ml1.value.append(3)

In [55]: ml1
Out[55]: [1, 2, 3]

In [56]: ml2
Out[56]: [1, 2]

In [57]: print(d1)
{[1, 2, 3]: 999}

In [58]: d1[ml1]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-58-1e9c8efcdfed> in <module>
----> 1 d1[ml1]

KeyError: [1, 2, 3]

In [59]: d1[ml2]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-59-aff57dc87ac2> in <module>
----> 1 d1[ml2]

KeyError: [1, 2]
复制代码

我们可以发现,假如我们对ml1进行了修改,字典d1中的键值也跟着发生了变化,这是因为列表是可变对象,多个引用共同使用一份内容,一处修改影响所有引用。如果这个时候,我们再想通过ml1取出值,会提示错误,原因是修改过后的ml1内容变了,哈希值也变了,字典无法通过新的哈希值定位到原来存放键值对的位置上。此外,如果我们通过内容和哈希值和变化之前的ml1都一样的ml2尝试取出d1的值,也会错误。因为字典查询除了要计算哈希找出桶的下标外,还要因为避免哈希碰撞而再一次核对查询键和桶里的键是否真的相等,而此时字典中的键因为ml1的修改而跟着变化了,因此ml2的值和桶中的键值不相等,也无法取出值。

我们也可以思考其他的哈希方式,比如通过对象的id即内存地址来计算哈希,但是这样会有更加显而易见的问题。对字典进行查询的键,只能是基于初始插入对象的引用,相同值的对象和字面量将永远无法查出想要的结果,因为他们的内存地址不同。虽然实际上,对用户自定义类的对象内存地址取哈希是默认操作,但是那是在面向对象的思想中比较好用,对于内置列表这种基础类型,使用此方案是反直觉的。

In [98]: ml1=mylist.MyList([1,2])

In [99]: ml2=mylist.MyList([1,2])

In [100]: ml3=ml1

In [101]: print(id(ml1),id(ml2),id(ml3))
140172863009408 140172873557376 140172863009408

In [102]: ml1==ml2==ml3
Out[102]: True

In [103]: d1={}

In [104]: d1[ml1]=999

In [105]: print(d1)
{[1, 2]: 999}

In [106]: d1[ml1]
Out[106]: 999

In [107]: d1[ml3]
Out[107]: 999

In [108]: d1[ml2]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-108-aff57dc87ac2> in <module>
----> 1 d1[ml2]

KeyError: [1, 2]

In [109]: d1[mylist.MyList([1,2])]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-109-bcd49cc44bab> in <module>
----> 1 d1[mylist.MyList([1,2])]

KeyError: [1, 2]

复制代码

究其原因,用于索引的哈希值是基于对象某个时刻的某个属性生成的,是静态的。而在找到对应的桶后,从桶中找到和传入键相匹配的键值对,是基于对象本身的。对于不可变类型,他们的哈希值和桶中键值对都是不变的。而对于可变类型,他们变化将会导致桶中键值对的动态变化,但哈希索引仍然为静态,这就产生了矛盾。

解决方案

使用元组代替列表,元组是不可变类型,其哈希函数是基于内容本身生成的,使用的方式符合直觉。

参考

[1] Why Lists Can't Be Dictionary Keys

[2] Why can't I use a list as a dict key in python?

猜你喜欢

转载自juejin.im/post/7033238662963789855
今日推荐