Omitir la comprobación de IDisposable de foreach con un enumerador de tipo de valor genérico

Tengo un resultado confuso con genéricos y restricciones que estoy tratando de entender. Dejando de lado si algo de esto es una buena idea o no, este código:

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");
    }
}

Produce esta salida:

Result was 0
24 extra bytes allocated

Dentro Iteratedel bucle foreach se genera código para encuadrar el enumerador y comprobar si se implementa IDisposable. El boxeo está provocando la asignación.

Si cambio la restricción de tipo para Iterateque sea where T : struct, IValueEnumerator<int, MyEnumerable>el compilador, parece reconocer que el iterador no puede implementar IDisposable y omite el boxeo y no obtengo asignaciones:

Result was 0
0 extra bytes allocated

He probado una variedad de compiladores y todos tienen el mismo comportamiento.

¿Es esto por diseño? Creo que la cláusula de restricción original sería suficiente para que el compilador no genere el IDisposablecheque. ¿Hay restricciones adicionales que podría agregar para que siga siendo genérico pero que el compilador no implemente el IDisposablecontrol en el foreach, o al menos no haga el boxeo?

Answer

Bien, he descubierto esto, tanto por qué estaba sucediendo como una solución.

Primero, esto solo sucede en la depuración, no en el lanzamiento. En el lanzamiento, parece que el compilador es lo suficientemente inteligente como para evitar la IDisposableverificación adicional.

Luego, la where T : struct, IValueEnumerator<int, MyEnumerable>restricción le dice al compilador que T será un tipo de valor pero no le dice que T no lo será IDisposasble. En la depuración, parece que la misma función genérica debe poder ejecutarse en cualquier tipo permitido por las restricciones y algunos de esos tipos podrían implementarse IDisposabley otros no. La ruta de menor resistencia para el compilador es simplemente encuadrar el valor y verificar si es un archivo IDisposable.

Desafortunadamente, no hay nada como where T : not IDisposableasegurarle al compilador que no tendrá que deshacerse del enumerador. Sin embargo, podemos ir por el otro lado y hacer que la interfaz se implemente IDisposable. Entonces el compilador sabe que T es desechable y puede llamarlo Dispose()sin encasillarlo primero.

Es decir, no causará ningún encasillamiento ni asignaciones adicionales en la depuración si la interfaz se ve así:

public interface IValueEnumerator<T, EnumeratorType> : IDisposable
    where EnumeratorType : struct, IDisposable
{
    T Current { get; }
    bool MoveNext();
    EnumeratorType GetEnumerator();
}

La estructura de implementación tiene que agregar un Dispose()método vacío al que llamará foreach después de su iteración, lo cual es menos eficiente que cuando el compilador sabe que el enumerador definitivamente no implementa IDisposabley puede omitir todo eso. Pero evita cualquier boxeo y no se hacen asignaciones.