【问题标题】:Why modifying dict during iteration doesn't always raise exception?为什么在迭代期间修改 dict 并不总是引发异常?
【发布时间】:2017-04-18 18:08:36
【问题描述】:

在迭代时删除一个项目通常会导致RuntimeError: dictionary changed size during iteration异常:

d = {1: 2}
# exception raised
for k in d:
  del d[k]

更准确地说,删除本身会成功。但是,要进入下一轮迭代,解释器必须调用next(it),其中it 是通过它之前获得的字典的迭代器。此时,next() 会注意到字典大小发生了变化,并抱怨。

到目前为止一切顺利。但是,如果我们同时删除和添加一个项目到字典会怎样:

d = {1: 1}
# no exception raised
for k in d:
  # order of next two lines doesn't matter
  d[k*10] = k*10
  del d[k]

我几乎可以肯定这是不安全的(文档暗示在迭代期间既不允许插入也不允许删除)。为什么解释器允许这段代码正常运行?

我唯一的猜测是,每当调用插入或删除方法时,检查哪些迭代器无效的成本太高了。所以dict 并没有试图完美地提出这个异常。相反,它只是跟踪每个迭代器中字典的大小,并在迭代器实际被要求移动到下一项时检查它是否没有改变。有没有能够以低成本实现全面验证的方法?

【问题讨论】:

  • 您是在寻找使您的循环更健壮的东西,还是想讨论 Python 的实现细节?
  • 看起来您希望字典键在循环中不可变。我不认为这是可行的。
  • @KlausD。嗯,我猜两者都有?如果有一种技术可以做到这一点,我会考虑自己使用它。但为了了解它的成本(运行时间、代码复杂性等),了解 CPython 为什么不使用它对我来说很重要。
  • @DYZ 不可变键比我问的要强大得多。我不想仅仅因为给定键的值被更改而引发异常 - 在循环中这样做是完全安全的(事实上,如果它不是 python 将是一种损坏的语言!)
  • @DYZ Btw,制作不可变版本的 dict 可作为 python 中的内置功能使用:immutable_d = types.MappingProxyType(d)。如果根本不需要任何修改,则在循环中使用它是安全的。当然,仍然可以通过使用原始的(可变的)d 来搞砸。无论如何,这是我需要的更严格的限制。

标签: python python-3.x dictionary python-internals


【解决方案1】:

没有任何方法可以以低成本实现全面验证吗?

这里有一个与该主题相关的comment from Alex Martelli

因为容器甚至不跟踪它上面的迭代器,更不用说钩子甚至改变方法来循环遍历每个这样的迭代器,并以某种方式神奇地让每个迭代器知道更改。这将是很多微妙、复杂的代码,并且检查会减慢非常频繁的操作

因此,至少根据核心 Python 开发人员的说法,我们无法以低成本进行全面验证。

【讨论】:

  • 嗯,我认为 Alex Martelli 指的是在迭代时允许修改字典的困难。这比检测修改要困难得多。
【解决方案2】:

最简单的答案是因为您删除了 1 个项目添加了 1 个项目,所以实际上大小已更改的事实 永远不会被捕获RuntimeError 在迭代器的大小与该迭代器的字典之间存在差异时引发:

if (di->di_used != d->ma_used) {
    PyErr_SetString(PyExc_RuntimeError,
                    "dictionary changed size during iteration");
    di->di_used = -1; /* Make this state sticky */
    return NULL;
} 

当您添加一个和删除一个时,di->di_usedd->ma_used 保持相同(加一并减一)。操作(del 和 key add)是在 dict 对象 d 上执行的,并且由于这些操作的平衡,在我添加的前面的 if 子句中没有发现不匹配。

但是,如果你添加两个键,例如,你会得到同样的错误:

d = {1: 1}
for k in d:
  del d[k]
  d[1] = 1
  d[2] = 2

RuntimeErrorTraceback (most recent call last)
<ipython-input-113-462571d7e0df> in <module>()
      1 d = {1: 1}
      2 # no exception raised
----> 3 for k in d:
      4   # order of next two lines doesn't matter
      5   del d[k]

RuntimeError: dictionary changed size during iteration

因为意识到尺寸已经改变在这里被抓住了。当然,如果您减少两次,就会发生与以前相同的行为,它就会平衡。

正如我在评论部分重复的那样,评估插入或删除是否以平衡的方式发生的检查并不像检查大小是否简单地改变那么简单。另外两个帐户对我来说也没有意义:

  • 如果人们确实选择在迭代期间更改字典,那么他们可能不会以平衡的方式进行操作,因此检查到位应该足以应对最常见的情况。李>
  • 如果您决定添加更多检查,您将影响 Python 中几乎所有内容的性能(因为 dicts 无处不在)。

总的来说,我怀疑添加此检查是否会受益;对于大多数人来说,在更改集合的同时迭代集合并不是最好的主意。

像成年人一样,我们应该意识到 Python 不应该为我们检查所有内容,相反,当他们知道不需要的影响时不要做任何事情。

【讨论】:

  • 嗯,从技术上讲是的。但我的意思是为什么 dict 设计为只在插入数量不等于删除数量时才会抱怨。当它们相等(且非零)时,代码同样不安全。
  • @max 因为这是一个无法像最常见的不平衡插入/删除情况一样简单解决的要求。最后,Python 真的对你能做什么和不能做什么并不严格,如果你想做一些愚蠢的事情,那就去做吧,但要面对后果。
  • 我在下面的答案中提出的解决方案太慢了,我猜?
  • @max 是的,我不太确定保护某人从字典中添加/删除元素是否真的值得额外的开销(即使在 C 中重新实现到度数较小);尤其是在实际上是 Python backbone 的内置集合上(并且对于不太可能的场景)。
【解决方案3】:

确保在循环中尝试插入或删除键时引发异常的一种方法是保持对字典的修改次数。然后迭代器可以在他们的__next__ 方法中检查该数字没有改变(而不是验证字典大小没有改变)。

这段代码可以做到这一点。使用 SafeDict 或其 keys() / items() / values() 代理,循环可以避免意外插入/删除:

class SafeKeyIter:
    def __init__(self, iterator, container):
        self.iterator = iterator
        self.container = container
        try:
            self.n_modifications = container.n_modifications
        except AttributeError:
            raise RuntimeError('container does not support safe iteration')

    def __next__(self):
        if self.n_modifications != self.container.n_modifications:
            raise RuntimeError('container modified duration iteration')
        return next(self.iterator)

    def __iter__(self):
        return self


class SafeView:
    def __init__(self, view, container):
        self.view = view
        self.container = container

    def __iter__(self):
        return SafeKeyIter(self.view.__iter__(), self.container)

class SafeDict(dict):
    def __init__(self, *args, **kwargs):
        self.n_modifications = 0
        super().__init__(*args, **kwargs)

    def __setitem__(self, key, value):
        if key not in self:
            self.n_modifications += 1
        super().__setitem__(key, value)

    def __delitem__(self, key):
        self.n_modifications += 1
        super().__delitem__(key)

    def __iter__(self):
        return SafeKeyIter(super().__iter__(), self)

    def keys(self):
        return SafeView(super().keys(), self)

    def values(self):
        return SafeView(super().values(), self)

    def items(self):
        return SafeView(super().items(), self)

# this now raises RuntimeError:
d = SafeDict({1: 2})
for k in d:
    d[k * 100] = 100
    del d[k]

这似乎并不太贵,所以我不确定为什么它没有在 CPython dict 中实现。也许在字典上更新n_modifications 的额外成本被认为太高了。

【讨论】:

  • 这很有趣,所以我对其进行了一些基准测试。与常规字典相比,创建SafeDict 似乎只增加了大约 5% 的开销(如果用 C 实现,可能会更少)。不过,迭代和更新 10000 项 SafeDict 中的每个值比 10000 项 dict 慢了一个数量级。 I put that benchmark here
  • @Gerrat 嗯,您正在将我的纯 python 实现与 C 实现进行比较。在 ·__next__` 中甚至有一行纯 python 的那一刻,你会看到一个数量级的命中。为了进行有意义的基准测试,需要用 C 重写。
  • A C 实现肯定会更快。在没有 C 实现的情况下,说要快多少有点猜测。我确实发现您的实现很有趣 - 将您的概念证明发布在 Dev's mailing list 上可能是值得的
猜你喜欢
  • 1970-01-01
  • 2011-01-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-02-19
  • 2014-09-20
  • 1970-01-01
  • 2021-11-28
相关资源
最近更新 更多