Почему изменение dict во время итерации не всегда вызывает исключение?

Удаление элемента из итерации через него обычно приводит к исключению 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 не пытается быть идеальным в создании этого исключения. Вместо этого он просто отслеживает размер словаря внутри каждого итератора и проверяет, что он не изменился всякий раз, когда итератору на самом деле предлагается перейти к следующему элементу. Нет ли подхода, который позволил бы полностью проверить достоверность по низкой цене?

Ответ 1

Один из подходов к обеспечению того, чтобы исключение возникало всякий раз, когда предпринималась попытка вставить или удалить ключ во время цикла, заключается в том, чтобы поддерживать количество изменений, внесенных в словарь. Затем итераторы могут проверить, что это число не изменилось в их методе __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 в словаре была слишком высокой.

Ответ 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_used остается неизменным до d->ma_used (который увеличивается на единицу и уменьшается на единицу). Операции (del и добавление ключа) выполняются на объекте 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 (из-за dict быть вездесущим).

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

Как взрослые, мы должны понимать, что Python не должен проверять все для нас и вместо этого просто не делать что-то, когда они знают нежелательные эффекты.

Ответ 3

Нет ли подхода, который бы обеспечивал полную проверку при низкой стоимости?

Вот комментарий от Alex Martelli по теме.

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

Итак, по крайней мере, в соответствии с ядром Python dev, мы не можем иметь полную проверку при низкой стоимости.