《APPX時賦科技》實習遊記(Ⅲ)-C# .NET中的泛型及其相關應用(下)

延續先前的文章,介紹反射(Reflection)在泛型上的應用

柯基
appxtech
Published in
18 min readMar 31, 2023

--

目錄
一、前情提要
二、實作上的考量
三、反射 ( Reflection )
1. Property
2. Method
3. Generic Method?
四、補充:dynamic
五、結語
六、參考文章/延伸閱讀

這篇延續同系列前兩篇的內容,介紹實作泛型時好用的技巧-反射(Reflection)。由於這篇的內容是我從實習期間所做的專案中做發想,因此會較偏實作取向,對泛型還不熟悉的朋友可以先從上篇看起,已經熟稔泛型概念的朋友則可以直接往下看~

這系列的內容皆發想自我在時賦科技擔任後端實習時,在各種專案中的經驗總結,對其他技術有興趣或想更了解時賦科技的朋友,請前往以下Medium連結!

前情提要

泛型是一種概念。主要實現方法是使用型別參數 ( Type parameter ) 取代原先在函式、類別、介面等等之中不變的型別。對於傳入型別的相關限制,可使用泛型約束,讓程式在編譯時期即可攔下不合法的型別。對於取得關於型別參數的資訊,可使用Type函式庫的各種操作,結合物件導向多型的概念,設計安全、方便、好用的泛型實作。

實作上的考量

前兩篇都有提到泛型使用上的問題,在這裡再提一次。

  1. 型別是否可為Null?
  2. 型別是否存在該變數?
  3. 型別是否存在該方法?
  4. 型別是否相容函式內其他呼叫?

根據先前所提,第1點可以使用Type來判斷型別是否為Nullable,第4點則可以使用泛型約束做編譯時期的簡易限制。至於第2及第3點,便是我們今天所要探討的。此外,伴隨著這兩個問題,可以很容易發現另一個隨之而生的疑惑:

該如何使用型別內的這些變數及方法?

理所當然的,確認一件事物的存在,必定是有所需求。以汽車為例,購車車主確定了汽車的廠牌、顏色、牌照正確(Type),並確定方向盤、手煞車、方向燈、安全帶等配置皆正常安裝(反射),最後還確定了家裡車庫停得下這部車(泛型約束),正欣喜欲狂準備開新車兜風時,卻發現車商沒給車鑰匙!別說開車,連車門都打不開了,檢查如此周詳何用之有?

當然,微軟不可能開發一個賣車不賣車鑰匙的語言,反射 ( Reflection ) 不僅可以檢查型別中變數或方法的存在,還可對變數進行存取,或呼叫對應的方法,下面就來介紹這個埋哽兩篇的好用函式庫。

方向盤好比型別內的方法,有車鑰匙才碰得到方向盤 Photo by Oliur on Unsplash

反射 ( 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();

東西有點多,先來列出我們的搜索條件

  1. 名字要是Accelerate
  2. 一個型參及一個參數
  3. 參數的型別要與型參的值相同

如先前所述,有時基本的搜索條件無法滿足我們時,就必須將所有結果撈出來,並進行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

透過以上例子可以得到以下資訊:

  1. T.IsGenericType == False: 證明了T是一個實實在在的型別。
  2. T.ContainsGenericParameters == True: 與一般型別不同,T仍有未被特定型別取代的參數(其實就是他自己啦)。
  3. 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 同樣的效果了。

說到這裡,應該會出現兩個問題:

  1. 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編譯過並順利執行,可放心食用,也可自行放到本地嘗試。

未來會繼續分享各種技術,還請各位多多指教!

參考文章/延伸閱讀

--

--