# C# Delegate 與匿名型別:事件、委派鏈與實戰 ## 主題引入與重要性 ### 說明 在現代後端系統中,事件驅動架構與非同步流程已成日常。C# 的 delegate(委派)與 event(事件)為建立鬆耦合的通知與回呼機制提供了語言層級的支援;匿名型別則讓我們能以最小成本快速封裝短期資料,尤其在 LINQ 查詢與資料整形時極具價值。對進階後端工程師而言,懂得善用這兩者,不只是語法技巧,更關乎系統可維護性、除錯效率與效能穩定性。然而,委派鏈在多執行緒環境下的執行順序、例外處理與記憶體生命週期,以及匿名型別的型別邊界與配對規則,都可能在高負載或複雜場景引發微妙且難以復現的問題。本文將以實務角度,結合理論與範例,提供一套可應用、可重構的操作指南。 ### 重點列表 - 事件驅動與非同步:委派與事件讓回呼流程更安全並具可測試性,適合高彈性、鬆耦合的後端模組間協作。 - 委派鏈的風險:多個訂閱者的組合執行順序、單一處理常式拋出例外的影響、跨執行緒觸發的同步問題,是除錯關鍵。 - 匿名型別的價值與限制:極速封裝臨時結果(尤其 LINQ),但不宜跨方法邊界,避免模糊 API 合約與難以維護。 - 效能與可觀測性:避免在熱路徑頻繁建立匿名型別,委派觸發需考量同步內容(SynchronizationContext)與執行緒安全。 - 除錯導向設計:以可記錄、可追蹤的事件封裝、命名與解耦策略,讓生產問題快速定位。 ### 補充範例/常見問答 ```csharp using System; using System.Collections.Generic; using System.Linq; public class Intro { // 定義一個委派,描述「兩數相加」這種行為的簽章 public delegate int SumDelegate(int a, int b); public static void Main() { // 1) 使用委派包裝一個純函式 SumDelegate sum = (x, y) => x + y; // 使用 Lambda 建立委派實例 int r = sum(3, 5); // 呼叫委派,就像呼叫方法 Console.WriteLine($"3 + 5 = {r}"); // 輸出 8 // 2) 使用匿名型別在 LINQ 中快速封裝結果 var items = new List<int> { 1, 2, 3, 4 }; var projection = items.Select(n => new { Original = n, // 原值 IsEven = n % 2 == 0, // 是否偶數 Square = n * n // 平方 }); // 匿名型別通常只在目前範圍內消費(例如立即列印或傳給下一個 LINQ) foreach (var row in projection) { Console.WriteLine($"n={row.Original}, Even={row.IsEven}, Square={row.Square}"); } } } ``` - Q:匿名型別可以作為方法回傳型別嗎? A:不建議。匿名型別是編譯器產生的內部型別,無法在強型別 API 合約中穩定表達。若需要回傳,請定義 record/類別或使用 ValueTuple(具名欄位)來維持可讀性與相容性。 --- ## Delegate 基礎與進階概念 ### 說明 Delegate 是型別安全的函式指標,讓方法能以資料形式被傳遞與呼叫。搭配 event,可建立可控的發布/訂閱模型。對進階工程師而言,關鍵在於:理解委派鏈(多個訂閱者)、例外處理策略、同步與非同步呼叫差異、以及在多執行緒環境中維持可預期性與可觀測性。 ### 重點列表 - 定義與用法:delegate 宣告一種方法簽章;可用具名方法或 Lambda 指派;呼叫時與一般方法無異。 - 委派鏈(Multicast):同一委派可包含多個目標;呼叫時依註冊順序逐一執行;任何一個處理常式拋出例外都可能中斷後續。 - 同步 vs 非同步:同步呼叫簡單但可能阻塞;非同步呼叫(Task、async/await)需注意例外收斂與同步內容。 - 事件封裝:event 限制外部只能 += 或 -=,避免被覆寫整個委派參考,提升封裝與安全性。 - 除錯觀點:保存快照(複製委派到區域變數)以避免競態;對 InvocationList 迴圈個別 try-catch;加上來源識別資訊提升追蹤性。 ### 補充範例/常見問答 ```csharp using System; using System.Threading.Tasks; public class Delegates101 { // 定義一種委派簽章:接受 int,回傳 int public delegate int Transformer(int x); // 同步示範 public static int Double(int x) => x * 2; // 具名方法 public static async Task Main() { // 1) 建立委派實例(具名方法 + Lambda 混合) Transformer t = Double; // 指到具名方法 t += (x) => x + 10; // 加入另一個處理常式,形成「委派鏈」 // 呼叫委派鏈:依序執行 Double -> Lambda // 注意:只有最後一個回傳值會被保留(此處為 x+10 的結果) int result = t(5); Console.WriteLine($"Result of chain: {result}"); // 輸出 15 // 2) 非同步委派:以 Func<Task> 或 Func<T, Task<R>> 表達 Func<int, Task<int>> asyncWork = async (n) => { await Task.Delay(50); // 模擬 I/O return n * n; // 回傳平方 }; int squared = await asyncWork(7); // 非同步等待結果 Console.WriteLine($"7^2 = {squared}"); // 3) 安全地呼叫委派鏈:個別捕捉例外,避免單一失敗殃及其他 Transformer chain = null; chain += (x) => x + 1; chain += (x) => throw new InvalidOperationException("測試例外"); chain += (x) => x * 3; // 取快照避免在呼叫過程中被異動(例如其他執行緒 -= 訂閱) var snapshot = chain; if (snapshot != null) { foreach (Transformer handler in snapshot.GetInvocationList()) { try { int val = handler(2); Console.WriteLine($"Handler OK => {val}"); } catch (Exception ex) { Console.WriteLine($"Handler failed: {ex.Message}"); } } } } } ``` - Q:為什麼委派鏈只回傳最後一個處理常式的回傳值? A:MulticastDelegate 的語義是依序呼叫所有目標,整體呼叫結果是最後一次委派呼叫的回傳值。若需要彙總多個結果,請自行迴圈 GetInvocationList() 並蒐集回傳值,或改用事件模型傳遞上下文與結果容器。 --- ## 事件機制與委派鏈的實戰應用 ### 說明 事件進一步限制了委派的可見性:外部只能訂閱/取消訂閱,而不能直接指派委派,避免外部覆蓋既有的訂閱。實務上,事件廣用於通知狀態改變(如任務完成、緩存更新)。在多執行緒或非同步情境下,必須處理競態條件、事件風暴、例外隔離與訂閱者漏釋放導致的記憶體洩漏。 ### 重點列表 - 設計事件通知系統:以 EventArgs 或自訂不可變的 Payload,保持 API 穩定;命名清晰如 SomethingHappened。 - 安全觸發:複製快照、使用 ?.Invoke、或手動迭代委派鏈並個別 try-catch,避免單點失敗。 - 執行緒策略:釐清觸發執行緒與處理執行緒;必要時用 Task.Run 或 Channel/Queue 將處理轉移背景執行緒。 - 訂閱者管理:訂閱與移除需對稱;長壽命物件訂閱短壽命來源易洩漏,考慮弱事件或集中式事件匯流排。 - 觀測與壓制:在高頻事件中加入節流/緩衝,並對事件來源打點(計數、耗時、錯誤率)便於營運監控。 ### 補充範例/常見問答 ```csharp using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; public class MessageEventArgs : EventArgs { public string Topic { get; } public string Payload { get; } public MessageEventArgs(string topic, string payload) { Topic = topic; // 主題 Payload = payload; // 訊息內容 } } public class EventBus { // 自訂事件:外部只能 += 或 -=,無法直接賦值覆蓋 public event EventHandler<MessageEventArgs> MessagePublished; // 觸發事件的安全方法 protected virtual void OnMessagePublished(MessageEventArgs e) { // 取快照,避免觸發期間委派鏈被修改造成 race condition var snapshot = MessagePublished; if (snapshot == null) return; foreach (EventHandler<MessageEventArgs> handler in snapshot.GetInvocationList()) { try { handler(this, e); // 個別保護,避免單一失敗中斷整體通知 } catch (Exception ex) { // 在生產中應寫入記錄系統(例如結合事件名稱與訂閱者識別) Console.Error.WriteLine($"Handler error: {ex.Message}"); } } } // 非同步發布:將事件轉移到背景執行緒處理,避免阻塞呼叫端 public Task PublishAsync(string topic, string payload, CancellationToken ct = default) { var e = new MessageEventArgs(topic, payload); return Task.Run(() => OnMessagePublished(e), ct); } } public class Demo { public static async Task Main() { var bus = new EventBus(); // 訂閱者 A:快速處理 bus.MessagePublished += (s, e) => { Console.WriteLine($"A received: {e.Topic} - {e.Payload}"); }; // 訂閱者 B:故意丟例外,觀察不影響其他訂閱者 bus.MessagePublished += (s, e) => { if (e.Topic == "Error") throw new InvalidOperationException("B failed"); Console.WriteLine("B OK"); }; // 訂閱者 C:模擬較慢處理 bus.MessagePublished += (s, e) => { Thread.Sleep(20); // 模擬 I/O 或計算 Console.WriteLine("C done"); }; await bus.PublishAsync("Info", "Hello"); await bus.PublishAsync("Error", "Oops"); // B 會失敗,但 A、C 照常執行 } } ``` - Q:為什麼事件會造成記憶體洩漏? A:事件是強參考;若長壽命物件(Publisher)持有短壽命物件(Subscriber)的處理常式參考,短壽命物件即使不再使用也無法被垃圾回收。解法:在適當時機 -= 取消訂閱、使用弱事件(WeakEvent pattern)、或集中式事件匯流排管理訂閱生命週期。 --- ## 匿名型別的宣告與應用實務 ### 說明 匿名型別是編譯器產生的不可變(預設唯讀)型別,常用於 LINQ 的 Select 投影或臨時資料整形。它讓我們省去定義 DTO 的負擔,但必須控制使用範圍,避免滲入公開 API,造成維護與相容性風險。 ### 重點列表 - 宣告與語法:使用 new { Prop = expr, ... } 建立;編譯器推斷型別,並產生 Equals/GetHashCode 基於屬性值。 - 常見場景:LINQ 投影、群組匯總、日誌結構化欄位;在單一方法或查詢管線內快速消費。 - 限制與取捨:不可命名公開型別、不可跨方法/模組作為合約;在熱路徑大量建立會造成 GC 壓力。 - 可替代方案:ValueTuple((X: int, Y: int))或 record 做為穩定的 API 合約;匿名型別可用於過渡,後續重構為明確型別。 - 陷阱:兩個匿名型別相等的條件是「屬性名稱與順序與型別」完全相同;否則即使欄位同名同值也不是相同型別。 ### 補充範例/常見問答 ```csharp using System; using System.Collections.Generic; using System.Linq; public class AnonymousTypesDemo { public static void Main() { var products = new[] { new { Name = "Pen", Price = 10m, Qty = 3 }, // 這裡也使用匿名型別做示範 new { Name = "Book", Price = 80m, Qty = 1 }, new { Name = "Bag", Price = 120m, Qty = 2 }, }; // 1) LINQ 投影:建立另一個匿名型別,包含總價與標籤 var report = products.Select(p => new { p.Name, // 習慣:沿用來源名稱,易讀 Total = p.Price * p.Qty, // 計算欄位 Tag = p.Price > 50 ? "High" : "Low" }); foreach (var row in report) { Console.WriteLine($"{row.Name}: {row.Total} ({row.Tag})"); } // 2) 不要把匿名型別外露成公開 API:以下僅在方法內部使用 var top = report.Where(x => x.Total >= 100) .OrderByDescending(x => x.Total) .FirstOrDefault(); Console.WriteLine($"Top: {top?.Name} -> {top?.Total}"); } } ``` - Q:匿名型別可以當作 Dictionary 的鍵嗎? A:可以,因為匿名型別實作了基於所有屬性值的 Equals/GetHashCode。但要小心生命週期與型別邊界,且在 API 合約中不建議使用,避免未來重構造成相容性問題。若需公開合約,請使用 record 或具名的不可變型別。 --- ## 最佳實踐、除錯技巧與效能調優 ### 說明 當系統成長,委派與匿名型別的使用將遍布各層。良好實踐能降低追查時間與效能波動,協助團隊維持一緻性與可觀測性。本節聚焦如何平衡彈性與複雜性,並提供可落地的除錯與調優策略。 ### 重點列表 - 委派與事件的設計守則:使用明確命名、固定簽章(EventHandler<T>)、不可變 EventArgs;事件觸發時紀錄來源、延遲、例外。 - 安全觸發模板:快照 + 個別 try-catch + 超時與計數,必要時以背景處理隔離對呼叫端的影響。 - 訂閱生命週期:集中管理訂閱與釋放;在服務啟停或作用域(Scope)結束時統一清理。 - 匿名型別效能:避免在熱路徑大量分配;對高頻路徑改用 struct record 或 ValueTuple;避免在迴圈中隱含閉包捕捉外部變數。 - 除錯工具與可視化:利用 IDE 的「平行堆疊」、「委派目標檢視」、日誌關聯 ID(CorrelationId),快速定位委派鏈與事件風暴。 ### 補充範例/常見問答 ```csharp using System; using System.Diagnostics; using System.Threading.Tasks; public class SafeEvent<T> where T : EventArgs { public event EventHandler<T> Occurred; // 安全觸發模板:度量 + 隔離例外 public void Raise(object sender, T args, Action<Exception> onError = null) { var snapshot = Occurred; if (snapshot == null) return; foreach (EventHandler<T> handler in snapshot.GetInvocationList()) { var sw = Stopwatch.StartNew(); try { handler(sender, args); // 執行處理常式 } catch (Exception ex) { onError?.Invoke(ex); // 回報錯誤(寫日志或計量) } finally { sw.Stop(); if (sw.ElapsedMilliseconds > 100) // 簡單門檻偵測慢 handler { Console.WriteLine($"Slow handler: {handler.Method.Name} took {sw.ElapsedMilliseconds} ms"); } } } } } public class DemoBestPractice { public static async Task Main() { var ev = new SafeEvent<EventArgs>(); ev.Occurred += (s, e) => Console.WriteLine("Fast"); ev.Occurred += (s, e) => { Task.Delay(150).Wait(); Console.WriteLine("Slow"); }; ev.Raise(null, EventArgs.Empty, ex => Console.Error.WriteLine(ex.Message)); } } ``` - Q:如何追蹤「多重訂閱」造成的重複執行? A:在訂閱時加入唯一識別(例如透過封裝方法,記錄 handler.Method/Target 與事件名稱),或建立中介層(Event Subscription Registry)去重與檢查重複註冊。發現重複後紀錄告警並拒絕再次註冊。 --- ## 綜合案例實作與重構策略 ### 說明 本案例示範一個簡化的「訂單處理系統」:當新訂單進入,系統透過事件通知多個模組(計價、庫存、審計記錄)。我們將展示如何管理委派鏈、控制例外對隔離域的影響、以匿名型別進行查詢整形,以及最終將匿名型別重構為穩定的合約型別。 ### 重點列表 - 事件驅動流程:OrderCreated 事件通知三個處理模組,彼此無耦合。 - 委派鏈治理:個別 try-catch,失敗不波及其他;慢訂閱者監測與分流。 - 匿名型別過渡:查詢合併時使用匿名型別,對外 API 改以 record 發佈。 - 重構策略:當匿名型別被重複使用或跨界傳遞時,立即提煉為具名型別;在熱路徑用 ValueTuple/struct 減少配置。 - 效能瓶頸定位:對事件處理計時,出現長尾時做分流(背景隊列)、或加快 I/O(快取/批次)。 ### 補充範例/常見問答 ```csharp using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; // 1) 事件與資料契約 public record Order(string Id, string Sku, int Qty, decimal UnitPrice); public class OrderCreatedEventArgs : EventArgs { public Order Order { get; } public OrderCreatedEventArgs(Order order) => Order = order; } public class OrderBus { public event EventHandler<OrderCreatedEventArgs> OrderCreated; public void Publish(Order order, Action<Exception> onError = null) { var snapshot = OrderCreated; if (snapshot == null) return; foreach (EventHandler<OrderCreatedEventArgs> handler in snapshot.GetInvocationList()) { var sw = Stopwatch.StartNew(); try { handler(this, new OrderCreatedEventArgs(order)); } catch (Exception ex) { onError?.Invoke(ex); // 隔離錯誤 } finally { sw.Stop(); if (sw.ElapsedMilliseconds > 50) Console.WriteLine($"Slow handler: {handler.Method.Name} took {sw.ElapsedMilliseconds} ms"); } } } } // 2) 訂閱者:計價、庫存、審計 public class PricingService { public void OnOrderCreated(object? sender, OrderCreatedEventArgs e) { // 使用匿名型別進行查詢整形(僅在此方法內消費) var line = new { e.Order.Id, Subtotal = e.Order.UnitPrice * e.Order.Qty, Discount = e.Order.Qty >= 10 ? 0.1m : 0m, }; var total = line.Subtotal * (1 - line.Discount); Console.WriteLine($"[Pricing] Order={line.Id}, Total={total}"); } } public class InventoryService { public void OnOrderCreated(object? sender, OrderCreatedEventArgs e) { // 假裝檢查庫存 Console.WriteLine($"[Inventory] Reserve {e.Order.Qty} for {e.Order.Sku}"); // 若失敗可丟出例外,將被上層隔離 } } public class AuditService { public void OnOrderCreated(object? sender, OrderCreatedEventArgs e) { // 寫入審計記錄(同步簡化示範) Console.WriteLine($"[Audit] Order received: {e.Order.Id}"); } } // 3) 整合與重構 public class Program { public static async Task Main() { var bus = new OrderBus(); var pricing = new PricingService(); var inv = new InventoryService(); var audit = new AuditService(); // 訂閱 bus.OrderCreated += pricing.OnOrderCreated; bus.OrderCreated += inv.OnOrderCreated; bus.OrderCreated += audit.OnOrderCreated; // 發布 bus.Publish(new Order("O-1001", "SKU-ABC", 12, 50m), ex => Console.Error.WriteLine(ex)); // 重構建議: // - 若 PricingService 中的匿名型別被多次使用,改為定義 record PricingResult(string OrderId, decimal Subtotal, decimal Discount, decimal Total) // - 若 Inventory 與 Audit 處理變慢,改以背景隊列(Channel/Queue)非同步處理並批次寫入 await Task.CompletedTask; } } ``` - Q:何時該把匿名型別重構為具名型別? A:當相同匿名結構在多處出現、跨方法傳遞、或需要公開為 API 合約時,立即提煉為 record/類別。這能提升可讀性、可重用性與型別安全,並利於序列化與版本治理。 --- ## 延伸閱讀與資源總結 ### 說明 本節彙整委派、事件與匿名型別的設計要點,以及延伸學習的高品質資源,協助你在面對更複雜的非同步事件流與資料整形需求時,有可依循的參考。 ### 重點列表 - 設計哲學:用委派與事件解耦,用明確契約界定資料邊界;匿名型別適合「近場使用」,跨界即刻提煉。 - 工具與方法:以快照、安全觸發、個別 try-catch、計量與紀錄形成標準模板;以基準測試(BenchmarkDotNet)與分析器(Analyzer)守護品質。 - 學習路徑:先掌握語法與行為,再進一步探討多執行緒一致性與可觀測性落地策略。 ### 補充範例/常見問答 - 推薦資源: - Microsoft Docs: Delegates, Events, Anonymous Types - .NET Blog 與 Channel 9 深入解說事件與非同步模式 - 開源專案:DotNetRuntime(觀察事件與 GC 行為)、EventStore 範例 - 書籍:Concurrency in C# Cookbook、Pro .NET Memory Management - Q:如何在不破壞既有訂閱者的前提下演進事件的負載內容? A:使用版本化的 EventArgs(例如 OrderCreatedV2EventArgs 繼承原版本或新增屬性),並暫時同時發布兩個事件;或採用單一事件名稱但 EventArgs 向下相容,確保舊訂閱者仍可運作。逐步淘汰舊版事件。 --- ## 結論 委派與事件讓我們能以語言原生的方式實作回呼與發布/訂閱,對於鬆耦合架構、非同步流程與跨模組協作至關重要;匿名型別則在資料整形與 LINQ 場景提供高效率的臨時封裝。實務上,請以安全觸發模板(快照、個別 try-catch、計量)、嚴謹的訂閱生命週期管理,以及清晰可觀測的日誌策略,降低委派鏈在多執行緒下的風險。同時,將匿名型別限制在近場使用,一旦跨界或重複使用即刻提煉為具名型別(record/DTO),在效能敏感路徑則改用 ValueTuple/struct。建議你從小型事件流開始套用本文模板,逐步導入到核心服務,並以基準與監控數據持續調整。當你能在「彈性、可維護性、效能」三者間取得良好平衡,委派與匿名型別將成為你後端系統設計的可靠基石。