12

When I try to update a set while iterating over its elements, what should be its behavior?

I tried it over various scenarios and it does not iterate over elements added after iteration is started and also the elements removed during iteration. If I remove and put back any element during iteration, that element is being considered. What's the exact behavior and how does it work?

This prints all the permutations of a string:

def permutations(s):
    ans = []
    def helper(created, remaining):
        if len(created) == len(s):
            ans.append(''.join(created))
            return
        for ch in remaining:
            remaining.remove(ch)
            created.append(ch)
            helper(created, remaining)
            remaining.add(ch)
            created.pop()
    helper([], set(s))
    return ans

Here the behavior is unpredictable, sometimes e is printed while sometimes it's not:

ab = set(['b','c','d'])
x = True
for ch in ab:
    if x:
        ab.remove('c')
        ab.add('e')
        x = False
    print(ch)

Here I always see 'c' only once. Even when first character is 'c':

ab = set(['b','c','d'])
x = True
for ch in ab:
    if x:
        ab.remove('c')
        ab.add('c')
        x = False
    print(ch)

And an alternate way to achieve the same objective of the above function:

def permwdups(s):
    ans = []
    def helper(created, remaining):
        if len(created) == len(s):
            ans.append(''.join(created))
            return
        for ch in remaining:
            if (remaining[ch]<=0):
                continue
            remaining[ch] -=1
            created.append(ch)
            helper(created, remaining)
            remaining[ch] +=1
            created.pop()
    counts = {}
    for i in range(len(s)):
        if s[i] not in counts:
            counts[s[i]] = 1
        else:
            counts[s[i]]+= 1
    helper([], counts)
    print(len(set(ans)))
    return ans
MSeifert
  • 145,886
  • 38
  • 333
  • 352
user3285099
  • 517
  • 1
  • 3
  • 13
  • 6
    Yeah don't do that. – Stefan Pochmann Dec 29 '17 at 06:57
  • Should I avoid changing list/set during iteration? As you see in the above example, I will have to create copies to avoid and space complexity goes up. – user3285099 Dec 29 '17 at 07:04
  • 2
    Are you asking just because you're curious about the implementation details, or do you have any intention of actually using that? For lists I occasionally modify while I iterate because it's fairly predictable and convenient, but I think not even I would dare to modify a *set* while iterating it. Or maybe I just haven't had a use case for that yet... – Stefan Pochmann Dec 29 '17 at 07:07
  • 1
    In your `permutations`/`helper`, that space complexity is irrelevantly small. You're dealing with permutations, the data will be small anyway or else your result would explode. But if you do want to save time and space there, I recommend giving the `helper` only a *list* and an *index*, meaning the helper shall still permute everything at this index and after. Or something like that. – Stefan Pochmann Dec 29 '17 at 07:16
  • Btw, just saw your `set([str[i] for i in range(len(str))])`. That's the same as `set(str)`. And better don't call a variable `str` (shadows the built-in which you then can't access anymore, plus it's confusing because people expect it to be the built-in). – Stefan Pochmann Dec 29 '17 at 07:19
  • @StefanPochmann do you modify lists while iterating over them in the sense of inserting new elements/outright deleting elements, or in the sense or mutating objects in the list? Because the former is far less safe than the latter – ubadub Dec 29 '17 at 07:21
  • If iteration over set is predictable, I would use it. It does seem predictable with some use cases while it's not for some use cases as I have mentioned above. I asked for implementation of it so that I understand the behavior and use it wisely if it helps. – user3285099 Dec 29 '17 at 07:22
  • While removal and addition of elements of set during iteration behave unpredictably, addition and removal do seem to behave well which is why I am curious about how the iteration is implemented. – user3285099 Dec 29 '17 at 07:25
  • @user3285099 I think this is the video I watched a while back that explained it well. It's about dicts, but I think sets are done the same way: https://www.youtube.com/watch?v=C4Kc8xzcA68 – Stefan Pochmann Dec 29 '17 at 07:26
  • I recommend you watch that and then tell me whether you think you can predict it :-) – Stefan Pochmann Dec 29 '17 at 07:27
  • @ubadub I meant inserting/deleting as well. Mostly `append`. But have a look at Martijn's answer and my recent conversation with him in the comments there: https://stackoverflow.com/a/15175745/1672429 – Stefan Pochmann Dec 29 '17 at 07:34
  • @StefanPochmann Video clearly explains how set/dict is implemented but doesn't answer how iteration works. The naive implementation would be to iterate over the sequential memory address of the keys since they are sequential in memory. But this would not work if resizing happens during iteration. The same problem is present for lists but as Martijn points out append in lists is good during iteration so the iteration ought to be more sophisticated than just go over memory addresses. – user3285099 Dec 29 '17 at 08:54
  • @MartijnPieters – user3285099 Dec 29 '17 at 08:58
  • @user3285099 Sometimes I wish I could summon people like that, but that's not how notifications work :-P. You'd have to ask Martijn somewhere where he actually gets notified about it. Anyway, the video does talk about iteration a bit from 4:20 to 5:00. And later on it says that modification during iteration is prohibited and shows how that can cause an exception. I think the iterator does pretty much just keep a pointer to the set object and an index into it and walks through that. Here's a demo where that even survives an actual internal resizing: https://ideone.com/celqTh – Stefan Pochmann Jan 04 '18 at 16:39
  • @StefanPochmann In one of your comments you mentioned that iteration in above example can be done with list and an index. How would you do it? – user3285099 Jan 07 '18 at 21:43

1 Answers1

35

It's actually very easy, sets are implemented in CPython as hash - item table:

  hash  |  item  
-----------------
    -   |    -
-----------------
    -   |    -
       ...

CPython uses open-addressing so not all rows in the table are filled and it determines the position of an element based on the (truncated) hash of the item with a "pseudo-randomized" position determination in case of collisions. I will ignore truncated-hash-collisions in this answer.

I'll also ignore the specifics of the hash-truncation and just work with integers, which all (with some exceptions) map their hash to the actual value:

>>> hash(1)
1
>>> hash(2)
2
>>> hash(20)
20

So when you create a set with the values 1, 2 and 3 you'll have (roughly) the following table:

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1
-----------------
    2   |    2
-----------------
    3   |    3
       ...

The set is iterated from the top of the table to the end of the table and empty "rows" are ignored. So if you would iterate over that set without modifying it you would get the numbers 1, 2 and 3:

>>> for item in {1, 2, 3}: print(item)
1
2
3

Basically the iterator starts in row 0 and sees that the row is empty and goes to row 1 which contains the item 1. This item is returned by the iterator. The next iteration it's in row 2 and returns the value there, which is 2, then it goes to row 3 and returns the 3 that is stored there. The following iteration the iterator is in row 4 which is empty, so it goes to row 5 which is also empty, then to row 6, .... until it reaches the end of the table and throws a StopIteration exception, which signals that the iterator finished.

enter image description here

However if you would change the set while iterating over it the current position (row) where the iterator is is remembered. That means if you add an item in a previous row the iterator won't return it, if it's added in a later row it will be returned (at least if it's not removed again before the iterator is there).

Assume, you always remove the current item and add an integer which is item + 1 to the set. Something like this:

s = {1}
for item in s: 
    print(item)
    s.discard(item)
    s.add(item+1)

The set before the iteration looks like this:

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1
-----------------
    -   |    -
       ...

The iterator will start in row 0 find it is empty and goes to row 1 which contains the value 1 which is then returned and printed. If the arrow would indicate the position of the iterator it would look like this in the first iteration:

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1      <----------
-----------------
    -   |    -

Then the 1 is removed and the 2 is added:

  hash  |  item  
-----------------
    -   |    -
-----------------
    -   |    -      <----------
-----------------
    2   |    2

So in the next iteration the iterator finds the value 2 and returns it. Then the two is added and a 3 is added:

  hash  |  item  
-----------------
    -   |    -
-----------------
    -   |    -
-----------------
    -   |    -      <----------
-----------------
    3   |    3

And so on.

Until it reaches 7. At that point something interesting happens: The truncated hash of 8 means that the 8 will be put in row 0, however row 0 has already been iterated over so it will stop with 7. The actual value might depend on Python version and the add/removal history of the set, for example just changing the order of the set.add and set.discard operations will give a different result (goes up to 15 because the set is resized).

enter image description here

For the same reason the iterator would only display 1 if one would add the item - 1 in each iteration, because 0 would be (because of hash 0) to the first row:

s = {1}
for item in s: 
    print(item)
    s.discard(item)
    s.add(item-1)

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1      <----------
-----------------
    -   |    -

  hash  |  item  
-----------------
    0   |    0
-----------------
    -   |    -
-----------------
    -   |    -      <----------

Visualized with a simple GIF:

enter image description here

Note that these examples are very simplistic, if the set resizes during the iteration it will re-distribute the stored items based on the "new" truncated hash and it will also remove dummies that are left behind when you remove an item from a set. In that case you still can (roughly) predict what will happen but it will become a lot more convoluted.

One additional but very important fact is that Python (since Python 3.4) randomizes the hash value of strings for each interpreter. That means that each Python session will produce different hash-values for strings. So if you add/remove strings while iterating the behavior will also be random.

Suppose you have this script:

s = {'a'}
for item in s: 
    print(item)
    s.discard(item)
    s.add(item*2)

and you run it multiple times from the command line the result will be different.

For example my first run:

'a'
'aa'

My second / third / fourth run:

'a'

My fifth run:

'a'
'aa'

That's because scripts from the command line always start a new interpreter session. If you run the script multiple times in the same session the results won't differ. For example:

>>> def fun():
...     s = {'a'}
...     for item in s: 
...         print(item)
...         s.discard(item)
...         s.add(item*2)

>>> for i in range(10):
...     fun()

produces:

a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa

But it could also have given 10 times a or 10 times a, aa and aaaa, ...


To summarize it:

  • A value added to a set during iteration will be displayed if the item is put in a position that hasn't been iterated over. The position depends on the truncated hash of the item and the collision strategy.

  • The truncation of the hash depends on the size of the set and that size depends on the add/removal history of the set.

  • With strings one cannot predict the position because their hash values are randomized on a per-session-basis in recent Python versions.

  • And most importantly: Avoid modifying the set / list / dict / ... while iterating over it. It almost always leads to problems and even if it doesn't it will confuse anyone reading it! Although there are a few, very rare cases where adding elements to a list while iterating over it makes sense. That would require very specific comments alongside it, otherwise it would look like a mistake! Especially with sets and dicts you will rely on implementation details that might change at any point!


Just in case you're curious I inspected the internals of the set using (somewhat fragile and probably only works on Python 3.6 and definitely not usable in production-code) Cython introspection in a Jupyter Notebook:

%load_ext Cython

%%cython

from cpython cimport PyObject, PyTypeObject
cimport cython

cdef extern from "Python.h":
    ctypedef Py_ssize_t Py_hash_t

    struct setentry:
        PyObject *key
        Py_hash_t hash

    ctypedef struct PySetObject:
        Py_ssize_t ob_refcnt
        PyTypeObject *ob_type
        Py_ssize_t fill
        Py_ssize_t used
        Py_ssize_t mask
        setentry *table
        Py_hash_t hash
        Py_ssize_t finger

        setentry smalltable[8]
        PyObject *weakreflist

cpdef print_set_table(set inp):
    cdef PySetObject* innerset = <PySetObject *>inp
    for idx in range(innerset.mask+1):
        if (innerset.table[idx].key == NULL):
            print(idx, '<EMPTY>')
        else:
            print(idx, innerset.table[idx].hash, <object>innerset.table[idx].key)

Which prints the key-value table inside the set:

a = {1}
print_set_table(a)

for idx, item in enumerate(a):
    print('\nidx', idx)
    a.discard(item)
    a.add(item+1)
    print_set_table(a)

Note that the output will contain dummys (left-overs from deleted set-items) and they will sometimes disappear (when the set get's too full or resized).

MSeifert
  • 145,886
  • 38
  • 333
  • 352
  • I have also watched the youtube video of Brandom Rhodes where he says when you add elements to set and it size exceeds allocated size, it would resize but when you delete elements it is not resized. That means during iteration adding a lot of items and then deleting all of them would make the iteration unstable. https://trinket.io/python/7911daac2e If you add and delete the same strings, iteration seem to be stable. – user3285099 Jan 07 '18 at 21:37
  • a = set([]) import random lets1 = ''.join([chr(ord('a') +i) for i in range(13)]) lets2 = ''.join([chr(ord('a') +i) for i in range(13,26)]) for i in range(15): a.add(random.choice(lets1)) newset = set([]) for chr in a: newset.add(chr) toadd = [] for i in range(150): t = random.choice(lets2) a.add(t) toadd.append(t) for s in toadd: if s in a: a.remove(s) print(newset == a) – user3285099 Jan 07 '18 at 21:37
  • @user3285099 Yes, it would definitely make it unstable. That's the reason why the CPython developers added a check and it throws a `RuntimeError: Set changed size during iteration`. However you can work around that by keeping the set size constant - that doesn't mean that modifying the set while iterating over it is well defined (it isn't), one just avoided the exception. Note that in the case of a resize during the iteration anything might happen because a resize will also trigger re-distribution of the items and a different hash-truncation (so the order may change). – MSeifert Jan 07 '18 at 21:49
  • I think it's worth emphasizing the last point again: "And most importantly: Avoid modifying the set / list / dict / ... while iterating over it. It almost always leads to problems and even if it doesn't it will confuse anyone reading it!" – MSeifert Jan 07 '18 at 21:51
  • 2
    What do you mean with *"Sets also cannot resize while you iterate over them"*? [As I had shown](https://stackoverflow.com/questions/48018680/updating-a-set-while-iterating-over-its-elements?noredirect=1#comment83174435_48018680), they can: https://ideone.com/celqTh. And typo: You say your code might print `aaa` but that's impossible, you probably meant `aaaa`. – Stefan Pochmann Jan 08 '18 at 09:02
  • @StefanPochmann I changed the answer accordingly and thank you for the comment. The resize was indeed a left-over of the first draft (before I checked it and found out myself that it was wrong - see previous comments). I also added the Cython code I used to inspect the internals in case anyone is interested in verifying the results. :) – MSeifert Jan 08 '18 at 14:10