C# 泛型方法和非泛型方法的重载解析

5
我在互联网和stackoverflow上进行了一些基本的搜索,并看到了很多关于当涉及通用版本方法和非通用版本方法时重载分辨率的讨论。我理解重载分辨率是在编译时完成的-因此如果我有这段代码:
public class A<T>
{
    public void DoStuff(T value)
    {
         InternalDoStuff(value);
    }

    protected void InternalDoStuff(int value)
    {
         Console.WriteLine("Non-generic version");
    }

    protected void InternalDoStuff(T value)
    {
         Console.WriteLine("Generic version");
    }

}

public class Test
{
    static void Main (string [] args)
    {
         A<int> a = new A<int> ();
         a.DoStuff(100);
    }
}

输出将是“通用版本”,因为编译器已经解析了“InternalDoStuff”的分辨率,编译器看到的是“在DoStuff中使用T类型参数调用了InternalDoStuff”。

但我不知道这是否会有任何区别:

public class B : A <int> 
{

}

public class Test
{
    static void Main (string [] args)
    {
         B b = new B ();
         b.DoStuff(100);
    }
}

现在我能说编译器有足够的信息来决定"B是A的一个特定版本",因此调用InternalDoStuff的非泛型版本吗?

有没有一般原则来分析这种重载解析问题?


3
您的第一个问题是“我可以说编译器有足够的信息来调用非泛型版本吗?”这个问题可以通过自己尝试来回答。您的第二个问题可以通过阅读C#规范中的重载解析部分来回答。 - Eric Lippert
4个回答

4

第二种方法在任何方面都与第一种方法没有区别。

从A派生类B并不会改变为类A生成的IL代码。B只是继承了那些方法。

如果您查看类A的IL代码,您会发现它编译为调用通用版本而不是非通用版本-

.method public hidebysig instance void DoStuff(!T 'value') cil managed
{
    .maxstack 8
    L_0000: nop 
    L_0001: ldarg.0 
    L_0002: ldarg.1 
    L_0003: call instance void
            ConsoleApplication1.A`1<!T>::InternalDoStuff(!0) <-- Generic version
    L_0008: nop 
    L_0009: ret 
}

来自Jon Skeet的文章这里 -

提醒一下,重载是指具有相同名称但不同签名的两个方法。在编译时,编译器会根据参数的编译时类型和方法调用的目标来确定它将调用哪个方法。 (我假设你没有在这里使用dynamic,这会使事情变得更加复杂。)

正如他提到的使用动态推迟解析直到运行时。此代码片段将为您的两种方法调用非泛型版本方法。

public void DoStuff(T value)
{
   dynamic dynamicValue = value;
   InternalDoStuff(dynamicValue);
} 

请参考Jon SkeetEric Lippert在此详细描述的答案。


3
从 A 派生类 B 不会改变为 A 类生成的 IL 代码。B 只是继承那些方法。-- 这是一个有说服力的观点。 - Xinchao

2
在C++中,程序在执行期间可能会创建的每种类型都必须在编译时生成。虽然C++模板看起来像是C#泛型,但它们的行为更类似于替换宏。因为编译器为每个可能从通用类型替换中产生的类单独生成,所以它可以分别评估每个类的重载解析。而C#泛型则不是这样工作的。
C#代码的编译分为两个阶段。第一阶段在构建时完成。处理该阶段的编译器将源代码转换为“公共中间语言”形式(VB.NET、F#等使用相同的CIL形式,因此称之为公共)。源代码中的每个通用类定义(例如 List<T>)都会在CIL形式中产生一个类定义。编译器在生成CIL之前对将应用哪些函数重载进行所有决策。
稍后,在运行程序时,公共语言运行时不会为程序可能使用的所有类都生成代码,而是将每个类的代码生成延迟到首次使用它时。在此步骤中,类似于List<int>的东西将产生不同的机器代码,而类似于List<string>List<KeyValuePair<Dictionary<int,string>, Tuple<Cat,Dog>>>将产生不同的机器代码。程序想要使用的可能类型的集合不需要是有界的。在C#中,可以合法地有一个方法,给定一个通用参数T,该方法将使用List<T>调用通用方法(如果给定了List<T>,则会传递List<List<T>>,如果给定了,则会传递List<List<List<T>>>等)。如果嵌套得太深,程序可能会因为内存不足或类似的问题而崩溃,但与C++不同,程序可能生成的类型数量不需要在编译时受到限制;只有在程序尝试实际使用太多不同的类型时才会出问题。
公共语言运行时能够进行某些类型的泛型替换,但它不处理重载解析(如上所述,这是在C#到CIL转换步骤中处理的)。虽然在CLR中拥有像重载解析之类的东西可能会有一些优势,但这也会使CLR变得更加复杂。如果特别棘手的重载解析问题需要0.25秒,那对于编译C#代码来说可能不是问题,但停止运行时0.25秒进行此类操作是不可取的。

我不确定您是否公正地通过C#泛型或宏来解释C++模板。此外,请考虑C++模板比C#泛型存在了很长时间,因此传统上是通过模板来解释C#泛型及其差异(但是对于已经熟悉泛型的受众来说,反过来可能更合适;然而,这样的受众不需要问这个问题)。 - Ben Voigt
@BenVoigt:C++模板的语义级别远高于预处理器宏,但我的观点是,如果定义了一个模板Foo<T>,并定义了类型为Foo<Larry>Foo<Curly>Foo<Moe>的变量,则C++编译器将把它们转换为三个非泛型类。相比之下,如果Foo<T>是一个C#泛型类,则C#编译器将输出一段指定泛型类的CIL代码。 - supercat
是的,我同意。C++编译器执行模板实例化,而C#编译器不实例化泛型(这也是专门化不是泛型特性的原因),甚至在CLR级别,没有泛型实例化除非类型参数是值类型,虽然使用值类型进行实例化确实允许内联和避免装箱,但它不改变重载选择。 - Ben Voigt
因此,你所说的“公共语言运行时不会为程序可能使用的所有类生成代码,而是将推迟对每个类的代码生成,直到首次实际使用它”也不完全准确,因为“每个类的代码生成”实际上从未发生。所有类都有一组本地代码,它们依靠接口指针进行虚拟调度。 - Ben Voigt
@BenVoigt:我认为在List<Button>.GetEnumerator()List<Control>.GetEnumerator()之间共享代码并不会特别影响程序语义,因为构造指向其中一个的委托将报告与构造另一个委托不同的“Method”。共享一些可执行代码可能会减少内存消耗,但这是CLR实现细节。 - supercat
好的,语义将遵循规范,共享不会改变这一点。但是规范是为了实现共享而编写的。如果所有封闭泛型类型都映射到公共机器代码,并基于v表中的单个偏移量进行虚拟调用,则无法使用不同的方法重载来处理它们。 - Ben Voigt

1

DoStuff 方法内部调用的 InternalDoStuff 是在编译时期的 A<T> 绑定的。调用来自 B 的实例并不会对重载决策产生影响。

当编译 DoStuff 方法时,有两个InternalDoStuff 成员可供选择:

  • InternalDoStuff(T value)
  • InternalDoStuff(int value)

DoStuff 方法传递一个 T 值,因此不能使用带有 int 的重载。因此只有一个适用的成员 InternalDoStuff(T),编译器选择了这个。


谢谢,伙计。但是听起来你建议的结果和Alex一样——将调用“非泛型”版本。我试过了,结果是“泛型版本”。无论我针对哪个版本的.Net框架,都没有关系。关于你的评论“以前是泛型,现在完全实例化”,你所说的“完全实例化”方法具体是什么意思? - Xinchao
@Matthew,抱歉,我误读了原始示例。我以为对B的调用是对InternalDoStuff的调用。 - JaredPar
如果是InternalDoStuff,那么将调用非泛型版本。但是它可能不会这样做,因为B使A“具体或完全初始化”。它收敛于解决InternalDoStuff(T值)与InternalDoStuff(int值)的问题。如果您使InternalDoStuff公开并直接在A<int>上调用它,则结果将相同:将调用非泛型版本。 - Xinchao
让我困惑的是:如果你写了 b.DoStuff("a string"),编译器会抱怨。这给我留下了印象,即编译器确实知道B中的T是int类型。在纯粹分析A中的DoStuff时,参数的“编译时类型”是未知的(T)。然而,鉴于B的上下文以及从B调用DoStuff的事实,在编译时很明显传递给DoStuff的值是int类型。我认为编译器可以在B的情况下进行某种文本替换... - Xinchao
2
@Mathew,你需要意识到的是,编译有两个独立的点,发生的次数也不同:DoStuff 的主体和 DoStuff 的调用。DoStuff 的主体只编译一次,并且不对 T 做任何假设。DoStuff 的调用会被编译多次,并且如果已解析,则会考虑当前值 T。你要寻找的解析类型在 C++ 等语言中是可能的。但这在那里起作用是因为 DoStuff 的主体被多次编译。 - JaredPar

1
这将调用“非泛型”版本:

enter image description here

public class A<T>
{
    public virtual void DoStuff(T value)
    {
        InternalDoStuff(value);
    }

    protected void InternalDoStuff(int value)
    {
        Console.WriteLine("Non-generic version");
    }

    protected void InternalDoStuff(T value)
    {
        Console.WriteLine("Generic version");
    }

}
public class B : A<int>
{
    public override void DoStuff(int value)
    {
        InternalDoStuff(value);
    }
}

我试过了。两种情况下输出都是“通用版本”。我尝试着将目标设置为 .Net 4.5、4.0、3.5,结果都一样。(我使用的是VS2012) - Xinchao
@Matthew - 上面的代码将打印非泛型版本。请注意,Alex已经重写了DoStuff方法。 - Rohit Vats
我在我的系统上添加了输出的截图(Net 4.5,64位,发布版本)。 - Alex
啊,好的。我没有认真阅读你的代码。在你的情况下,你覆盖了类B中的DoStuff方法。然后使用“int”类型的参数调用了InternalDoStuff方法-这在编译时已知。这就是为什么你得到了“非泛型”的输出结果。 - Xinchao
是的,这就是虚函数调度,在运行时调用最派生版本的函数。 - Rohit Vats
是的,你确实需要给编译器帮一个忙 :D 另外一种方法是将“DoStuff”方法实现为“InternalDoStuff((dynamic)value);” - Alex

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接