1. 程式人生 > >C#學習筆記(三)—–C#高階特性中的委託與事件(中)

C#學習筆記(三)—–C#高階特性中的委託與事件(中)

C#高階特性中的委託與事件(中)

事件

  • 委託本身又是一個更大的模式(pattern)的基本單位,這個模式稱為publish-subscribe(釋出——訂閱)。委託的使用及其對publish-subscribe模式的支援是本章的重點。本章描述的所有內容幾乎都可以單獨使用委託來實現。然而,本章所著眼的事件構造提供了額外的“封裝性”,使publish-subscribe模式更容易實現,更不容易出錯。
  • 一個委託值是可以引用一系列方法的,這些方法將順序呼叫。這樣的委託稱為多播委託(multicast delegate)。這樣一來,單一事件(比如物件狀態的改變)的通知就可以釋出給多個訂閱者。
  • 雖然事件在C# 1.0中就有了,但C# 2.0對泛型的引入極大地改變了編碼規範,因為使用泛型委託資料型別意味著不再需要為每種可能的事件簽名宣告一個委託。所以,本章的最低起點是C# 2.0。但是,仍在使用C# 1.0的讀者也不是不能使用事件,只是必須宣告自己的委託資料型別。

使用多播委託來編碼Observer模式

  • 來考慮一個溫度控制的例子。在這個假想的情形中,一個加熱器(Heater)和一個冷卻器(Cooler)連線到同一個自動調溫器。為了控制加熱器和冷卻器的開啟和關閉,要向它們通知溫度的變化。自動調溫器將溫度的變化釋出給多個訂閱者——也就是加熱器和冷卻器。
 class Cooller
    {
        private float _temprature;
        public float Temprature { get { return _temprature; } set { _temprature = value; } }
        public
Cooller(float newTemprature) { Temprature = newTemprature; } public void OnTempratureChanged(float newTemperature) { if (newTemperature>Temprature) { Console.WriteLine("Coller:Off"); } else
{ Console.WriteLine("Coller:On"); } } } class Heater { private float _temprature; public float Temprature { get { return _temprature; } set { _temprature = value; } } public Heater(float newTemprature) { Temprature = newTemprature; } public void OnTempratureChanged(float newTemperature) { if (newTemperature>Temprature) { Console.WriteLine("Heater:On"); } else { Console.WriteLine("Heater:Off"); } } }
除了溫度比較,兩個類幾乎完全一致,事實上,如果在OnTempretureChanged方法中使用對一個比較方法的委託,兩個類還可以再減少一個。每個類都儲存了啟動裝置所需的溫度。此外,兩個類都提供了OnTemperatureChanged()方法。呼叫OnTemperatureChanged()方法的目的是向Heater和Cooler類指出溫度已發生改變。在方法的實現中,用newTemperature同儲存好的觸發溫度進行比較,從而決定是否讓裝置啟動。
兩個OnTemperatureChanged()方法都是訂閱者方法。作為訂閱者方法,很重要的一點在於,它們的引數和返回型別必須與來自Thermostat類的委託匹配,Thermostat類的詳情將在下一節討論。

- 定義釋出者:Thermostat類負責向heater和cooler物件例項報告溫度變化。下例展示了Thermostat類。

public class Thermostat
{
// Define the event publisher
public Action<float> OnTemperatureChange { get; set; }
public float CurrentTemperature
{
get{return _CurrentTemperature;}
set
{
if (value != CurrentTemperature)
{
_CurrentTemperature = value;
}
}
}
private float _CurrentTemperature;
}

Thermostat包含一個名為OnTemperatureChange的屬性,它具有Action<float>委託型別。OnTemperatureChange儲存了訂閱者列表。注意,只需一個委託欄位即可儲存所有訂閱者。換言之,來自同一個釋出者的溫度變化通知會同時被Cooler和Heater類接收。
Thermostat的最後一個成員是CurrentTemperature屬性。它負責設定和獲取由Thermostat類報告的當前溫度值。
連線釋出者和訂閱者:

class Program
{
public static void Main()
{
Thermostat thermostat = new Thermostat();
Heater heater = new Heater(60);
Cooler cooler = new Cooler(80);
string temperature;
thermostat.OnTemperatureChange +=
heater.OnTemperatureChanged;
thermostat.OnTemperatureChange +=
cooler.OnTemperatureChanged;
Console.Write("Enter temperature: ");
temperature = Console.ReadLine();
thermostat.CurrentTemperature = int.Parse(temperature);
}
}

上述程式碼使用+=操作符來直接賦值,向OnTemperatureChange委託註冊了兩個訂閱者,即heater.OnTemperatureChanged和cooler.OnTemperatureChanged。
通過獲取使用者輸入的溫度值,可以設定thermostat(自動調溫器)的CurrentTemperature (當前溫度)。然而,目前還沒有寫任何程式碼將溫度變化釋出給訂閱者。

  • 呼叫委託:Thermostat類的CurrentTemperature屬性每次發生變化時,你都希望呼叫委託來通知訂閱者(heater和cooler)溫度的變化。為此,需要修改CurrentTemperature屬性來儲存新值,並向每個訂閱者發出一個通知:
public class Thermostat
{
...
public float CurrentTemperature
{
get{return _CurrentTemperature;}
set
{
if (value != CurrentTemperature)
{
_CurrentTemperature = value;
// INCOMPLETE: Check for null needed
// Call subscribers
OnTemperatureChange(value);
}
}
}
private float _CurrentTemperature;
}

現在,對CurrentTemperature的賦值包含了一些特殊的邏輯,可以向訂閱者通知CurrentTemperature發生的變化。為了向所有訂閱者發出通知,只需執行一個簡單的C#語句,即OnTemperatureChange(value);。這個語句將溫度的變化釋出給cooler和heater物件。在此,只需執行一個呼叫,即可向多個訂閱者發出通知——這正是將委託更明確地稱為“多播委託”的原因。
上上面的程式碼中,遺漏了事件釋出程式碼的一個重要部分。假如當前沒有訂閱者註冊接收通知,則OnTemperatureChange為null,執行OnTemperatureChange(value)語句就會引發一個NullReferenceException異常。為了避免這個問題,有必要在觸發事件之前檢查null值。下面的程式碼清單演示了會如何檢查null值:

public class Thermostat
{
...
public float CurrentTemperature
{
get{return _CurrentTemperature;}
set
{
if (value != CurrentTemperature)
{
_CurrentTemperature = value;
// If there are any subscribers
// then notify them of changes in
// temperature
Action<float> localOnChange =
OnTemperatureChange;
if(localOnChange != null)
{
// Call subscribers
localOnChange(value);
}
}
}
}
private float _CurrentTemperature;
}

我們並不是一開始就檢查null值,而是首先將OnTemperatureChange賦給另一個委託變數localOnChange。這個簡單的修改可確保在檢查null值和傳送通知之間,假如所有OnTemperatureChange訂閱者都(由一個不同的執行緒)移除,那麼不會引發NullReferenceException異常。關於執行緒的討論我們會在後面的筆記中來繼續完善這個例子的解釋。現在先給出規範:要在呼叫委託前檢查它的值是不是null值。

  • 提示:將-=操作符賦值給一個委託會建立一個新的例項。既然委託是引用型別,那麼肯定有人會覺得奇怪:為什麼賦值給一個區域性變數,再用那個區域性變數就可以保證null檢查的執行緒安全性?由於localOnChange指向的位置就是OnTemperatureChange指向的位置,所以很自然的結論就是:OnTemperatureChange中發生的任何變化都會在localOnChange中反映出來。但實情並非如此。事實上,對OnTemperatureChange -=<listener>的任何呼叫都不會從OnTemperatureChange刪除一個委託而使它的委託比之前少一個。相反,會賦值一個全新的多播委託,原始的多播委託不受任何影響(localOnChange也指向那個原始的多播委託)。
  • 如前所述,由於訂閱者可以由不同的執行緒從委託中增加或刪除,所以在進行null值檢查前有必要將委託引用複製到一個區域性變數中。但是,這雖然能防止呼叫空委託,卻不能防止所有可能的競態條件。例如,一個執行緒進行復制,另一個執行緒將這個委託重置為null,然後原始執行緒可以呼叫委託的前一個值,藉此通知一個已經不再在訂閱者列表中的訂閱者。在多執行緒程式中,訂閱者應確保在這種情況下的健壯性。一個“過氣”的訂閱者隨時都可能被呼叫。
  • 委託操作符:為了合併Thermostat例子中的兩個訂閱者,要使用+=操作符。這樣會獲取第一個委託,並將第二個委託新增到委託鏈中。第一個委託的方法返回後,會呼叫第二個委託。從委託鏈中刪除委託,則要使用-=操作符,如下面程式碼清單所示:
Thermostat thermostat = new Thermostat();
Heater heater = new Heater(60);
Cooler cooler = new Cooler(80);
Action<float> delegate1;
Action<float> delegate2;
Action<float> delegate3;
delegate1 = heater.OnTemperatureChanged;
delegate2 = cooler.OnTemperatureChanged;
Console.WriteLine("Invoke both delegates:");
delegate3 = delegate1;
delegate3 += delegate2;
delegate3(90);
Console.WriteLine("Invoke only delegate2");
delegate3 -= delegate1;
delegate3(30);
輸出如下所示:
Invoke both delegates:
Heater: Off
Cooler: On
Invoke only delegate2
Cooler: Off

除此之外,還可以使用+和-操作符來合併委託,如下所示:

Thermostat thermostat = new Thermostat();
Heater heater = new Heater(60);
Cooler cooler = new Cooler(80);
Action<float> delegate1;
Action<float> delegate2;
Action<float> delegate3;
// Note: Use new Action(
// cooler.OnTemperatureChanged) for C# 1.0 syntax.
delegate1 = heater.OnTemperatureChanged;
delegate2 = cooler.OnTemperatureChanged;
Console.WriteLine("Combine delegates using + operator:");
delegate3 = delegate1 + delegate2;
delegate3(60);
Console.WriteLine("Uncombine delegates using - operator:");
delegate3 = delegate3 - delegate2;
delegate3(60);
輸出和上面的是一樣的。

使用賦值操作符會清除之前的所有訂閱者,並允許用新訂閱者替換它們。這是委託很容易讓人犯錯的一個設計,因為在本來應該使用+=操作符的時候,很容易就會錯誤地寫成=。解決這個問題的良方是事件,詳情將在稍後筆記中講述。
應注意的是,無論+、-還是它們的複合賦值版本(+=和-=),在內部都是使用靜態方法System.Delegate.Combine()和System.Delegate.Remove()來實現的。兩個方法都獲取delegate型別的兩個引數(public static Delegate Combine(Delegate a, Delegate b);Remove類似,可以通過visual studio來檢視System.Delegate.Combine()和Remove的定義)。第一個方法Combine()會連線兩個引數,將兩個委託的呼叫列表按順序連線到一起。第二個方法Remove()則搜尋由第一個引數指定的委託鏈,刪除由第二個引數指定的委託。
對於Combine()方法,一個有趣的地方在於,它的兩個引數都可以為null。如果其中任何一個引數為null,Combine()就返回非空的那個。如果兩個都為null,則Combine()返回null。這就解釋了為什麼可以呼叫thermostat.OnTemperatureChange += heater.OnTemperatureChanged;而不引發異常(即使thermostat.OnTemperatureChange尚未賦值)。

  • 順序呼叫:下圖展示了上例中委託的呼叫順序
    委託的呼叫順序
    雖然程式碼中只包含一個簡單的OnTemperatureChange()呼叫,但這個呼叫會廣播給兩個訂閱者,使cooler和heater都會收到溫度發生變化的通知。假如新增更多的訂閱者,它們也會收到OnTemperatureChange()的通知。
    雖然一個OnTemperatureChange()呼叫造成每個訂閱者都收到通知,但它們仍然是順序呼叫的,而不是同時呼叫,因為它們全都在一個執行執行緒上呼叫。

  • 多播委託的內部工作機制:
    為了理解事件是如何工作的,你需要回顧第12章中我們第一次探討System.Delegate型別的內部機制的部分。delegate關鍵字是派生自System.MulticastDelegate的一個型別的別名。System.MulticastDelegate則是從System.Delegate派生的,後者由一個物件引用(以滿足非靜態方法的需要)和一個方法引用構成。建立委託時,編譯器自動使用System.MulticastDelegate型別而不是System.Delegate型別。MulticastDelegate類包含一個物件引用和一個方法引用,這和它的Delegate基類一樣。但除此之外,它還包含對另一個System.MulticastDelegate物件的引用。
    向多播委託新增方法時,MulticastDelegate類會建立委託型別的一個新例項,在新例項中為新增的方法儲存物件引用和方法引用,並在委託例項列表中新增新的委託例項作為下一項。實際上,MulticastDelegate類維護著一個Delegate物件連結串列。從概念上講,可以下圖那樣表示Thermostat的例子。
    這裡寫圖片描述
    呼叫多播委託時,連結串列中的委託例項會被順序呼叫。通常,委託是按照它們新增時的順序呼叫的,但CLI規範並未對此做出硬性規定,而且這個順序可能被覆蓋。所以,程式設計師不應依賴於一個特定的呼叫順序。

  • 錯誤處理:錯誤處理凸顯了順序通知可能造成的問題。假如一個訂閱者引發了異常,鏈中的後續訂閱者就收不到通知。例如,假定修改Heater的OnTemperatureChanged()方法,使它引發異常,那麼會發生什麼?

class Program
{
public static void Main()
{
Thermostat thermostat = new Thermostat();
Heater heater = new Heater(60);
Cooler cooler = new Cooler(80);
string temperature;
thermostat.OnTemperatureChange +=
heater.OnTemperatureChanged;
// Using C# 3.0. Change to anonymous method
// if using C# 2.0
thermostat.OnTemperatureChange +=
(newTemperature) =>
{
throw new InvalidOperationException();
};
thermostat.OnTemperatureChange +=
cooler.OnTemperatureChanged;
Console.Write("Enter temperature: ");
temperature = Console.ReadLine();
thermostat.CurrentTemperature = int.Parse(temperature);
}
}

雖然cooler和heater已進行了訂閱來接收訊息,但Lambda表示式異常會使鏈發生中斷,造成cooler物件收不到通知。為了避免這個問題,使所有訂閱者都能收到通知(不管之前的訂閱者有過什麼行為),必須手動遍歷訂閱者列表,並單獨呼叫它們。下列程式碼展示了需要在CurrentTemperature屬性中進行的更新。

public class Thermostat
{
// Define the event publisher
public Action<float> OnTemperatureChange;
public float CurrentTemperature
{
get{return _CurrentTemperature;}
set
{
if (value != CurrentTemperature)
{
_CurrentTemperature = value;
if(OnTemperatureChange != null)
{
List<Exception> exceptionCollection =
new List<Exception>();
foreach (
Action<float> handler in
OnTemperatureChange.GetInvocationList())//這裡是程式碼關鍵
{
try
{
handler(value);
}
catch (Exception exception)
{
exceptionCollection.Add(exception);
}}
if (exceptionCollection.Count > 0)
{
throw new AggregateException(
"There were exceptions thrown by OnTemperatureChange Event subscribers.",exceptionCollection);
}}}}}
private float _CurrentTemperature;
}
輸出:
Enter temperature: 45
Heater: On
Error in the application
Cooler: Off

這個程式碼清單演示了你可以從委託的GetInvocationList()方法獲得一份訂閱者列表。列舉該列表中的每一項,可以返回給單獨的訂閱者。如果隨後將每個訂閱者呼叫都放到一個try/catch塊中,就可以先處理好任何出錯的情形,再繼續迴圈迭代。在這個例子中,儘管委託偵聽者(delegate listener)引發了一個異常,但cooler仍會接收到溫度發生改變的通知。所有通知都發送完畢之後,程式碼清單13-9通過引發一個AggregateException來報告所有已發生的異常。AggregateException包裝了一個異常集合。集合中的異常可以通過InnerExceptions屬性訪問。用這種方法,所有異常都得到報告,同時所有訂閱者都不會錯過通知。

本節完