HLQ001: Assigment causes boxing of enumerator

July 17, 2023 ยท View on GitHub

Cause

An enumerable or async enumerable, that uses value type enumerators, is assigned to a non-public variable, field or property whose type causes the enumerator to be boxed.

Severity

Warning

Rule description

Enumerables and async enumerables can be implemented so that they use value type enumerators. The advantage is that calls to its methods are not virtual and the enumerator is allocated in the stack.

Most collections in the .NET framework are implemented this way. Here's an excerpt of the implementation of System.Collections.Generic.List<T> :

public class List<T> : IList<T>, IList, IReadOnlyList<T>
{
    public Enumerator GetEnumerator()
        => new Enumerator(this);

    IEnumerator<T> IEnumerable<T>.GetEnumerator()
        => new Enumerator(this);

    IEnumerator IEnumerable.GetEnumerator()
        => new Enumerator(this);
        
    public struct Enumerator : IEnumerator<T>, IEnumerator
    {
        public bool MoveNext()
        {
            ...
        }
            
        public T Current => _current;

        object IEnumerator.Current
        {
            get
            {
                ...
            }
        }       
    }
}

Notice that the public GetEnumerator() method returns the inner type Enumerator which is implemented as a struct. The other two overloads are implemented explicitly so that they are called when the List<T> is cast to one of these interfaces.

The C# foreach is implemented so that the public GetEnumerator() can be called. For example:

var list = new List<int>();
foreach (var item in list)
    Console.WriteLine(item);

The compiler interprets it as the following:

List<int> list = new List<int>();
List<int>.Enumerator enumerator = list.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    ((IDisposable)enumerator).Dispose();
}

The list variable is declared as type List<int> and the enumerator variable as type List<int>.Enumerator.

Changing the type of the list variable to any of the interfaces implemented by List<T>

IReadOnlyList<int> list = new List<int>();
foreach (var item in list)
    Console.WriteLine(item);

results in the GetEnumerator() overload that is called now to be one implemented explicitly, which returns an interface. foreach is then expanded accordingly with the type of the enumerator variable now being IEnumerator<int> :

IReadOnlyList<int> readOnlyList = new List<int>();
IEnumerator<int> enumerator = readOnlyList.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    if (enumerator != null)
    {
        enumerator.Dispose();
    }
}

This causes the Enumerator instance to be copied to the heap and all calls to its MoveNext() method and Current property to be virtual, reducing performance.

The state machine generated by await foreach performs exactly the same behavior when using async enumerables with value-typed enumerators.

Benchmarking

The following benchmark compares the performance of enumerating a List<int> with enumerating the same source when cast to IEnumerable<int>.

Source: https://github.com/NetFabric/NetFabric.Hyperlinq.Analyzer/blob/master/NetFabric.Hyperlinq.Analyzer.Benchmarks/HLQ001_AssignmentBoxingAnalyzer.cs


BenchmarkDotNet v0.13.6, Windows 10 (10.0.19045.3269/22H2/2022Update)
Intel Core i7-7567U CPU 3.50GHz (Kaby Lake), 1 CPU, 4 logical and 2 physical cores
.NET SDK 8.0.100-preview.5.23303.2
  [Host] : .NET 6.0.20 (6.0.2023.32017), X64 RyuJIT AVX2
  .NET 6 : .NET 6.0.20 (6.0.2023.32017), X64 RyuJIT AVX2
  .NET 7 : .NET 7.0.8 (7.0.823.31807), X64 RyuJIT AVX2
  .NET 8 : .NET 8.0.0 (8.0.23.28008), X64 RyuJIT AVX2


MethodJobRuntimeCountMeanErrorStdDevMedianRatioRatioSDGen0Code SizeAllocatedAlloc Ratio
Enumerable.NET 6.NET 6.0100688.79 ns19.412 ns55.383 ns658.77 nsbaseline****0.0191NA40 B****
List.NET 6.NET 6.0100144.42 ns2.604 ns3.898 ns142.93 ns4.78x faster0.41x-99 B-NA
Enumerable.NET 7.NET 7.0100759.17 ns10.801 ns9.019 ns758.33 nsbaseline0.0191NA40 B
List.NET 7.NET 7.0100119.67 ns2.774 ns7.824 ns115.64 ns6.30x faster0.45x-NA-NA
Enumerable.NET 8.NET 8.0100205.70 ns4.133 ns10.521 ns201.16 nsbaseline0.0191NA40 B
List.NET 8.NET 8.010071.41 ns1.463 ns3.777 ns69.56 ns2.88x faster0.17x-NA-NA
Enumerable.NET 6.NET 6.01000067,502.42 ns1,642.014 ns4,522.582 ns66,228.52 nsbaseline****-NA40 B****
List.NET 6.NET 6.01000015,093.08 ns695.963 ns1,962.976 ns14,331.64 ns4.53x faster0.58x-99 B-NA
Enumerable.NET 7.NET 7.01000075,459.92 ns1,443.395 ns3,725.862 ns73,818.92 nsbaseline-NA40 B
List.NET 7.NET 7.01000010,823.01 ns193.144 ns257.841 ns10,703.44 ns7.06x faster0.46x-NA-NA
Enumerable.NET 8.NET 8.01000017,761.84 ns121.790 ns113.922 ns17,733.51 nsbaseline-NA40 B
List.NET 8.NET 8.0100005,675.34 ns94.276 ns200.909 ns5,600.56 ns3.16x faster0.10x-NA-NA

How to fix violations

Change the type of the variable, field or property so that boxing is avoided. Using var, instead of the explicit type, guarantees that the correct type is always used.

When to suppress warnings

Optionally, when foreach is not used to enumerate the collection.

Example of a violation

The following example shows the multiples cases of assignment that are detected by this rule:

class Class1
{
    IList<int> privateField = new List<int>();
    IAsyncEnumerable<int> privateAsyncField = new OptimizedAsyncEnumerable();

    IList<int> PrivateProperty { get; set; } = new List<int>();
    IAsyncEnumerable<int> PrivateAsyncProperty { get; set; } 
    	= new OptimizedAsyncEnumerable();

    void Method()
    {
        IList<int> localVariable = new List<int>();
        IAsyncEnumerable<int> localAsyncVariable = new OptimizedAsyncEnumerable();

        privateField = new List<int>();
        PrivateProperty = new List<int>();
        localVariable = new List<int>();
        privateAsyncField = new OptimizedAsyncEnumerable();
        PrivateAsyncProperty = new OptimizedAsyncEnumerable();
        localAsyncVariable = new OptimizedAsyncEnumerable();
    }
}

Example of how to fix

Changing the types List<int> or using var for the local variable declaration, solves the issue:

class Class1
{
    List<int> privateField = new List<int>();
    OptimizedAsyncEnumerable privateAsyncField = new OptimizedAsyncEnumerable();

    List<int> PrivateProperty { get; set; } = new List<int>();
    OptimizedAsyncEnumerable PrivateAsyncProperty { get; set; } 
    	= new OptimizedAsyncEnumerable();

    void Method()
    {
        var localVariable = new List<int>();
        var localAsyncVariable = new OptimizedAsyncEnumerable();

        privateField = new List<int>();
        PrivateProperty = new List<int>();
        localVariable = new List<int>();
        privateAsyncField = new OptimizedAsyncEnumerable();
        PrivateAsyncProperty = new OptimizedAsyncEnumerable();
        localAsyncVariable = new OptimizedAsyncEnumerable();
    }
}