1. 回呼 (callback) 與非同步程式設計
回呼(callback)— 是把某個方法傳進去,等某個操作完成後呼叫它的機制。常見於非同步操作、計時器、資料處理、以及使用者介面相關的情境。
範例 1:帶回呼的非同步操作
假設我們有個應用,使用者輸入查詢,結果會有延遲(例如從網路取得)。拿到資料後我們要更新畫面。
// 用於接收資料的委派
public delegate void DataReceivedHandler(string result);
// 非同步下載資料的機制(模擬)
public void DownloadDataAsync(DataReceivedHandler callback)
{
// 假設下載需要一些時間(用計時器模擬)
Task.Delay(1000).ContinueWith(_ =>
{
string data = "搜尋結果: <資料>";
callback(data); // 呼叫回呼委派
});
}
// 使用方式:
DownloadDataAsync(result =>
{
Console.WriteLine("已收到: " + result);
});
這種做法讓後續的邏輯完全和取得資料的機制分離,程式會更有彈性。
2. 將委派當作方法參數:策略與比較器
常見情境:讓使用者把「邏輯」(一個函式)傳進你的方法,讓使用者自己決定如何比較、過濾或轉換元素。
範例 2:用委派實作 Strategy 模式
假設我們有一個排序,但希望使用者可以用不同方式排序 — 例如按照名字、日期、大小等。
public delegate bool CompareFunc(int a, int b);
public void BubbleSort(int[] arr, CompareFunc compare)
{
for (int i = 0; i < arr.Length; i++)
{
for (int j = 0; j < arr.Length - 1; j++)
{
if (compare(arr[j], arr[j + 1]))
{
// 交換位置
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
// 從大到小排序的比較器
CompareFunc descending = (a, b) => a < b;
// 使用方式
int[] numbers = { 3, 1, 4, 2 };
BubbleSort(numbers, descending);
Console.WriteLine(string.Join(", ", numbers)); // 會輸出:4, 3, 2, 1
這個技巧是把自己的「策略」注入到別人的程式裡而不改動原始碼的通用方式。
3. 匿名方法、lambda 表達式與委派
隨著 C# 發展,為了每件事都寫一個類別或方法變得很不方便。幸好有匿名方法與 lambda,可以在需要時動態產生委派。
範例 3:用 lambda 當委派
Func<int, int, int> operation = (x, y) => x * y;
int result = operation(3, 5); // 15
範例 4:根據名稱選擇運算(switch + Delegates)
Func<int, int, int> op;
string userInput = "sum"; // "sub", "mul", "div"
switch (userInput)
{
case "sum": op = (a, b) => a + b; break;
case "sub": op = (a, b) => a - b; break;
case "mul": op = (a, b) => a * b; break;
case "div": op = (a, b) => a / b; break;
default: throw new Exception("未知的運算!");
}
Console.WriteLine(op(6, 2));
別忘了驗證輸入 — 委派可以帶來彈性和可讀性。
4. 委派與處理鏈(“責任鍊”)
因為委派支援多址(multicast),所以很容易組成處理器的鏈條。
範例 5:文字過濾器的串聯
假設我們有一組「過濾器」,要依序處理字串。
public delegate string StringFilter(string input);
string RemoveDigits(string input) => new string(input.Where(ch => !char.IsDigit(ch)).ToArray());
string ToUpper(string input) => input.ToUpper();
StringFilter filters = RemoveDigits;
filters += ToUpper;
// 委派會把字串依序傳給每個過濾器
string text = "嗨123";
foreach (StringFilter filter in filters.GetInvocationList())
{
text = filter(text);
}
Console.WriteLine(text); // 會輸出: "嗨"(注意:ToUpper 對中文不會有大小寫改變)
重要:如果直接呼叫 filters(text),會只回傳最後一個處理器的結果,而不是整個串聯的結果!若要讓值「流經」每一個處理器,像上面那樣顯式用 GetInvocationList() 迭代。
5. 委派用來在執行時綁定行為
過去要替換行為常常要寫新的類別或介面。用委派與 lambda,可以把很多「小型」多態行為用函式表達出來。
範例 6:機器人行為的動態命令
public class Robot
{
public event Action<string>? OnCommandReceived;
public void ReceiveCommand(string command)
{
OnCommandReceived?.Invoke(command);
}
}
// 使用方式:
var robot = new Robot();
robot.OnCommandReceived += cmd => Console.WriteLine($"機器人執行:{cmd}");
robot.OnCommandReceived += cmd =>
{
if (cmd == "開機")
Console.WriteLine("系統啟動中...");
};
// 嘗試一下
robot.ReceiveCommand("開機");
robot.ReceiveCommand("向前移動");
這種手法常用在測試、原型、DI(dependency injection)容器,以及把商業邏輯當成參數傳遞。
6. 委派當作狀態訂閱
假設有個類別保存某個狀態,當它改變時我們想通知所有訂閱者。用委派(或事件)可以很簡單做到。
範例 7:帶變化通知的類別
public class Notifier<T>
{
private T _value = default!;
public event Action<T>? ValueChanged;
public T Value
{
get => _value;
set
{
if (!Equals(_value, value))
{
_value = value;
ValueChanged?.Invoke(_value);
}
}
}
}
// 使用方式:
var intValue = new Notifier<int>();
intValue.ValueChanged += v => Console.WriteLine($"新值:{v}");
intValue.Value = 5; // 會觸發事件
intValue.Value = 10;
這個模式幾乎是「精簡版的反應式程式」,是 MVVM、data binding 和很多現代 UI 框架的基礎。
7. 委派、閉包與詞法作用域
lambda 與匿名方法可以捕捉外部上下文的變數(closure)。這很方便,但有時會導致意想不到的錯誤。
範例 8:捕捉變數與迴圈陷阱
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var a in actions)
a(); // 會輸出三次 3 (!)
為什麼會這樣?因為閉包參考的是同一個變數 i,迴圈結束後它的值是 3。如果想要記住當時的值 0, 1, 2,該怎麼辦?
for (int i = 0; i < 3; i++)
{
int loopValue = i; // 把當前值「凍結」起來
actions[i] = () => Console.WriteLine(loopValue);
}
現在程式會照預期運作。這類陷阱是新手使用 lambda 時常掉進的坑!
8. 委派的組合
多址委派(multicast)包含方法清單,你可以加入(+=)或移除(-=)處理器。
特性:根據參考與簽名移除
void Handler1() => Console.WriteLine("1");
void Handler2() => Console.WriteLine("2");
Action a = Handler1;
a += Handler2;
a -= Handler1; // 只會留下 Handler2
a?.Invoke(); // 會輸出 "2"
範例:動態管理處理器
Action a = Handler1;
a += Handler1;
a -= Handler1; // 現在清單中還會剩下一個 Handler1!
9. 委派、可擴充性與控制反轉 (IoC)
在大型應用裡,常常希望元件能「呼叫」外部程式碼而不耦合太緊。委派能夠在不造成 tight coupling 的情況下實作擴充、外掛與回呼。
範例:在建構子注入行為
public class Greeter
{
private readonly Func<string> _getName;
public Greeter(Func<string> getName)
{
_getName = getName;
}
public void Greet() => Console.WriteLine($"哈囉, {_getName()}!");
}
// 注入不同的行為:
var greeter1 = new Greeter(() => "安雅");
var greeter2 = new Greeter(() => DateTime.Now.ToShortTimeString());
greeter1.Greet(); // "哈囉, 安雅!"
greeter2.Greet(); // "哈囉, 14:35!"
實務上這個做法有助於寫出易測試和易維護的程式。
10. 有用的小細節
委派在標準介面和 LINQ 中的應用
如果你在用 LINQ、集合或非同步,早晚會遇到委派。
- 很多方法像 List<T>.Find, Array.Sort, Where, Select 都接受委派(Func<T, bool>, Comparison<T> 等)。
- LINQ 的方法允許你傳入過濾、轉換、聚合的邏輯 — 不需要為每種情況創建新類別。
範例:物件排序的比較器
var people = new[] { "伊凡", "瑪麗亞", "彼得" };
Array.Sort(people, (a, b) => a.Length.CompareTo(b.Length));
Console.WriteLine(string.Join(", ", people)); // 伊凡, 彼得, 瑪麗亞
委派與柯里化(部分套用參數)
用匿名方法 / lambda 你可以「固定」部分參數,得到新的函式。
範例:部分套用
Func<int, int, int> sum = (x, y) => x + y;
// 建一個永遠加 10 的函式
Func<int, int> add10 = y => sum(10, y);
Console.WriteLine(add10(5)); // 15
委派比較的特性
在 C# 可以比較委派是否相等(==),當且僅當它們的呼叫清單(invocation list)相同。
void Handler1() { }
void Handler2() { }
Action a1 = Handler1;
Action a2 = Handler1;
Console.WriteLine(a1 == a2); // True
Action a3 = Handler1; a3 += Handler2;
Action a4 = Handler1; a4 += Handler2;
Console.WriteLine(a3 == a4); // True
但如果委派是基於匿名方法或 lambda 建出來的,則比較的是實例本身(instance)。
委派的序列化
委派可以被序列化,但前提是它們所指向的方法定義在可序列化的類別中,且所有型別都可用。從 .NET 8 開始,BinaryFormatter 預設被關閉且被視為過時,未來版本可能會移除;在生產環境中序列化委派幾乎不常見。
委派與事件的互動:何時用哪一個?
- 委派 — 是一種型別/變數,你可以直接呼叫它。
- 事件(event)— 用來限制對委派的存取:外部只能訂閱/退訂(+=/-=),但只能在類別內部觸發(invoke)。
- 事件總是委派型別,但不是任何委派都是事件。
怎麼選?如果你想讓邏輯能被「類別外」決定,直接用委派。若要控制訂閱/退訂,保護變數不被非法改寫,就用事件。
11. 使用委派常見錯誤
錯誤 #1:混淆委派和事件。
把公開的委派欄位(public Action MyAction;)拿來當事件用(應該是 public event Action MyAction;)會破壞封裝。外部程式可能會意外或故意覆寫所有訂閱者(instance.MyAction = null;)或直接呼叫它們,這會破壞類別的邏輯。
錯誤 #2:多址委派返回值的處理不正確。
如果委派會回傳值(例如,Func<string, int>),直接呼叫(myDelegate("test"))只會得到鏈中最後一個方法的結果。要取得所有訂閱者的結果,必須透過 GetInvocationList() 迭代呼叫每個項目。
// 範例:遍歷所有訂閱者的結果
var list = myDelegate.GetInvocationList();
foreach (var d in list)
{
var r = ((Func<string, int>)d)("測試");
Console.WriteLine(r);
}
錯誤 #3:在閉包中捕捉迴圈變數。
新手常見的經典陷阱:在 for 迴圈內建立的 lambda 捕捉了迭代變數本身,而不是它當時的值。
// 錯誤示範:所有動作會輸出最後的 i 值
for (int i = 0; i < 3; i++)
{
actions[i] = () => Console.WriteLine(i);
}
// 正確示範:為每次迭代建立本地複本
for (int i = 0; i < 3; i++)
{
int copy = i;
actions[i] = () => Console.WriteLine(copy);
}
錯誤 #4:造成記憶體洩漏。
如果一個實例方法訂閱了某個長期存在物件的委派,但沒有適時退訂,就會發生記憶體洩漏。長期存在的物件會持有對訂閱者的參考,垃圾回收就無法回收訂閱者。特別注意在生命週期短的類別(像 UI 元件)中退訂。
GO TO FULL VERSION