使用fromkeys创建字典与可变对象的意外

21 投票
3 回答
6304 浏览
提问于 2025-04-17 06:32

我在使用Python 2.6和3.2的时候,发现了一些让我感到惊讶的行为:

>>> xs = dict.fromkeys(range(2), [])
>>> xs
{0: [], 1: []}
>>> xs[0].append(1)
>>> xs
{0: [1], 1: [1]}

不过,在3.2版本中,dict的推导式表现得更有礼貌:

>>> xs = {i:[] for i in range(2)}
>>> xs
{0: [], 1: []}
>>> xs[0].append(1)
>>> xs
{0: [1], 1: []}
>>> 

为什么fromkeys会这样表现呢?

3 个回答

2

来回答你问的问题:fromkeys之所以这样工作,是因为没有其他合理的选择。让fromkeys去判断你的参数是否可变,并每次都创建新副本,这既不合理也不可能。在某些情况下,这样做没有意义,而在其他情况下则根本无法实现。

你传入的第二个参数实际上只是一个引用,它是以这种方式被复制的。在Python中,[]的赋值意味着“一个新的列表的单一引用”,而不是“每次访问这个变量时都创建一个新列表”。另一种选择是传入一个生成新实例的函数,这正是字典推导式为你提供的功能。

以下是创建多个可变容器实际副本的一些选项:

  1. 正如你在问题中提到的,字典推导式允许你对每个元素执行任意语句:

    d = {k: [] for k in range(2)}
    

    这里重要的是,这相当于在for循环中放置赋值k = []。每次循环都会创建一个新列表并将其赋值给一个值。

  2. 使用dict构造函数的另一种形式,正如@Andrew Clark所建议的:

    d = dict((k, []) for k in range(2))
    

    这会创建一个生成器,当执行时,它会将一个新列表赋值给每个键值对。

  3. 使用collections.defaultdict而不是普通的dict

    d = collections.defaultdict(list)
    

    这个选项与其他选项有点不同。defaultdict不会提前创建新的列表引用,而是每次你访问一个不存在的键时,都会调用list。因此,你可以根据需要懒惰地添加键,这在某些情况下非常方便:

    for k in range(2):
        d[k].append(42)
    

    因为你已经设置了新元素的工厂,这实际上会像你在原问题中预期的那样工作。

  4. 当你访问可能的新键时,使用dict.setdefault。这与defaultdict做的事情类似,但它的优点在于更可控,只有你想要创建新键的访问才会实际创建它们:

    d = {}
    for k in range(2):
        d.setdefault(k, []).append(42)
    

    缺点是每次调用这个函数时都会创建一个新的空列表对象,即使它从未被赋值给一个值。这不是一个大问题,但如果你频繁调用它,或者你的容器不如list简单,这可能会累积起来。

7

在第一个版本中,你使用了同一个空列表作为两个键的值,所以如果你改变了一个,另一个也会跟着改变。

看看这个:

>>> empty = []
>>> d = dict.fromkeys(range(2), empty)
>>> d
{0: [], 1: []}
>>> empty.append(1) # same as d[0].append(1) because d[0] references empty!
>>> d
{0: [1], 1: [1]}

在第二个版本中,每次创建字典时,都会生成一个新的空列表对象,因此这两个列表是相互独立的。

至于为什么fromkeys()会这样工作——如果它不是这样工作,那才让人感到惊讶呢。fromkeys(iterable, value)会根据iterable中的键来构建一个新的字典,所有的键都有相同的值value。如果这个值是一个可变对象,而你改变了这个对象,你还能期待发生什么呢?

22

你的 Python 2.6 示例可以用下面的方式来理解,这样可能会更清楚:

>>> a = []
>>> xs = dict.fromkeys(range(2), a)

结果字典中的每一项都会指向同一个对象。也就是说,如果你改变了这个对象,所有字典中的条目都会看到这个变化,因为它们都是同一个对象。

>>> xs[0] is a and xs[1] is a
True

可以使用字典推导式,如果你还在用 Python 2.6 或更早的版本,没有字典推导式的话,可以通过使用 dict() 和生成器表达式来实现类似的效果:

xs = dict((i, []) for i in range(2))

撰写回答