Custom dict, который позволяет удалять во время итерации

ОБНОВЛЕНО на основе ответа Леннарта Регебро

Предположим, вы итерации через словарь, а иногда нужно удалить элемент. Следующие действия очень эффективны:

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]

Единственное накладное время здесь - создание списка ключей для удаления; если он не станет большим по сравнению со значением словаря, это не проблема. Однако этот подход требует некоторого дополнительного кодирования, поэтому он не очень популярен.

Популярный подход понимания речи:

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

приводит к полному копированию словаря и, следовательно, имеет риск появления глупых результатов, если словари растут большими, или вызывающая функция часто называется.

Гораздо лучший подход заключается в том, чтобы скопировать ключи, а не целые словарные слова:

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() возвращает представление, а не копию.)

В большинстве случаев это не повредит производительности, так как время, чтобы проверить даже самое простое условие (не говоря уже о других вещах, которые вы делаете в цикле) обычно больше времени, чтобы добавить один ключ к список.

Тем не менее, мне интересно, можно ли даже избежать этого с помощью пользовательского словаря, который позволяет удалять во время итерации:

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__, итератор знает, куда идти, даже не глядя на текущий элемент (ему нужно было бы только взглянуть на элемент, когда он сначала добирается до него). И если нет следующего элемента, то итератор может просто установить флаг, который вызовет возникновение исключения StopIteration всякий раз, когда __next__ вызывается снова.

Если элемент, который итератор пытается продвинуть, оказывается удаленным, он отлично подходит для создания исключения; нет необходимости поддерживать удаление при одновременном продолжении нескольких итераций.

Существуют ли какие-либо проблемы с этим подходом?

Одна из проблем заключается в том, что я не уверен, что это можно сделать без материальных накладных расходов по сравнению с существующими dict; в противном случае было бы быстрее использовать подход list(dict_)!

UPDATE:

Я пробовал все версии. Я не сообщаю о сроках, поскольку они явно зависят от конкретной ситуации. Но можно с уверенностью сказать, что во многих случаях самый быстрый подход, вероятно, будет list(dict_). В конце концов, если вы думаете, копия - это самая быстрая операция, которая линейно растет с размером списка; почти любые другие накладные расходы, если они также пропорциональны размеру списка, скорее всего, будут больше.

Мне очень нравятся все идеи, но поскольку я должен выбрать только один, я принимаю решение менеджера контекста, поскольку он позволяет использовать словарь как обычный или "улучшенный" с очень небольшими изменениями кода.

Ответ 1

Как вы заметили, вы можете сохранить элементы, которые нужно удалить, и отложить их удаление до конца. Затем возникает проблема, когда следует очистить их и как убедиться, что метод очистки в конечном итоге вызван. Ответ на этот вопрос - это менеджер контекста, который также является подклассом dict.

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

Использование:

# 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 вне контекстного менеджера.

Вы также можете реализовать это как класс-оболочку для словаря:

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

Можно даже сделать класс-оболочку полноценным в качестве словаря, если вы хотите, хотя это справедливый бит больше кода.

По производительности, это, по общему признанию, не такая победа, но мне это нравится с точки зрения программиста. Второй метод должен быть очень немного быстрее, поскольку он не тестирует флаг для каждого удаления.

Ответ 2

Что вам нужно сделать, так это не изменять список ключей, которые вы выполняете. Вы можете сделать это тремя способами:

  • Сделайте копию ключей в отдельном списке и повторите это. Затем вы можете безопасно удалить ключи в словаре во время итерации. Это самый простой и быстрый, если словарь не является огромным, и в этом случае вы должны начать думать о использовании базы данных в любом случае. Код:

    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. Это также быстро. Это то, что вы делаете в первом примере.

    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]
    
  • Единственный способ избежать создания какого-либо нового списка - это, как вы полагаете, сделать специальный словарь. Но это требует, когда вы удаляете ключи, он фактически не удаляет ключи, но только помечайте их как удаленные, а затем удалите их по-настоящему только после вызова метода очистки. Для этого требуется довольно большая реализация, и есть крайние случаи, и вы помыслите себя, забыв очистить и т.д. Итерация по словарю должна по-прежнему включать удаленные ключи, которые вас укусят в какой-то момент. Поэтому я бы не рекомендовал этого. Кроме того, однако вы реализуете это в Python, вы, скорее всего, просто получите список вещей для удаления, поэтому он скорее всего будет сложной и подверженной ошибкам версией версии 2. Если вы реализовать его на C, вы, вероятно, могли бы уйти с копированием, добавив флаги непосредственно в структуру хэш-ключа. Но, как уже упоминалось, проблемы действительно затмевают преимущества.

Ответ 3

Вы можете выполнить это путем итерации по статическому списку пар ключ/значение словаря, а не итерации над представлением словаря.

В принципе, будет выполняться итерация list(dict_.items()) вместо dict_.items():

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):

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))

и вывод:

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

Ответ 4

Наивная реализация для Python 2.x и 3.x:

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

Ответ 5

Python 3.2 имеет такой dict в stdlib:

#!/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)

Выход

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

Итерация выполняется по связанному списку, см. __iter__() реализация метода. Удаление безопасно (в Python 3.2), хотя элементы являются слабыми ссылками.

Ответ 6

  • Вы можете сделать копию списка ключей (вам не нужно копировать те значения) в начале итерации и перебрать их (проверяя, что ключ есть). Это неэффективно, если имеется много ключей.
  • Вы можете организовать встроенный код первого примера внутри класса. __iter__ и __delitem__, а другие специальные методы должны сотрудничать, чтобы сохранить список элементов, которые нужно удалить во время итерации. Когда нет текущих итераций, __delitem__ может просто удалить элемент, но когда происходит хотя бы одна итерация, он должен просто добавить ключ, который нужно удалить в список. Когда последняя активная итерация заканчивается, она должна фактически удалить вещи. Это несколько неэффективно, если есть много ключей для удаления и, конечно, взорвется, если будет продолжаться хотя бы одна итерация.

Ответ 7

Это может работать как компромисс между двумя примерами - две строки длиннее второй, но короче и немного быстрее, чем первая. Python 2:

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]

Разделить на функцию и до одной строки каждый вызов (независимо от того, является ли это более читаемым или нет вашим вызовом):

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])

Независимо от того, где хранится код, вам нужно будет хранить ключи, требующие удаления где-нибудь. Единственный способ - использовать выражения генератора, которые будут взрываться в момент, когда вы удалите ключ в первый раз.