【抬杠C#】接口默认方法的底层实现(翻译)
原文链接:https://mattwarren.org/2020/02/19/Under-the-hood-of-Default-Interface-Methods/
背景
“接口默认实现 Default Implementations in Interfaces”,指的就是C#8中出现的“接口默认方法 Default Interface Methods (DIM)”。如果你从没有听说过这个特性,这里有协议帮助你入门的链接:
- Default implementations in interfaces (官方文档)
- Default Interface Methods (C#语言提案), 有几个值得注意的章节:
- Diamond inheritance and classes (菱形继承)
- Interface methods vs structs (接口方法 vs 接口体)
- Structs and default implementations (结构体和默认实现)
- Champion “default interface methods” (接口默认方法的语言提案,这里面包括很多语言设计会议的记录)
- Tutorial: Update interfaces with default interface methods in C# 8.0 (指南:在C#8.0中结合接口默认方法来更新接口)
此外,下面有一些讨论这个特性的博客,你可以看到,关于这个特性是否有用是存在分歧的。
- Default Interface Methods in C# 8 (C# 8 中的接口默认方法)
- C# 8: Default Interface Methods Implementation (C# 8: 接口默认方法实现)
- Default Interface Members, What Are They Good For? (默认接口成员,这东西好在哪儿)
- C# 8: Default implementations in interfaces (C# 8: 接口中的默认实现)
- Interfaces in C# 8.0 gets a makeover (C# 8中的接口得到了改进)
- C# 8.0 and .NET Standard 2.0 - Doing Unsupported Things (C# 8.0 和 .NET Standard 2.0 - 正在做不受支持的事情)
- Interfaces in C# 8 are a Bit of a Mess (C# 8中的接口有点混乱)
- The most controversial C# 8.0 feature: Default Interface Methods Implementation (Reddit discussion) (C# 8中最具争议的特性:默认接口方法实现)
不过,这篇文章并不是讨论这个特性是什么,你可以怎么用,以及这个特性好不好。
相反,我们将探索一下接口默认方法在底层是怎么实现的,看看为了让这个特性能够运转.NET Core运行时都做了哪些改动,以及这些改动都是怎么开发的。
开发时间线和PR
首先,你可以查看下面这些链接,从而对于之前完成的事情(关于接口默认方法)有一个“高层次”的认知:
- GitHub Project for Default Interface Methods
- List of all the PRs done during the Project
- 为了查看运行时都改动了哪些部分,你可以在.Net Core运行时的源码中搜索 FEATURE_DEFAULT_INTERFACES, 该特性均由一个#define的宏控制。
- 此外你可以查看Mono中完成的相关工作,Epic: Default Interface Implementation #6961 和 Update default interfaces support #11267
初始化工作、原型设计和时间线
- 所有的原型设计分成了这几个PR,从2017年3月到7月:
- 所有的初始化工作于2017年12月合并到了master分支 Merge dev/defaultintf to master #15370
- 整个特性(接口默认方法)于2019年3月被默认开启 Enable FeatureDefaultInterfaces unconditionally #23225
- 该特性于2019年3月官宣。
原型设计后出现完成的比较有意思的PR (按时间从新到旧排序)
自从原型设计工作被合并之后,有一些确保接口默认方法能在不同场景之间运转的额外工作完成了:
- Use native code slot for default interface methods #25770 (为接口默认方法使用native代码槽)
- Allow reabstraction of default interface methods #23313 (允许接口默认方法的再抽象)
- Throw the right exception when interface dispatch is ambiguous #22295 (当接口方法分发不明确的时候抛出正确的异常)
- Implement two pass algorithm for variant interface dispatch #21355 (为协变/逆变接口的分发实现Two-Pass算法)
- Make it possible to Reflection.Emit default interface methods #16257 (允许Reflection.Emit接口默认方法)
- Fix reflection to work with default interface methods #16034 (修复接口默认方法相关的反射)
- Stop treating all calls to instance interface methods as callvirt #15925 (不再将所有的实例接口方法的调用都视为callvirt)
- [Default Interfaces] Edit and Continue #9601 (其余的一些改动汇总)
原型设计以来的完成Bug修复 (按时间从新到旧排序)
此外,有很多确保接口默认方法和CLR原有的特性能正常共同运转的bug被修复了。
- Block usage of default interfaces feature in COM scenarios #23970 (阻止再COM中使用接口默认方法)
- Remove legacy behavior around non-virtual interface calls #23032 (移除关于非virtual接口调用的原有遗留行为)
- Fix constrained call corner cases #22464 (修复受约束调用的一些边角场景)
- Fix delegate creation for default interface methods on structs #22427 (修复创建基于结构体的接口默认方法的委托)
- Fix stack walking and reporting of default interface methods #21525 (修复接口默认方法的stack walking and reporting)
- Allow supressing exceptions in diamond inheritance cases #20458 (允许在菱形继承场景中阻止相关异常)
- Handle generics in methodimpls for default interface methods #20404 (处理对于接口默认方法中的泛型实现)
- Do not devirtualize shared default interface methods #15979 (不要对共享的接口默认方法进行devirtualize)
- Catch ambiguous interface method resolution exceptions #15978 (捕获不明确的接口方法的Resolution抛出的异常)
有可能的未来工作
最后,无法确定这些工作什么时候完成,但是有一些相关的issue:
- Support for default interface method devirtualization #9588 (支持接口默认方法的devirtualization)
- Debugger support #9556 (调试器支持)
- Interfaces implemented by arrays #9552 (用数组实现的接口)
- Support constrained interface calls on value types #9490 (支持值类型的受约束接口调用)
- Add support for default interfaces in type generator #9479 (在类生成器中支持默认接口)
接口默认方法实战
既然我们已经看了相关的工作,接下来我们看看这些都是什么意思,我们以如下一段可以简单演示接口默认方法的代码作为开始:
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- This is the ‘Generic Resolver’ phase of ‘Virtual Stub Dispatch’.
VSD_ResolveWorker(..)
hereVirtualCallStubManager::ResolveWorker(..)
hereVirtualCallStubManager::Resolver(..)
hereMethodTable::FindDispatchSlot(..)
hereMethodTable::FindDispatchImpl(..)
here or hereMethodTable::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 可能会被调用了两次:
- 尝试寻找精确匹配(allowVariance=false)
- 如果第一次没找到,尝试寻找可变的匹配(allowVariance=true)
- FindDefaultInterfaceImplementation 的全部代码可以查看这里,还是很直截了当的,相对来说也比较容易懂,包含良好注释仅270行。其算法描述如下:
- 根据接口从派生类到父类遍历,这是一个相当直接的实现,如果没找到合适的,有可能会再找一次。
- 接着扫描每一个类寻找匹配:
- 如果之前的步骤找到了一个匹配,那就二次校验一下它是不是到目前为止的最具体的接口匹配,通过一个“备选列表”然后根据一下每一个场景分类:
- 最终,一次扫描就完成了,然后检查是否有冲突,当allowVariance=true时是可以接受的,否则就抛出一个异常。
- 好了,最佳候选就找到了,并且会返回给调用者
菱形继承问题
有几个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).
总结
到这里你就张掌握了这个特性的全部内容,让我们为.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>的接口默认方法,就可以解决这个问题了。
最后,开源大法好。