关于python:自定义dict,允许在迭代期间删除

custom dict that allows delete during iteration

根据Lennart Regebro的答案更新

假设您迭代一个字典,有时需要删除一个元素。以下是非常有效的:

1
2
3
4
5
6
7
8
remove = []
for k, v in dict_.items():
  if condition(k, v):
    remove.append(k)
    continue
  # do other things you need to do in this loop
for k in remove:
  del dict_[k]

这里唯一的开销是构建要删除的键列表;除非它比字典的大小大,否则这不是问题。然而,这种方法需要一些额外的编码,所以它不是很流行。

流行的听写理解方法:

1
2
3
dict_ = {k : v for k, v in dict_ if not condition(k, v)}
for k, v in dict_.items():
  # do other things you need to do in this loop

结果得到完整的字典副本,因此如果字典变大或经常调用包含函数,就有可能出现愚蠢的性能问题。

一个更好的方法是只复制键而不是整个字典:

1
2
3
4
5
for k in list(dict_.keys()):
  if condition(k, dict_[k]):
    del dict_[k]
    continue
  # do other things you need to do in this loop

(注意,所有代码示例都在python 3中,因此keys()items()返回一个视图,而不是一个副本。)

在大多数情况下,它不会对性能造成太大的影响,因为检查最简单条件(更不用说循环中正在做的其他事情)的时间通常比向列表中添加一个键的时间要长。

不过,我想知道是否有可能避免使用允许在迭代时删除的自定义字典:

1
2
3
4
5
for k, v in dict_.items():
  if condition(k, v):
    del dict_[k]
    continue
  # do other things you need to do in this loop

也许迭代器可以一直向前看,这样当调用__next__时,迭代器甚至不看当前元素就知道该往哪里走(它只需要在第一次到达元素时才看它)。如果没有下一个元素,迭代器可以设置一个标志,当再次调用__next__时,它将导致StopIteration异常。

如果迭代器试图前进的元素最终被删除,则可以引发异常;当同时进行多个迭代时,不需要支持删除。

这种方法有什么问题吗?

一个问题是,与现有的dict方法相比,我不确定它是否可以在没有材料开销的情况下完成;否则,使用list(dict_)方法会更快!

更新:

我试过所有的版本。我不报告时间,因为他们显然非常依赖于具体情况。但可以肯定地说,在许多情况下,最快的方法可能是list(dict_)。毕竟,如果你考虑一下,拷贝是最快的操作,它会随着列表的大小而线性增长;几乎任何其他开销,只要它与列表的大小成比例,就可能更大。

我真的很喜欢所有的想法,但是因为我只需要选择一个,所以我接受上下文管理器解决方案,因为它允许使用字典作为普通的或"增强的",只需很小的代码更改。


如您所注意的,您可以将要删除的项目存储在某个地方,并将其删除推迟到稍后。然后问题就变成了何时清除它们以及如何确保最终调用了purge方法。对此的答案是上下文管理器,它也是dict的一个子类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class dd_dict(dict):    # the dd is for"deferred delete"
    _deletes = None
    def __delitem__(self, key):
        if key not in self:
            raise KeyError(str(key))
        dict.__delitem__(self, key) if self._deletes is None else self._deletes.add(key)
    def __enter__(self):
        self._deletes = set()
    def __exit__(self, type, value, tb):
        for key in self._deletes:
            try:
                dict.__delitem__(self, key)
            except KeyError:
                pass
        self._deletes = None

用途:

1
2
3
4
5
6
7
8
9
10
11
# make the dict and do whatever to it
ddd = dd_dict(a=1, b=2, c=3)

# now iterate over it, deferring deletes
with ddd:
    for k, v in ddd.iteritems():
        if k is"a":
            del ddd[k]
            print ddd     # shows that"a" is still there

print ddd                 # shows that"a" has been deleted

当然,如果您不在with块中,则删除是即时的;因为这是dict子类,所以它的工作方式与上下文管理器之外的常规dict类似。

您还可以将其实现为字典的包装类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class deferring_delete(object):
    def __init__(self, d):
        self._dict = d
    def __enter__(self):
        self._deletes = set()
        return self
    def __exit__(self, type, value, tb):
        for key in self._deletes:
            try:
                del self._dict[key]
            except KeyError:
                pass
        del self._deletes
    def __delitem__(self, key):
        if key not in self._dict:
            raise KeyError(str(key))
        self._deletes.add(key)

d = dict(a=1, b=2, c=3)

with deferring_delete(d) as dd:
    for k, v in d.iteritems():
        if k is"a":
            del dd[k]    # delete through wrapper

print d

如果您愿意的话,甚至可以使包装器类作为一个字典完全工作,尽管这是一个相当多的代码。

从性能上讲,这当然不是什么胜利,但我喜欢从程序员友好的角度来看。第二种方法应该稍微快一点,因为它不在每次删除时测试标志。


您需要做的是不要修改您遍历的键列表。您可以通过三种方式完成此操作:

  • 在一个单独的列表中复制这些键,并对其进行迭代。然后,您可以在迭代期间安全地删除字典中的键。这是最简单和最快的,除非字典很大,在这种情况下,您应该开始考虑在任何情况下使用数据库。代码:

    1
    2
    3
    4
    5
    for k in list(dict_):
      if condition(k, dict_[k]):
        del dict_[k]
        continue
      # do other things you need to do in this loop

  • 不要复制正在迭代的键,而是复制要删除的键。换句话说,不要在迭代时删除这些键,而是将它们添加到列表中,然后在迭代完成后删除该列表中的键。这比1稍微复杂一些。但远低于3。它也很快。这就是您在第一个示例中所做的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    delete_these = []
    for k in dict_:
      if condition(k, dict_[k]):
        delete_these.append(k)
        continue
      # do other things you need to do in this loop

    for k in delete_these:
        del dict_[k]
  • 如你所建议的,避免编出某种新列表的唯一方法就是编一本特别的字典。但这要求删除键时,它不会实际删除键,而是将它们标记为已删除,然后仅在调用purge方法后才真正删除它们。这需要大量的实现,并且存在一些边缘情况,您会忘记清除等,从而欺骗自己,并且迭代字典时必须仍然包含已删除的键,这在某些时候会咬到您。所以我不推荐这个。另外,不管您在Python中如何实现这一点,您可能会再次得到一个要删除的内容列表,因此它可能只是2的一个复杂且容易出错的版本。如果您在C中实现它,那么您可以通过将标志直接添加到哈希键结构中来避免复制。但如前所述,这些问题确实掩盖了好处。


  • 您可以通过迭代字典的键/值对的静态列表来实现这一点,而不是迭代字典视图。

    基本上,迭代list(dict_.items())而不是dict_.items()将有效:

    1
    2
    3
    4
    5
    for k, v in list(dict_.items()):
      if condition(k, v):
        del dict_[k]
        continue
      # do other things you need to do in this loop

    下面是一个示例(ideone):

    1
    2
    3
    4
    5
    6
    7
    dict_ = {0: 'a', 1: 'b', 2: 'c', 3: 'd', 4: 'e', 5: 'f', 6: 'g'}
    for k, v in list(dict_.items()):
        if k % 2 == 0:
            print("Deleting ", (k, v))
            del dict_[k]
            continue
        print("Processing", (k, v))

    输出:

    1
    2
    3
    4
    5
    6
    7
    Deleting   (0, 'a')
    Processing (1, 'b')
    Deleting   (2, 'c')
    Processing (3, 'd')
    Deleting   (4, 'e')
    Processing (5, 'f')
    Deleting   (6, 'g')


    python 3.2在stdlib中有这样的dict:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #!/usr/bin/env python3
    from collections import OrderedDict as odict

    d = odict(zip(range(3),"abc"))
    print(d)
    for k in d:
        if k == 2:
           del d[k]
    print(d)

    。产量

    1
    2
    OrderedDict([(0, 'a'), (1, 'b'), (2, 'c')])
    OrderedDict([(0, 'a'), (1, 'b')])

    迭代是在链表上执行的,参见__iter__()方法实现。删除是安全的(在Python3.2中),即使项是弱引用。


    python 2.x和3.x的简单实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    import sys
    from collections import deque


    def _protect_from_delete(func):
        def wrapper(self, *args, **kwargs):
            try:
                self._iterating += 1
                for item in func(self, *args, **kwargs):
                    yield item
            finally:
                self._iterating -= 1
                self._delete_pending()
        return wrapper

    class DeletableDict(dict):
        def __init__(self, *args, **kwargs):
            super(DeletableDict, self).__init__(*args, **kwargs)
            self._keys_to_delete = deque()
            self._iterating = 0

        if sys.version_info[0] != 3:
            iterkeys = _protect_from_delete(dict.iterkeys)
            itervalues = _protect_from_delete(dict.itervalues)
            iteritems = _protect_from_delete(dict.iteritems)
        else:
            keys = _protect_from_delete(dict.keys)
            values = _protect_from_delete(dict.values)
            items = _protect_from_delete(dict.items)  
        __iter__ = _protect_from_delete(dict.__iter__)

        def __delitem__(self, key):
            if not self._iterating:
                return super(DeletableDict, self).__delitem__(key)
            self._keys_to_delete.append(key)

        def _delete_pending(self):
            for key in self._keys_to_delete:
                super(DeletableDict, self).__delitem__(key)
            self._keys_to_delete.clear()

    if __name__ == '__main__':
        dct = DeletableDict((i, i*2) for i in range(15))
        if sys.version_info[0] != 3:
            for k, v in dct.iteritems():
                if k < 5:
                    del dct[k]
            print(dct)
            for k in dct.iterkeys():
                if k > 8:
                    del dct[k]
            print(dct)
            for k in dct:
                if k < 8:
                    del dct[k]
            print(dct)
        else:
            for k, v in dct.items():
                if k < 5:
                    del dct[k]
            print(dct)

    迭代键、项或值时,它设置标志self._iterating。在__delitem__中,它检查删除项目的能力,并将密钥存储在临时队列中。在迭代结束时,它会删除所有挂起的键。

    这是非常幼稚的实现,我不建议在生产代码中使用它。

    编辑

    增加了对python 3的支持和@jsbueno注释的改进。

    python 3在ideone.com上运行


    这可以作为两个示例之间的折衷方案——两条线比第二条线长,但比第一条线短且略快。Python2:

    1
    2
    3
    4
    5
    dict_ = {k : random.randint(0, 40000) for k in range(0,200000)}

    dict_remove = [k for k,v in dict_.iteritems() if v < 3000]
    for k in dict_remove:
        del dict_[k]

    分成一个函数,每一个调用都只剩下一行(无论您的调用是否可读):

    1
    2
    3
    4
    5
    def dict_remove(dict_, keys):
        for k in keys:
            del dict_[k]

    dict_remove(dict_, [k for k,v in dict_.iteritems() if v < 3000])

    无论代码存储在何处,您都必须将需要删除的密钥存储在某个地方。唯一的解决方法是使用生成器表达式,它将在您第一次删除键时爆炸。


  • 您可以在迭代开始时复制键列表(不需要复制TE值),并迭代这些键(检查键是否存在)。如果有很多钥匙,这是低效的。
  • 您可以安排将第一个示例代码嵌入到类中。__iter____delitem__以及其他特殊方法需要协作,以便在迭代发生时保留要删除的项的列表。当没有当前迭代时,__delitem__只能删除一个项目,但当至少发生一个迭代时,它应该将要删除的键添加到一个列表中。当最后一个活动迭代完成时,它实际上应该删除一些东西。如果需要删除大量的键,那么这就有点低效了,当然,如果总是至少进行一次迭代,那么这将导致崩溃。