CodeGym /課程 /C# SELF /委派:進階情境

委派:進階情境

C# SELF
等級 54, 課堂 3
開放

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 元件)中退訂。

2
任務
C# SELF, 等級 54, 課堂 3
上鎖
用 delegate 實現的文字處理鏈
用 delegate 實現的文字處理鏈
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION