Value-Type vs Reference-Type Enumerators in C#
Introduction
Whether your enumerator is a value type or reference type can mean the difference between zero-allocation iteration and a heap allocation on every loop. The C# compiler generates different foreach code depending on what GetEnumerator() returns — and most developers never notice the performance gap. This article benchmarks both paths and shows how .NET’s own collections exploit this distinction.
NOTE: It uses the indexer in the case of arrays or spans. Check my other article “Array iteration performance in C#” to learn about those cases.
Reference-type enumerators
Classes and interfaces are reference types. If GetEnumerator() returns any of these then the enumerator is a reference type.
If the collection provided to foreach is of types IEnumerable or IEnumerable<T>, then the return type of GetEnumerator() is IEnumerator or IEnumerator<T> respectively. This means that the enumerator will be a reference type.
Enumerable.Range() is a method that returns IEnumerable<int>. Let’s use a foreach to iterate all the values of the collection:
1
2
3
var source = Enumerable.Range(0, 10);
foreach(var item in source)
Console.WriteLine(item);
You can see in SharpLab that the compiler converts this code to something equivalent to the following:
1
2
3
4
5
6
7
8
9
10
11
12
IEnumerator<int> enumerator = Enumerable.Range(0, 10).GetEnumerator();
try
{
while (enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
}
finally
{
enumerator?.Dispose();
}
It calls GetEnumerator() to get an instance of the enumerator. Notice that the enumerator is of type IEnumerator<int>, an interface.
It then uses a while loop with enumerator.MoveNext() as condition. Inside of the loop, it calls enumerator.Current to get the item.
NOTE: Because the
IEnumerable<T>derives fromIDisposable, it callsenumerator.Dispose()inside a finally to guarantee that it’s called even if an exception is thrown inside the loop.
You can also see in SharpLab the IL generated:
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
IL_0000: ldc.i4.0
IL_0001: ldc.i4.s 10
IL_0003: call class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32> [System.Linq]System.Linq.Enumerable::Range(int32, int32)
IL_0008: callvirt instance class [System.Runtime]System.Collections.Generic.IEnumerator`1<!0> class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32>::GetEnumerator()
IL_000d: stloc.0
.try
{
// sequence point: hidden
IL_000e: br.s IL_001b
// loop start (head: IL_001b)
IL_0010: ldloc.0
IL_0011: callvirt instance !0 class [System.Runtime]System.Collections.Generic.IEnumerator`1<int32>::get_Current()
IL_0016: call void [System.Console]System.Console::WriteLine(int32)
IL_001b: ldloc.0
IL_001c: callvirt instance bool [System.Runtime]System.Collections.IEnumerator::MoveNext()
IL_0021: brtrue.s IL_0010
// end loop
IL_0023: leave.s IL_002f
} // end .try
finally
{
// sequence point: hidden
IL_0025: ldloc.0
IL_0026: brfalse.s IL_002e
IL_0028: ldloc.0
IL_0029: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
// sequence point: hidden
IL_002e: endfinally
} // end handler
Notice that the instruction callvirt is used to call the MoveNext() and Current.
Value-type enumerators
Collections can implement a GetEnumerator() method that returns a value-type enumerator.
NOTE: Even if the collection implements
IEnumerable<T>, it’s possible to make it an explicit implementation, and add a public implementation that returns the value-type enumerator.
List<T> is one example of these enumerables. Let’s use a foreach loop to iterate all the values of the collection:
1
2
3
4
5
6
7
8
9
10
11
12
List<int>.Enumerator enumerator = new List<int>().GetEnumerator();
try
{
while (enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
}
finally
{
((IDisposable)enumerator).Dispose();
}
The code is very similar to the case of reference-type enumerator but notice that now the enumerator type is of type List<int>.Enumerator. You can see in GitHub that this is a value-type.
You can also see in SharpLab the IL generated:
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
IL_0000: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
IL_0005: callvirt instance valuetype [System.Collections]System.Collections.Generic.List`1/Enumerator<!0> class [System.Collections]System.Collections.Generic.List`1<int32>::GetEnumerator()
IL_000a: stloc.0
.try
{
// sequence point: hidden
IL_000b: br.s IL_0019
// loop start (head: IL_0019)
IL_000d: ldloca.s 0
IL_000f: call instance !0 valuetype [System.Collections]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
IL_0014: call void [System.Console]System.Console::WriteLine(int32)
IL_0019: ldloca.s 0
IL_001b: call instance bool valuetype [System.Collections]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
IL_0020: brtrue.s IL_000d
// end loop
IL_0022: leave.s IL_0032
} // end .try
finally
{
// sequence point: hidden
IL_0024: ldloca.s 0
IL_0026: constrained. valuetype [System.Collections]System.Collections.Generic.List`1/Enumerator<int32>
IL_002c: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_0031: endfinally
} // end handler
Notice that the instruction call is used to call the enumerator methods.
NOTE: The instruction
callvirtis used to callDispose()but, because of thecontrained.instruction in the line before, the assembly generated will be similar to thecallinstruction.
Benchmarking
Let’s use BenchmarkDotNet to run the following benchmarks:
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
using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
public class ValueTypeEnumerator
{
List<int>? list;
IEnumerable<int>? enumerable;
[Params(100, 10_000)]
public int Count { get; set; }
[GlobalSetup]
public void GlobalSetup()
{
list = System.Linq.Enumerable.Range(0, Count).ToList();
enumerable = list;
}
[Benchmark(Baseline = true)]
public int Enumerable()
{
var sum = 0;
foreach (var item in enumerable!)
sum += item;
return sum;
}
[Benchmark]
public int List()
{
var sum = 0;
foreach (var item in list!)
sum += item;
return sum;
}
}
It compares the performance of iterating a List<int> with 100 and 10.000 items when cast to IEnumerable<int> (reference-type enumerator) and when directly using List<int> (value-type enumerator).
I used a configuration to test on .NET 6, .NET 7, and .NET 8 RC1 (all “modern” NET versions).
Notice that the difference ranges from 300% and 700% on x64 and a steady 450% on Arm64. Value-type enumerators always performing better than reference-type enumerators.
Also notice that value-type enumerators do not allocate on the heap, not adding pressure to the garbage collector. One other thing to note is that the performance improves significantly between .NET 7 and .NET 8. That’s one good reason to upgrade to .NET 8 as soon as possible.
Conclusions
Virtual calls are required in types that support inheritance. Value types in .NET do not support inheritance so all methods are called directly. When a value-type is cast to an interface, it’s copied into the heap and converted to a reference type (boxing).
Iterating a collection means calling MoveNext() and Current for each item. Using a value-type enumerator may make a huge difference in performance for large collections.
When using a collection avoid casting it to an interface. On public APIs, consider using immutable collections. All collections provided by the .NET framework make use of value type enumerators. If you implement a new collection type, don’t forget to do it also.


