【问题标题】:Can C# generics be used to elide virtual function calls?可以使用 C# 泛型来省略虚函数调用吗?
【发布时间】:2011-09-03 18:59:44
【问题描述】:

我同时使用 C++ 和 C#,我一直在想的是是否可以在 C# 中使用泛型来省略接口上的虚函数调用。考虑以下几点:

int Foo1(IList<int> list)
{
    int sum = 0;
    for(int i = 0; i < list.Count; ++i)
        sum += list[i];
    return sum;
}

int Foo2<T>(T list) where T : IList<int>
{
    int sum = 0;
    for(int i = 0; i < list.Count; ++i)
        sum += list[i];
    return sum;
}

/*...*/
var l = new List<int>();
Foo1(l);
Foo2(l);

在 Foo1 内部,每次访问 list.Count 和 list[i] 都会导致一次虚函数调用。如果这是使用模板的 C++,那么在对 Foo2 的调用中,编译器将能够看到可以省略和内联虚函数调用,因为具体类型在模板实例化时是已知的。

但这同样适用于 C# 和泛型吗?当您调用 Foo2(l) 时,在编译时就知道 T 是一个 List,因此 list.Count 和 list[i] 不需要涉及虚函数调用。首先,这会是一个不会严重破坏某些东西的有效优化吗?如果是这样,编译器/JIT 是否足够聪明,可以进行这种优化?

【问题讨论】:

    标签: c# interface virtual


    【解决方案1】:

    这是一个有趣的问题,但不幸的是,您“欺骗”系统的方法不会提高程序的效率。如果可以,编译器可以相对轻松地为我们完成!

    您是正确的,当通过接口引用调用IList&lt;T&gt; 时,方法是在运行时调度的,因此不能内联。因此对IList&lt;T&gt;方法如Count和索引器的调用将通过接口调用。

    另一方面,通过将其重写为泛型方法,您无法获得任何性能优势(至少在当前的 C# 编译器和 .NET4 CLR 中不能)。

    为什么不呢?首先是一些背景。 C# 泛型的工作是编译器编译具有可替换参数的泛型方法,然后在运行时用实际参数替换它们。这你已经知道了。

    但是方法的参数化版本对变量类型的了解并不比你和我在编译时更多。在这种情况下,编译器只知道Foo2listIList&lt;int&gt;。我们在通用 Foo2 中拥有与在非通用 Foo1 中相同的信息。

    事实上,为了避免代码膨胀,JIT 编译器只为所有引用类型生成一个通用方法的实例化。这是描述这种替换和实例化的Microsoft documentation

    如果客户端指定了引用类型,则 JIT 编译器将服务器 IL 中的泛型参数替换为 Object,并将其编译为本机代码。该代码将用于对引用类型而不是泛型类型参数的任何进一步请求。请注意,这种方式 JIT 编译器仅重用实际代码。仍然根据托管堆的大小分配实例,并且没有强制转换。

    这意味着 JIT 编译器的方法版本(对于引用类型)不是类型安全的,但这并不重要,因为编译器在编译时已确保所有类型安全。但更重要的是,对于您的问题,没有办法执行内联并获得性能提升。

    编辑: 最后,根据经验,我刚刚对Foo1Foo2 进行了基准测试,它们产生了相同的性能结果。换句话说,Foo2Foo1快。

    让我们添加一个“可内联”版本Foo0 进行比较:

    int Foo0(List<int> list)
    {
        int sum = 0;
        for (int i = 0; i < list.Count; ++i)
            sum += list[i];
        return sum;
    }
    

    这是性能比较:

    Foo0 = 1719
    Foo1 = 7299
    Foo2 = 7472
    Foo0 = 1671
    Foo1 = 7470
    Foo2 = 7756
    

    因此您可以看到可以内联的Foo0 比其他两个要快得多。您还可以看到Foo2 稍慢,而不是与Foo0 一样快。

    【讨论】:

    • 非常好。在 LINQPad 中查看 Foo1Foo0 的 IL 可以很好地显示差异。在Foo1 中,callvirt 操作显示对IList&lt;System.Int32&gt;get_ItemICollection&lt;System.Int32&gt;.get_Count 的调用,而Foo0callvirt 操作是List&lt;System.Int32&gt;.get_ItemList&lt;System.Int32&gt;.get_CountFoo2 的缓慢可能是因为需要执行ldarga.s,然后在callvirt 前加上constrained. 而不是仅仅执行ldarg.1
    • 这不是说你可以通过让结构体实现你的接口来欺骗系统,然后在你的泛型方法/类中使用这些结构体吗?由于它们是值类型,运行时将为每个结构类型创建多个具体实现...
    【解决方案2】:

    这确实有效,并且(如果函数不是虚拟的)会导致非虚拟调用。原因是,与 C++ 不同,CLR 泛型在 JIT 时间为每个唯一的泛型参数集定义一个特定的具体类(通过尾随 1、2 等的反射指示)。如果方法是虚拟的,它会像任何具体的、非虚拟的、非泛型的方法一样导致虚拟调用。

    关于 .net 泛型要记住的是:

    Foo<T>; 
    

    然后

    Foo<Int32>
    

    在运行时是一个有效的类型,与

    分开且不同
    Foo<String>
    

    ,并且所有虚拟和非虚拟方法都被相应地处理。这就是为什么你可以创建一个

    List<Vehicle>
    

    并添加一个 Car 到它,但你不能创建一个类型的变量

    List<Vehicle> 
    

    并将其值设置为

    的实例
    List<Car>
    

    。它们属于不同的类型,但前者有一个Add(...) 方法,它接受Vehicle 的参数,Car 的超类型。

    【讨论】:

    • 我不确定在多大程度上可以避免虚函数调用,但是在接口可以由结构实现的情况下,可以通过将结构作为通用参数传递来消除装箱,即约束到接口,而不是将其作为接口传递。
    猜你喜欢
    • 2021-02-05
    • 2022-01-02
    • 1970-01-01
    • 2014-06-19
    • 2021-02-20
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多