I have a confusing result with generics and constraints I'm trying to understand. Leaving aside whether any of this is a good idea or not, this code:
using System;
using System.Collections.Generic;
public interface IValueEnumerator<T, EnumeratorType> where EnumeratorType : struct
{
T Current { get; }
bool MoveNext();
EnumeratorType GetEnumerator();
}
public struct MyEnumerable : IValueEnumerator<int, MyEnumerable>
{
private int index;
private int count;
public MyEnumerable(int count)
{
this.index = -1;
this.count = count;
}
public bool MoveNext() => ++index < count;
public MyEnumerable GetEnumerator() => this;
public int Current => index;
}
public class Program
{
public static int Iterate<T>(T enumerable) where T : struct, IValueEnumerator<int, T>
{
int z = 0;
foreach (int y in enumerable)
z += y;
return z;
}
public static void Main()
{
var prevMem = System.GC.GetAllocatedBytesForCurrentThread();
MyEnumerable z = new MyEnumerable();
int result = Iterate(z);
long diff = GC.GetAllocatedBytesForCurrentThread() - prevMem;
Console.WriteLine($"Result was {result}");
Console.WriteLine($"{diff} extra bytes allocated");
}
}
Produces this output:
Result was 0
24 extra bytes allocated
Inside Iterate
the foreach loop is generating code to box the enumerator and check if it implements IDisposable
. The boxing is causing the allocation.
If I change the type constraint for Iterate
to be where T : struct, IValueEnumerator<int, MyEnumerable>
the compiler seems to recognize that the iterator can't implement IDisposable and it skips the boxing and I get no allocations:
Result was 0
0 extra bytes allocated
I've tried on a variety of compilers and they all have the same behavior.
Is this by design? I would think the original constraint clause would be enough for the compiler not to generate the IDisposable
check. Are there additional constraints I could add so that it would still be generic but have the compiler not implement the IDisposable
check in the foreach, or at the least not do the boxing?
Okay I've figured this out, both why it was happening and a fix.
First, this only happens in debug, not in release. In release it seems the compiler is smart enough to avoid the extra IDisposable
check.
Next, the where T : struct, IValueEnumerator<int, MyEnumerable>
constraint tells the compiler that T will be a value type but doesn't tell it that T won't be IDisposasble
. In debug it seems the same generic function has to be able to run on any types allowed by the constraints and some of those types might implement IDisposable
and some might not. The path of least resistance for the compiler is to just box the value and check if it's an IDisposable
.
Unfortunately there's nothing like where T : not IDisposable
to assure the compiler that it won't have to dispose of the enumerator. However we can go the other way and have the interface implement IDisposable
. Then the compiler knows that T is disposable and it can call Dispose()
on it without boxing it first.
That is, it won't cause any boxing or extra allocations in debug if the interface looks like:
public interface IValueEnumerator<T, EnumeratorType> : IDisposable
where EnumeratorType : struct, IDisposable
{
T Current { get; }
bool MoveNext();
EnumeratorType GetEnumerator();
}
The implementing struct has to add an empty Dispose()
method which the foreach will call after its iteration, which is less efficient than when the compiler knows the enumerator definitely doesn't implement IDisposable
and can skip all that. But it does avoid any boxing and there aren't any allocations made.