接口的泛型和用法,无需装箱值实例

时间:2018-11-29 14:09:21

标签: c# generics compiler-optimization jit

据我了解,泛型是一种优雅的解决方案,可以解决诸如List之类的泛型集合中发生的额外装箱/拆箱过程问题。但是我无法理解泛型如何解决在泛型函数中使用接口的问题。换句话说,如果我想传递实现通用方法接口的值实例,将执行装箱吗?编译器如何处理这种情况?

据我了解,为了使用接口方法,应将值实例装箱,因为调用“虚拟”函数需要引用对象中包含“私有”信息(所有信息都包含在引用对象中(也有一个同步块))

这就是为什么我决定分析一个简单程序的IL代码以查看泛型函数中是否使用了任何装箱操作:

public class main_class
{
    public interface INum<a> { a add(a other); }
    public struct MyInt : INum<MyInt>
    {
        public MyInt(int _my_int) { Num = _my_int; }
        public MyInt add(MyInt other) => new MyInt(Num + other.Num);
        public int Num { get; }
    }

    public static a add<a>(a lhs, a rhs) where a : INum<a> => lhs.add(rhs);

    public static void Main()
    {
        Console.WriteLine(add(new MyInt(1), new MyInt(2)).Num);
    }
}

我认为add(new MyInt(1), new MyInt(2))将使用装箱操作,因为添加通用方法使用了INum<a>接口(否则编译器如何在没有装箱的情况下发出值实例的虚拟方法调用?)。但是我很惊讶。这是IL的一段Main代码:

IL_0000: ldc.i4.1
IL_0001: newobj instance void main_class/MyInt::.ctor(int32)
IL_0006: ldc.i4.2
IL_0007: newobj instance void main_class/MyInt::.ctor(int32)
IL_000c: call !!0 main_class::'add'<valuetype main_class/MyInt>(!!0, !!0)
IL_0011: stloc.0

此类清单没有box指令。似乎newobj不在堆上创建值实例,对于值,它在堆栈上创建它们。这是文档中的描述:

  

(ECMA-335标准(通用语言   基础结构)III.4.21)值类型通常不使用newobj创建。通常将它们分配为参数   或局部变量,使用newarr(对于从零开始的一维数组),或者作为对象字段。   分配后,将使用initobj对其进行初始化。但是,newobj指令可用于   在堆栈上创建值类型的新实例,然后可以将其作为参数传递并存储   在当地等等。

因此,我决定检查add函数。这很有趣,因为它也不包含任何包装盒说明:

.method public hidebysig static 
!!a 'add'<(class main_class/INum`1<!!a>) a> (
    !!a lhs,
    !!a rhs
) cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 15 (0xf)
    .maxstack 8

    IL_0000: ldarga.s lhs
    IL_0002: ldarg.1
    IL_0003: constrained. !!a
    IL_0009: callvirt instance !0 class main_class/INum`1<!!a>::'add'(!0)
    IL_000e: ret
} // end of method main_class::'add'

我的假设出了什么问题?泛型可以调用无框的值的虚拟方法吗?

1 个答案:

答案 0 :(得分:9)

  

据我所知,泛型是解决诸如List<T>之类的泛型集合中发生的额外装箱/拆箱过程问题的一种优雅解决方案。

消除拳击是仿制药的设计方案,是的。但是正如Damien在评论中指出的那样,更通用的功能是启用更简洁,更类型安全的代码。

  

如果我想传递一个实现通用方法接口的值实例,会进行装箱吗?

有时候,是的。但是由于装箱昂贵,CLR寻求避免装箱的方法。

  

我认为add(new MyInt(1), new MyInt(2))将使用装箱操作,因为添加通用方法使用了INum<a>接口

我知道您为什么要进行这种扣除,但这是错误的。您调用的方法主体如何使用信息是无关紧要的。问题是:您正在调用的方法的签名是什么? C#类型推断确定您正在调用add<MyInt>,因此签名等同于调用:

public static MyInt add(MyInt lhs, MyInt rhs)

现在,您正确地指出存在约束。 C#编译器验证是否满足约束。 这不会更改方法的调用约定。该方法需要两个MyInt,并且您已经为其传递了两个MyInt,它们是值类型,因此它们按值进行传递。

  

似乎newobj不会在堆上创建值实例,对于值,它会在堆栈上创建它们。

请确保清楚:它将在IL程序的抽象评估堆栈上创建它们。抖动是否将代码转换为将值放入当前线程的实际堆栈中的代码,这是抖动的实现细节。例如,它可以选择将它们放入寄存器中,或放入具有堆栈逻辑属性但实际上存储在堆中的数据结构中。

  

add也不包含框内说明

是的,您只是没有看到它们。它包含一个受约束的callvirt,它是一个条件框。

受约束的callvirt具有以下语义:

  • 在堆栈上必须有对接收者的引用。其中有:ldarga将接收者的地址放在堆栈中。如果接收者是引用类型,则包含引用的变量的地址将在堆栈上。如果是值类型,则保存该值类型的变量的地址将在堆栈上。 (同样,这是我们在此处考虑的虚拟机堆栈。)

  • 参数必须在堆栈上。他们是; INum<MyInt>.add的参数是一个MyInt,同样,它通过值传递,并且该值位于ldarg的堆栈中。

  • 如果接收者是引用类型,则我们取消引用刚刚创建的双重引用以获取引用,并且虚拟调用会正常进行。 (当然,抖动可以自由地优化这种双重引用!记住,我描述的所有这些语义都是IL程序的虚拟机的,而不是运行它的真实机器的!)

  • 如果接收方是值类型,并且该值类型实现了您正在调用的方法,则通常会调用该值类型的方法:即,不对值进行装箱。 这是您的示例所在的情况,因此我们避免装箱。

  • 如果接收方是未实现您要调用的方法的值类型,则将值类型装箱,然后以对接收方的引用作为参考来调用该方法。 锻炼读者:创建适合这种情况的程序。

  

我的假设出了什么问题?

您已经假设通过接口对值类型的方法的调用必须使接收方处于框内,但这不是事实。

  

泛型可以在不装箱的情况下调用值的虚拟方法吗?

是的