【抬杠C#】接口默认方法的底层实现(翻译)

时间:2024-01-26 11:15:20

原文链接:https://mattwarren.org/2020/02/19/Under-the-hood-of-Default-Interface-Methods/

背景

“接口默认实现 Default Implementations in Interfaces”,指的就是C#8中出现的“接口默认方法 Default Interface Methods (DIM)”。如果你从没有听说过这个特性,这里有协议帮助你入门的链接:

此外,下面有一些讨论这个特性的博客,你可以看到,关于这个特性是否有用是存在分歧的。


不过,这篇文章并不是讨论这个特性是什么,你可以怎么用,以及这个特性好不好。

相反,我们将探索一下接口默认方法在底层是怎么实现的,看看为了让这个特性能够运转.NET Core运行时都做了哪些改动,以及这些改动都是怎么开发的。


开发时间线和PR

首先,你可以查看下面这些链接,从而对于之前完成的事情(关于接口默认方法)有一个“高层次”的认知:

初始化工作、原型设计和时间线 

原型设计后出现完成的比较有意思的PR (按时间从新到旧排序)

自从原型设计工作被合并之后,有一些确保接口默认方法能在不同场景之间运转的额外工作完成了:

原型设计以来的完成Bug修复 (按时间从新到旧排序)

此外,有很多确保接口默认方法和CLR原有的特性能正常共同运转的bug被修复了。

有可能的未来工作

最后,无法确定这些工作什么时候完成,但是有一些相关的issue:


接口默认方法实战 

既然我们已经看了相关的工作,接下来我们看看这些都是什么意思,我们以如下一段可以简单演示接口默认方法的代码作为开始:

interface INormal {
    void Normal();
}

interface IDefaultMethod {
    void Default() => WriteLine("IDefaultMethod.Default");
}

class CNormal : INormal {
    public void Normal() => WriteLine("CNormal.Normal");
}

class CDefault : IDefaultMethod {
    // Nothing to do here!
}

class CDefaultOwnImpl : IDefaultMethod {
    void IDefaultMethod.Default() => WriteLine("CDefaultOwnImpl.IDefaultMethod.Default");
}

// Test out the Normal/DefaultMethod Interfaces
INormal iNormal = new CNormal();
iNormal.Normal(); // prints "CNormal.Normal"

IDefaultMethod iDefault = new CDefault();
iDefault.Default(); // prints "IDefaultMethod.Default"

IDefaultMethod iDefaultOwnImpl = new CDefaultOwnImpl();
iDefaultOwnImpl.Default(); // prints "CDefaultOwnImpl.IDefaultMethod.Default"

明白以上是怎么实现的第一个方法是使用 Type.GetInterfaceMap(Type) (这个方法之前必须为了接口默认方法而做改动),可以写成如下代码:

private static void ShowInterfaceMapping(Type @implemetation, Type @interface) {
    InterfaceMapping map = @implemetation.GetInterfaceMap(@interface);
    Console.WriteLine($"{map.TargetType}: GetInterfaceMap({map.InterfaceType})");
    for (int counter = 0; counter < map.InterfaceMethods.Length; counter++) {
        MethodInfo im = map.InterfaceMethods[counter];
        MethodInfo tm = map.TargetMethods[counter];
        Console.WriteLine($"   {im.DeclaringType}::{im.Name} --> {tm.DeclaringType}::{tm.Name} ({(im == tm ? "same" : "different")})");
        Console.WriteLine("       MethodHandle 0x{0:X} --> MethodHandle 0x{1:X}",
            im.MethodHandle.Value.ToInt64(), tm.MethodHandle.Value.ToInt64());
        Console.WriteLine("       FunctionPtr  0x{0:X} --> FunctionPtr  0x{1:X}",
            im.MethodHandle.GetFunctionPointer().ToInt64(), tm.MethodHandle.GetFunctionPointer().ToInt64());
    }
    Console.WriteLine();
}

上面将会输出:

//ShowInterfaceMapping(typeof(CNormal), @interface: typeof(INormal));
//ShowInterfaceMapping(typeof(CDefault), @interface: typeof(IDefaultMethod));
//ShowInterfaceMapping(typeof(CDefaultOwnImpl), @interface: typeof(IDefaultMethod));

TestApp.CNormal: GetInterfaceMap(TestApp.INormal)
   TestApp.INormal::Normal --> TestApp.CNormal::Normal (different)
       MethodHandle 0x7FF993916A80 --> MethodHandle 0x7FF993916B10
       FunctionPtr  0x7FF99385FC50 --> FunctionPtr  0x7FF993861880

TestApp.CDefault: GetInterfaceMap(TestApp.IDefaultMethod)
   TestApp.IDefaultMethod::Default --> TestApp.IDefaultMethod::Default (same)
       MethodHandle 0x7FF993916BD8 --> MethodHandle 0x7FF993916BD8
       FunctionPtr  0x7FF99385FC78 --> FunctionPtr  0x7FF99385FC78

TestApp.CDefaultOwnImpl: GetInterfaceMap(TestApp.IDefaultMethod)
   TestApp.IDefaultMethod::Default --> TestApp.CDefaultOwnImpl::TestApp.IDefaultMethod.Default (different)
       MethodHandle 0x7FF993916BD8 --> MethodHandle 0x7FF993916D10
       FunctionPtr  0x7FF99385FC78 --> FunctionPtr  0x7FF9938663A0

所以从这里我们可以看出,在 IDefaultMethod 接口被 CDefault 类实现的这个例子中,接口方法和方法实现是相同的。在其余的两个例子中,接口方法和方法实现是不同的。

不过我们可以看的更底层一些,使用 WinDBG 和 SOS扩展 来查看运行时使用的内部数据结构。

(编者注:后面我会写一篇文章专门介绍怎么使用WinDBG和SOS扩展来调试.Net Core程序)

首先,我看来看看 INormal 接口的 MethodTable(dumpmt)

> dumpmt -md 00007ff8bcc31dd8
EEClass:         00007FF8BCC2C420
Module:          00007FF8BCC0F788
Name:            TestApp.INormal
mdToken:         0000000002000002
File:            C:\DefaultInterfaceMethods\TestApp\bin\Debug\netcoreapp3.0\TestApp.dll
BaseSize:        0x0
ComponentSize:   0x0
Slots in VTable: 1
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
           Entry       MethodDesc    JIT Name
00007FF8BCB70580 00007FF8BCC31DC8   NONE TestApp.INormal.Normal()

 我们可以看到这个接口有一个 Normal() 方法的入口,如我们所料。不过我们使用 MethodDesc(dumpmd) 再看看更多细节。

> dumpmd 00007FF8BCC31DC8                                    
Method Name:          TestApp.INormal.Normal()               
Class:                00007ff8bcc2c420                       
MethodTable:          00007ff8bcc31dd8                       
mdToken:              0000000006000001                       
Module:               00007ff8bcc0f788                       
IsJitted:             no                                     
Current CodeAddr:     ffffffffffffffff                       
Version History:                                             
  ILCodeVersion:      0000000000000000                       
  ReJIT ID:           0                                      
  IL Addr:            0000000000000000                       
     CodeAddr:           0000000000000000  (MinOptJitted)    
     NativeCodeVersion:  0000000000000000 

所以方法确实存在于接口定义中,而且很明显的这个方法没有被JIT优化过(IsJitted: no),而且实际上也永远不会被JIT,因为它永远不会被执行。

现在我们来比较一下 IDefaultMethod 接口的相关输出,跟上面的操作一样。

> dumpmt -md 00007ff8bcc31e68
EEClass:         00007FF8BCC2C498
Module:          00007FF8BCC0F788
Name:            TestApp.IDefaultMethod
mdToken:         0000000002000003
File:            C:\DefaultInterfaceMethods\TestApp\bin\Debug\netcoreapp3.0\TestApp.dll
BaseSize:        0x0
ComponentSize:   0x0
Slots in VTable: 1
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
           Entry       MethodDesc    JIT Name
00007FF8BCB70590 00007FF8BCC31E58    JIT TestApp.IDefaultMethod.Default()
> dumpmd 00007FF8BCC31E58 Method Name: TestApp.IDefaultMethod.Default() Class: 00007ff8bcc2c498 MethodTable: 00007ff8bcc31e68 mdToken: 0000000006000002 Module: 00007ff8bcc0f788 IsJitted: yes Current CodeAddr: 00007ff8bcb765c0 Version History: ILCodeVersion: 0000000000000000 ReJIT ID: 0 IL Addr: 0000000000000000 CodeAddr: 00007ff8bcb765c0 (MinOptJitted) NativeCodeVersion: 0000000000000000

从这里我们可以看到很不一样的东西,在 MethodTable 的 MethodDesc 里显示这个方法已经被JIT优化了。


在接口中启用方法

这里我们也可以看到为什么接口默认方法不需要对.NET ‘Intermediate Language’ (IL) opcodes做任何改动,而是放开一个之前已有的限制。在这个改动之前,你不能给接口添加 ‘virtual非abstract’ 或者 ‘非virtual’ 的方法。 

  • “Virtual Non-Abstract Interface Method.” (BFA_VIRTUAL_NONAB_INT_METHOD) (virtual非abstract的接口方法)
  • “Nonvirtual Instance Interface Method.” (BFA_NONVIRT_INST_INT_METHOD) (非virtual的接口方法)

这于ECMA-335规范的改动相关,来自接口默认方法设计文档

The major changes are:

  • Interfaces are now allowed to have instance methods (both virtual and non-virtual). Previously we only allowed abstract virtual methods.
    • Interfaces obviously still can’t have instance fields.
  • Interface methods are allowed to MethodImpl other interface methods the interface requires (but we require the MethodImpls to be final to keep things simple) - i.e. an interface is allowed to provide (or override) an implementation of another interface’s method

然而,允许 ‘virtual非abstract’ 或者 ‘非virtual’ 的方法在接口中存在仅仅是一个开始,运行时接下来还需要允许代码调用这些方法,而这将要困难的多。


解决默认接口方法的分发(Method Dispatch)

自从.Net 2.0以来,所以的接口方法调用会通过一种被称为(Virtual Stub Dispatch)的机制来实现:

Virtual stub dispatching (VSD)是一种通过存根(stubs)的方式对virtual方法调用的技术,而不是传统的虚方法表。过去,接口分发要求接口有一个过程唯一的id,以及每个已加载的接口会被添加到一个全局的接口虚表映射(global interface virtual table map)。这些要求意味着,在NGEN场景中所有的接口和所有实现接口的类不得不存储在运行时,这将显著的增加程序启动工作集。使用存根分发的动机之前为了消除这些相关的工作集,同时也将剩余的工作分散贯穿了进程的生命周期中。虽然对于虚实例和接口方法调用都可以使用VSD,但目前来说VSD仅用于接口分发。

如果想进一步了解相关信息,我推荐阅读由Lukas Atkinson写的‘Interface Dispatch’一文中的 C#’s slotmaps 这一章。

所以,为了是接口默认方法能运转,运行时不得不联结所有的‘默认方法’,使得它们可以集成VSD技术。我们可以通过调用栈看到这些处理步骤,对于一个给定的接口 pInterfaceMD 和默认方法调用 pInterfaceMT,从ResolveWorkerAsmStub 一直到 FindDefaultInterfaceImplementation(..)处找到了正确的方法体

- coreclr.dll!MethodTable::FindDefaultInterfaceImplementation(MethodDesc *pInterfaceMD, MethodTable *pInterfaceMT, MethodDesc **ppDefaultMethod, int allowVariance, int throwOnConflict) Line 6985	C++
- coreclr.dll!MethodTable::FindDispatchImpl(unsigned int typeID, unsigned int slotNumber, DispatchSlot *pImplSlot, int throwOnConflict) Line 6851	C++
- coreclr.dll!MethodTable::FindDispatchSlot(unsigned int typeID, unsigned int slotNumber, int throwOnConflict) Line 7251	C++
- coreclr.dll!VirtualCallStubManager::Resolver(MethodTable *pMT, DispatchToken token, OBJECTREF *protectedObj, unsigned __int64 *ppTarget, int throwOnConflict) Line 2208	C++
- coreclr.dll!VirtualCallStubManager::ResolveWorker(StubCallSite *pCallSite, OBJECTREF *protectedObj, DispatchToken token, VirtualCallStubManager::StubKind stubKind) Line 1874	C++
- coreclr.dll!VSD_ResolveWorker(TransitionBlock *pTransitionBlock, unsigned __int64 siteAddrForRegisterIndirect, unsigned __int64 token, unsigned __int64 flags) Line 1683	C++
- coreclr.dll!ResolveWorkerAsmStub() Line 42	Unknown

如果你想对调用栈做进一步探索,你可以阅读以下链接:

  • ResolveWorkerAsmStub here
  • VSD_ResolveWorker(..) here
  • VirtualCallStubManager::ResolveWorker(..) here
  • VirtualCallStubManager::Resolver(..)here
  • MethodTable::FindDispatchSlot(..) here 
  • MethodTable::FindDispatchImpl(..) here or here
  • MethodTable::FindDefaultInterfaceImplementation(..) here

FindDefaultInterfaceImplementation(..)的分析

FindDefaultInterfaceImplementation(..) 这个方法的代码是接口默认方法这个特性的核心,它是怎么实现的呢?这里有一个出自 Finalize override lookup algorithm #12753 的列表可以一定程度上说明其复杂性:

  • 以持续记录一个当前的最佳候选列表的方式正确地检测菱形继承中的可行情况(比如接口I2/I3都重写了I1,而I4同时重写了接口I2/I3)。我之前寻找过一个最简单的算法,它不需要构建任何复杂的图/DFS,这是由于大部分情况接口列表会很小,以及接口分发缓存能确保大部分情况我们无需再次执行方法分发(会慢)。如果需要重新分发的话,我们可以重新讨论一下,使得能构建拓扑排序。
  • VerifyVirtualMethodsImplemented 现在可以正确地校验默认接口的场景了。这个方法中,如果能找到接口的至少一个实现尽早返回的话,想必是极好的。对于有冲突的重写情况,无需因为性能问题而担心。
  • NotSupportedException 会在有冲突的重写情况中被抛出,并且会带有正确的错误信息。
  • 当检测到方法实现重写的时候正确地支持GVM(编者按:应该指的是 Generic Virtual methods)
  • 重新讨论了关于给接口增加方法实现的代码。增加了正确的方法实现校验,以及确保方法实现是virtual和final的(如果不是final就抛出异常)
  • 增加了方法有多个实现的场景的测试。定位并修复了一个bug,是关于为接口创建方法实现的时候存根数组不够大。 

另外,上文提到的“Two-Pass”算法的实现于 Implement two pass algorithm for variant interface dispatch #21355,里面包含了一个关于 需要处理的边角问题 的有趣的讨论。

以下是 FindDefaultInterfaceImplementation 其中的算法的概括:

  • 从 MethodTable::FindDispatchImpl(..)查看源码开始,此处 FindDefaultInterfaceImplementation 可能会被调用了两次:
  1. 尝试寻找精确匹配(allowVariance=false)
  2. 如果第一次没找到,尝试寻找可变的匹配(allowVariance=true)
  • FindDefaultInterfaceImplementation 的全部代码可以查看这里,还是很直截了当的,相对来说也比较容易懂,包含良好注释仅270行。其算法描述如下:
  1. 根据接口从派生类到父类遍历,这是一个相当直接的实现,如果没找到合适的,有可能会再找一次。
  2. 接着扫描每一个类寻找匹配:
    1. 精确的匹配
    2. 可变泛型匹配,例如接口通过类型转换匹配,但根源是拥有相同的TypeDef
    3. 更具体的接口,这个匹配构建的更复杂,因为泛型实例化
    4. 更具体的接口,但是没有泛型参与,所以简单很多。
  3. 如果之前的步骤找到了一个匹配,那就二次校验一下它是不是到目前为止的最具体的接口匹配,通过一个“备选列表”然后根据一下每一个场景分类:
    1. 一个被忽略的"tie",例如同一类型上的可变匹配
    2. 一个“更具体”的匹配,是用来更新“备选列表”的。
    3. 一个“更不具体”的匹配,这个就不用继续处理了。
  4. 最终,一次扫描就完成了,然后检查是否有冲突,当allowVariance=true时是可以接受的,否则就抛出一个异常。
  5. 好了,最佳候选就找到了,并且会返回给调用者

菱形继承问题

有几个PR和Issue提到了关于接口默认方法中的“菱形继承问题”,不过,菱形继承问题什么呢?

有一个可以展开研究的好地方是一个测试用例,diamondshape.cs。不过这里有一个更简明的例子,来自C#8 语言提案 

interface IA
{
    void M();
}
interface IB : IA
{
    override void M() { WriteLine("IB"); }
}
class Base : IA
{
    void IA.M() { WriteLine("Base"); }
}
class Derived : Base, IB // allowed?
{
    static void Main()
    {
        Ia a = new Derived();
        a.M();           // what does it do?
    }
}

 问题在于哪一个匹配接口的方法应该被调用,是 IB.M() 还是 Base.IA.M()最终决议是使用最具体的那个。

Closed Issue: Confirm the draft spec, above, for most specific override as it applies to mixed classes and interfaces (a class takes priority over an interface).

See https://github.com/dotnet/csharplang/blob/master/meetings/2017/LDM-2017-04-19.md#diamonds-with-classes.


总结

到这里你就张掌握了这个特性的全部内容,让我们为.NET(Core)开源欢呼!感谢运行时的开发人员,他们让问题和PR易于理解,并在其代码中添加了如此出色的注释!也赞扬语言设计师向所有人提供他们的提案和会议记录(比如LDM-2017-04-19)。

不论你认为这个特性是不是有用,你都很难说这个特性没有被好好设计以及好好实现。

但是使这个特性更具特色的是,它需要编译器和运行时团队共同努力才能实现!


编者结语

本人英语翻译水平堪忧,有很多地方其实不知道怎么翻译,有的保留了原文,有的查了相关的文档(比如一些专有名词)。所以肯定有不准确的地方,还请各位读者朵朵留言斧正,在此先感谢了。

我个人对接口默认方法的态度是由一开始不理解到后来觉得挺不错的,至少目前看起来很不错,在我看来,它解决了两个大问题:

  • 和安卓互操作(java平台就支持这个特性,所以如果C#支持了就方便多了,不用各种想办法模拟了)
  • 扩展方法的局限性,因为扩展方法不能是virtual的,比如linq中的很多依赖于接口的扩展方法无法直接根据具体类类型来优化,而是不得不在代码中做类型判断,例如:
public static int Count<TSource>(this IEnumerable<TSource> source)
{
    if (source == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    }

    if (source is ICollection<TSource> collectionoft)
    {
        return collectionoft.Count;
    }

    if (source is IIListProvider<TSource> listProv)
    {
        return listProv.GetCount(onlyIfCheap: false);
    }

    if (source is ICollection collection)
    {
        return collection.Count;
    }

    int count = 0;
    using (IEnumerator<TSource> e = source.GetEnumerator())
    {
        checked
        {
            while (e.MoveNext())
            {
                count++;
            }
        }
    }

    return count;
}
    • 假如有一个类型实现了IEnumerable<TSource>但没实现ICollection<TSource>同时又有一个Count字段,那根据上面代码,这个count字段是无法被调用的。
    • 如果把Count()方法作为IEnumerable<TSource>的接口默认方法,就可以解决这个问题了。

最后,开源大法好。