聊一下 C# 的 擴充方法(Extension Methods)

最近在重構 C# 的程式,剛好看到自己以前寫的擴充方法,就想說來聊聊這個我特別喜歡的語言特性

C# 的擴充方法(Extension Methods),這一特性讓開發者可以「擴充」任何類別、結構的方法,並且不破壞封裝性

它的概念非常單純,實際上就只是呼叫靜態類別而已
但因為它可以將方法掛在任何類別/結構上,使用起來的感覺非常好

擴充方法自 C# 3.0 開始支援,最早在 .NET Framework 3.5 跟著 LINQ 這個東西一起引入

我最早接觸 C# 是在 .NET Framework 4.0 的時候,因為只有寫 WinForm,所以幾乎沒用過這個功能
印象中當時 LINQ 主要用在 SQL 查詢,可以直接在程式上寫類似 SQL 的語法
但在其他方面則鮮少使用 (也許 ASP.NET 當時有使用,但我那時並沒有接觸 .NET 的 Web 開發)

這個功能的寫法非常簡單,就是撰寫一個靜態 Extension 類,並在擴充方法的第一個參數加上 this 指定要擴充的類別
只要引用 Extension 類所在的 namespace,C# 會自動把擴充方法掛到對應的類別上,可以像使用原生方法一樣使用擴充方法

範例如下:

StringExtension.cs
namespace Example.Extensions {
public static class StringExtensions {
public static string Indent(this string str, int size) {
string[] parts = str.Split();
for (int i = 0; i < parts.Length; i++) {
parts[i] = "".PadLeft(size, ' ') + parts[i];
}
return string.Join("\n", parts);
}
}
}
Program.cs
using Example.Extensions;

namespace Example {
public static class Program {
public static void Main(string[] args) {
string text = "Hello\nWorld";
Console.WriteLine(text.Indent(4));
}
}
}

由於擴充方法本身就是一個靜態方法,因此上面的程式實際上也可以寫成這樣:

Program.cs
using Example.Extensions;

namespace Example {
public static class Program {
public static void Main(string[] args) {
string text = "Hello\nWorld";
Console.WriteLine(StringExtensions.Indent(text, 4));
}
}
}

擴充方法的好處

用法直覺

擴充方法的好處就是使用起來非常直覺
而且可以去除冗長的靜態方法呼叫

如果是以前,要使用一些常用類的靜態方法只能用下面的方式呼叫:

string str = "abcdefg";
string rotatedStr = StringUtils.Rotate(str, 3); // efgabcd

但是有了擴充方法之後,就可以把常用類寫成擴充類,直接使用擴充方法:

string str = "abcdefg";
string rotatedStr = str.Rotate(3); // efgabcd

整個程式看起來就簡潔不少

方法鏈式呼叫 (Method chaining)

擴充方法最強大的部份在於它可以用於方法鏈式呼叫

比如說 LINQ 就是藉由統一返回 IEnumerable<T> 對象使 output 可以當作 input 傳入
搭配擴充方法對 IEnumerable<T> 進行擴充,實現鏈式呼叫

經典的鏈式呼叫例子:

int[] numbers = new[] {1, 2, 3, 4, 5};
int[] numbersBetween2And4 = numbers
.Where(n => n >= 2)
.Where(n => n <= 4)
.ToArray(); // [2, 3, 4]

簡化操作物件的流程

擴充方法可以用於簡化操作物件的流程

比如說鏈式呼叫,藉由擴充方法,可以把一些常用的鏈式呼叫包成一塊,讓程式可讀性提高

以前面的例子來說,可以將兩個 Where 包在一起變成 Between
所以前面的 numberBetween2And4 就可以直接寫成:

int[] numbers = new[] {1, 2, 3, 4, 5};
int[] numbersBetween2And4 = numbers.Between(2, 4).ToArray(); // [2, 3, 4]

像這樣簡化的設計在後期很常見
比如說在 EntityFramework 中就可以對 DbContextOptions 使用 UseNpgsql 擴充方法
僅僅一行就可以幫忙設定連線到 PostgresDB 所需的配置:

options
.UseNpgsql(connectionString) // 套用 postgresql
.UseLoggerFactory(logFactory) // 掛上 log 輸出

用擴充方法之前先想一想適不適合

當初我用 C# 寫自己的遊戲伺服器時,我在想,如果將封包的操作以擴充方法掛在遊戲物件上,這樣就可以隱藏封包發送的邏輯並且用起來也很直覺
但直到最近我在重構舊 Code 的時候,才意識到這是個壞點子

我發現,為了藉由玩家物件發送封包,我會在任何衍生的類別傳入玩家物件,讓依賴變得複雜
因為封包邏輯跟操作邏輯混在一起,看程式時我會開始疑惑使用的方法是要發送封包還是單純的遊戲邏輯
雖然可以在名稱上下功夫,比如 SendXXXPacket 之類的,不過我想表達的是,用過頭它會污染自動完成列表

我自己認為擴充方法適合幾種狀況:

  • 常用方法

    比如說範例的 Indent,或是 LINQ 本身就是非常好的例子

    如果 Java 有這個特性,我想寫起來就不會那麼冗長了 XD

  • 簡化對物件操作的流程

    對於一些常用的流程操作,就可以寫一個擴充方法來進行簡化

    例如說,我需要對某張地圖的玩家群體發送封包
    我就可以對玩家集合建立一個 Broadcast 擴充方法
    之後只要是任何玩家群體,我都可以直接呼叫 Broadcast 方法一次性的發送封包給多名玩家

簡而言之,擴充方法本身的意圖要非常明確,而且是針對被擴充的物件進行處理

所以在使用之前還是先想想這個方法的意圖,再想想適不適合使用擴充方法
不要為了使用擴充方法而使用,然後建立一堆垃圾擴充

後記

這篇文章其實是在坐車時想到的,因為最近都在重構舊 Code,想說剛好可以當個題材
原先預想大概一個小時就可以寫完了,結果寫到後面發現一些描述跟內容感覺不對,就又打掉重寫
最後花了我大概四個小時寫這篇 🙃

本來只是想簡單寫一下,但寫到一半又覺得內容太少,寫到結尾又覺得哪裡不對,結果就變成這樣了 XD