C#反射的委託建立器

風靈使發表於2018-07-07

.Net 的反射是個很好很強大的東西,不過它的效率卻實在是不給力。已經有很多人針對這個問題討論過了,包括各種各樣的 DynamicMethod 和各種各樣的效率測試,不過總的來說解決方案就是利用 Expression TreeDelegate.CreateDelegate 或者 Emit 構造出反射操作對應的委託,從而實現加速反射的目的。

雖然本篇文章同樣是討論利用委託來加速反射呼叫函式,不過重點並不在於如何提升呼叫速度,而是如何更加智慧的構造出反射的委託,並最終完成一個方便易用的委託建立器 DelegateBuilder

它的設計目標是:

  1. 能夠對方法呼叫、建構函式呼叫,獲取或設定屬性和獲取或設定欄位提供支援。
  2. 能夠構造出特定的委託型別,而不僅限於 Func<object, object[], object> 或者其它的 FuncAction,因為我個人很喜歡強型別的委託,同時類似 void MyDeleagte(params int[] args)這樣的委託有時候也是很有必要的,如果需要支援 refout 引數,就必須使用自定義的委託型別了。
  3. 能夠支援泛型方法,因為利用反射選擇泛型方法是件很糾結的事(除非沒有同名方法),而且還需要再 MakeGenericMethod
  4. 能夠支援型別的顯式轉換,在對某些 private 類的例項方法構造委託時,例項本身就必須使用 object 傳入才可以。

其中的 3、4 點,在前幾篇隨筆《C# 判斷型別間能否隱式或強制型別轉換》和《C# 泛型方法的型別推斷》中已經被解決了,並且整合到了 PowerBinder 中,這裡只要解決 1、2 點就可以了,這篇隨筆就是來討論如何根據反射來構造出相應的委託。

就目前完成的效果,DelegateBuilder 可以使用起來還是非常方便的,下面給出一些示例:

class Program {
    public delegate void MyDelegate(params int[] args);
    public static void TestMethod(int value) { }
    public void TestMethod(uint value) { }
    public static void TestMethod<T>(params T[] arg) { }
    static void Main(string[] args) {
        Type type = typeof(Program);
        Action<int> m1 = type.CreateDelegate<Action<int>>("TestMethod");
        m1(10);
        Program p = new Program();
        Action<Program, uint> m2 = type.CreateDelegate<Action<Program, uint>>("TestMethod");
        m2(p, 10);
        Action<object, uint> m3 = type.CreateDelegate<Action<object, uint>>("TestMethod");
        m3(p, 10);
        Action<uint> m4 = type.CreateDelegate<Action<uint>>("TestMethod", p);
        m4(10);
        MyDelegate m5 = type.CreateDelegate<MyDelegate>("TestMethod");
        m5(0, 1, 2);
    }
}

可以說效果還是不錯的,這裡的 CreateDelegate 的用法與Delegate.CreateDelegate 完全相同,功能卻大大豐富,幾乎可以只依靠 delegate typetypememberName 構造出任何需要的委託,省去了自己反射獲取型別成員的過程。

這裡特別要強調一點:這個類用起來很簡單,但是簡單的背後是實現的複雜,所以各種沒有發現的 bug 和推斷錯誤是很正常的。

我再補充一點:雖然在這裡我並不打算討論效率問題,但的確有不少朋友對效率問題有點糾結,我就來詳細解釋下這個問題。

第一個問題:為什麼要用委託來代替反射。如果手頭有 Reflector 之類的反編譯軟體,可以看看 System.Reflection.RuntimeMethodInfo.Invoke 方法的實現,它首先需要檢查引數(檢查預設引數、型別轉換之類的),然後檢查各種 Flags,然後再呼叫 UnsafeInvokeInternal 完成真正的呼叫過程,顯然比直接呼叫方法要慢上不少。而如果利用 Expression Tree 之類的方法構造出了委託,它就相當於只多了一層方法呼叫,效能不會損失多少(據說如果 Emit 用得好還能更快),因此才需要利用委託來代替反射。

第二個問題:什麼時候適合用委託來代替反射。現在假設有一家公園,它的門票是 1 元,它還有一種終身票,票價是 20 元。如果我只是想進去看看,很可能以後就不再去了,那麼我直接花 1 元進去是最合適的。但如果我想天天去溜達溜達,那麼花 20 元買個終身票一定更加合適。

相對應的,1 元的門票就是反射,20 元的終身票就是委託——如果某個方法我只是偶爾呼叫一下,那麼直接用反射就好了,反正損失也不是很大;如果我需要經常呼叫,花點時間構造個委託出來則是更好的選擇,雖然構造委託這個過程比較慢,但它受用終身的。

第三個問題:怎麼測試委託和反射的效率。測試效率的前提就是假設某個方法是需要被經常呼叫的,否則壓根沒必要使用委託。那麼,基本的結構如下所示:

Stopwatch sw = new Stopwatch();
Type type = typeof(Program);
sw.Start();
Action<int> action = type.CreateDelegate<Action<int>>("TestMethod");
for (int i = 0; i < 10000; i++)
{
    action(i);
}
sw.Stop();
Console.WriteLine("DelegateBuilder:{0} ms", sw.ElapsedMilliseconds);
sw.Start();
MethodInfo method = type.GetMethod("TestMethod");
for (int i = 0; i < 10000; i++)
{
    method.Invoke(null, new object[] { i });
}
sw.Stop();
Console.WriteLine("Reflection:{0} ms", sw.ElapsedMilliseconds);

這裡將構造委託的過程和反射得到 MethodInfo 的過程都放在了迴圈的外面,是因為它們只需要獲取一次,就可以一直使用的(也就是所謂的“預處理”)。至於時候將它們放在 StopWatchStartStop 之間,就看是否想將預處理所需的時間也計算在內了。

目前我能想到的問題就這三個了,如果還有什麼其它相關問題,可以聯絡我。

言歸正傳,下面就來分析如何為反射構造出相應的委託。為了簡便起見,我將使用 Expression Tree 來構造委託,這樣更加易讀,而且效率也並不會比 Emit 低多少。對於 Expression 不熟悉的朋友可以參考 Expression 類。

一、從 MethodInfo 建立方法的委託

首先從建立方法的委託說開來,因為方法的委託顯然是最常用、最基本的了。Delegate 類為我們提供了一個很好的參考,它的 CreateDelegate 方法有十個過載,這些過載之間的關係可以用下面的圖表示出來,他們的詳細解釋可見 MSDN

Delegate.CreateDelegate

這些方法的確很給力,用起來也比較方便,儘管在我看來還不夠強大:)。為了易於上手,自己的方法委託建立方法的行為也應該類似於 Delegate.CreateDelegate 方法,因此接下來會先分析 CreateDelegate 方法的用法,然後再解釋如何自己建立委託。

1.1 建立開放的方法委託

CreateDelegate(Type, MethodInfo) 和 CreateDelegate(Type, MethodInfo, Boolean) 的功能是相同的,都是可以建立靜態方法的委託,或者是顯式提供例項方法的第一個隱藏引數(稱開放的例項方法,從 .Net Framework 2.0 以後支援)的委託。以下面的類為例:

class TestClass {
    public static void TestStaticMethod(string value) {}
    public void TestMethod(string value) {}
}

要建立 TestStaticMethod 方法的委託,需要使用 Action<string> 委託型別,程式碼為

Delegate.CreateDelegate(typeof(Action<string>), type.GetMethod("TestStaticMethod"))

得到的委託的效果與 TestStaticMethod(arg1) 相同。

要建立 TestMethod 方法的委託,則需要使用 Action<TestClass, string> 委託型別才可以,第一個參數列示要在其上呼叫方法的 TestClass 的例項:

Delegate.CreateDelegate(typeof(Action<TestClass, string>), type.GetMethod("TestMethod"))

得到的委託的效果與 arg1.TestMethod(arg2) 相同。

這個方法的用法很明確,自己實現起來也非常簡單:

首先對開放的泛型方法構造相應的封閉的泛型方法,做法與上一篇《C# 使用 Binder 類自定義反射》中的 2.2.2 處理泛型方法 一節使用的演算法相同,這裡就不再贅述了。

接下就可以直接利用 Expression.Call 建立一個方法呼叫的委託,並對每個引數新增一個強制型別轉換(Expression.Convert)即可。需要注意的是如果 MethodInfo 是例項方法,那麼第一個引數要作為例項使用。最後用 Expression 構造出來的方法應該類似於:

// method 對應於靜態方法。
returnType MethodDelegate(PT0 p0, PT1 p1, ... , PTn pn) {
    return method((T0)p0, (T1)p1, ... , (Tn)pn);
}
// method 對應於例項方法。
returnType MethodDelegate(PT0 p0, PT1 p1, ... , PTn pn) {
    return ((T0)p0).method((T1)p1, ... , (Tn)pn);
}

構造開放的方法委託的核心方法如下所示:

private static Delegate CreateOpenDelegate(Type type,
    MethodInfo invoke, ParameterInfo[] invokeParams,
    MethodInfo method, ParameterInfo[] methodParams)
{
    // 要求引數數量匹配,其中例項方法的第一個引數用作傳遞例項物件。
    int skipIdx = method.IsStatic ? 0 : 1;
    if (invokeParams.Length == methodParams.Length + skipIdx)
    {
        if (method.IsGenericMethodDefinition)
        {
            // 構造泛型方法的封閉方法,對於例項方法要跳過第一個引數。
            Type[] paramTypes = GetParameterTypes(invokeParams, skipIdx, 0, 0);
            method = method.MakeGenericMethodFromParams(methodParams, paramTypes);
            if (method == null) { return null; }
            methodParams = method.GetParameters();
        }
        // 方法的引數列表。
        ParameterExpression[] paramList = GetParameters(invokeParams);
        // 構造呼叫引數列表。
        Expression[] paramExps = GetParameterExpressions(paramList, skipIdx, methodParams, 0);
        if (paramExps != null)
        {
            // 呼叫方法的例項物件。
            Expression instance = null;
            if (skipIdx == 1)
            {
                instance = ConvertType(paramList[0], method.DeclaringType);
                if (instance == null)
                {
                    return null;
                }
            }
            Expression methodCall = Expression.Call(instance, method, paramExps);
            methodCall = GetReturn(methodCall, invoke.ReturnType);
            if (methodCall != null)
            {
                return Expression.Lambda(type, methodCall, paramList).Compile();
            }
        }
    }
    return null;
}

1.2 建立第一個引數封閉的方法委託

CreateDelegate(Type, Object, MethodInfo) 和 CreateDelegate(Type, Object, MethodInfo, Boolean) 是最靈活的建立委託的方法,可以建立靜態或例項方法的委託,可以提供或不提供第一個引數。先來給出所有用法的示例:

class TestClass {
    public static void TestStaticMethod(string value) {}
    public void TestMethod(string value) {}
}

對於 TestStaticMethod (靜態方法)來說:

firstArgument 不為 null,則在每次呼叫委託時將其傳遞給方法的第一個引數,此時稱為通過第一個引數封閉,要求委託的簽名包括方法除第一個引數之外的所有引數,使用方法為

Delegate.CreateDelegate(typeof(Action), "str", type.GetMethod("TestStaticMethod"))

得到的委託的效果與 TestStaticMethod(firstArgument) 相同。
firstArgumentnull,且委託和方法的簽名匹配(即所有引數型別都相容),則此時稱為開放的靜態方法委託,使用方法為

Delegate.CreateDelegate(typeof(Action<string>), null, type.GetMethod("TestStaticMethod"))

得到的委託的效果與 TestStaticMethod(arg1) 相同。
firstArgumentnull,且委託的簽名以方法的第二個引數開頭,其餘引數型別都相容,則此時稱為通過空引用封閉的委託,使用方法為

Delegate.CreateDelegate(typeof(Action), null, type.GetMethod("TestStaticMethod"))

得到的委託的效果與 TestStaticMethod(null) 相同。
對於 TestMethod (例項方法)來說:

firstArgument 不為 null,則 firstArgument 被傳遞給隱藏的例項引數(就是 this),這時成為封閉的例項方法,要求委託的簽名必須和方法的簽名匹配,使用方法為

Delegate.CreateDelegate(typeof(Action<string>), new TestClass(), type.GetMethod("TestMethod"))

得到的委託效果與 firstArgument.TestMethod(arg1) 相同。
firstArgumentnull,且委託顯示包含方法的第一個隱藏引數(就是 this),則此時稱為開放的例項方法委託,使用方法為

Delegate.CreateDelegate(typeof(Action<TestClass, string>), null, type.GetMethod("TestMethod"))

得到的委託效果與 arg1.TestMethod(arg2) 相同。
firstArgumentnull,且委託的簽名與方法的簽名匹配,則此時稱為通過空引用封閉的委託,使用方法為

Delegate.CreateDelegate(typeof(Action<string>), null, type.GetMethod("TestMethod"))

這種用法比較奇怪,這種用法類似於對空例項呼叫例項方法(null.TestMethod(obj)),在方法體內得到的 this 就是 null,在實際當中不是很有用。
將以上六點總結來看,就是根據方法是靜態方法還是例項方法,以及委託與方法簽名的匹配方式就可以決定如何構造委託了。下面就是判斷的流程圖:

這裡寫圖片描述
圖2 方法委託的流程圖

對於開放的靜態或例項方法,可以使用上一節完成的方法;對於封閉的靜態或例項方法,做法也比較類似,只要將 firstArgument 作為靜態方法的第一個引數或者是例項使用即可;在流程圖中特地將通過空引用封閉的例項方法拿出來,是因為 Expression 不能實現對 null 呼叫例項方法,只能夠使用 Delegate.CreateDelegate 來生成委託,然後在外面再套一層自己的委託以實現強制型別轉換。這麼做效率肯定會更低,但畢竟這種用法基本不可能見到,這裡僅僅是為了保證與 CreateDelegate 的統一。

1.3 建立通用的方法委託

這裡我多加了一個方法,就是建立一個通用的方法委託,這個委託的宣告如下:

public delegate object MethodInvoker(object instance, params object[] parameters);

通過這個委託,就可以呼叫任意的方法了。要實現這個方法也很簡單,只要用 Expression 構造出類似於下面的方法即可。

object MethodDelegate(object instance, params object[] parameters) {
  // 檢查 parameters 的長度。
  if (parameters == null || parameters.Length != n + 1) {
    throw new TargetParameterCountException();
  }
  // 呼叫方法。
  return instance.method((T0)parameters[0], (T1)parameters[1], ... , (Tn)parameters[n]);
}

對於泛型方法,顯然無法進行泛型引數推斷,直接報錯就好;對於靜態方法,直接無視 instance 引數就可以。

public static MethodInvoker CreateDelegate(this MethodInfo method)
{
    ExceptionHelper.CheckArgumentNull(method, "method");
    if (method.IsGenericMethodDefinition)
    {
        // 不對開放的泛型方法執行繫結。
        throw ExceptionHelper.BindTargetMethod("method");
    }
    // 要執行方法的例項。
    ParameterExpression instanceParam = Expression.Parameter(typeof(object));
    // 方法的引數。
    ParameterExpression parametersParam = Expression.Parameter(typeof(object[]));
    // 構造引數列表。
    ParameterInfo[] methodParams = method.GetParameters();
    Expression[] paramExps = new Expression[methodParams.Length];
    for (int i = 0; i < methodParams.Length; i++)
    {
        // (Ti)parameters[i]
        paramExps[i] = ConvertType(
            Expression.ArrayIndex(parametersParam, Expression.Constant(i)),
            methodParams[i].ParameterType);
    }
    // 靜態方法不需要例項,例項方法需要 (TInstance)instance
    Expression instanceCast = method.IsStatic ? null :
        ConvertType(instanceParam, method.DeclaringType);
    // 呼叫方法。
    Expression methodCall = Expression.Call(instanceCast, method, paramExps);
    // 新增引數數量檢測。
    methodCall = Expression.Block(GetCheckParameterExp(parametersParam, methodParams.Length), methodCall);
    return Expression.Lambda<MethodInvoker>(GetReturn(methodCall, typeof(object)),
        instanceParam, parametersParam).Compile();
}

二、從 ConstructorInfo 建立建構函式的委託

建立建構函式的委託的情況就很簡單了,建構函式沒有靜態和例項的區分,不存在泛型方法,而且委託和建構函式的簽名一定是匹配的,實現起來就如同 1.1 建立開放的方法委託,不過這是用到的實 Expression.New 方法而不是 Expression.Call 了。

public static Delegate CreateDelegate(Type type, ConstructorInfo ctor, bool throwOnBindFailure)
{
    ExceptionHelper.CheckArgumentNull(ctor, "ctor");
    CheckDelegateType(type, "type");
    MethodInfo invoke = type.GetMethod("Invoke");
    ParameterInfo[] invokeParams = invoke.GetParameters();
    ParameterInfo[] methodParams = ctor.GetParameters();
    // 要求引數數量匹配。
    if (invokeParams.Length == methodParams.Length)
    {
        // 建構函式的引數列表。
        ParameterExpression[] paramList = GetParameters(invokeParams);
        // 構造呼叫引數列表。
        Expression[] paramExps = GetParameterExpressions(paramList, 0, methodParams, 0);
        if (paramExps != null)
        {
            Expression methodCall = Expression.New(ctor, paramExps);
            methodCall = GetReturn(methodCall, invoke.ReturnType);
            if (methodCall != null)
            {
                return Expression.Lambda(type, methodCall, paramList).Compile();
            }
        }
    }
    if (throwOnBindFailure)
    {
        throw ExceptionHelper.BindTargetMethod("ctor");
    }
    return null;
}

與通用的方法委託類似的,我也使用下面的委託

public delegate object InstanceCreator(params object[] parameters);

來建立通用的建構函式的委託,與通用的方法委託的實現也很類似。

public static Delegate CreateDelegate(Type type, ConstructorInfo ctor, bool throwOnBindFailure)
{
    ExceptionHelper.CheckArgumentNull(ctor, "ctor");
    CheckDelegateType(type, "type");
    MethodInfo invoke = type.GetMethod("Invoke");
    ParameterInfo[] invokeParams = invoke.GetParameters();
    ParameterInfo[] methodParams = ctor.GetParameters();
    // 要求引數數量匹配。
    if (invokeParams.Length == methodParams.Length)
    {
        // 建構函式的引數列表。
        ParameterExpression[] paramList = GetParameters(invokeParams);
        // 構造呼叫引數列表。
        Expression[] paramExps = GetParameterExpressions(paramList, 0, methodParams, 0);
        if (paramExps != null)
        {
            Expression methodCall = Expression.New(ctor, paramExps);
            methodCall = GetReturn(methodCall, invoke.ReturnType);
            if (methodCall != null)
            {
                return Expression.Lambda(type, methodCall, paramList).Compile();
            }
        }
    }
    if (throwOnBindFailure)
    {
        throw ExceptionHelper.BindTargetMethod("ctor");
    }
    return null;
}

三、從 PropertyInfo 建立屬性的委託

有了建立方法的委託作為基礎,建立屬性的委託就非常容易了。如果委託具有返回值那麼意味著是獲取屬性,不具有返回值(返回值為 typeof(void))意味著是設定屬性。然後利用 PropertyInfo.GetGetMethod 或 PropertyInfo.GetSetMethod 來獲取相應的 get 訪問器或 set 訪問器,最後直接呼叫建立方法的委託就可以了。

封閉的屬性委託也同樣很有用,這樣可以將屬性的例項與委託繫結。

對於屬性並沒有建立通用的委託,是因為屬性的訪問分為獲取和設定兩部分的,這兩部分難以有效的結合到一塊。

四、從 FieldInfo 建立欄位的委託

在建立欄位的委託時,就不能使用現有的方法了,而必須用 Expression.Assign 自己完成欄位的賦值。欄位的委託同樣可以分為開放的欄位委託和使用第一個引數封閉的欄位委託,其判斷過程如下:
這裡寫圖片描述
圖3 欄位委託流程圖

欄位的處理很簡單,就是通過 Expression.Field 訪問欄位,然後通過 Expression.Assign 對欄位進行賦值,或者直接返回欄位的值。圖中單獨列出來的“通過空引用封閉的例項欄位”,同樣是因為不能用程式碼訪問空物件的例項欄位,這顯然是個毫無意義的操作,不過為了與通過空引用封閉的屬性得到的結果相同,這裡總是丟擲 System.NullReferenceException

五、從 Type 建立成員委託

這個方法提供了建立成員委託的最靈活的方式,它可以根據給出的成員名稱、BindingFlags 和委託的簽名決定是建立方法、建構函式、屬性還是欄位的委託。

它的做法就是,依次利用 PowerBinder.Casttype 中查詢與給定委託簽名匹配的方法、屬性和欄位,並嘗試為每個匹配的成員構造委託(使用前面四個部分中給出的方法)。當某個成員成功構造出委託,那麼它就是最後需要的那個。

由於 PowerBinder 可以支援查詢泛型方法和顯式型別轉換,因此構造委託的時候也自然就能夠支援泛型方法和顯式型別轉換了。

DelegateBuilder 構造委託的方法算是到此結束了,完整的原始碼可見 DelegateBuilder.cs,總共大約 2500 行,不過其中大部分都是註釋和各種方法過載(目前有 54 個過載),VS 程式碼度量的結果只有 509 行。

相關文章