the collection iterated in foreach cannot be modified. Or…

Today we’ll talk about a non-obvious feature of some collections in .NET. We won’t beat around the bush for too long and will start with a self-test task.

An enumerator remains valid as long as the collection remains unchanged

So the question is: how will the methods behave? SetRemove And ListRemove? For convenience, I will duplicate the code in text; We will begin discussing the results immediately after listing the code.

void SetRemove()
{
    int[] arr = { 1, 2, 3 };
    HashSet<int> set = arr.ToHashSet();

    foreach (var item in set)
    {
        set.Remove(item);
    }
}

void ListRemove()
{
    int[] arr = { 1, 2, 3 };
    List<int> list = arr.ToList();

    foreach (var item in list)
    {
        list.Remove(item);
    }
}

The most interesting part starts when testing these methods on different frameworks:

  • on .NET Framework and ListRemoveand in SetRemove an exception occurs as expected InvalidOperationException;

  • in modern .NET in the method ListRemove an exception occurs as expected, but in SetRemove – No.

And if with behavior ListRemove everything is clear then SetRemove raises questions. Isn't there a contract that the iterable in foreach the collection should not change?

Let's refresh our memory and look at the documents HashSet.GetEnumerator on learn.microsoft.com:

An enumerator remains valid as long as the collection remains unchanged. If changes are made to the collection, such as adding, modifying, or deleting elements, the enumerator is irrecoverably invalidated and the next call to MoveNext or IEnumerator.Reset throws an InvalidOperationException.

Yeah, it works with .NET Framework, but not with .NET.

Well, let's go the well-known and proven way – let's dig into the source codes.

How does collection change checking work?

If you already know the answer to the question in the title, you can skip this section. However, I would still recommend skimming through it to refresh your memory.

We will look at an example with Listas it is a little simpler. In general, for the other collections the essence is the same. K HashSet We'll come back a little later.

The check itself works quite simply:

  • the collection stores its version in the field _version;

  • methods Add, RemoveAt and others like them change this version;

  • method GetEnumerator creates an iterator in designer which stores the current version of the collection, as well as a link to the collection itself;

  • methods MoveNext And Reset directly or indirectly the version is being checked;

  • If the version stored in the iterator differs from the current version of the collection, then the collection has changed – an exception is thrown.

Let's look at an example:

int[] arr = { 1, 2, 3 };
var list = arr.ToList();

foreach (var item in list)
{
    list.Remove(item);
}

We remove the sugar and explicitly introduce work with iterators – the code will become something like this:

...
var list = arr.ToList();
var enumerator = list.GetEnumerator();

try
{
    while (enumerator.MoveNext())
    {
       int current = enumerator.Current;
       list.Remove(current);
    }
}
finally
{ ... }

Checking versions here will work like this:

  • an object is created list; list._version — 0;

  • an object is created enumeratorthe version from list; list._version — 0, enumerator._version — 0;

  • call enumerator.MoveNext(). The versions match, everything is OK;

  • call list.Remove() changes the version of the collection; list._version – 1; enumerator._version — 0;

  • call enumerator.MoveNext() – versions do not match, an exception is thrown InvalidOperationException.

I hope it became clearer. Now let's go back to HashSet.

What's the catch with HashSet.Remove?

Let's return to our set example:

int[] arr = { 1, 2, 3 };
HashSet<int> set = arr.ToHashSet();

foreach (var item in set)
{
    set.Remove(item);
}

We remember that on .NET Framework this code works as expected and throws an exception. This is because the concept fits into the algorithm we described:

But what about modern .NET?

So we found the main reason for the strange behavior: Remove does not change the version -> in MoveNext the match check succeeds -> no exception is thrown -> in modern .NET you can change the integrated HashSet.

The fact that the version really does not change can be easily checked in the debugger:

var set = new HashSet<int>();
// set._version -> 0
set.Add(62);
// set._version -> 1
set.Remove(62);
// set._version -> 1

Let's go back to the source code. Digging into blame, we can find what we are interested in commit:

We see the comment:

Functionally, bringing over Dictionary's implementation yields a few notable changes, namely that Remove and Clear no longer invalidate enumerations. The tests have been updated accordingly.

It turns out that this behavior is also relevant for Dictionaryand also for the method Clear.

The behavior of the dictionary can also be easily checked through the debugger:

var dictionary = new Dictionary<int, string>();
// dictionary._version -> 0
dictionary.Add(0, string.Empty);
// dictionary._version -> 1
dictionary.Remove(0);
// dictionary._version -> 1

And the last interesting point: the corresponding commit is dated 2020, so these are quite old edits. I wonder how many developers knew about these features? 🙂

**
What is interesting to me personally is not even the implementation details, but that the contract is of the form “iterated in foreach collections should not change” now with an asterisk. Yes, in general they should not, but they can. Not everyone, and not on all frameworks, but they can.

I would prefer that the contract under discussion remain “as is”. It seems that using the described features for sets/dictionaries will only introduce additional confusion.

Well, we'll see ¯_(ツ)_/¯

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *