C#.Net築基-解密委託與事件

安木夕發表於2024-08-05

image.png

委託與事件是C#中歷史比較悠久的技術,從C#1.0開始就有了,核心作用就是將方法作為引數(變數)來傳遞和使用。其中委託是基礎,需要熟練掌握,程式設計中常用的Lambda表示式、Action、Func都是委託,包括事件也是基於委託實現的。


01、認識委託delegate

1.1、什麼是委託?

委託是一種用來包裝方法的特殊型別,可以將方法包裝為物件進行傳遞、呼叫,類似函式指標。delegate 關鍵字用來定義一個委託型別,語法類似方法申明,可以看做是一個“方法簽名模板”,和方法一樣定義了方法的返回值、引數。

  • delegate 定義的委託是一個類,繼承自 System.MulticastDelegate、System.Delegate,“方法名”就是委託型別的名稱。
  • 委託的使用同其他普通型別,例項指向一個方法的引用,該方法的申明和委託定義的“方法簽名模板”須匹配(支援協變逆變)。
  • 委託支援連線多個委託(方法),稱為多播委託(MulticastDelegate),執行時都會呼叫。
public delegate void Foo(string name); //申明一個委託型別
void Main()
{
    Foo faction;     //申明一個Foo委託(例項)變數
	faction = DoFoo; //賦值一個方法
	faction += str => { Console.WriteLine($"gun {str}"); };  //新增多個"方法例項"
    faction += DoFoo; //繼續新增,可重複
	faction("sam");          //執行委託,多個方法會依次執行
    faction.Invoke("zhang"); //同上,上面呼叫方式實際上還是執行的Invoke方法。
}
private void DoFoo(string name){
	Console.WriteLine($"hello {name}");
}

委託的主要使用場景:核心就是把方法作為引數來傳遞,分離方法申明和方法實現。

  • 回撥方法,包裝方法為委託,作為引數進行傳遞,解耦了方法的申明、實現和呼叫,可以在不同的地方進行。
  • Lambda表示式,這是委託的簡化語法形式,更簡潔,比較常用。
  • 事件,事件是一種特殊的委託,是基於委託實現的,可以看做是對委託的封裝。

1.2、Delegate API

🔸Delegate屬性 說明
Method 獲取委託所表示的方法資訊,多個值返回最後一個
Target 獲取委託方法所屬的物件例項,多個值返回最後一個,靜態方法則為null
所以要注意:委託、事件不用時要移除,避免GC無法釋放資源。
🔸Delegate靜態成員 -
CreateDelegate 用程式碼建立指定型別的委託,包括多個過載方法
Combine(Delegate, Delegate) 將多個委託組合為一個新委託(鏈),簡化語法++=Foo d = d1 + d2;
Remove(source, value) 移除指定委託的呼叫列表,返回新的委託。簡化語法--=d -= d1
RemoveAll(source, value) 同上,區別是Remove值移除找到的最後一個,RemoveAll 移除所有找到的
🔸MulticastDelegate成員 -
GetInvocationList() 按照呼叫順序返回此多路廣播委託的委託列表

1.3、解密委託“型別”

delegate 定義的委託,編譯器會自動生成一個密封類,so,委託本質上就是一個類。該委託類繼承自 System.MulticastDelegateMulticastDelegate又繼承自 System.Delegate,Delegate是委託的基類,她們都是抽象類( abstract class)。

delegate定義的委託編譯後的IL程式碼如下(已簡化),可檢視線上sharplab

public delegate void Foo(string name,int age); //申明一個委託型別

//編譯器生成的Foo委託類(簡化程式碼)
class public auto ansi sealed Foo extends [System.Runtime]System.MulticastDelegate]
{
    void Foo(object obj, IntPtr method) { ... }
    public virtual void Invoke (string name,int32 age) { ... }
    public virtual  BeginInvoke (string name,int32 age,System.AsyncCallback callback, object 'object') { ... }
    public virtual void EndInvoke (class [System.Runtime]System.IAsyncResult result)  { ... }
} 
  • 委託的建構函式有兩個引數,obj為方法所在的物件,method為方法指標。該建構函式由編譯器呼叫,瞭解即可。
  • 執行委託的三個方法InvokeBeginInvokeEndInvoke 簽名和委託申明一致。
  • 執行一個委託(方法)就是呼叫foo.Invoke(),其簡化語法為foo()BeginInvokeEndInvoke用於非同步呼叫。
  • 因為委託本質上就是一個類,所以委託的定義通常在類外部(和類平級)。

📢 委託、事件的執行,推薦使用?.Invoke,判斷是否為nullfoo?.Invoke()

測試一下委託的繼承層次:

public delegate void Foo(string name); //申明一個委託型別
void Main()
{
    Foo faction; //申明一個Foo委託變數
	faction = DoFoo; //賦值
	
	var ftype = faction.GetType();
	while (ftype != null)
	{
		Console.WriteLine(ftype.FullName);
		ftype = ftype.BaseType;
	}
	//輸出:
	//Foo
	//System.MulticastDelegate
	//System.Delegate
	//System.Object
}
private void DoFoo(string name){
	Console.WriteLine($"hello {name}");
}

1.4、多播委託MulticastDelegate

我們編碼中使用的委託、事件其實都是多播委託 MulticastDelegate,可包含多個(單一)委託。MulticastDelegate 中有一個委託連結串列_invocationList,可存放多個(單一)委託(可重複新增),當執行委託時,委託連結串列中的委託方法會依次執行。

🔸新增移除:推薦用+-運算子新增、移除委託,其本質是呼叫Delegate的靜態方法Delegate.CombineDelegate.Remove

image.png

📢注意:委託方法的+-是執行緒不安全的,事件的addremove是執行緒安全的。

image.png

🔸執行委託 A.Invoke()/A(),:所有(委託)方法都會執行。可透過 GetInvocationList() 獲取委託(方法)列表,手動控制執行。

  • 如果其中一個方法執行報錯,連結串列後面的就不會執行了。
  • 如果委託方法有返回值,則只能獲取最後一個結果。

📢注意:新增、移除操作都會返回一個新的委託,原有委託並不受影響,委託是恆定的

public delegate void Foo(string name); //申明一個委託型別
void Main()
{
	Foo f1 = default; //申明一個Foo委託變數
	f1 += DoFoo; //新增一個方法
	f1 += DoFoo; //再新增一個方法
	f1 += str => { Console.WriteLine($"gun {str}"); };  //繼續新增
	f1("sam");  //執行了3次方法
	f1 -= DoFoo;//移除
	f1("sam");  //執行了2次方法
	
	Foo f2 = DoFoo;
	Foo f3 = f1+f2;  //組合委託
	Foo f4 = (Foo)Delegate.Combine(f1,f2); //同上
	Console.WriteLine(f3==f4); //True,內部方法列表中的元素相同,則委託相同
	Console.WriteLine(f3-f2 == f1); //True,移除委託
}
private void DoFoo(string name)
{
	Console.WriteLine($"hello {name}");
}

1.5、匿名方法和Lambda表示式

  • 匿名方法是一種沒有名分(名字)的方法,用 delegate關鍵字申明,可傳遞給委託或Lambda表示式。
  • Lambda表示式和匿名方法一樣,本質上都是委託,生成的IL程式碼是類似的。Lambda表示式更簡潔,支援型別推斷,所以現代的程式設計中基本都是用Lambda表示式了。
public delegate void Foo(string name); //申明一個委託型別
void Main()
{
	//匿名方法
	Foo f1 = delegate(string name){
		Console.WriteLine(name);
	};
	Action a1 = delegate() { Console.WriteLine("hello");};
	f1("sam");
	a1();
    
	//Lambda表示式
	Foo f2 = name=>Console.WriteLine(name);
	f2("king");
}

匿名方法、Lambda方法 會被編譯為一個私有方法,在一個私有的類中。


02、內建委託型別Action、Func

由上文可知委託在編譯時會建立一個型別,為提高效能、效率,避免大量不必要重複的委託定義,.Net內建了一些泛型委託 ActionFunc,基本上可以滿足大多數常用場景。

  • Action:支援0到16個泛型引數的委託,無返回值。
  • Func:支援0到16個輸入泛型引數,及一個返回值的泛型委託。
  • Predicatebool Predicate<in T>(T obj),用於測試判斷的委託,返回測試結果bool

image.png

原始碼:

public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
...
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
...
public delegate bool Predicate<in T>(T obj);

上面委託引數inout是標記可變性(協變、逆變)的修飾符,詳見後文《泛型T & 協變逆變


03、認識事件Event

3.1、什麼是事件event

事件是一種特殊型別的委託,他是基於委託實現的,是對委託的進一步封裝,因此使用上和委託相似。事件使用 event關鍵字進行申明,任何其他元件都可以訂閱事件,當事件被觸發時,它會呼叫所有已經訂閱它的委託(方法)。

事件是基於委託的一種(事件驅動)程式設計模型,用於在物件之間實現基於釋出-訂閱模式的通知機制,是實現觀察者模式的方式之一。常用在GUI程式設計、非同步程式設計以及其他需要基於訊息的系統。

void Main()
{
	var u = new User();
	//訂閱事件
	u.ScoreChanged += (sender, e) => { Console.WriteLine(sender); };
	u.AddScore(100);
	u.AddScore(200);
}
public class User
{
	public int Score { get; private set; }

	public event EventHandler ScoreChanged;   //定義事件,使用內建的“事件”委託 EventHandler

	public void AddScore(int score)
	{
		this.Score += score;
		this.ScoreChanged?.Invoke(this, null); //觸發事件
	}
}

🔸事件的關鍵角色

  • ①事件的釋出者,釋出事件的所有者,在合適的時候觸發事件,並透過事件引數傳遞資訊:

    • sender:事件源,就是引發事件的釋出者。
    • EventArgs:事件引數,一般是繼承System.EventArgs的物件,當然這不是必須的,在.NET Core中事件引數可以是任意型別。System.EventArgs 只是一個空的class,啥也沒有。
  • ②事件的訂閱者:訂閱釋出的事件,事件發生後執行的具體操作。

📢 EventHandler(object? sender, EventArgs e)、EventArgs<T>Button.Click算是微軟的標準事件模式,是一種習慣約定。

🔸事件使用實踐

  • 使用+= 訂閱事件,支援任意多個訂閱。-=移除不用的事件訂閱,避免記憶體溢位,注意-=對匿名方法、Lambda無效,因為每次都是新的委託。
  • 事件的觸發需判斷null,避免沒有訂閱時觸發報錯:Progress?.Invoke()
  • 事件委託型別以“EventHandler”結尾,大多數場景下使用EventHandler<TEventArgs>即可,當然也可以自定義,或使用Action

🔸事件命名:名詞+動詞(被動)

  • 事件已發生用過去式:Closed、PropertyChanged。
  • 事件將要發生用現在式,Closing、ToolTipOpening。
  • 訂閱的方法字首通常加“On”、“Raise”,fileLister.Progress += OnProgress;

3.2、解密事件-“封裝委託”

image.png

事件的定義:public event EventHandler MyEvent;,其中EventHandler就是一個委託,下面為其原始碼:

public delegate void EventHandler(object? sender, EventArgs e);
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

當定義個事件時,C#編譯器會生成對委託的事件包裝,類似屬性對欄位的包裝,線上sharplab原始碼。

//定義一個事件
public event EventHandler MyEvent;
//用其他委託定義事件
public event Action<string> MyEvent2;

//編譯後的IL程式碼(簡化)**********

//委託欄位
private EventHandler m_MyEvent;
//類似屬性的get、set訪問器,透過+ - 來訂閱、取消事件訂閱。
public event EventHandler MyEvent 
{
    add { m_MyEvent += value; }    //Delegate.Combine
    remove { m_MyEvent -= value; } //Delegate.Remove
}
  • 定義事件的“EventHandler”為一個委託,可以是任意委託型別,C#中大多使用內建泛型委託EventHandler<TEventArgs>
  • 編譯後生成了一個私有委託欄位m_MyEvent,這是事件的核心。
  • 生成了add訂閱、remove取消訂閱的方法,控制委託的新增和移除,使用時用+=-=語法。上面程式碼是簡化過的,實際程式碼要稍複雜一點點,主要是加了執行緒安全處理。
  • 自定義事件也可以直接使用上面示例中的addremove的方式封裝。

📢 由上可以看出事件是基於委託封裝的,類似屬性封裝欄位。外部只能add訂閱、remove取消訂閱,事件(委託)的執行(觸發)只能在內部進行。

3.3、標準事件模型

C#內部有大量的事件應用,形成了一個預設的事件(標準的)模式,主要定義了用於建立事件的委託、事件引數。

  • System.EventArgs :事件引數,這是標準事件模型的核心,作為事件引數的基類,用來繼承自定義實現一些事件要傳遞的欄位(屬性)。
  • 委託返回值為void
  • 委託兩個引數senderEventArgssender為觸發事件的物件,也是事件的廣播者;EventArgs為事件的引數。
  • 委託以“EventHandler”命名結尾。
  • 內建的泛型版本EventHandler<TEventArgs> 可以滿足上述條件,是一個比較通用的標準事件委託。
public class EventArgs
{
	public static readonly EventArgs Empty = new EventArgs();
}
public delegate void EventHandler(object? sender, EventArgs e);
//通用泛型版本
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

當然這個這個模式並不是必須的,只是一種程式設計習慣或規範。

3.4、該用委託還是事件?

事件是基於委託的,事件的功能委託大都能支援,兩者功能和使用都比較相似,都支援單播、多播,後期繫結,那兩者該如何選擇呢?

  • 事件一般沒有返回值,當然你想要也是可以的。
  • 事件提供更好的封裝,類似屬性對欄位的封裝,符合開閉原則。事件的執行只能在內部,外部只能+=訂閱、-=取消訂閱。

所以結論

  • 簡單場景用委託:一對一通訊、傳遞方法。
  • 複雜場景用事件:一對多通訊、需要安全許可權封裝。

04、其他-委託的效能問題?

由前文我們知道委託實際上都是一個多播委託型別,執行委託時實際是執行Invoke()方法,內部會迭代執行方法列表,這要比直接方法呼叫要慢不少。

public static int Sum(int x, int y) => x + y;   //方法
public static Func<int, int, int> SumFunc = Sum;//委託

public void Sum_MethodCall() //直接呼叫方法
{
	int sum = 0;
	for (int i = 0; i < 10; i++)
	{
		sum += Sum(i, i + 1);
	}
}
public void Sum_FuncCall()  //呼叫委託
{
	int sum = 0;
	for (int i = 0; i < 10; i++)
	{
		sum += SumFunc(i, i + 1);
	}
}

.Net6中執行Benchmark測試對比如下,直接呼叫的效率要高4-5倍。

image.png

.Net7.Net8中作了大量效能最佳化,委託呼叫達到了類似直接呼叫的效能,因此再也不用擔心委託的效能缺陷了。下圖為.Net8Benchmark測試。

image.png


參考資料

  • System.Delegate 和 delegate 關鍵字
  • 標準 .NET 事件模式
  • 還弄不明白【委託和事件】麼?,適合入門。
  • 由淺入深理解C#中的事件,比較細緻,適合入門
  • C# 的委託與事件大致是怎麼一回事,B站影片
  • .NET中委託效能的演變

©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀

相關文章