Use Caution When Using Select with IEnumerable or IQueryable
I was recently caught off guard by the behavior of an IEnumerable
produced by a Select
statement. The code I was using looked a bit like what is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
public void ProjectionExample(List<ModelClass> modelClasses)
{
var projections = modelClasses.Select(x => new ProjectedClass(x));
projections.First().Name = "Foo";
foreach (var projection in projections)
{
if (projection.Name == "Foo")
{
// This IF is never hit.
}
}
}
In the code, I used a Select
to transform a model class into another class. Then I made changes to some members of the projected class (line 4) and was surprised to find that those changes where not present when looping through the collection. As a result an if (line 8) condition was never met, and some unit tests were failing. Lets take a look at what was happening.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// https://source.dot.net/#System.Linq/System/Linq/Select.cs
private sealed partial class SelectIListIterator<TSource, TResult> : Iterator<TResult>
{
public SelectIListIterator(IList<TSource> source, Func<TSource, TResult> selector)
{
...
}
public override bool MoveNext()
{
...
if (_enumerator.MoveNext())
{
_current = _selector(_enumerator.Current);
return true;
}
...
}
...
}
A simplified version of the source code for Select
source.dot.net is shown above. In out example we select from a list, so we are interested in the SelectIListIterator.MoveNext()
method. This is the method being called every time we access the contents of projections
. It doesn’t matter whether if we are looping, filtering, calling First
or Take
…. We always use MoveNext()
.
In turn, MoveNext()
calls the Func selector
to produce the value. In our case, this was x => new ProjectedClass(x)
. Maybe the problem with the code above is jumping out at you now. The First
and foreach
are calling MoveNext()
independently and as a result they create different instances of ProjectedClass
. This explains why changing the Name property didn’t seem to be permanent.
This is what is meant by deferred execution and we need to be particularly careful when using it with Select
.
We can extend this with a quick example. Here, we again produce a collection (with only one member) called projections
which is an IEnumerable
that will transform one type into another. We then test the IEnumerable
and several other collection types to see if they use deferred or eager execution.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using System;
using System.Collections.Generic;
using System.Linq;
namespace SelectEnumerable
{
internal class Program
{
private static void Main(string[] args)
{
var sourceInts = new List<IntHolder> { new IntHolder { Value = 1 } };
var projections = sourceInts.Select(x =>
{
Console.WriteLine("Projecting IntHolder to StrHolder");
return new StrHolder { Value = x.Value.ToString() };
});
TestCollection("IEnumerable", () => projections);
TestCollection("IEnumerable (with Where)", () => projections.Where(x => true));
TestCollection("IEnumerable (with Skip)", () => projections.Skip(0));
TestCollection("IQueryable", () => projections.AsQueryable());
TestCollection("Array", () => projections.ToArray());
TestCollection("HashSet", () => projections.ToHashSet());
TestCollection("List", () => projections.ToList());
TestCollection("IEnumerable, produced from intermediate List", () => projections.ToList().AsEnumerable());
TestCollection("IEnumerable, produced by filtering an intermediate List", () => projections.ToList().Where(x => true));
}
private static void TestCollection(string typeName, Func<IEnumerable<StrHolder>> func)
{
Console.WriteLine($"{typeName}:");
var projections = func();
var a = projections.First();
var b = projections.First();
if (a == b)
{
Console.WriteLine($"{typeName} used Eager Execution on the Select.");
}
else
{
Console.WriteLine($"{typeName} used Defered Execution on the Select.");
}
Console.WriteLine();
}
}
internal class IntHolder { public int Value { get; set; } }
internal class StrHolder { public string Value { get; set; } }
}
This console output produced is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
IEnumerable:
Projecting IntHolder to StrHolder
Projecting IntHolder to StrHolder
IEnumerable used Defered Execution on the Select.
IEnumerable (with Where):
Projecting IntHolder to StrHolder
Projecting IntHolder to StrHolder
IEnumerable (with Where) used Defered Execution on the Select.
IEnumerable (with Skip):
Projecting IntHolder to StrHolder
Projecting IntHolder to StrHolder
IEnumerable (with Skip) used Defered Execution on the Select.
IQueryable:
Projecting IntHolder to StrHolder
Projecting IntHolder to StrHolder
IQueryable used Defered Execution on the Select.
Array:
Projecting IntHolder to StrHolder
Array used Eager Execution on the Select.
HashSet:
Projecting IntHolder to StrHolder
HashSet used Eager Execution on the Select.
List:
Projecting IntHolder to StrHolder
List used Eager Execution on the Select.
IEnumerable, produced from intermediate List:
Projecting IntHolder to StrHolder
IEnumerable, produced from intermediate List used Eager Execution on the Select.
IEnumerable, produced by filtering an intermediate List:
Projecting IntHolder to StrHolder
IEnumerable, produced by filtering an intermediate List used Eager Execution on the Select.
As we can see, the methods ToArray
, ToList
and ToHashSet
all perform eager execution when creating their target collection type. As a result, when we use any of these after our Select
statement then each instance of our ProjetedClass
is created one time. Therefore, creating a List
, Array
or HashSet
will make your code easier to correctly reason about, which in turn swill mean it is more likely to be correct. The only exception that jumps out at me is a loop like this foreach (var item in sourceInts.Select(x => new StrHolder(x)){ ... }
because the Select
is guaranteed to only be iterated over once.
Leave a Comment
Your email address will not be published. Required fields are marked *