2016-12-04 56 views
12

Usuwanie elementu z wyciągnięcie iteracja to zazwyczaj powodują RuntimeError: dictionary changed size during iteration wyjątkiem:Dlaczego modyfikowanie dict podczas iteracji nie zawsze powoduje wyjątek?

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

Aby być bardziej precyzyjnym, sama delecja uda. Jednakże, aby przejść do kolejnej rundy iteracji, interpreter musi wywołać next(it), gdzie it jest iteratorem przez słownik, który wcześniej uzyskał. W tym momencie next() zauważy, że rozmiar słownika się zmienił i narzeka.

Jak dotąd tak dobrze. Ale co, jeśli oboje usunąć i dodać element do słownika:

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] 

Jestem prawie pewien, że to nie jest bezpieczne (docs sugerować ani wkładki ani usuwać wolno podczas iteracji). Dlaczego tłumacz umożliwia działanie tego kodu bez błędu?

Podejrzewam tylko, że zbyt drogie jest sprawdzenie, które iteratory są unieważniane za każdym razem, gdy wywoływana jest metoda wstawiania lub usuwania. Tak więc dict nie stara się być idealnym o podniesienie tego wyjątku. Zamiast tego, po prostu śledzi rozmiar słownika wewnątrz każdego iteratora i sprawdza, czy nie zmieniło się, gdy iterator jest proszony o przejście do następnego elementu. Czy nie ma podejścia, które umożliwiłoby pełną walidację przy niskim koszcie?

+0

Szukasz czegoś, co sprawi, że twoja pętla stanie się bardziej niezawodna, czy chcesz omówić szczegóły implementacji Pythona? –

+0

Wygląda na to, że chcesz mieć niezmienne klucze słownikowe w pętli. Nie sądzę, żeby było to wykonalne. – DyZ

+0

@KlausD. Hmm, myślę, że jedno i drugie? Jeśli istnieje technika, która może to zrobić, rozważyłbym skorzystanie z niego osobiście. Ale aby zrozumieć jego koszty (czas pracy, złożoność kodu, itp.), Byłoby dla mnie ważne, aby wiedzieć, dlaczego CPython go nie używa. – max

Odpowiedz

1

Czy nie ma podejścia, które umożliwiłoby pełną walidację przy niskim koszcie?

Tutaj znajduje się odpowiedni comment from Alex Martelli na ten temat.

because a container doesn't even keep track of iterators that are out on it, much less hook even altering-method to loop over every such iterator and somehow magically let each iterator know about the alterations. It would be a lot subtle, complex code, and checks slowing down very frequent operations

Tak więc, przynajmniej według podstawowego dewelopera Python, nie możemy mieć pełnej walidacji przy niskim koszcie.

+1

Hmm Myślę, że Alex Martelli odwoływał się do trudności * zezwalania * na modyfikacje słownika podczas iteracji. Jest to znacznie trudniejsze niż * wykrywanie * modyfikacji. – max

2

Najprostsza odpowiedź brzmi: bo usunąć 1 pkt i dodać 1 element więc fakt, że wielkość zmieniła faktycznie nigdy wplątuje; RuntimeError jest podniesiona, gdy istnieje różnica między wielkością iteracyjnej i słownika dla tego iteratora:

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; 
} 

kiedy dodać jedną i usunąć jedną, di->di_used pozostaje taka sama do d->ma_used (który zostanie zwiększony o jeden i zmniejszony o jeden). Operacje (del i dodanie klucza) są wykonywane na obiekcie dict obiektu d, a ze względu na saldo tych operacji nie znaleziono niezgodności w poprzedniej klauzuli if, którą dodałem.

Ale jeśli dodać dwa klucze, na przykład, można uzyskać ten sam błąd:

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 

bo wiedząc, że wielkość zmieniła się złapać tutaj. Jeśli, oczywiście, zmniejszysz dwukrotnie, zachowuje się tak samo jak poprzednio, wyrównuje się.

Jak powtarzałem w sekcji komentarzy, sprawdzanie, czy wstawienia lub usunięcia wystąpiły w sposób zrównoważony, nie jest tak proste, jak sprawdzenie, czy rozmiar po prostu się zmienił.To również nie ma sensu do mnie na dwóch innych kont:

  • Jeśli ludzie rzeczywiście wybrać do zmiany słownika podczas iteracji, kursy są oni nie będą robić to w sposób zrównoważony tak czeku w miejscu powinno wystarczyć na najczęstsze przypadki.
  • Jeśli zdecydujesz się dodać więcej kontroli, wpłynie to na wydajność prawie każdej rzeczy w Pythonie (ze względu na to, że dict są wszechobecne).

Ogólnie wątpię, że dodanie tej kontroli przyniosłoby korzyści; jest dość dobrze ustalona dla większości iteracji nad kolekcją, a zmiana nie jest najlepszym pomysłem.

Podobnie jak osoby dorosłe, powinniśmy zrozumieć, że Python nie powinien sprawdzać wszystkiego dla nas, a zamiast tego po prostu nie rób rzeczy, gdy znają niepożądane efekty.

+0

Cóż, technicznie rzecz biorąc tak. Ale chodzi mi o to, dlaczego "dykt" został tak zaprojektowany, że narzeka tylko wtedy, gdy liczba wstawień nie jest równa liczbie usunięć. Gdy są równe (i niezerowe), kod jest równie niebezpieczny. – max

+0

@max Ponieważ jest to wymaganie, którego nie można rozwiązać w sposób trywialny, ponieważ najczęściej występuje przypadek niezbilansowanych wstawień/usunięć. W końcu Python nie jest * naprawdę * ścisły o tym, co możesz i czego nie możesz zrobić, jeśli chcesz zrobić coś głupiego, idź dalej, ale zmierzyć się z konsekwencją. –

+0

moje proponowane rozwiązanie w poniższej odpowiedzi byłoby zbyt wolne, jak sądzę? – max

4

Jednym ze sposobów zagwarantowania, że ​​wyjątek zostanie zgłoszony w przypadku próby wstawienia lub usunięcia klucza w pętli, jest zachowanie liczby modyfikacji dokonanych w słowniku. Następnie iteratory mogą sprawdzić, czy liczba ta nie zmieniła się w metodzie __next__ (zamiast sprawdzać, czy rozmiar słownika się nie zmienił).

Ten kod spowoduje to. Korzystanie SafeDict lub jego keys()/items()/values() proxy, pętle stają się bezpieczne od przypadkowego wstawienia/skasowania:

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] 

nie wydają się zbyt drogie, więc nie jestem pewien, dlaczego nie jest to realizowane w CPython dict . Być może dodatkowy koszt aktualizacji n_modifications w słowniku został uznany za zbyt wysoki.

+0

To jest interesujące, więc przeprowadziłem kilka testów porównawczych. Stworzenie "SafeDict" tylko wydawało się dodać około 5% narzutów w porównaniu z normalnym dyktatem (i jeśli zaimplementowane w C, prawdopodobnie mniej). Iterowanie i aktualizowanie każdej wartości w elemencie 10000 "SafeDict" było o cały rząd wielkości wolniejsze niż 10000 pozycji. [Tutaj umieściłem ten benchmark] (https://trinket.io/python3/a891539584) – Gerrat

+0

@ Gerrat hmm porównujesz moją czystą implementację Pythona do implementacji C. W momencie, gdy w środku · __next__ znajduje się nawet jedna linia czystego Pythona, zobaczysz trafienie rzędu wielkości. Aby uzyskać sensowny test porównawczy, należy go przepisać w C. – max

+0

Implementacja C będzie z pewnością szybsza. Bez implementacji C trudno się domyślić, o ile szybciej. Uważam, że twoja implementacja jest interesująca - warto opublikować swój dowód na [liście dyskusyjnej Dev] (https://mail.python.org/mailman/listinfo/python-dev) – Gerrat