Предотвратить модификацию пользовательского класса во время итерации

Если у меня есть класс с интерфейсом:

class AnIteratable(object):

  def __init__(self):
    #initialize data structure

  def add(self, obj):
    # add object to data structure

  def __iter__(self):
    #return the iterator

  def next(self):
    # return next object

... как бы я настроил все так, чтобы, если add() вызывался в середине итерации, возникало исключение, подобное:

In [14]: foo = {'a': 1}

In [15]: for k in foo:
   ....:     foo[k + k] = 'ohnoes'
   ....:     
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
<ipython-input-15-2e1d338a456b> in <module>()
----> 1 for k in foo:
      2     foo[k + k] = 'ohnoes'
      3 

RuntimeError: dictionary changed size during iteration

Обновление. Если интерфейсу нужны дополнительные методы, добавьте их. Я также удалил реализацию __iter__().

Обновление №2 На основе ответа Киндалла я смоделировал следующую псевдореализацию. Обратите внимание, что _datastruture и связанные с ним методы, индексирующие его, являются абстракциями, и автору класса придется написать свои собственные механизмы обхода структуры данных и указателей местоположения.

class AnIteratable(object):

  def __init__(self):
    self._itercount = 0
    self._datastructure = init_data_structure() #@UndefinedVariable
    # _datastructure, and the methods called on it, are abstractions.

  def add(self, obj):
    if self._itercount:
      raise RuntimeError('Attempt to change object while iterating')
    # add object to data structure

  def __iter__(self):
    self._itercount += 1
    return self.AnIterator(self)

  class AnIterator(object):

    def __init__(self, aniterable):
      self._iterable = aniterable
      self._currentIndex = -1 #abstraction
      self._notExhausted = True

    def next(self):
      if self._iterable._datastructure.hasNext(self._currentIndex):
        self._currentIndex += 1
        return self._iterable._datastructure.next(self._currentIndex)
      else:
        if self._notExhausted:
          self._iterable._itercount -= 1
        self._notExhausted = False
        raise StopIteration

    def __next__(self):
      return self.next()

    # will be called when there are no more references to this object
    def __del__(self): 
      if self._notExhausted:
        self._iterable._itercount -= 1

Обновление 3 После прочтения еще немного, кажется, что __del__, вероятно, не правильный путь. Следующее может быть лучшим решением, хотя оно требует, чтобы пользователь явно освободил не исчерпанный итератор.

    def next(self):
      if self._notExhausted and 
              self._iterable._datastructure.hasNext(self._currentIndex):
      #same as above from here

    def discard(self):
      if self._notExhausted:
        self._ostore._itercount -= 1
      self._notExhausted = False

person elhefe    schedule 18.09.2012    source источник
comment
Как вы реализуете next?   -  person David Robinson    schedule 18.09.2012
comment
Если foo является dict, вы можете использовать for k in foo.keys()[:]. В другом случае это зависит от реализации метода next.   -  person Alexey Kachayev    schedule 18.09.2012
comment
@DavidRobinson Нет, это просто интерфейс. Мне интересно, как бы вы сделали это для произвольного класса с произвольной структурой данных, поскольку я предполагаю, что такая ситуация довольно распространена.   -  person elhefe    schedule 18.09.2012


Ответы (2)


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

Подумайте о том, где вы храните позицию итератора.

Выделите итератор в отдельный класс. Сохраните размер объекта при создании экземпляра итератора. Проверяйте размер всякий раз, когда вызывается next()

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

Python 2.7.3 (default, Aug  1 2012, 05:14:39) 
[GCC 4.6.3] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> d = {i:i for i in range(3)}
>>> d
{0: 0, 1: 1, 2: 2}
>>> for k in d:
...     d[k+3] = d.pop(k)
...     print d
... 
{1: 1, 2: 2, 3: 0}
{2: 2, 3: 0, 4: 1}
{3: 0, 4: 1, 5: 2}
{4: 1, 5: 2, 6: 0}
{5: 2, 6: 0, 7: 1}
{6: 0, 7: 1, 8: 2}
{7: 1, 8: 2, 9: 0}
{8: 2, 9: 0, 10: 1}
{9: 0, 10: 1, 11: 2}
{10: 1, 11: 2, 12: 0}
{11: 2, 12: 0, 13: 1}
{12: 0, 13: 1, 14: 2}
{13: 1, 14: 2, 15: 0}
{16: 1, 14: 2, 15: 0}
{16: 1, 17: 2, 15: 0}
{16: 1, 17: 2, 18: 0}

Намного больше, чем 3 итерации!

person John La Rooy    schedule 18.09.2012

Если элемент индексируется и имеет длину, вы можете сделать что-то вроде этого, что похоже на то, как это делает dict:

class AnIterable(list):

    def __iter__(self):
         n = len(self)
         i = 0
         while i < len(self):
             if len(i) != n:
                 raise RuntimeError("object changed size during iteration")
             yield self[i]
             i += 1

Недостатком является то, что если вызывающий объект делает несколько изменений, которые не приводят к чистому изменению длины (например, добавление, а затем удаление элемента), он не будет обнаружен. Конечно, вы можете использовать счетчик ревизий (увеличивающийся всякий раз, когда какой-либо другой метод вносит изменения), а не просто проверять длину:

class AnIterable(object):

    def __init__(self, iterable=()):
        self._content = list(iterable)
        self._rev = 0

    def __iter__(self):
        r = self._rev
        for x in self._content:
            if self._rev != r:
                 raise RuntimeError("object changed during iteration")
            yield x

    def add(self, item):
        self._content.append(item)
        self._rev += 1

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

Другой подход заключается в том, чтобы вести подсчет «живых» итераторов, увеличивая атрибут экземпляра при создании итератора и уменьшая его, когда он исчерпан. Затем в add() вы проверяете, равен ли этот атрибут нулю, и вызываете исключение, если это не так.

class AnIterable(object):

    def __init__(self, iterable=()):
        self._itercount = 0
        self._content   = list(iterable)

    def __iter__(self):
         self._itercount += 1
         try:
             for x in self._content:
                 yield x
         finally:
             self._itercount -= 1

    def add(self, obj):
        if self._itercount:
            raise RuntimeError("cannot change object while iterating")
        self._content.append(obj)

Для бонусных баллов реализуйте __del__() в итераторе, чтобы счетчик также уменьшался, когда объект выходит за пределы области действия, не исчерпавшись. (Остерегайтесь двойного декремента!) Это потребует определения вашего собственного пользовательского класса итератора, а не использования того, который Python дает вам, когда вы используете yield в функции, и, конечно, нет никакой гарантии относительно того, когда __del__() будет вызываться в любом кейс.

Увы, вы не можете на самом деле помешать кому-либо обойти любую «защиту», которую вы добавляете. Мы все здесь взрослые по обоюдному согласию.

Чего вы ни в коем случае не можете сделать, так это просто использовать self в качестве своего итератора.

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

Чтобы убедиться, что вызывающие объекты используют диспетчер контекста, вы можете отказаться от итерации, если вы не находитесь в контексте (например, проверьте в __iter__() флаг, установленный в __enter__()), затем сохраните список объектов итератора и сделайте их недействительными при выходе из контекста. (например, установите флаг в каждом итераторе, чтобы он вызывал исключение на следующей итерации).

person kindall    schedule 18.09.2012
comment
Мне особенно нравится тот факт, что этот подход будет обнаруживать любые изменения длины объекта-контейнера (т.е. удаления, даже если у OP нет интерфейса для этой операции в их примере кода). Единственная проблема может заключаться в том, что предполагается, что контейнер индексируется. - person martineau; 18.09.2012
comment
Конечно, если вы добавите и удалите элемент между итерациями, это не поймает... - person kindall; 18.09.2012
comment
Что касается реализации идеи живого итератора: я думаю, вы совершаете ту же ошибку, что и я в ответе (с тех пор я удалил), в том смысле, что вы предполагаете, что генератор будет использован до конца. Я не совсем уверен, но похоже, что в случае отказа от интератора _itercount не будет правильно декрементироваться. - person martineau; 18.09.2012
comment
Да, в примере есть этот недостаток. Вот почему я сказал, что для бонусных баллов реализуйте '__del__()'. Вам нужно было бы написать свой собственный класс итератора, который мне не хотелось отлаживать. :-) - person kindall; 18.09.2012
comment
Да, я думаю, это в основном то, что @gnibbler говорит в своем ответе (создайте отдельный класс итератора). Если у меня есть немного времени и я чувствую такую ​​мотивацию, я мог бы сделать это... в конце концов, это похоже на фундаментальную проблему с итератором, для которой нужен рецепт или шаблон для подражания. - person martineau; 18.09.2012
comment
На самом деле, теперь, когда я подумал об этом еще немного, реализация метода __del__(), вероятно, не будет надежно работать для сигнала о том, что итератор вышел за пределы области действия, не будучи исчерпанным, потому что Python не дает никаких гарантий относительно того, когда, если вообще когда-либо, он действительно вызовет его, даже если вы удалите объект явно с del x. - person martineau; 18.09.2012
comment
@kindall, что вы думаете о псевдо-реализации в обновлении № 2 в OP? - person elhefe; 18.09.2012
comment
@martineau __del__ всегда вызывается, когда больше нет ссылок на объект. del x не обязательно вызовет x.__del__, потому что del просто уменьшает счетчик ссылок. Ситуации, когда __del__ никогда не вызывается, вероятно (?) Маловероятно для этого случая, или если они происходят, это не имеет большого значения (например, при выходе из программы, потому что в трассировке стека есть ссылка). - person elhefe; 18.09.2012
comment
@elhefe: __del__() вызывается, когда экземпляр собирается быть уничтоженным, что не обязательно происходит сразу после того, как его счетчик ссылок обнуляется. По моему опыту, это происходит, когда вызывается сборщик мусора (если когда-либо) — другими словами, просто выход за пределы области действия или наличие del obj не гарантирует, что x.__del__() будет вызван в ближайшее время. Это отличается от того, как обстоят дела, например, с C++, и это одна из причин, по которой они изобрели оператор with. - person martineau; 18.09.2012
comment
@martineau Хм, судя по моему наивному чтению спецификации языка, похоже, что __del__ называется когда количество ссылок = 0. Выход за пределы области действия / del не обязательно вызовет __del__, если вокруг висит больше ссылок. Однако, если вы правы, кажется, что на самом деле нет никакого способа создать итератор, который мог бы обнаруживать изменения в структуре данных, по которой он выполняет итерацию, кроме как путем прямого запроса структуры данных, что в некоторых случаях может быть непозволительно. - person elhefe; 19.09.2012
comment
@elhefe: я узнал об этом из первых рук, узнав, как это работает на собственном опыте. Ссылка на язык не неверна, просто немного вводит в заблуждение (или, возможно, недостаточно конкретна) о том, когда именно что-то происходит. - person martineau; 19.09.2012
comment
@martineau: Ну, это отстой. Какая ситуация вызывает задержку/разъединение между уменьшением счетчика ссылок до нуля и вызовом __del__? Я просто сделал несколько простых тестов, и он всегда вызывался немедленно. - person elhefe; 19.09.2012
comment
@martineau: неважно, просто прочитайте это. Кажется, отсутствие надежного деструктора действительно затрудняет создание безопасных итераторов. - person elhefe; 19.09.2012
comment
@elhefe: Да, я читал этот вопрос и ответ Ильи Н. на него раньше (и я один из многих, кто проголосовал за его ответ). Я заметил, что также проголосовал за ответ Чарльза Мерриама на вопрос - тот, который полагает, что не используйте __del__. Некоторые вещи в Python похожи на это и просто требуют соблюдения определенных правил при их использовании, иначе они не будут работать должным образом (т.е. поведение не может быть автоматически или полностью применено для каждого случая). - person martineau; 19.09.2012
comment
Хм, отсрочка удаления — это не совсем то же самое, что и прямой запрет на них. Также может быть немного запутанным расширение для обработки (при откладывании) всех возможных методов изменения контента. На самом деле я не уверен, что даже простая отсрочка удаления будет работать логично во всех случаях, поскольку проверки могут быть сделаны позже во время той же итерации, чтобы проверить, было ли там что-то удаленное. Нет, я начинаю думать, что неспособность сделать это - большая дыра (также известная как бородавка) в Python. - person martineau; 20.09.2012
comment
@martineau Да. Я действительно не думаю, что какой-либо из ответов идеален на данный момент. Для своей реализации я пока использую код из моего обновления OP #3, хотя мне не нравится добавлять нестандартные методы в интерфейс итератора. - person elhefe; 20.09.2012
comment
@elhefe: вы можете подумать о том, чтобы превратить ваше обновление № 3 в контекстный менеджер, который избавит вас от необходимости помнить о вызове discard() - хотя в целом я предпочитаю (конечно) все, что только что работало, а не то, что требует от пользователей соблюдения специальных правил. правила доступа, поэтому чем ближе к этому, тем лучше. - person martineau; 20.09.2012