C# 如何在 C++ 不允许虚拟模板方法的情况下允许虚拟泛型方法?

Posted

技术标签:

【中文标题】C# 如何在 C++ 不允许虚拟模板方法的情况下允许虚拟泛型方法?【英文标题】:How can C# allow virtual generic methods where C++ can't allow virtual template methods? 【发布时间】:2014-06-22 11:24:51 【问题描述】:

C++ 不支持虚拟模板方法。原因是每当对这种方法进行新的实例化时,这都会改变vtable(必须将其添加到vtable)。

相比之下,Java 确实允许虚拟泛型方法。在这里,如何实现这一点也很清楚:Java 泛型在运行时被擦除,因此泛型方法是运行时的常用方法,因此无需更改 vtable

但是现在到 C#。 C# 确实具有具体化的泛型。对于具体化的泛型,尤其是在使用值类型作为类型参数时,必须有不同版本的泛型方法。但是我们遇到了与 C++ 相同的问题:每当对泛型方法进行新的实例化时,我们都需要更改 vtable。

我对 C# 的内部工作并不太了解,所以我的直觉可能完全是错误的。那么对 C#/.NET 有更深入了解的人能否告诉我他们如何能够在 C# 中实现泛型虚拟方法?

这里的代码来说明我的意思:

[MethodImpl(MethodImplOptions.NoInlining)]
static void Test_GenericVCall()

    var b = GetA();
    b.M<string>();
    b.M<int>();


[MethodImpl(MethodImplOptions.NoInlining)]
static A GetA()

    return new B();


class A

    public virtual void M<T>()
    
    


class B : A

    public override void M<T>()
    
        base.M<T>();
        Console.WriteLine(typeof(T).Name);
    

在函数Test_GenericVCall 中调用M 时,CLR 如何分派到正确的JITed 代码?

【问题讨论】:

c++ 和 c# 中的泛型以非常不同的方式处理!你的问题太笼统了。 @πάνταῥεῖ:我不认为它太宽泛。如果不举一些具体的例子,很难问这个问题。 jitted代码调用一个辅助方法来获取要调用的函数的地址。 Visual Studio 不允许我调试该助手。这当然不是普通的虚拟通话。 具体化的泛型在运行时生成具体类型。所以调度表永远不会有问题,它们是不同的。 @HansPassant 在示例代码中只有一个 vtable 必须根据泛型参数分派到两种不同的方法。 【参考方案1】:

运行此代码并分析 IL 和生成的 ASM 可以让我们了解发生了什么:

internal class Program

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Test()
    
        var b = GetA();
        b.GenericVirtual<string>();
        b.GenericVirtual<int>();
        b.GenericVirtual<StringBuilder>();
        b.GenericVirtual<int>();
        b.GenericVirtual<StringBuilder>();
        b.GenericVirtual<string>();
        b.NormalVirtual();
    

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static A GetA()
    
        return new B();
    

    private class A
    
        public virtual void GenericVirtual<T>()
        
        

        public virtual void NormalVirtual()
        
        
    

    private class B : A
    
        public override void GenericVirtual<T>()
        
            base.GenericVirtual<T>();
            Console.WriteLine("Generic virtual: 0", typeof(T).Name);
        

        public override void NormalVirtual()
        
            base.NormalVirtual();
            Console.WriteLine("Normal virtual");
        
    

    public static void Main(string[] args)
    
        Test();
        Console.ReadLine();
        Test();
    

我用 WinDbg 断点 Program.Test:

.loadby sos clr; !bpmd CSharpNewTest CSharpNewTest.Program.Test

然后我使用 Sosex.dll 的 !muf 命令向我展示交错的源代码、IL 和 ASM:

0:000> !muf
CSharpNewTest.Program.Test(): void
    b:A

        002e0080 55              push    ebp
        002e0081 8bec            mov     ebp,esp
        002e0083 56              push    esi
var b = GetA();
    IL_0000: call CSharpNewTest.Program::GetA()
    IL_0005: stloc.0  (b)
>>>>>>>>002e0084 ff15c0371800    call    dword ptr ds:[1837C0h]
        002e008a 8bf0            mov     esi,eax
b.GenericVirtual<string>();
    IL_0006: ldloc.0  (b)
    IL_0007: callvirt A::GenericVirtuallong
        002e008c 6800391800      push    183900h
        002e0091 8bce            mov     ecx,esi
        002e0093 ba50381800      mov     edx,183850h
        002e0098 e877e49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e009d 8bce            mov     ecx,esi
        002e009f ffd0            call    eax
b.GenericVirtual<int>();
    IL_000c: ldloc.0  (b)
    IL_000d: callvirt A::GenericVirtuallong
        002e00a1 6830391800      push    183930h
        002e00a6 8bce            mov     ecx,esi
        002e00a8 ba50381800      mov     edx,183850h
        002e00ad e862e49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e00b2 8bce            mov     ecx,esi
        002e00b4 ffd0            call    eax
b.GenericVirtual<StringBuilder>();
    IL_0012: ldloc.0  (b)
    IL_0013: callvirt A::GenericVirtuallong
        002e00b6 6870391800      push    183970h
        002e00bb 8bce            mov     ecx,esi
        002e00bd ba50381800      mov     edx,183850h
        002e00c2 e84de49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e00c7 8bce            mov     ecx,esi
        002e00c9 ffd0            call    eax
b.GenericVirtual<int>();
    IL_0018: ldloc.0  (b)
    IL_0019: callvirt A::GenericVirtuallong
        002e00cb 6830391800      push    183930h
        002e00d0 8bce            mov     ecx,esi
        002e00d2 ba50381800      mov     edx,183850h
        002e00d7 e838e49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e00dc 8bce            mov     ecx,esi
        002e00de ffd0            call    eax
b.GenericVirtual<StringBuilder>();
    IL_001e: ldloc.0  (b)
    IL_001f: callvirt A::GenericVirtuallong
        002e00e0 6870391800      push    183970h
        002e00e5 8bce            mov     ecx,esi
        002e00e7 ba50381800      mov     edx,183850h
        002e00ec e823e49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e00f1 8bce            mov     ecx,esi
        002e00f3 ffd0            call    eax
b.GenericVirtual<string>();
    IL_0024: ldloc.0  (b)
    IL_0025: callvirt A::GenericVirtuallong
        002e00f5 6800391800      push    183900h
        002e00fa 8bce            mov     ecx,esi
        002e00fc ba50381800      mov     edx,183850h
        002e0101 e80ee49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e0106 8bce            mov     ecx,esi
        002e0108 ffd0            call    eax
b.NormalVirtual();
    IL_002a: ldloc.0  (b)
        002e010a 8bce            mov     ecx,esi
        002e010c 8b01            mov     eax,dword ptr [ecx]
        002e010e 8b4028          mov     eax,dword ptr [eax+28h]
    IL_002b: callvirt A::NormalVirtual()
        002e0111 ff5014          call    dword ptr [eax+14h]

    IL_0030: ret 

感兴趣的是普通的虚拟调用,可以与通用的虚拟调用进行比较:

b.NormalVirtual();
    IL_002a: ldloc.0  (b)
        002e010a 8bce            mov     ecx,esi
        002e010c 8b01            mov     eax,dword ptr [ecx]
        002e010e 8b4028          mov     eax,dword ptr [eax+28h]
    IL_002b: callvirt A::NormalVirtual()
        002e0111 ff5014          call    dword ptr [eax+14h]

看起来很标准。让我们看一下泛型调用:

b.GenericVirtual<string>();
    IL_0024: ldloc.0  (b)
    IL_0025: callvirt A::GenericVirtuallong
        002e00f5 6800391800      push    183900h
        002e00fa 8bce            mov     ecx,esi
        002e00fc ba50381800      mov     edx,183850h
        002e0101 e80ee49b71      call    clr!JIT_VirtualFunctionPointer (71c9e514)
        002e0106 8bce            mov     ecx,esi
        002e0108 ffd0            call    eax

好的,所以通用虚拟调用是通过加载我们的对象b(在esi,被移动到ecx),然后调用到clr!JIT_VirtualFunctionPointer来处理的。还推送了两个常量:183850 in edx。我们可以得出结论,这可能是函数 A.GenericVirtual&lt;T&gt; 的句柄,因为它对于 6 个调用站点中的任何一个都没有改变。 另一个常量183900 看起来是泛型参数的类型句柄。 确实,SSCLI 证实了怀疑:

HCIMPL3(CORINFO_MethodPtr, JIT_VirtualFunctionPointer, Object * objectUNSAFE, CORINFO_CLASS_HANDLE classHnd, CORINFO_METHOD_HANDLE methodHnd)

所以,查找基本上委托给JIT_VirtualFunctionPointer,它必须准备一个可以调用的地址。假设它会 JIT 并返回一个指向 JIT 代码的指针,或者创建一个蹦床,当第一次调用时,它将 JIT 函数。

0:000> uf clr!JIT_VirtualFunctionPointer
clr!JIT_VirtualFunctionPointer:
71c9e514 55              push    ebp
71c9e515 8bec            mov     ebp,esp
71c9e517 83e4f8          and     esp,0FFFFFFF8h
71c9e51a 83ec0c          sub     esp,0Ch
71c9e51d 53              push    ebx
71c9e51e 56              push    esi
71c9e51f 8bf2            mov     esi,edx
71c9e521 8bd1            mov     edx,ecx
71c9e523 57              push    edi
71c9e524 89542414        mov     dword ptr [esp+14h],edx
71c9e528 8b7d08          mov     edi,dword ptr [ebp+8]
71c9e52b 85d2            test    edx,edx
71c9e52d 745c            je      clr!JIT_VirtualFunctionPointer+0x70 (71c9e58b)

clr!JIT_VirtualFunctionPointer+0x1b:
71c9e52f 8b12            mov     edx,dword ptr [edx]
71c9e531 89542410        mov     dword ptr [esp+10h],edx
71c9e535 8bce            mov     ecx,esi
71c9e537 c1c105          rol     ecx,5
71c9e53a 8bdf            mov     ebx,edi
71c9e53c 03ca            add     ecx,edx
71c9e53e c1cb05          ror     ebx,5
71c9e541 03d9            add     ebx,ecx
71c9e543 a180832872      mov     eax,dword ptr [clr!g_pJitGenericHandleCache (72288380)]
71c9e548 8b4810          mov     ecx,dword ptr [eax+10h]
71c9e54b 33d2            xor     edx,edx
71c9e54d 8bc3            mov     eax,ebx
71c9e54f f77104          div     eax,dword ptr [ecx+4]
71c9e552 8b01            mov     eax,dword ptr [ecx]
71c9e554 8b0490          mov     eax,dword ptr [eax+edx*4]
71c9e557 85c0            test    eax,eax
71c9e559 7430            je      clr!JIT_VirtualFunctionPointer+0x70 (71c9e58b)

clr!JIT_VirtualFunctionPointer+0x47:
71c9e55b 8b4c2410        mov     ecx,dword ptr [esp+10h]

clr!JIT_VirtualFunctionPointer+0x50:
71c9e55f 395804          cmp     dword ptr [eax+4],ebx
71c9e562 7521            jne     clr!JIT_VirtualFunctionPointer+0x6a (71c9e585)

clr!JIT_VirtualFunctionPointer+0x55:
71c9e564 39480c          cmp     dword ptr [eax+0Ch],ecx
71c9e567 751c            jne     clr!JIT_VirtualFunctionPointer+0x6a (71c9e585)

clr!JIT_VirtualFunctionPointer+0x5a:
71c9e569 397010          cmp     dword ptr [eax+10h],esi
71c9e56c 7517            jne     clr!JIT_VirtualFunctionPointer+0x6a (71c9e585)

clr!JIT_VirtualFunctionPointer+0x5f:
71c9e56e 397814          cmp     dword ptr [eax+14h],edi
71c9e571 7512            jne     clr!JIT_VirtualFunctionPointer+0x6a (71c9e585)

clr!JIT_VirtualFunctionPointer+0x64:
71c9e573 f6401801        test    byte ptr [eax+18h],1
71c9e577 740c            je      clr!JIT_VirtualFunctionPointer+0x6a (71c9e585)

clr!JIT_VirtualFunctionPointer+0x85:
71c9e579 8b4008          mov     eax,dword ptr [eax+8]
71c9e57c 5f              pop     edi
71c9e57d 5e              pop     esi
71c9e57e 5b              pop     ebx
71c9e57f 8be5            mov     esp,ebp
71c9e581 5d              pop     ebp
71c9e582 c20400          ret     4

clr!JIT_VirtualFunctionPointer+0x6a:
71c9e585 8b00            mov     eax,dword ptr [eax]
71c9e587 85c0            test    eax,eax
71c9e589 75d4            jne     clr!JIT_VirtualFunctionPointer+0x50 (71c9e55f)

clr!JIT_VirtualFunctionPointer+0x70:
71c9e58b 8b4c2414        mov     ecx,dword ptr [esp+14h]
71c9e58f 57              push    edi
71c9e590 8bd6            mov     edx,esi
71c9e592 e8c4800400      call    clr!JIT_VirtualFunctionPointer_Framed (71ce665b)
71c9e597 5f              pop     edi
71c9e598 5e              pop     esi
71c9e599 5b              pop     ebx
71c9e59a 8be5            mov     esp,ebp
71c9e59c 5d              pop     ebp
71c9e59d c20400          ret     4

在SSCLI中可以查看实现,看来还是适用的:

HCIMPL3(CORINFO_MethodPtr, JIT_VirtualFunctionPointer, Object * objectUNSAFE,
                                                       CORINFO_CLASS_HANDLE classHnd,
                                                       CORINFO_METHOD_HANDLE methodHnd)

    CONTRACTL 
        SO_TOLERANT;
        THROWS;
        DISABLED(GC_TRIGGERS);      // currently disabled because of FORBIDGC in HCIMPL
     CONTRACTL_END;

    OBJECTREF objRef = ObjectToOBJECTREF(objectUNSAFE);

    if (objRef != NULL && g_pJitGenericHandleCache)
    
        JitGenericHandleCacheKey key(objRef->GetMethodTable(), classHnd, methodHnd);
        HashDatum res;
        if (g_pJitGenericHandleCache->GetValueSpeculative(&key,&res))
            return (CORINFO_GENERIC_HANDLE)res;
    

    // Tailcall to the slow helper
    ENDFORBIDGC();
    return HCCALL3(JIT_VirtualFunctionPointer_Framed, OBJECTREFToObject(objRef), classHnd, methodHnd);

HCIMPLEND

所以基本上它会检查缓存以查看我们之前是否见过这种类型/类组合,否则将其发送到JIT_VirtualFunctionPointer_Framed,后者调用MethodDesc::GetMultiCallableAddrOfVirtualizedCode 以获取它的地址。 MethodDesc 调用传递了对象引用和泛型类型句柄,因此它可以查找要分派到的虚函数以及虚函数的版本(即使用什么泛型参数)。

如果您想更深入地了解,所有这些都可以在 SSCLI 中查看 - 似乎这在 4.0 版本的 CLR 中没有改变。

简而言之,CLR 可以满足您的期望;生成不同的调用站点,这些调用站点携带调用虚拟通用函数的类型的信息。然后将其传递给 CLR 以进行调度。复杂性在于 CLR 必须同时跟踪通用虚函数及其 JIT 处理的版本。

【讨论】:

非常详尽的答案,谢谢。因此,与非泛型方法相比,调用泛型方法似乎确实存在一些运行时开销,对吧?很有趣。 是的,与普通的虚拟调度相比,虚拟泛型函数似乎确实有很大的开销。可能是因为通用虚函数很少见。它可以像优化接口调用的方式一样进行优化(JIT 编译器在“快速”中修补它在任何调用站点看到的虚拟调用,特定于该调用站点)。【参考方案2】:

为了有一个共同的术语,我将 C++ templates 和 C# 泛型称为“模式代码”。

模式代码在生成具体代码的地方需要:

模式的完整描述(模式的源代码,或类似的东西) 关于它被实例化的模式参数的信息 足以将两者结合起来的强大编译环境

在 C++ 中,模式在编译单元级别生成具体代码。我们有完整的编译器、template 的完整源代码和 template 参数的完整类型信息,所以我们摇一摇。

传统的泛型(未具体化)也会在类似的位置生成具体代码,但它们随后允许使用新类型进行运行时扩展。因此使用运行时类型擦除而不是相关类型的完整类型信息。 Java 显然这样做只是为了避免需要新的泛型字节码(参见上面的编码)。

具体化的泛型将原始泛型代码打包成某种表示形式,这种表示形式足够强大,可以将泛型重新应用到新类型上。在运行时,C# 有一个完整的编译器副本,并且添加的类型也带有基本完整的关于它是从什么编译而来的信息。通过所有 3 个部分,它可以将模式重新应用于新类型。

C++ 不携带编译器,它没有存储足够的关于类型或模板的信息以在运行时应用。已经进行了一些尝试,将模板实例化延迟到 C++ 中的链接时间。

因此,当传递新类型时,您的虚拟泛型方法最终会编译新方法。在运行时。

【讨论】:

Yakk:很简单的解释,但我认为它击中了钉子的头。谢谢。【参考方案3】:

C++ 模板和 C# 泛型都是旨在实现泛型编程范式的功能:编写不依赖于它们所操作的数据类型的算法和数据结构。

但它们的工作方式截然不同

泛型在代码上注入类型信息以在运行时可用。所以不同的算法/数据结构知道他们正在使用什么类型,并进行自我调整。由于类型信息在运行时可用/可访问,可以在运行时完成类型决定并取决于运行时输入。这就是为什么多态性(也是运行时决定)和 C# 泛型可以很好地协同工作的原因。

另一方面,C++ 模板是一个非常不同的野兽。它们是一个编译时代码生成系统。这意味着模板系统所做的是在编译时根据使用的类型生成不同版本的代码。即使这可以实现许多泛型无法实现的强大功能(实际上 C++ 模板系统是图灵完备的),代码生成是在编译时完成的,所以 我们必须知道在编译时使用的类型。 由于模板只是为使用的不同类型生成不同版本的代码,给定函数模板template&lt;typename T&gt; void foo( const T&amp; t );foo( 1 )foo( 'c' )不要调用同一个函数,它们调用intchar 分别生成版本。

这就是为什么多态不能与模板一起使用的原因:每个函数模板实例都是一个不同的函数,因此使模板多态是没有意义的。运行时应该调用什么版本?

【讨论】:

【参考方案4】:

C++ 一般直接编译成原生代码,C.Foo&lt;int&gt;(int)C.Foo&lt;long&gt;(long) 的原生代码可能不同。此外,C++ 通常将指向本机代码的指针存储在 vtable 中。结合这些,您会看到如果C.Foo&lt;T&gt; 是虚拟的,那么指向每个实例化的指针都需要成为 vtable 的一部分。

C# 没有这个问题。 C# 编译为 IL,而 IL 被 JITted 为本机代码。 IL vtables 不包含指向本机代码的指针,它们包含指向 IL 的指针(有点)。除此之外,.NET 泛型不允许专业化。所以在 IL 级别,C.Foo&lt;int&gt;(int)C.Foo&lt;long&gt;(long)总是看起来完全相同相同。

因此,C++ 的问题对于 C# 来说根本不存在,也不是需要解决的问题。

P.S.:Java 方法实际上也被 .NET 运行时使用。通常,无论泛型类型参数如何,泛型方法都会产生完全相同相同的本机代码,并且在这种情况下,该方法只会有一个实例。这就是为什么您有时会在堆栈跟踪中看到对 System.__Canon 的引用,它是 Java 的 ? 的粗略运行时等效项。

【讨论】:

System.__Canon 是引用类型的占位符,因为所有引用类型都具有相同的大小(指针大小)。值类型并非如此 - 每个值类型都被 JIT 转换为不同的版本,因此这不能回答问题。 @Janiels 这只是一个 P.S.。我的其余答案确实适用于任何类型。 在 IL 级别不存在此问题。但在 x86 级别确实如此。 @usr 正如我试图在回答中解释的那样,虚拟方法实现主要存在于 IL 级别,而不是机器代码级别。所以不,这个问题在 x86 级别不存在,因为在那个时候,不再有任何虚拟方法。可能有用于实现虚方法的函数指针,但 JITter 的工作是以本机代码不必直接关注相关细节的方式执行此操作。 我理解这个问题是关于实现细节的。查看示例代码的反汇编,看起来 vcall 是针对泛型方法的事实确实影响了生成的代码。【参考方案5】:

在 C# 泛型之前,我已经很久没有做 C# 的事情了,所以我不知道 C# 实现在内部通常是如何做的。

但是,在 C++ 端,虚拟模板受到单独翻译每个翻译单元的设计目标的限制。

以下是虚拟函数模板的假设示例,它不会用当前的 C++ 编译:

#include <iostream>
using namespace std;

struct Base

    template< int n >
    virtual void foo()  cout << "Base::foo<" << n << ">" << endl; 

    static auto instance() -> Base&;
;

auto main()
    -> int

    Base::instance().foo<666>();


//-------------------------------- Elsewhere:

struct Derived: Base

    template< int n >
    virtual void foo()  cout << "Derived::foo<" << n << ">" << endl; 
;

auto Base::instance() -> Base&

    static Derived o;
    return o;

手动实现的方法如下:

#include <iostream>
#include <map>
#include <typeindex>
using namespace std;

struct Base

    virtual ~Base() 

    template< int n >
    struct foo_pointer
    
        void (*p)( Base* );
    ;

    template< int n >
    using Foo_pointer_map = map<type_index, foo_pointer< n >>;

    template< int n >
    static
    auto foo_pointer_map()
        -> Foo_pointer_map< n >&
    
        static Foo_pointer_map< n > the_map;
        return the_map;
    

    template< int n >
    static
    void foo_impl( Base* )  cout << "Base::foo<" << n << ">" << endl; 

    template< int n >
    void foo()   foo_pointer_map< n >()[type_index( typeid( *this ) )].p( this ); 

    static auto instance() -> Base&;
;

bool const init_Base = []() -> bool

    Base::foo_pointer_map<666>()[type_index( typeid( Base ) )].p = &Base::foo_impl<666>;
    return true;
();

auto main()
    -> int

    Base::instance().foo<666>();


//-------------------------------- Elsewhere:

struct Derived: Base

    template< int n >
    static
    void foo_impl( Base* )  cout << "Derived::foo<" << n << ">" << endl; 
;

bool const init_Derived = []() -> bool

    // Here one must know about the instantiation of the base class function with n=666.
    Base::foo_pointer_map<666>()[type_index( typeid( Derived ) )].p = &Derived::foo_impl<666>;
    return true;
();

auto Base::instance() -> Base&

    static Derived o;
    return o;

此代码编译并产生人们期望从第一个代码得到的结果,但只能通过使用有关模板的所有实例化的知识,实例化可能位于不同的翻译单元中。

在初始化查找表时,此知识通常不可用。

不过,现代 C++ 编译器确实提供了整个程序优化,并可能在链接时生成代码,因此它可能不会超出当前技术。 IE。不是技术上的不可能,而是不切实际。除此之外还有动态库的问题,当然 C++ 不支持,但仍然是 C++ 编程实际现实的一部分。

【讨论】:

以上是关于C# 如何在 C++ 不允许虚拟模板方法的情况下允许虚拟泛型方法?的主要内容,如果未能解决你的问题,请参考以下文章

C# 中的 C++ 模板继承等价物是啥?

聊聊 C# 和 C++ 中的 泛型模板 底层玩法

为啥 C# 接口方法没有声明为抽象或虚拟的?

在不使用 COM 的情况下从 C++ 调用 C# 方法

c++ 模板

C++ 在不知道子类型的情况下从父类型调用子方法