【抬槓C#】如何實現介面的base呼叫

月光雙刀 發表於 2022-06-10
C#

背景

在三年前釋出的C#8.0中有一項重要的改進叫做介面預設實現,從此以後,介面中定義的方法可以包含方法體了,即預設實現。

不過對於介面的預設實現,其實現類或者子介面在重寫這個方法的時候不能對其進行base呼叫,就像子類重寫方法是可以進行base.Method()那樣。例如:

public interface IService
{
    void Proccess()
    {
        Console.WriteLine("Proccessing");
    }
}

public class Service : IService
{
    public void Proccess()
    {
        Console.WriteLine("Before Proccess");
        base(IService).Proccess(); // 目前不支援,也是本文需要探討的部分
        Console.WriteLine("End Proccess");
    }
}

當初C#團隊將這個特性列為了下一步的計劃(點此檢視細節),然而三年過去了依然沒有被提上日程。這個特性的缺失無疑是一種很大的限制,有時候我們確實需要介面的base呼叫來實現某些需求。本文將介紹兩種方法來實現它。

 

方法1:使用反射找到介面實現並進行呼叫

這種方法的核心思想是,使用反射找到你需要呼叫的介面實現的MethodInfo,然後構建DynamicMethod使用OpCodes.Call去呼叫它即可。

首先我們定義方法簽名用來表示介面方法的base呼叫。

public static void Base<TInterface>(this TInterface instance, Expression<Action<TInterface>> selector);
public static TReturn Base<TInterface, TReturn>(this TInterface instance, Expression<Func<TInterface, TReturn>> selector);

所以上一節的例子就可以改寫成:

public class Service : IService
{
    public void Proccess()
    {
        Console.WriteLine("Before Proccess");
        this.Base<IService>(m => m.Proccess());
        Console.WriteLine("End Proccess");
    }
}

於是接下來,我們就需要根據lambda表示式找到其對應的介面實現,然後呼叫即可。

 

第一步根據lambda表示式獲取MethodInfo和引數。要注意的是,對於屬性的呼叫我們也需要支援,其實屬性也是一種方法,所以可以一併處理。

private static (MethodInfo method, IReadOnlyList<Expression> args) GetMethodAndArguments(Expression exp) => exp switch
{
    LambdaExpression lambda => GetMethodAndArguments(lambda.Body),
    UnaryExpression unary => GetMethodAndArguments(unary.Operand),
    MethodCallExpression methodCall => (methodCall.Method!, methodCall.Arguments),
    MemberExpression { Member: PropertyInfo prop } => (prop.GetGetMethod(true) ?? throw new MissingMethodException($"No getter in propery {prop.Name}"), Array.Empty<Expression>()),
    _ => throw new InvalidOperationException("The expression refers to neither a method nor a readable property.")
};

 

第二步,利用Type.GetInterfaceMap獲取到需要呼叫的介面實現方法。此處注意的要點是,instanceType.GetInterfaceMap(interfaceType).InterfaceMethods 會返回該介面的所有方法,所以不能僅根據方法名去匹配,因為可能有各種過載、泛型引數、還有new關鍵字宣告的同名方法,所以可以按照方法名+宣告型別+方法引數+方法泛型引數唯一確定一個方法(即下面程式碼塊中IfMatch的實現)

internal readonly record struct InterfaceMethodInfo(Type InstanceType, Type InterfaceType, MethodInfo Method);

private static MethodInfo GetInterfaceMethod(InterfaceMethodInfo info)
{
    var (instanceType, interfaceType, method) = info;
    var parameters = method.GetParameters();
    var genericArguments = method.GetGenericArguments();
    var interfaceMethods = instanceType
        .GetInterfaceMap(interfaceType)
        .InterfaceMethods
        .Where(m => IfMatch(method, genericArguments, parameters, m))
        .ToArray();

    var interfaceMethod = interfaceMethods.Length switch
    {
        0 => throw new MissingMethodException($"Can not find method {method.Name} in type {instanceType.Name}"),
        > 1 => throw new AmbiguousMatchException($"Found more than one method {method.Name} in type {instanceType.Name}"),
        1 when interfaceMethods[0].IsAbstract => throw new InvalidOperationException($"The method {interfaceMethods[0].Name} is abstract"),
        _ => interfaceMethods[0]
    };

    if (method.IsGenericMethod)
        interfaceMethod = interfaceMethod.MakeGenericMethod(method.GetGenericArguments());

    return interfaceMethod;
}

 

第三步,用獲取到的介面方法,構建DynamicMethod。其中的重點是使用OpCodes.Call,它的含義是以非虛方式呼叫一個方法,哪怕該方法是虛方法,也不去查詢它的重寫,而是直接呼叫它自身。

private static DynamicMethod GetDynamicMethod(Type interfaceType, MethodInfo method, IEnumerable<Type> argumentTypes)
{
    var dynamicMethod = new DynamicMethod(
        name: "__IL_" + method.GetFullName(),
        returnType: method.ReturnType,
        parameterTypes: new[] { interfaceType, typeof(object[]) },
        owner: typeof(object),
        skipVisibility: true);

    var il = dynamicMethod.GetILGenerator();
    il.Emit(OpCodes.Ldarg_0);

    var i = 0;
    foreach (var argumentType in argumentTypes)
    {
        il.Emit(OpCodes.Ldarg_1);
        il.Emit(OpCodes.Ldc_I4, i);
        il.Emit(OpCodes.Ldelem, typeof(object));
        if (argumentType.IsValueType)
        {
            il.Emit(OpCodes.Unbox_Any, argumentType);
        }
        ++i;
    }
    il.Emit(OpCodes.Call, method);
    il.Emit(OpCodes.Ret);
    return dynamicMethod;
}

 

最後,將DynamicMethod轉為強型別的委託就完成了。考慮到效能的優化,可以將最終的委託快取起來,下次呼叫就不用再構建一次了。

完整的程式碼點這裡 

 

方法2:利用函式指標

這個方法和方法1大同小異,區別是,在方法1的第二步,即找到介面方法的MethodInfo之後,獲取其函式指標,然後利用該指標構造委託。這個方法其實是我最初找到的方法,方法1是其改進。在此就不多做介紹了

 

方法3:利用Fody在編譯時對介面方法進行IL的call呼叫

方法1雖然可行,但是肉眼可見的效能損失大,即使是用了快取。於是乎我利用Fody編寫了一個外掛InterfaceBaseInvoke.Fody

其核心思想就是在編譯時找到目標介面方法,然後使用call命令呼叫它就行了。這樣可以把效能損失降到最低。該外掛的使用方法可以參考專案介紹

 

效能測試

方法 平均用時 記憶體分配
父類的base呼叫 0.0000 ns -
方法1(DynamicMethod) 691.3687 ns 776 B
方法2(FunctionPointer) 1,391.9345 ns 1,168 B
方法3(InterfaceBaseInvoke.Fody 0.0066 ns -

 

總結

本文探討了幾種實現介面的base呼叫的方法,其中效能以InterfaceBaseInvoke.Fody最佳,在C#官方支援以前推薦使用。歡迎大家使用,點心心,謝謝大家。