標籤:
首先需要明確一點:這裡提到的可變參數方法,指的是具有 CallingConventions.VarArgs 呼叫慣例的方法,而不是包含 params 參數的方法。可以通過MethodBase.CallingConvention 屬性來擷取某個方法的呼叫慣例。
舉個常見的例子來說,C 語言的 printf 方法大多數人應該都知道,它的作用是向標準輸出資料流(stdout)寫入格式化字串,printf 的方法簽名是:
int printf(const char * format, ...);
方法簽名中的 ...,就表示這個方法是可變參數的,可以根據需要傳遞任意個數的參數,參數的類型也可以互不相同。
C# 中的 params 參數則具有更強的約束,雖然參數個數可以不固定,但參數的類型必須都是相同的。而實際上,C# 中也可以聲明如 C 語言的那種可變參數,只不過大多用於調用非託管 dll 提供的方法,而不是用於託管方法。本文會從 P/Invoke、C# 中可變參數方法的聲明、IL 代碼和 RuntimeArgumentHandle 四個方面介紹可變參數方法。
一、可變參數方法的 P/Invoke
如果一個非託管 dll 提供了一個可變參數方法,該如何在 C# 中調用它?
最簡單的辦法顯然是按需調用——儘管提供的方法是可變參數的,但我可能並不需要那麼多的自由,只需要一種或幾種固定的參數就好。這種情況下,方法的簽名直接按照需要去寫就好,還是以 printf 為例:
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]public static extern int printf(string format, string text);[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]public static extern int printf(string format, int num, int x, int y);// 調用方法printf("Hello %s!\n", "World"); // Hello World!printf("Hello %d! is %d x %d\n", 42, 6, 7); // Hello 42! is 6 x 7
需要注意的是,DllImport 需要顯式指定 CallingConvention = CallingConvention.Cdecl,這樣會由調用方清理堆棧,才能支援可變參數的方法。
如果的確需要完整的可變參數方法呢?可以使用一些特殊的關鍵字來做到這一點,這些關鍵字並未給出官方文檔,但確實存在於 C# 編譯器中。如下定義printf 方法,注意參數使用的是 __arglist 關鍵字,並未指定任何參數類型和參數名稱:
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]public static extern int printf(string format, __arglist);
調用方法時,也必須將可變參數用 __arglist() 括起來:
printf("Hello %s!\n", __arglist("World")); // Hello World!printf("Hello %s! is %d x %c\n", __arglist("World", 6, ‘7‘)); // Hello World! is 6 x 7
這裡要區分 __arglist 和 __arglist(),__arglist 是用於可變參數方法的聲明和方法體內引用可變參數的,而 __arglist() 是用與可變參數方法的調用的。注意第二個樣本,三個參數的類型各不相同,分別是字串、整數和字元。
二、C# 中的可變參數方法
上面說到了非託管 dll 能夠提供可變參數方法,C# 也能調用這樣的方法,那麼 C# 自身是否能聲明這樣的方法?
答案其實很明顯,既然 __arglist 關鍵字能在 P/Invoke 方法中使用,顯然也能在普通的方法中使用。只不過這時需要使用 ArgIterator 結構和TypedReference 結構來訪問參數,而不是普通的參數存取方法。
先來看一段簡單的樣本:
public static void printf(string format, __arglist) { Console.Write(format); ArgIterator args = new ArgIterator(__arglist); while (args.GetRemainingCount() > 0) { Console.WriteLine("{0}: {1}", Type.GetTypeFromHandle(args.GetNextArgType()), TypedReference.ToObject(args.GetNextArg())); }}printf("Hello %s! is %d x %c\n", __arglist("World", 6, ‘7‘));// Hello %s! is %d x %c// System.String: World// System.Int32: 6// System.Char: 7
這段樣本中,已經把可變參數的用法基本都展示出來了,下面再來簡單介紹一下。
首先是構造 ArgIterator 執行個體,就是通過調用建構函式 new ArgIterator(__arglist)。
然後是遍曆可變參數,ArgIterator.GetRemainingCount 方法能夠返回可變參數列表中剩餘的參數個數,並且每次調用 ArgIterator.GetNextArg 方法擷取下一個參數時都會自動減一。
下一個參數的目標類型可以利用 ArgIterator.GetNextArgType 方法擷取,這個方法不會使迭代前進到下一個參數(比較類似於 Peek 方法)。需要注意得到的結果是 RuntimeTypeHandle 結構,需要使用 Type.GetTypeFromHandle 方法才能拿到能用的類型;而且並非是參數的實際類型,僅僅是調用方法時__arglist() 中指定的目標參數類型。例如:
printf("Hello %s! is %d x %c\n", __arglist((IEnumerable<char>)"World", (object)6, (IComparable<char>)‘7‘));// Hello %s! is %d x %c// System.Collections.Generic.IEnumerable`1[System.Char]: World// System.Object: 6// System.IComparable`1[System.Char]: 7
得到的參數值的類型是 TypedReference,需要使用 TypedReference.ToObject 靜態方法才能得到參數的實際值。需要注意 TypedReference 的另外兩個靜態方法:GetTargetType 和 TargetTypeToken,它們與 ArgIterator.GetNextArgType 方法一樣只能得到調用時的目標參數類型。
關於 TypedReference 還有一些未公布的關鍵字,但它們並不建議使用,因為一般用不到這些功能,或者有可替代的託管方法。
__makeref,用於建立 TypedReference 執行個體:
string str = "any value";TypedReference typeRef = __makeref(str);
__refvalue,用於擷取或設定 TypedReference 執行個體的值,要求類型必須與 TypedReference 的目標類型完全相同,而且用法完比較怪異:
__refvalue(typeRef, string) = "other value";Console.WriteLine(__refvalue(typeRef, string));
注意這裡仍然是目標類型,並非是值的實際類型:
object str = "any value";TypedReference typeRef = __makeref(str);__refvalue(typeRef, object) = "other value";Console.WriteLine(__refvalue(typeRef, object));
__reftype,用於擷取 TypedReference 的目標類型,與 TypedReference.GetTargetType 等價:
Console.WriteLine(__reftype(typeRef));
這裡再強調一遍,除了 __arglist 關鍵字之外,其它關鍵字不建議使用。Visual Studio 2013 的語法檢查可以識別 __arglist 關鍵字,其它關鍵字會提示法錯誤(但能夠編譯通過)。
C# 中的可變參數方法具有以下特點:
- 可變參數方法是不符合 CLS 規範 的。
- 介面可以聲明可變參數方法,可變參數方法也可以是 virtual 方法,並能夠由子類重寫。
- 通過反射擷取的參數個數,只會包含固定參數(
__arglist 之前的參數)。因為 __arglist 僅僅代表方法的呼叫慣例,並不是實際的參數。
- 可變參數方法可以包含
0 個固定參數,即聲明類似 void MyMethod(__arglist) 的方法。
__arglist 不能用在委託中。
三、可變參數方法的 IL 代碼
上面從 C# 語言的角度介紹了可變參數方法,最後來剖析一下它的 IL 原理。
可變參數方法的調用,同樣是使用 call 指令和 callvirt 指令,但需要明確指定參數類型。例如printf("Hello %s! is %d x %c\n", __arglist("World", 6, ‘7‘)); 對應的 IL 代碼如下所示:
IL_0000: ldstr "Hello %s! is %d x %c\n"IL_0005: ldstr "World"IL_000A: ldc.i4.6IL_000B: ldc.i4.s 55IL_000D: call void Cyjb.TestProgram::printf(string, string, int32, char)
簡單解釋一下,就是按順序將四個參數(一個固定參數和三個可變參數)推送到堆棧上,最後調用方法。可以看到 __arglist() 的作用就是展開方法參數,並且填充參數類型。注意這裡將所有四個參數的類型都寫入了 IL,才能正確調用可變參數的方法,這也是為什麼特別提供了 ILGenerator.EmitCall 方法來調用可變參數的方法。
public static void printf(string format, __arglist) 方法聲明的 IL 代碼如下所示:
.method public hidebysig static vararg void printf (string format) cil managed
注意這裡方法的參數實際上只有一個固定參數 format,只不過在方法的簽名部分多了一個 vararg,表示方法是可變參數的,與反射得到的結果相同。
方法體中倒沒有什麼特殊的地方,同樣是調用 ArgIterator 和 TypedReference 的相關方法,不過用到了 arglist 指令來為 ArgIterator 建構函式 提供參數,該指令就是由 __arglist 關鍵字而來的,其作用是返回指向可變參數列表的非託管指標。
上面提到的 __makeref、__refvalue 和 __reftype 關鍵字,則分別對應於 mkrefany、refanyval 和 refanytype 指令,這裡不再詳述。
四、RuntimeArgumentHandle
前面說到,委託中是不能使用 __arglist 關鍵字的,那麼如果為可變參數方法建立委託呢?如果注意看 ArgIterator 的建構函式,可以發現它的參數是一個RuntimeArgumentHandle 結構,這個結構中包含一個指向可變參數的參數列表的指標。
因此,完全可以使用 RuntimeArgumentHandle 來代替方法聲明中的 __arglist 關鍵字,如下所示:
public static void printf(string format, RuntimeArgumentHandle handle) { ArgIterator args = new ArgIterator(handle); // 其它代碼}
與 public static void printf(string format, __arglist) 聲明具有完全相同的效果,而且 RuntimeArgumentHandle 完全可以用在任何地方。
但是這個 printf 方法的調用卻是個很大的問題,因為我們無法建立有效 RuntimeArgumentHandle 結構的執行個體(它沒有含帶參數的建構函式),而且__arglist("World", 6, ‘7‘) 這樣使用也是不可以的(從上面的 IL 代碼可以看出,__arglist() 的作用是將參數展開)。
要調用這樣的方法,必須再封裝一層包含 __arglist 的方法:
public static void Wrap(string format, __arglist) { printf(format, __arglist);}
可以認為,方法體中的 __arglist 關鍵字就是一個隱式建立的 RuntimeArgumentHandle 執行個體,甚至可以直接 RuntimeArgumentHandle handle = __arglist;這樣使用。
這樣做看起來的確是多此一舉,但如果要調用包含 RuntimeArgumentHandle 參數的委託,也只有這一種辦法了,普通方法更適合繼續使用 __arglist。
C# 中的可變參數方法(VarArgs)