C# Event (1) —— 我想搞個事件

Oberon發表於2022-03-09

本文地址:https://www.cnblogs.com/oberon-zjt0806/p/15975299.html
本文最初來自於部落格園
本文遵循CC BY-NC-SA 4.0協議,轉載請註明出處
本文還會有後續,所以本篇只是一個非常簡單的場景

Event(事件)

何謂Event,我們不妨看一下詞典對Event的解釋:

something that happens, especially something important, interesting or unusual.
在發生的某件事,特指比較重要、有趣或者非同尋常的事情
- Longman Dictionary

歷史上或社會上發生的不平常的大事情。
- 現代漢語詞典

可能會有人覺得,這說了跟沒說一樣,其實不然,無論是中文還是英文的解釋,都側重於一點——

不尋常,值得關注

而我們的目的也在於此,我們需要關注程式內部可能發生的一些特定的狀況,然後在這個時間點下采取行動,這個特定的發生狀況的時間點就是我們需要關注的事件。

當然,詞典可能把事件這個此上升到了社會這種高度,認為特別重大的才叫做事件。但在程式中,其實任何需要關注的事情我們都可以冠以事件的名頭。

比如??

你每天早上9點上班,但是你家到目的地需要差不多1個小時,那麼8點鐘到了(OnTime8)這件事就可以成為你需要關注的事件

如果你不希望因為遲到導致自己遭受懲罰的時候,那麼你就必須保證在8點鐘到了的時候採取正確的行動——起床(GetUp)。
換言之,你不能錯過這個事件。

那我如何不錯過這個事件??

當然,作為一個頂級大忙人,你可不會在這個事件發生之前一直盯著鍾看,不然你可能就沒有心思做別的事情了,畢竟在8點之前你還有別的事情要忙,比如……忙著睡覺

在這種情況下,如果你不想因為睡過頭而錯過這一事件的時候(而你自己又沒辦法一直盯著鍾發呆)的情況下,就需要有別的東西(或者人,如果可以的話)幫你盯著時間,然後在適當的時機告知你。

比如,一臺效能強勁的鬧鐘(強勁到無論你睡多死都能把你叫醒),就是一個不錯的選擇。

釋出者-訂閱者(Publisher-Subscriber)

好了,現在你發現你和鬧鐘之間構成了這樣一種關係:

鬧鐘(當然和世界時間保持一致)負責盯著時間,在觀察到指標從7到達8的時候會想盡一切辦法通知你(包括但不限於),而你只需要等著接收通知即可。

鬧鐘負責釋出訊息,而你關注著鬧鐘發來的訊息,這就構成了一對“釋出者-訂閱者”的關係(你訂閱了鬧鐘釋出的事件)。

graph LR publisher[釋出者] subscriber[訂閱者] publisher -->|釋出事件| subscriber -->|訂閱通知| publisher subscriber -->|採取行動| subscriber alarm[鬧鐘] you[你] alarm -->|8點啦| you -->|聽到| alarm you -->|起床| you

關於釋出者和訂閱者這一名稱,可以想象一家報紙,在發生某個重大事件時,這家報紙把這個事件刊載到頭版分發出去,所有訂閱這家報紙的人就都能獲取到這一訊息。

C#的Event

現在我們可以用C#來表示上面我們提到的這個關係。

假設一個人是這樣的:

public partial class Person
{
	private bool isSleeping;
	public bool IsSleeping{get => isSleeping;}
	public Person()
	{
		isSleeping = true;
		Console.WriteLine("Sleeping...");
	}
	public void GetUp()
	{
		isSleeping = false;
		Console.WriteLine("Wake up!");
	}
}

So far so good,這個Person將作為訂閱者,但是這個人一旦睡著,除非他自己主動想GetUp以外沒有任何可以醒來的方式。

於是,我們把釋出者(也就是鬧鐘)引入進來,需要說明的是,我們這裡並不打算做一個可以真的會走的鬧鐘,只是做一個簡單的鬧鐘模型來模擬一下經過。

public partial class Alarm
{
	private int hour;
	public int Hour
	{
		get => hour;
		private set
		{
			hour = value % 24;
			Console.WriteLine($"It's {hour} o'clock.");
		}
	}
	public Alarm(int hour)
	{
		this.Hour = hour;
	}
	public void StepHour() //往前走1小時
	{
		Hour++;
	}
}

現在兩個角色都建立起來了,但是沒有建立事件的聯絡,也就是說現在的鬧鐘和你之間各玩各的,誰也管不著誰。

宣告一個事件

C#中使用event關鍵字來宣告一個事件……

……

那事件本身如何處理呢??實際上C#中事件處理器本質上是一個特殊的委託

我們知道,委託是可執行的方法集,就像一把槍,裝上若干可執行的函式作為子彈,然後在呼叫委託的時候把子彈全部打出去(裡面的方法挨個呼叫一遍),當然了,和子彈不同,方法打出去之後並不會消失,而是還留在委託內(術語上稱之為多路廣播機制)。C#事件就是使用委託這套機制來實現的。

在這個例子中我們姑且先定義一個無引數的委託:

public void delegate Time8Handler();

然後在Alarm中加上:

public partial class Alarm
{
	public event Time8Handler Time8;
}

這樣就在Alarm側宣告瞭一個名為Time8事件,需要注意這句宣告,public event Time8Handler Time8;如果拿走event關鍵字,那麼這句話就是一個普通的委託宣告,但具備event之後它就變成了事件委託。

事件委託的特點

事件委託和普通委託有什麼區別呢。在事件釋出類的範圍內,事件和一個委託沒有什麼區別,但在事件外,事件有如下特點:

對於釋出器以外的物件,不能使用()Invoke主動呼叫事件委託(實際上是隻能出現在+=-=之左側)

瞭解了上述特點之後我們就能更進一步理解C#中的釋出-訂閱機制是如何運作的:

釋出者提供一個事件委託作為事件入口,比如Alarm.Time8,釋出者的事件允許訂閱者將自己的事件行為(處理函式)裝入這個事件。
訂閱者決定了自身在事件中採取的行動,而釋出者則只決定事件產生/行動執行的時機(何時呼叫)。
在釋出者呼叫事件的這一刻,事件內所有被裝入的行為方法會被一炮打出,訂閱者就會執行相應的行為。

釋出者(Publisher) - Alarm

於是在Alarm中,我們需要確定執行的Time8的執行時機,由於我們希望這個事件在hour達到8的時候執行,由於這裡我們使用屬性Hour做了封裝了,因此我們直接在setter裡以釋出者的名義呼叫該事件委託,setter裡面做如下修改

public partial class Alarm
{
	//...
	public int Hour
	{
		// getter ...

		private set
		{
			hour = value % 24;
			Console.WriteLine($"It's {hour} o'clock.");
			if(hour == 8)
				Time8?.Invoke(); // <<--1
		}
	}
	//...
}

注意<<--1處標記的程式碼,語法上這裡實際上可以直接用Time8()呼叫。但是我們不能排除Time8委託裡沒有裝入任何方法以及Time8 == null的情形,在這種情況下直接呼叫事件委託會丟擲System.NullReferenceException異常。

因此MSDN中推薦使用?.Invoke()的方式呼叫可以輕鬆避免這種問題,當然如果不喜歡這樣做的話那就謹記在Time8();之前用if(Time8 != null)來判斷一下。

至此釋出者的角色準備就緒。

訂閱者(Subscriber) - 你

好了,現在鬧鐘可以響了,但是如果你不在意鬧鐘的聲音的話,那鬧鐘就算把自己敲爛也不會叫醒你,畢竟常言說得好:

你永遠叫不醒一個裝睡的人

因此只有你關注(訂閱)了釋出者的事件時,你才會受到事件的影響。

首先我們考慮當Alarm.Time8事件發生時,作為Person的你會採取什麼行為,顯然,在本例中,你需要GetUp

不過注意一下,儘管在本例中你可以直接把public void GetUp()訂閱到Time8上(因為委託型相同),但這並非一個很好的做法,在更加複雜的情形下,我們可能需要GetUp同時還要執行別的事情(比如Wash或者Breakfast什麼的),種種原因可能會使你不能把這些行為都訂閱到這個事件上(1是委託型不保證相同,2是難以保證在多路廣播下委託裝入的函式執行順序是什麼樣的),因此更明智的做法是單獨為事件委託提供一個事件函式,然後讓這個事件函式單獨訂閱專門的事件,所以我們這裡在定義一個OnTime8函式:

public partial class Person
{
	public void OnTime8()
	{
		GetUp();
	}
}

到目前為止我們只是定義了打算用於事件的函式,但這個函式本身還沒有和Time8事件建立聯絡。

接下來我們有兩種做法來完成這件事,一是在Main中宣告AlarmPerson各一個例項,然後手動+=來進行實際的訂閱,像這樣:

static void Main(string[] args)
{
	Person p = new Person();
	Alarm a = new Alarm(6);
	a.Time8 += p.OnTime8; // <<--2
	// ...
}

<<--2處就完成了pa.Time8事件的訂閱。如果此刻我們繼續往下寫……

static void Main(string[] args)
{
	// ...
	a.StepHour(); // a變成了7點,沒到8點不會起床
	a.StepHour(); // a變成了8點,執行Time8委託,其中包含了p.OnTime8
}

執行之後的結果就是

Sleeping...
It's 6 o'clock.
It's 7 o'clock.
It's 8 o'clock.
Wake up!

還有一種做法,就是我在Person裡宣告一個Alarm然後讓Person訂閱自己AlarmTime8事件,這麼寫語義上更能體現這個鬧鐘是我的鬧鐘的含義,這種寫法我會在下面程式碼彙總中提供。

總結

這一部分我們初步瞭解了事件在C#中表現的角色和作用,通過一個十分簡單的例子體驗了一下事件的基本用法,在後續的幾篇中我們會遇到更加複雜的情形,敬請期待……

程式碼彙總

public delegate void Time8Handler();

public class Alarm
{
	private int hour;
	public int Hour
	{
		get => hour;
		private set
		{
			hour = value % 24;
			Console.WriteLine($"It's {hour} o'clock.");
			if (hour == 8)
				Time8?.Invoke();
		}
	}
	public Alarm(int hour)
	{
		this.Hour = hour;
	}
	public void StepHour()
	{
		Hour++;
	}

	public event Time8Handler Time8;

}

public class Person
{
	public Alarm alarm;
	private bool isSleeping;
	public bool IsSleeping { get => isSleeping; }
	public Person(int hour)
	{
		isSleeping = true;
		Console.WriteLine("Sleeping...");
		alarm = new Alarm(hour);
		alarm.Time8 += OnTime8;
	}
	public void GetUp()
	{
		isSleeping = false;
		Console.WriteLine("Wake up!");
	}

	public void OnTime8()
	{
		GetUp();
	}
}

public class Program
{
	static void Main(string[] args)
	{
		Person pYou = new Person(6);
		pYou.alarm.StepHour();
		pYou.alarm.StepHour();
	}
}

相關文章