目錄
一、前情提要
二、實作上的考量
三、反射 ( Reflection )
1. Property
2. Method
3. Generic Method?
四、補充:dynamic
五、結語
六、參考文章/延伸閱讀
這篇延續同系列前兩篇的內容,介紹實作泛型時好用的技巧-反射(Reflection)。由於這篇的內容是我從實習期間所做的專案中做發想,因此會較偏實作取向,對泛型還不熟悉的朋友可以先從上篇看起,已經熟稔泛型概念的朋友則可以直接往下看~
這系列的內容皆發想自我在時賦科技擔任後端實習時,在各種專案中的經驗總結,對其他技術有興趣或想更了解時賦科技的朋友,請前往以下Medium連結!
前情提要
泛型是一種概念。主要實現方法是使用型別參數 ( Type parameter ) 取代原先在函式、類別、介面等等之中不變的型別。對於傳入型別的相關限制,可使用泛型約束,讓程式在編譯時期即可攔下不合法的型別。對於取得關於型別參數的資訊,可使用Type函式庫的各種操作,結合物件導向多型的概念,設計安全、方便、好用的泛型實作。
實作上的考量
前兩篇都有提到泛型使用上的問題,在這裡再提一次。
- 型別是否可為Null?
- 型別是否存在該變數?
- 型別是否存在該方法?
- 型別是否相容函式內其他呼叫?
根據先前所提,第1點可以使用Type來判斷型別是否為Nullable,第4點則可以使用泛型約束做編譯時期的簡易限制。至於第2及第3點,便是我們今天所要探討的。此外,伴隨著這兩個問題,可以很容易發現另一個隨之而生的疑惑:
該如何使用型別內的這些變數及方法?
理所當然的,確認一件事物的存在,必定是有所需求。以汽車為例,購車車主確定了汽車的廠牌、顏色、牌照正確(Type),並確定方向盤、手煞車、方向燈、安全帶等配置皆正常安裝(反射),最後還確定了家裡車庫停得下這部車(泛型約束),正欣喜欲狂準備開新車兜風時,卻發現車商沒給車鑰匙!別說開車,連車門都打不開了,檢查如此周詳何用之有?
當然,微軟不可能開發一個賣車不賣車鑰匙的語言,反射 ( Reflection ) 不僅可以檢查型別中變數或方法的存在,還可對變數進行存取,或呼叫對應的方法,下面就來介紹這個埋哽兩篇的好用函式庫。
反射 ( Reflection )
「反射」這個詞充分傳達了它所做的事。簡單來說,反射允許我們存取.NET二次編譯出的metadata,包括物件或類別的相關資訊(見下方註解),更具體來說,透過變數或方法的名稱,我們就可以取得相關資訊,甚至是針對某個物件,取得該名稱變數的值,或對該物件執行對應名稱的方法。
前面所提及的 Type 由於同樣是取自物件的 metadata ,定義上同樣屬於反射的一員。在此因本篇介紹之反射應用與Type相差不少,因此分成兩篇做介紹。
透過使用反射,我們不需建構實體物件,就可對物件的屬性進行分析(在此不討論靜態類別)。就像把metadata當作一面鏡子,即使我們無法直接看見型別內的屬性,但也可以透過這面鏡子「反射」所看到。
這,就是反射。(此處僅為筆者本人見解,絕對不是真正的命名原因 XD)
Property
先宣告一個車子的類別,以方便下面舉例
class Car {
public int Price { get; set; }
public string Brand { get; set; }
public string Color { get; set; }
}
以往若要取得或設定非靜態類別中的變數,必定得建構一個物件,並以下列的方式呼叫:
Car car = new();
car.Price = 1000000
car.Brand = "Tesla"
car.Color = "White"
事實上這樣並沒有錯,想要查看或修改物件的變數,總不可能物件本身不存在吧?
然而,當這種做法遇上泛型時,就束手無策了。
public string GetVehicleColor<T>(T vehicle)
{
return vehicle.Color; // Compile error: 找不到Color的定義
}
由於在編譯期間,編譯器無法確定傳入的型別參數是否含有名叫Color的公共變數,只好在此先攔下這樣的寫法。
此時,為了讓編譯器安心,反射便要登場了。先看結果:
public string GetVehicleColor<T>(T vehicle)
{
Type vehicleType = vehicle.GetType(); // 取得Type
PropertyInfo prop = vehicleType.GetProperty("Color"); // 取得PropertyInfo
if(prop == null) return ""; // 若沒找到則回傳空字串
return prop.GetValue(vehicle).ToString(); // 回傳物件中對應property的值
}
對Type使用GetProperty並帶入想取得的變數名字,便可得到名為 PropertyInfo 的物件。
注意:取出 PropertyInfo 後,務必要檢查取出結果是否為null。反射本身因透過字串動態存取metadata,編譯器無法進行檢查,因此必須自行維護程式。
PropertyInfo 允許我們存取型別中成員變數的metadata,但由於此資訊是從型別的Type中所取得,並非取自於物件,因此若要取得物件的對應資訊,函式就需帶入此型別所創建的物件。此處便是使用GetValue時帶入vehicle物件,讓反射可以從vehicle物件中取得名叫Color的成員變數值。相同的,PropertyInfo 也可以使用SetValue,依序帶入物件及設定值來修改物件中對應變數的值,因性質相似在此不舉例說明。
在取得 PropertyInfo 時,可以在參數中帶入 BindingFlags 對搜索加上一層條件。BindingFlags 是一串Enum,使用位元紀錄Property查詢的設定。用法如下:
PropertyInfo prop = vehicleType.GetProperty(
"Color",
BindingFlags.Public | BindingFlags.IgnoreCase
);
此處指定了搜索條件為public的成員變數,且忽略搜索字串的大小寫。諸如此類的方法還有Static或NonPublic等等,且由於是使用or, NonPublic 和 Public 並不會互相衝突。要注意的是,多個Flag應用or位元運算子 ( | ) 來連結。
若你不滿於字串+Flags的搜索方式,當然可以使用GetProperties取得所有PropertyInfo,並使用LINQ進行想要的搜索,隨便舉個例子:
PropertyInfo prop = vehicleType.GetProperties()
.Where(p => p.Name.ToLower() == "Color".ToLower())
.Where(p => p.PropertyType.IsPublic).Single();
此範例利用LINQ達成了相同的結果。事實上,GetProperties 中也可以帶入Flags來做初步的篩選,讓Reflection的使用上更加彈性與美觀。
Method
我們先擴充上方汽車的例子,方便下面舉例:
enum TurnSignal
{
LEFT,
RIGHT,
NONE
}
class Car {
public int Price { get; set; }
public string Brand { get; set; }
public string Color { get; set; }
public double Velocity { get; private set; } = 0;
// 新增以下打方向燈、煞車及加速的函式
public TurnSignal Signal { get; private set; } = TurnSignal.NONE;
public void SetTurnSignal(TurnSignal signal)
{
if(signal == Signal) Signal = TurnSignal.NONE;
else Signal = signal;
}
public void Break()
{
Velocity -= 5;
}
public void Break(int v)
{
Velocity -= v;
}
public void Accelerate()
{
Velocity += 10;
}
// 約束泛型一定可以轉型成double(不嚴謹)
public void Accelerate<T>(T v) where T : struct, IConvertible
{
Velocity += double.Parse(v.ToString());
}
}
取得方法的方式跟取得變數並無兩異,這裡直接上例子:
public void UseVehicleTurnSignal<T>(T vehicle, TurnSignal signal)
{
Type vehicleType = vehicle.GetType(); // 取得Type
MethodInfo method = vehicleType.GetMethod("SetTurnSignal"); // 取得MethodInfo
if(method == null) return; // 若沒找到則回傳
method.Invoke(vehicle, new object[] { signal }); // 執行對應物件並傳入參數
}
這裡我們使用了GetMethod傳入方法名稱取得MethodInfo,並做簡單的null檢查。此處的取得方法跟前面一樣,可以使用Flags及GetMethods+LINQ 來進行客製化的查詢,此處不覆述。
與Property需要Get/Set不同,Method只有被呼叫的操作,因此需要使用MethodInfo中的Invoke來執行此方法。其中,參數必須帶入執行此方法的物件(畢竟在各個物件上執行相同的方法,產生的結果也會不同),以及用object[]進行boxing的參數陣列。若方法沒有任何參數,傳入null或空的object[]都可以被接受。
針對如Break這類被多載的方法,也可透過帶入參數型態來指定,見以下例子:
public void BreakCustomSpeed<T>(T vehicle, int breakSpeed)
{
Type vehicleType = vehicle.GetType(); // 取得Type
// 使用函式名稱和參數型態取得MethodInfo
MethodInfo method = vehicleType.GetMethod("Break", new Type[] { typeof(int) });
if(method == null) return; // 若沒找到則回傳
method.Invoke(vehicle, new object[] { breakSpeed }); // 執行對應物件並傳入參數
}
使用Type[]指定參數時,務必按照順序填入。若要選取多載中的無參數版本,也必須帶入空的Type[],否則會有模糊定義的Exception。
嗯,好像沒了?似乎型別參數內沒有我們抓不到的東西了?
雖然我們一直在談反射,但別忘了主題是泛型的應用。
Generic Method ?
以一個泛型的類別為例,既然都做成泛型了,裡面一定有幾個方法也是需要傳入型別參數的。此時若要從這個類別中取得泛型方法,就不是單單名字和參數便可以解決的了。
以汽車類別為例子,Accelerate多載了一個無參數及一個泛型的版本,要避免模糊定義取得泛型版本的Accelerate,可以使用以下做法:
MethodInfo method = vehicleType.GetMethods().Where(m =>
{
var parameters = m.GetParameters(); // 取得參數資訊(ParameterInfo[])
var args = m.GetGenericArguments(); // 取得型別參數(Type[])
return m.Name == "Accelerate" // 篩選名字
&& parameters.Length == 1 // 篩選參數只有一個
&& args.Length == 1 // 篩選型參只有一個
&& parameters[0].ParameterType == args[0]; // 篩選參數的型別為型參的值(在此為T)
}).Single();
東西有點多,先來列出我們的搜索條件
- 名字要是Accelerate
- 一個型參及一個參數
- 參數的型別要與型參的值相同
如先前所述,有時基本的搜索條件無法滿足我們時,就必須將所有結果撈出來,並進行LINQ查詢。
首先,使用GetMethods將所有方法取出,針對每個方法m,使用GetParameters取得關於所有參數的資訊,並再用GetGenericArguments取得型別參數的型別,此時,取出的型別可以是T本身,不需要帶入任何已知的型別去做判斷,就像當做一個普通的型別一樣正常使用,具體地說,便是在判斷完名字、參數及型參的長度皆為1之後,直接取得args中的T,並與第一個參數的型別 ( ParameterType) 做比較,由於從參數中取得的型別也是T,因此可以順利拿到泛型方法Accelerate<T>。
關於「取出的型別可以是T本身」可能有些不直覺,畢竟「參數本身」並不是一個型別。但若我們針對取出的T做以下驗證:
Type T = method.GetGenericArguments()[0];
Console.WriteLine(T.IsGenericType); // False
Console.WriteLine(T.ContainsGenericParameters); // True
Console.WriteLine(T.IsGenericMethodParameter); // True
透過以上例子可以得到以下資訊:
- T.IsGenericType == False: 證明了T是一個實實在在的型別。
- T.ContainsGenericParameters == True: 與一般型別不同,T仍有未被特定型別取代的參數(其實就是他自己啦)。
- T.IsGenericMethodParameter == True: T並非獨立存在,而是來自某個泛型方法 ( Accelerate<T> ) 的型別參數。
以上的驗證證明了此處取出的T與一般型別的差別了,或許由於上述第二點的關係,不能像其他型別使用 Activator.CreateInstance() 來創建物件,但在做為搜索的條件上,也算是堪用了。
關於執行此泛型方法,要多帶入型別參數才可執行,如下所示:
method = method.MakeGenericMethod(typeof(float));
method.Invoke(vehicle, new object[] { 3.14159 });
對MethodInfo使用MakeGenericMethod並帶入Type物件 ( Recall: MakeGenericType ),可達到帶入型別參數的效果,此處的操作便可等價於Accelerate<float>(3.14159),完成我們的目的。
使用反射取得泛型方法並執行的完整範例程式碼如下:
public void AccelerateVehicle<T>(T vehicle, float speed)
{
Type vehicleType = vehicle.GetType();
MethodInfo method = vehicle.GetType().GetMethods().Where(m =>
{
var parameters = m.GetParameters();
var args = m.GetGenericArguments();
return m.Name == "Accelerate"
&& parameters.Length == 1
&& args.Length == 1
&& parameters[0].ParameterType == args[0];
}).Single();
if(method == null) return;
method = method.MakeGenericMethod(typeof(float));
method.Invoke(vehicle, new object[] { speed });
}
透過反射各種取得Property和Method的方法,結合先前介紹的Type進行條件判斷,泛型的型參內幾乎沒有東西是我們用不了的了。
補充:dynamic
在第一篇介紹泛型的概念時,曾提過這個例子
public static T3 GetKineticEnergy<T1, T2, T3> (T1 m, T2 v)
{
return m * v * v / 2;
// Compilation error: Operator '*' cannot be applied to operands of type 'T1' and 'T2'
}
當時有於註釋提及C#泛型並不支援算術運算子。相反地,若有接觸過C++ template的朋友,應該知道以下寫法可以順利編譯並執行:
template<typename T1, typename T2, typename T3>
T3 GetKineticEnergy(T1 m, T2 v){
return m * v * v / 2;
}
cout << GetKineticEnergy<double, int, int>(5.5, 2) << endl;
其中的原因也很簡單。.NET 是一門主力於網頁開發的語言,既然要走開發,編譯器對於型別的檢查便需更加謹慎,因此即使你在呼叫泛型方法時,傳入已重載運算子的型別,編譯器在編譯時期仍不確定型別參數T是否有對應的運算子可以用,所以先把你攔下來,以免日後出大事。相對的,C++把這類的檢查放到執行時期再進行,因此可以順利編譯,但能不能執行又是另一回事。
問題來了,這麼方便的做法,我們可以在C#中做到同樣的效果嗎?
見以下程式碼:
public dynamic GetKineticEnergy(dynamic m, dynamic v){
return m * v * v / 2;
}
dynamic做為型別,在編譯時期跳過型別檢查,並在執行時期動態決定型別(也稱作Late-binding)。由於完全省略了所有對型別的檢查,透過這樣的寫法,可以避開編譯器對物件和運算子的檢查,自然而然就達成與 C++ template 同樣的效果了。
說到這裡,應該會出現兩個問題:
- var 與 dynamic 有什麼差別?
var: 執行期間決定型別,一旦確定後便不再更動(類似於C++的auto)。
dynamic: 執行期間決定型別,並且可隨時改變型別(類似於Python, javascript)。
下面的例子可以說明兩者的差異:
var v = 10;
v = "V"; // Compilation Error: Cannot implicitly convert type 'string' to 'int'
dynamic d = 10;
d = "V"
2.為何不用dynamic取代所有泛型型別?
泛型:傳入後決定型別,並一旦確定後便不再更動。
dynamic:傳入後決定型別,並且可隨時改變型別。
與前面的概念相似。雖然dynamic完全可以取代大部分泛型可以做到的事,但差別在於dynamic可以隨時動態轉換成其他型別,導致型別上的不安全,同時,dynamic也存在和object一樣boxing & unboxing的問題。講得白話點,就是dynamic太會變了,讓人很難管理。再者,若今天程式碼又冗又長,同時又要維護dynamic內到底藏了什麼型別,只是徒增工作量和系統效能罷了。
結語
終於把這三篇文收尾了,可喜可賀。
這篇的內容其實是發想自當初在做的專案,也是起初打算著重介紹的重點。但沒想到對前導的概念介紹太過龜毛,一個不小心就拖了兩篇,加上在寫這篇時剛好在做另一個案子,因此拖了將近一個月。雖然文件上的查詢也是花了不少時間,不過真的得好好檢討一下內容規劃的能力(汗)。
這系列的文是筆者第一次撰寫技術分享文,內容可能有些過冗或敘述錯誤的地方,還請各位不吝指教及指正。另外,本系列中的範例程式碼皆有使用.Net 6編譯過並順利執行,可放心食用,也可自行放到本地嘗試。
未來會繼續分享各種技術,還請各位多多指教!