C#高級編程第11版 - 第八章


導航

第八章 Delegates, Lambdas and Events

8.1 引用方法

委托是指向方法的.NET地址變量。將這個跟C++對比的話,C++中的函數指針僅僅只是一個訪問內存的地址的指針,因此它不是類型安全的。你無法判斷一個指針真實指向的是哪里,並且也不知道函數需要什么參數和返回值。這一點跟.NET完全不同。委托是類型安全的類,定義了返回類型和參數類型。委托類不單單只包含一個方法引用,它也可以保存多個方法的引用。

Lambda表達式直接跟委托相關。當參數是類型是一種委托類型,你可以使用Lambda表達式來實現一個被委托引用的方法( a method that's referenced from the delegate)。

本章闡述了委托和lambda表達式的基礎知識,並且向你演示了如何使用lambda表達式實現通過委托調用的方法。本章也演示了.NET是如何使用委托實現事件的。

8.2 委托

當你想將方法傳遞給另外一個方法的時候,委托就很有用。為了了解它是怎么運作的,考慮以下這樣的代碼:

int i = int.Parse("99");

在這里,你將數值當做參數傳遞給方法,就像在這個例子里,你不會意識到它有什么特別的地方,而當你遇到將一個方法代替數值作為參數時,你可能會覺得有點怪怪的。然而,某些時候你想通過一個方法來完成某些事情,這個方法除了操作數值之外,它可能需要根據實際需要,調用不同的方法,進行不同的處理。更深一步考慮的話,就是,在編譯的時候,你並不知道內部方法是啥。只有當運行時,你才知道哪些信息是可行的,並據此為第一個方法傳遞相應需要被調用的方法作為參數。這可能讓你感到疑惑,但我們將會用一組例子來讓你更加地了解它:

  • 線程和任務:通過這個功能,你可以在C#中,讓計算機並行開啟一個新的執行序列。這樣的指令序列也被叫作線程(thread),而你可以使用基類System.Threading.Thread的某個實例中的Start方法來啟動它。假如你想讓計算機開始一個新的執行序列,你需要告訴它從哪開始執行,這意味着,你需要提供可以執行的方法細節。換句話說,Thread的構造函數,需要得到這樣的一個參數,參數中定義了線程調用的是哪個方法。
  • 通用類庫:大部分的類庫都包含處理不同標准任務(various standard tasks)的代碼。通常這些類庫可以是內部封裝的(self-contained),這意味着你知道,當你在編寫某個類庫時,你明確知道任務過程是如何處理的。然而,在某些時候有的任務它還包含子任務(subtask),而這個子任務只有調用類庫的客戶端代碼(client code)才知道如何處理它。例如,假設你想編寫一個類,用來處理一組對象並將它們升序排列。排序處理過程中包含了重復地獲取兩個對象,並比較它們倆誰應該排在前面。假如你想讓你寫的類庫適用於比較所有類型的對象,那你並無法事先就給定兩個對象的比較方式。調用你這個類的客戶端代碼必須告訴你這個類,對於特定類型的對象,它究竟想按什么條件進行排序。客戶端代碼需要為你的類庫提供一個合適的方法用來處理這個比較過程。
  • 事件:事件時你的代碼中最常處理的情況,當某些事件發生的時候,它會發起通知並調用你寫好的代碼。圖像化界面編程(GUI)包含大量這樣的情況。當事件發生時,運行時需要知道調用哪個方法來處理它。而通過委托,我們可以將處理事件的方法作為參數告訴運行時。本章稍后將對這部分進行介紹。

在C和C++里,你可以直接獲取一個函數的地址,並將它當做一個參數進行傳遞,但這種方式不是類型安全的。你可以將任何函數傳遞給一個帶函數指針參數的方法。不幸的是,這種簡單直接的調用不單會導致某些安全問題,並且還會讓你在實踐面向對象編程的過程中,忽略(neglects)這樣一個事實——方法幾乎是獨立存在的。而在面向對象里,方法在調用前,它需要被關聯成某個類的實例。

因為存在這樣的一些問題,.NET Framework在語法上不允許直接訪問方法地址。而當你需要這么處理的時候,你可以將方法細節封裝成一種新的對象:委托。

委托,簡單來講,就是一種特殊類型的對象,它特殊就在於,其他對象可能是用來定義存儲某些具體數據的,而委托則是存儲了一個方法或者多個方法的訪問地址。

8.2.1 聲明委托

當你想在C#里使用class的時候,通常你需要分兩步走。首先,你需要定義這個類。這意味着,你需要告訴編譯器這個類都包含哪些字段和方法。然后,除非你只使用到類的靜態方法,你需要創建類的實例對象。委托同樣需要這樣的步驟。你首先聲明一個你要使用的委托,這意味着告訴編譯器,這個類型的委托想要使用什么樣類型的方法。在后台(behind the scenes),編譯器為創建了一個相應的類來代表那個委托。

聲明委托的語法如下有點像下面這樣:

delegate void IntMethodInvoker(int x);

這里我們定義了一個叫IntMethodInvoker的委托,並且指定了這個委托的每個實例,可以保存(hold)指向返回值為void並接收一個int類型參數的方法引用。委托最重要的一點是,它是類型安全的。當你定義委托的時候,你提供了完整的方法細節,包括方法的簽名以及它的返回值。

假定你想定義一個委托,叫做TwoLongOp,用來指代(represents)一個帶有兩個long類型參數並返回double類型的方法。你可以像這么做:

delegate double TwoLongsOp(long first, long second);

又或者,你想定義一個不接受任何參數僅僅返回string類型的方法委托,你可以像這么寫:

delegate string GetAString();

委托的語法與你定義方法很像,除了它沒有任何方法體,並且在方法開頭帶有delegate關鍵字聲明。因為在這里,你實際上是定義了一個新的類,因此你可以在任何類能定義的地方,定義一個新的委托——就是說,不管是在另外一個類里面,還是在任何類之外,又或者在上級命名空間之下,都可以。取決於你想將你定義的委托暴露給誰調用,以及委托的有效范圍,你可以像修飾普通class那樣給委托加上訪問修飾符——public,private,protected等等,如下所示:

public delegate string GetAString();

注意:定義一個委托真的意味着定義了一個新的類。委托類實際上派生自System.MulticastDelegate,而它又派生自基類System.Delegate。C#編譯器清楚委托類的存在並使用委托語法隱藏了委托類的操作細節。這也是另外一個可以用來說明C#是如何通過與基類聯動並盡可能地讓編程變得簡單的例子。

當你定義完一個委托之后,你可以創建它的實例對象,並且用創建的實例來保存特定方法的細節。

注意:這里的術語有些不盡如人意的地方。當你在提及"類"這個概念的時候,它有兩個不同的術語:class,用來表示更廣泛的定義,以及object,這代表的是類的實例。而不幸的是,當我們討論委托的時候,它只有一個術語。委托既可以用來代表委托類,也可以用來指代具體的委托實例。當你創建一個委托實例的時候,它本身也可能作為一個委托被引用(is also referred to as a delegate)。你需要根據上下文來理清我們說到的委托究竟代表何種含義。

8.2.2 使用委托

下面的代碼片段演示了如何使用委托。這是一個相當繁瑣的方式,為了調用int類型的ToString方法:

private delegate string GetAString();
public static void Main()
{
	int x = 40;
	GetAString firstStringMethod = new GetAString(x.ToString);
	Console.WriteLine($"String is {firstStringMethod()}");
	// With firstStringMethod initialized to x.ToString(),
	// the above statement is equivalent to saying
	// Console.WriteLine($"String is {x.ToString()}");
}

在上面的代碼中,實例化了一個委托類型GetString的變量firstStringMethod,並且將它指向整型變量x的ToString方法。C#里的委托通常帶有一個參數的構造函數,參數就是該委托想要引用的方法。方法簽名必須與你定義的委托類型完全匹配。在本例中,假如你試圖為變量firstStringMethod賦值其他方法(譬如帶有參數又或者返回類型不是string)的時候,你將會得到一個編譯錯誤。因為int.ToString是一個實例方法(不是靜態方法),因此你在給firstStringMethod賦值的時候,需要同時指定實例(x)來描述完整的方法名。

第二行代碼使用委托來顯示字符串。在所有代碼里,都支持直接使用委托實例名(這里是firstStringMethod),然后跟上一對小括號(如果有參數則傳遞參數)進行調用。效果跟直接使用委托封裝的方法是一樣的。因此上面代碼中的Console.WriteLine效果跟注釋里是一樣的。

事實上,支持委托實例直接通過小括號進行調用,跟你調用實例里的Invoke方法是一樣的,因為firstStringMethod是一個委托類型的變量,C#編譯器在后台將firstStringMethod()等同於:

firstStringMethod.Invoke();

為了減少代碼輸入量,在每個應用到委托實例的地方,你都可以只敲方法名。這種處理方式也被稱為委托推斷(delegate inference)。C#編譯器自動為它創建一個指定類型的委托實例。例如我們例子中的變量firstStringMethod的初始化過程是這樣子的:

GetAString firstStringMethod = new GetAString(x.ToString);

你可以省略這個new部分的代碼,僅僅將方法名傳遞給委托變量,實際上的處理是一樣的:

GetAString firstStringMethod = x.ToString;

C#編譯器檢測到委托類型firstStringMethod,因此它創建了一個GetAString類型的委托實例,將x.ToString作為參數傳遞給構造函數,並最終將實例賦值給firstStringMethod變量。

注意:在這里你不能在ToString后面敲上小括號變成"x.ToString()"這樣子。因為這意味着一次方法調用,這個方法調用返回的是一個string類型的對象,而這種類型無法賦值給一個委托類型的變量。你只能將方法的地址賦值給委托變量。

委托推斷可以在任何用到委托變量的地方生效。在本章稍后的部分,你將會看到事件(events)是如何使用這個功能特性的。

委托中保存了方法簽名和返回值,因此它可以確保方法調用是准確的,所以它是類型安全的。然而有趣的是,它並不關心被調用的方法的具體類型,不管它是靜態方法還是實例方法。

注意:給定委托的實例可以引用任意類型任意對象上的實例或者靜態方法,只要方法簽名跟委托需要的方法簽名一致即可。

為了演示這一點,接下來的例子中,我們擴展了先前的代碼,以便它能使用firstStringMethod委托來調用一組不同對象上的不同方法。在本例中,我們用到Currency結構體,Currency擁有自己的ToString重載並且還帶有一個同樣簽名的靜態方法GetCurrencyUnit。在示例中,同樣的委托比那輛可以用來調用這些方法:

struct Currency
{
	public uint Dollars;
	public ushort Cents;
	public Currency(uint dollars, ushort cents)
	{
		Dollars = dollars;
		Cents = cents;
	}
	public override string ToString() => $"${Dollars}.{Cents,2:00}";
	public static string GetCurrencyUnit() => "Dollar";
	public static explicit operator Currency (float value)
	{
		checked
		{
			uint dollars = (uint)value;
			ushort cents = (ushort)((value - dollars) * 100);
			return new Currency(dollars, cents);
		}
	}
	public static implicit operator float (Currency value) => value.Dollars + (value.Cents / 100.0f);
	public static implicit operator Currency (uint value) => new Currency(value, 0);
	public static implicit operator uint (Currency value) => value.Dollars;
}

現在你可以像下面這樣使用GetAString委托的實例:

private delegate string GetAString();
public static void Main()
{
	int x = 40;
	GetAString firstStringMethod = x.ToString;
	Console.WriteLine($"String is {firstStringMethod()}");
	var balance = new Currency(34, 50);
	// firstStringMethod references an instance method
	firstStringMethod = balance.ToString;
	Console.WriteLine($"String is {firstStringMethod()}");
	// firstStringMethod references a static method
	firstStringMethod = new GetAString(Currency.GetCurrencyUnit);
	Console.WriteLine($"String is {firstStringMethod()}");
}

以上的代碼為你演示了,你可以通過一個委托進行方法調用,並且隨后可以對其進行重新賦值,使得同一個委托指向不同類型的不同方法,甚至是同一類型中不同的實例或者靜態方法,只要它們的方法簽名和委托定義的一致即可。

當你運行程序,你會看到委托調用了不同的方法:

String is 40
String is $34.50
String is Dollar

盡管如此,你仍未曾看見進程是如何將委托傳遞給另外一個方法的,並且沒有什么特別有用的東西得到了實現。除了使用委托之外,你完全可以用更加直接的方式來調用int和Currence對象的ToString方法。不幸的是,委托的特性需要更加復雜的示例,你才能真正感受它們的實用性。接下來的章節里將會為你演示2個委托示例。第一個示例簡單的使用委托來調用一組不同的操作。它為你展示了如何將委托作為參數傳遞給方法,並且你如何使用委托數組,雖然它存在一些爭議,因為這並未做到更多委托能做的事情,你完全可以使用更簡單的方式去實現它。然后,我們將介紹第二個例子,它更加地復雜,通過一個BubbleSorter類,它實現了一個方法,將傳遞給它的一組對象進行升序排列,而這個類如果你不適用委托,寫起來會困難許多。

8.2.3 簡單的委托示例

在這個例子中,我們定義了一個MathOperations類,它包含了一組靜態方法,提供了兩個操作,來處理double類型的操作數,然后你可以使用委托來調用這些方法,MathOpertions類大概如下所示:

class MathOperations
{
	public static double MultiplyByTwo(double value) => value * 2;
	public static double Square(double value) => value * value;
}

你可以像下面這樣調用它:

using System;
namespace Wrox.ProCSharp.Delegates
{
	delegate double DoubleOp(double x);
	class Program
	{
		static void Main()
		{
			DoubleOp[] operations =
		 	{
				MathOperations.MultiplyByTwo, 
                MathOperations.Square
			};
			for (int i=0; i < operations.Length; i++)
			{
				Console.WriteLine($"Using operations[{i}]");
				ProcessAndDisplayNumber(operations[i], 2.0);
				ProcessAndDisplayNumber(operations[i], 7.94);
				ProcessAndDisplayNumber(operations[i], 1.414);
				Console.WriteLine();
			}
	}
	static void ProcessAndDisplayNumber(DoubleOp action, double value)
	{
		double result = action(value);
		Console.WriteLine($"Value is {value}, result of operation is {result}");
	}
}

在這段代碼里,你實例化了一個DoubleOp委托類型的數組(還記得當你定義一個委托之后,后台會為你生成相應的委托類,你可以簡單地將委托類型實例化,就像實例化普通類一樣,因此將它們的實例存儲到一個數組里也沒有任何問題)。數組中的元素被初始化成引用MathOperations類中的兩個方法。然后,你循環遍歷這個數組,將每個操作都應用到3個不同的值上。這個例子演示了其中一種使用委托的方式——將相關方法組織到一個數組中,然后你就可以通過循環來重復調用它們。

上述代碼中關鍵的一行是你將哪個委托傳遞給ProcessAndDisplayNumber方法,譬如這樣:

ProcessAndDisplayNumber(operations[i], 2.0);

之前的例子僅僅只是將委托的名稱進行傳遞,並不包含任何參數,而在這里,通過將operations[i]作為參數進行傳遞,語法上其實做了兩步處理:

  • operations[i]意味着一個委托,它引用了某個方法;
  • operations[i](2.0)意味着將2.0作為參數傳遞給委托引用的方法,進行一次方法調用。

ProcessAndDisplayNumber方法包含了兩個參數,第一個參數是一個DoubleOp委托類型,第二個參數是委托方法的參數:

static void ProcessAndDisplayNumber(DoubleOp action, double value)

在這個方法里,你可以像這樣進行調用:

double result = action(value); // 等同於action.Invoke(value)

這個語句的意思是,由action委托封裝的方法實例被調用,並且它的返回值存儲在result中。讓我們運行這個程序,它的結果應該如下所示:

Using operations[0]
Value is 2, result of operation is 4
Value is 7.94, result of operation is 15.88
Value is 1.414, result of operation is 2.828

Using operations[1]
Value is 2, result of operation is 4
Value is 7.94, result of operation is 63.043600000000005
Value is 1.414, result of operation is 1.9993959999999997

8.2.4 Action<T>和Func<T>委托

除了為每個參數和返回類型定義一個新的委托,你也可以直接使用Action<T>和Func<T>委托。泛型Action<T>委托意味着引用一個返回類型為void的方法。這個委托類帶有不同參數的版本,你最多可以傳遞16個不同類型的參數。而非泛型范本的Action類則用來調用那些不帶類型參數的方法。

Action<in T>可以用來調用帶一個參數的方法,而Action<in T1, in T2>則是處理兩個參數的方法,同理Action<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8>則是用來8個參數的方法。

Func<T>委托有着相似的使用方式。Func<T>允許你調用一個方法,並帶有一個返回值,跟Action<T>很像,Func<T>也定義了帶有不同數量參數的版本,你最多可以傳遞16個不同類型的參數,並最終返回一個類型。Func<out TResult>調用了一個帶有返回值的方法,但不包含任何參數。Func<in T, out TResult>則是調用帶有一個參數和一個返回值的方法,同理Func<in T1, in T2, in T3, in T4, out TResult>調用的方法帶有4個參數,並以此類推。

上一個小節中我們定義了一個委托,帶有一個double類型的參數和double類型的返回值,如下所示:

delegate double DoubleOp(double x);

除了定義這種自定義的DoubleOp委托之外,你也可以直接使用Func<in T,out TResult>,就像下面這樣:

Func<double, double>[] operations =
{
	MathOperations.MultiplyByTwo,
	MathOperations.Square
};

然后我們將ProcessAndDisplayNumber的參數類型進行修改就可以了:

static void ProcessAndDisplayNumber(Func<double, double> action, double value)
{
	double result = action(value);
	Console.WriteLine($"Value is {value}, result of operation is {result}");
}

8.2.5 BubbleSorter 示例

現在你已經有足夠的委托的相關知識了,接下來將為你演示委托真正有用的地方。假定你將編寫一個類,叫做BubbleSorter,這個類里實現了一個靜態方法,叫做Sort,它接收一個object類型的數組,並且將這個數組進行增序排列並返回。例如,你可以傳遞一個int類型的數組,如{0 ,5 ,6 ,2 ,1},通過這個方法,你將得到一個有序的數組{0, 1, 2, 5, 6}。

冒泡排序算法是一個常見算法並且可以非常簡單地對數字進行排序。它對於小集合的數字處理非常好用,因為對於大集合的數字(譬如10個以上),有其他的更有效率的算法。冒泡算法通過重復地循環遍歷數組,比較每一對數字,並且,假如有必要的話,就交換它們的位置,因此最大的數字將會逐漸地移動到數組的末尾。為了處理int數組,一個冒泡排序的代碼可能如下所示:

bool swapped = true;
do
{
	swapped = false;
	for (int i = 0; i < sortArray.Length—1; i++)
	{
		if (sortArray[i] > sortArray[i+1])) // problem with this test
		{
			int temp = sortArray[i];
			sortArray[i] = sortArray[i + 1];
			sortArray[i + 1] = temp;
			swapped = true;
		}
	}	
} while (swapped);

這段代碼給int數組用是沒有問題的,但你想讓你的Sort方法可以對任何類型的對象進行排序。換句話說,假如某個客戶端代碼給你傳遞了一個Currency結構體的數組又或者其他class或者自定義的結構體,你的Sort方法也必須能夠處理。在先前的代碼里,if(sortArray[i] < sortArray[i+1])這句代碼需要你對數組中的兩個對象進行比較,來決定哪個對象更大一些。你可以將這個用在int類型上,但對於那些沒有實現<運算符的class或者struct,它就不起效了。因此這里的解決的方式是,每個調用sort方法的類,必須將比較的方法用委托封裝好,當做參數傳遞過來。並且,為了對所有類型起效,我們使用泛型版本的Sort方法。

通過使用泛型版本的Sort<T>方法,它接收一個類型參數T,一個比較方法委托。這個委托方法接收兩個T類型的參數,並返回一個bool值,這里我們可以用Func<T1, T2, TResult>委托來指代,其中T1和T2是同樣的類型,因此你實現的Sort<T>方法如下所示:

static public void Sort<T>(IList<T> sortArray, Func<T, T, bool> comparison)

這里,comparison委托引用的方法,必須帶有兩個同類型的參數,並且當第一個參數值小於第二個的時候,將返回true,否則返回false。

現在你已經集齊了所有碎片。下面就是BubbleSorter類的完整定義:

class BubbleSorter
{
	static public void Sort<T>(IList<T> sortArray, Func<T, T, bool> comparison)
	{
		bool swapped = true;
		do
		{
			swapped = false;
			for (int i = 0; i < sortArray.Count-1; i++)
			{
				if (comparison(sortArray[i+1], sortArray[i]))
				{
					T temp = sortArray[i];
					sortArray[i] = sortArray[i + 1];
					sortArray[i + 1] = temp;
					swapped = true;
				}
			}
		} while (swapped);
	}
}

為了使用BubbleSorter類,你還需要定義另外一個類,用來創建一個需要排序的數組。在這個例子里,假定有家公司里有一組員工數據,並且想將他們按照薪資進行排序,每個員工通過一個Employee類的實例進行表示,代碼如下:

class Employee
{
	public Employee(string name, decimal salary)
	{
		Name = name;
		Salary = salary;
	}
	public string Name { get; }
	public decimal Salary { get; }
	public override string ToString() => $"{Name}, {Salary:C}";
	public static bool CompareSalary(Employee e1, Employee e2) => e1.Salary < e2.Salary;
}

注意為了和Func<T, T, bool>委托的簽名匹配,你在這個類里定義的CompareSalary必須包含兩個Employee參數,並且返回一個布爾值。如你所見,上面的實現基於工資進行比較。

現在你可以編寫一些客戶端代碼,來請求排序操作:

using System;
namespace Wrox.ProCSharp.Delegates
{
	class Program
	{	
		static void Main()
		{
			Employee[] employees =
			{
				new Employee("Bugs Bunny", 20000),
				new Employee("Elmer Fudd", 10000),
				new Employee("Daffy Duck", 25000),
				new Employee("Wile Coyote", 1000000.38m),
				new Employee("Foghorn Leghorn", 23000),
				new Employee("RoadRunner", 50000)
			};
			BubbleSorter.Sort(employees, Employee.CompareSalary);
			foreach (var employee in employees)
			{
				Console.WriteLine(employee);
			}
		}
	}
}

運行這段代碼,你將會發現雇員們正確的按照工資進行了排序:

Elmer Fudd, ¥10,000.00
Bugs Bunny, ¥20,000.00
Foghorn Leghorn, ¥23,000.00
Daffy Duck, ¥25,000.00
RoadRunner, ¥50,000.00
Wile Coyote, ¥1,000,000.38

8.2.6 多播委托

到目前為止,每個你看到的委托,都只負責一個方法調用。調用多少次委托,就相當於調用了所少次委托引用的方法。假如你想調用多個方法,你需要顯式地多次調用一個委托。然而,委托其實是可以封裝多個方法的。這種委托我們稱之為多播委托(multicast delegate)。當你調用多播委托的時候,它會按順序調用它引用的所有的方法。為了使多播委托有效,委托的返回值必須為void,否則,你只能得到委托引用的最后一個方法的返回值。

當委托返回類型為void的時候,你可以使用預定義的Action<T>委托,對於先前的DoubleOp例子,我們可以像這樣進行調用:

class Program
{
	static void Main()
	{
		Action<double> operations = MathOperations.MultiplyByTwo;
		operations += MathOperations.Square;
	}
}

之前你為了能保存兩個方法引用,你使用了委托數組,而在這里,你只需要簡單地通過多播委托的特性即可實現。多播委托能識別++=運算符。或者,你也可以將上面的例子擴展成多行的形式,如下所示:

Action<double> operation1 = MathOperations.MultiplyByTwo;
Action<double> operation2 = MathOperations.Square;
Action<double> operations = operation1 + operation2;

多播委托同樣能夠識別--=運算符,用來從委托中移除方法調用。

注意:在引擎下,一個多播委托實際上是一個派生自System.MulticastDelegate的類,而MulicastDelegate又派生自System.Delegate。System.MulticastDelegate擁有額外的成員,允許將方法鏈的調用存儲到一個列表中(allow the chaining of method calls into a list)。

為了演示多播委托的應用,接下來的代碼里改寫了SimpleDelegate例子,變成了一個新的示例:MulticastDelegate。因為現在你需要委托引用的方法返回值都為void類型,我們重寫了MathOperations類里的方法,將結果直接顯示到控制台上,而非作為返回值輸出:

class MathOperations
{
	public static void MultiplyByTwo(double value)
	{
		double result = value * 2;
		Console.WriteLine($"Multiplying by 2: {value} gives {result}");
	}
	public static void Square(double value)
	{
		double result = value * value;
		Console.WriteLine($"Squaring: {value} gives {result}");
	}
}

為了順應這份改動,你同樣需要重寫ProcessAndDisplayNumber方法:

static void ProcessAndDisplayNumber(Action<double> action, double value)
{
	Console.WriteLine();
	Console.WriteLine($"ProcessAndDisplayNumber called with value = {value}");
	action(value); // 沒有返回值了這里
}

現在你可以在Main方法里嘗試你的多播委托了:

static void Main()
{
	Action<double> operations = MathOperations.MultiplyByTwo;
	operations += MathOperations.Square;
	ProcessAndDisplayNumber(operations, 2.0);
	ProcessAndDisplayNumber(operations, 7.94);
	ProcessAndDisplayNumber(operations, 1.414);
	Console.WriteLine();
}

每次當ProcessAndDisplayNumber方法被調用,它將會顯示一條被調用的信息。后續的代碼調用action委托實例里的每一個委托:

action(value);

運行上面的代碼,結果如下所示:

ProcessAndDisplayNumber called with value = 2
Multiplying by 2: 2 gives 4
Squaring: 2 gives 4

ProcessAndDisplayNumber called with value = 7.94
Multiplying by 2: 7.94 gives 15.88
Squaring: 7.94 gives 63.043600000000005

ProcessAndDisplayNumber called with value = 1.414
Multiplying by 2: 1.414 gives 2.828
Squaring: 1.414 gives 1.9993959999999997

當你使用多播委托時,請記住同一個委托引用的方法鏈中的順序形式上是未定義的(formally undefined)。因此,請盡量不要編寫一些需要依賴於特定執行順序的方法。

通過一個委托來調用多個方法可能會導致一個更大的問題。多播委托包含了一個委托集合並挨個進行調用。假如其中某個方法拋出了一個異常,那么后續的調用就會中止。讓我們考慮下面這個MulticastIteration例子,這里我們用一個簡單的Action委托,不帶任何參數,返回值為void來演示。這個委托調用了兩個方法One和Two,請留意方法One中我們拋出了一個異常:

using System;
namespace Wrox.ProCSharp.Delegates
{
	class Program
	{
		static void One()
		{
			Console.WriteLine("One");
			throw new Exception("Error in one");
		}
		static void Two()
		{
			Console.WriteLine("Two");
		}
	}
}

在Main 方法里,委托d1首先引用了方法One,接下來我們把方法Two的地址也綁定給它。然后我們在一個try-catch語句塊里調用d1,異常將會被捕獲:

static void Main()
{
	Action d1 = One;
	d1 += Two;
	try
	{
		d1();
	}
	catch (Exception)
	{
		Console.WriteLine("Exception caught");
	}
}

執行Main方法,它的結果如下:

One
Exception caught

只有委托引用的第一個方法被調用,因為該方法拋出了一個異常,因此遍歷委托的過程就在這里被終止,所以方法Two不會被調用。

在這種場景下,你也可以通過自己枚舉委托的方法列表,來避免這種問題的發生。委托類里定義了一個方法,GetInvocationList,用來返回一個Delegate的數組對象。現在你可以通過它,直接調用它們引用的方法,捕獲異常,並執行下一個方法:

static void Main()
{
	Action d1 = One;
	d1 += Two;
	Delegate[] delegates = d1.GetInvocationList();
	foreach (Action d in delegates)
	{
		try
		{
			d();
		}
		catch (Exception)
		{
			Console.WriteLine("Exception caught");
		}
	}
}

當你運行修改后的代碼,你將會發現在異常捕獲之后,第二個方法仍然被正常調用:

One
Exception caught
Two

8.2.7 匿名方法

到目前為止,委托要起效的話,它必須指向某個已經存在的方法,這意味着,委托必須跟它想要引用的方法擁有相同的簽名。然而,這次提供了另外一種應用委托的方式——那就是匿名方法。匿名方法是一段代碼用來作為委托的參數。

定義一個引用匿名方法的委托在語法上沒有太大的變化,只是在委托實例化的時候發生了改變。下面的代碼為你演示了匿名委托是如何聲明和工作的:

class Program
{
	static void Main()
	{
		string mid = ", middle part,";
		Func<string, string> anonDel = delegate(string param)
		{
			param += mid;
			param += " and this was added to the string.";
			return param;
		};
		Console.WriteLine(anonDel("Start of string"));
	}
}

Func<string, string>委托接收一個string類型的參數並返回一個string類型的值。anonDel是這個委托類型的變量。在這里,我們沒有將某個具體的方法名稱賦值給變量,取而代之的是,我們使用了一個簡單的代碼塊,通過delegate關鍵字以及緊隨其后的string參數聲明來賦值。

就像你看到的那樣,這個代碼塊使用了方法級別(method-level)的字符串變量mid,這個變量是在方法外部定義的,並且直接在方法內部進行調用。這個代碼塊返回了一個字符串。隨后我們調用了這個委托,給它傳遞了參數"Start of string",並將返回值輸出到控制台上。

使用匿名方法的好處是它省了不少你需要書寫的代碼。你不需要為了給委托使用,而特地定義一個方法。當你為某個事件定義委托的時候,這點將會變得更加明顯(evident),它使得你不用書寫更多復雜代碼,尤其當你需要定義大量事件處理函數時。使用匿名方法並不會讓你的代碼跑的快些,編譯器依然將其當成一個正常的方法,只不過在后台為其自動生成一個你不知道的方法名而已。

在使用匿名方法時你必須遵守一系列的規則:

  • 你不能在匿名方法中使用任何跳轉語句,例如break,goto或者continue。反過來也一樣,外部的跳轉語句不能跳轉到匿名方法中去。
  • 匿名方法中不能使用不安全的代碼(Unsafe code),並且匿名方法外定義的ref或者out參數無法訪問,但是其他定義在匿名方法外的變量可以引用。
  • 假如同樣的功能需要被多次使用,就別用匿名方法了。在這種情況下,與其反復書寫匿名方法,還不如定義一個正常的命名方法進行復用呢。

注意:匿名方法的語法在C# 2.0開始就有,而在新版本的程序中你不再需要使用這個語法,因為lambda表達式提供了同樣並且更豐富的功能。然而你可能會在很多源代碼中遇見匿名方法,因此了解它也是好的。

lambda表達式從C# 3.0開始提供。

8.3 lambda 表達式

lambda表達式其中一種使用方式就是用來給委托類型賦值,以實現行內代碼(implement code inline)。在任何你需要用到委托類型作為參數的地方你都可以使用lambda表達式。前面的例子也可以改成lambda表達式的形式:

class Program
{
	static void Main()
	{
		string mid = ", middle part,";
		Func<string, string> lambda = param =>
		{
			param += mid;
			param += " and this was added to the string.";
			return param;
		};
		Console.WriteLine(lambda("Start of string"));
	}
}

在lambda表達式運算符=>的左邊,列出了所需的參數,而運算符右邊則定義了方法實現,並最終將其賦值給委托變量lambda。

8.3.1 參數

lambda表達式有不同的方式來定義參數。假如只有一個參數,那么僅僅只需要參數的名稱就足夠了。下面這個lambda表達式就用到了一個叫作s的參數。因為委托類型定義了string類型的參數,因此s就是string類型的。方法的實現通過調用了String.Format方法來返回一個字符串並最終輸出到控制台上:

Func<string, string> oneParam = s => $"change uppercase {s.ToUpper()}";
Console.WriteLine(oneParam("test"));

假如委托需要多個參數,你就需要將這些參數寫在一對小括號中。在下面這段代碼里,參數x和y都是double類型,通過Func<double, double, double>進行定義:

Func<double, double, double> twoParams = (x, y) => x * y;
Console.WriteLine(twoParams(3, 2));

為了便於理解,你也可以在括號里寫上參數的實際類型。假如編譯器無法正確適配重載版本,顯式聲明參數類型可以解決這個匹配問題:

Func<double, double, double> twoParamsWithTypes = (double x, double y) => x * y;
Console.WriteLine(twoParamsWithTypes(4, 2));

8.3.2 多行代碼

假如lambda表達式僅僅只包含一行語句,那么方法塊的左右大括號和return語句都可以省略,編譯器會為你隱式地添加return:

Func<double, double> square = x => x * x;

當然你想寫全也完全沒有問題,只不過上面這種寫法看起來更容易讀而已:

Func<double, double> square = x =>
{
	return x * x;
};

盡管如此,當你的方法實現需要用到多行代碼的時候,方法塊的{}和顯式的return關鍵字都是必須的:

Func<string, string> lambda = param =>
{
	param += mid;
	param += " and this was added to the string.";
	return param;
};

8.3.3 閉包

在lambda表達式里你也可以訪問代碼塊外的變量,這種方式被稱之為閉包(closure)。閉包是一個很棒(great)的特性,但假如使用不當,它也會變得十分危險。

在下面的例子里,我們用了一個Func<int, int>類型的lambda表達式,它接收一個int類型的參數並返回一個int值,而lambda表達式里我們用變量x來代表參數,在表達式里我們引用了局部變量someVal,它定義在表達式外部:

int someVal = 5;
Func<int, int> f = x => x + someVal;
Console.WriteLine(f(3)); // 8

只要你不知道,當f被調用時,lambda表達式實際上是創建了一個新的方法,這個語句看起來也沒那么奇怪。仔細看看這個代碼塊,f的返回值將會是x的值加上5,但這點並不關鍵。

假定變量someVal隨后發生了變化,然后lambda表達式被調用的時候,這個時候使用的確是新的someVal值,此時調用f(3)的結果將會是10:

someVal = 7;
Console.WriteLine(f(3)); // 10

類似的,當你在lambda表達式里修改了一個閉包變量的值時,你在外部訪問這個變量的時候,得到的也是修改后的值。

Console.WriteLine(someVal); // 7
Action<int> g = x => someVal = x;
g(5);
Console.WriteLine(someVal); // 5

現在,你可能會對為何能從內部或者外部修改閉包變量的值感到困惑。為了了解它是怎么發生的,讓我們考慮一下當你定義一個lambda表達式的時候,編譯器都做了什么。為了實現lambda表達式x => x + someVal,編譯器創建了一個匿名類,這個匿名類里包含了私有變量someVal,並且擁有一個構造函數來傳遞這個外部變量。這個構造函數會根據你將會訪問多少外部變量而生成。在這個簡單的例子里,構造函數僅僅只接收一個int類型的外部變量,這個匿名類還包含了一個匿名方法,方法體與lambda表達式一致,帶有同樣類型的參數和同樣類型的返回值:

public class AnonymousClass
{
	private int someVal;
	public AnonymousClass(int someVal)
	{
		this.someVal = someVal;
	}
	public int AnonymousMethod(int x) => x + someVal;
}

使用lambda表達式並且調用該方法的時候,創建了一個匿名類的實例並且當方法被調用的時候,將局部變量的值傳遞給匿名類的實例。

注意:當你在多線程使用閉包的時候,你可能會陷入並發沖突。因此在使用閉包時,最好只使用不可變類型。這樣做能夠保證值是永久不變的,並且不需要同步處理。

你可以在任何委托類型的地方使用lambda表達式,而另外一個可以使用lambda表達式的類型是Expression或者Expression<T>,編譯器用它來創建表達式樹。第12章LINQ將會介紹這部分內容。

8.4 事件

事件是基於委托的,並且對委托提供了發布/訂閱的機制。你可以在.NET框架中每個地方都看見事件的身影。在Windows應用程序中,Button類提供了Click事件。這種事件類型是一種委托。當Click事件觸發時需要一個處理方法,而這個方法需要提前進行定義,方法的參數和詳細信息都由委托類型存儲。

本小節的代碼示例中,事件被用來練習CarDealer和Consumer類。CarDealer類提供了一個事件,當一輛新車到達時觸發,而Consumer類則定於了這個事件,當新車到了的時候,會通知它。

8.4.1 事件發布程序

讓我們先介紹CarDealer類,它提供了基於事件的預訂功能。在CarDealer類里我們定義的事件名為NewCarInfo,類型是EventHandler<CarInfoEventArgs>,用關鍵字event進行定義。在類里還定義了一個方法NewCar,當事件被RaiseNewCarInfo方法調用觸發的時候,就調用NewCarInfo事件處理器進行處理:

using System;
namespace Wrox.ProCSharp.Delegates
{
	public class CarInfoEventArgs: EventArgs
	{
		public CarInfoEventArgs(string car) => Car = car;
		public string Car { get; }
	}
	public class CarDealer
	{
		public event EventHandler<CarInfoEventArgs> NewCarInfo;
		public void NewCar(string car)
		{
			Console.WriteLine($"CarDealer, new car {car}");
			NewCarInfo?.Invoke(this, new CarInfoEventArgs(car));
		}
	}
}

注意:空條件運算符?.在C# 6.0版本開始支持,第六章的時候我們介紹過這個運算符。

我們注意到CarDealer類里提供了一個EventHandler<CarInfoEventArgs>類型的event:NewCarInfo。通常來講,event通常使用的方法帶有兩個參數,第一個參數是object類型的,用來存儲發送事件的對象(sender),第二個參數則是用來存儲事件的詳細信息。根據不同的事件類型,第二個參數的類型的也不同。.NET 1.0為所有不同的數據類型定義了數以百計的事件委托。其實已經不再必要使用泛型委托EventHandler<T>。EventHandler<TEventArgs>定義了一個處理器,接收兩個參數並返回void,第一個參數是object類型,而第二個參數是泛型TEventArgs類型。

EventHandler<TEventArgs>的定義如下,其中限定了泛型類型必須是EventArgs類或者其派生類:

public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e) where TEventArgs: EventArgs

就像我們用到的CarInfoEventArgs一樣,也是派生自EventArgs類的。

在C#里,通過event關鍵字你可以在一行里聲明EventHandler,編譯器在后台為它創建了對應的委托類型變量,並且添加了add和remove關鍵字來訂閱/退訂委托。它與自動完成屬性以及完整屬性非常類似,完整的聲明語法如下所示:

private EventHandler<CarInfoEventArgs> _newCarInfo;
public event EventHandler<CarInfoEventArgs> NewCarInfo
{
	add => _newCarInfo += value;
	remove => _newCarInfo -= value;
}

注意:完整版的事件定義也是很有用的,假如你需要在其中添加其他的邏輯的話,譬如在多線程訪問中添加同步代碼。UWP和WPF控件有時候也通過完整定義來添加事件的冒泡和隧道處理(bubbling and tunneling functionality with the events)。

CarDealer類通過調用委托的Invoke方法來觸發事件。這將會調用所有訂閱了該事件的處理器。記住,就像前面多播委托演示的那樣,方法調用的順序是沒有保障的。為了能更好地控制多有處理器的執行,你可以使用Delegate類里的GetInvocationList方法來訪問委托里的所有項並且對每項單獨調用,就像之前演示的那樣。

在C# 6.0之前,事件的處理還要略微復雜一些,因為你需要對處理器進行空值處理,如下所示:

if (NewCarInfo != null)
{
	NewCarInfo(this, new CarInfoEventArgs(car));
}

而在C# 6.0之后,你就可以通過空條件運算符,就像前面那樣把代碼僅用一行書寫:

NewCarInfo?.Invoke(this, new CarInfoEventArgs(car));

記住在觸發事件之前,實現檢查委托是否為null是很有必要的,假如沒有任何人訂閱的話,委托的值就是null,此時調用其方法將會引發異常。

8.4.2 事件偵聽器

Consumer類被當做事件監聽器來用,這個類訂閱了CarDealer里的事件並且定義了一個NewCarIsHere方法來滿足EventHandler<CarInfoEventArgs>委托的參數要求:

public class Consumer
{
	private string _name;
	public Consumer(string name) => _name = name;
	public void NewCarIsHere(object sender, CarInfoEventArgs e)
	{
		Console.WriteLine($"{_name}: car {e.Car} is new");
	}
}

現在事件的發布者和訂閱者需要聯系起來。通過使用CarDealer里的NewCarInfo事件和+=來完成這個訂閱操作。名為Valtteri的Consumer訂閱了該事件,然后是名為Max的Consumer進行訂閱,再之后Valtteri通過-=退訂了這個事件:

class Program
{
	static void Main()
	{
		var dealer = new CarDealer();
		var valtteri = new Consumer("Valtteri");
		dealer.NewCarInfo += valtteri.NewCarIsHere;
		dealer.NewCar("Williams");
		var max = new Consumer("Max");
		dealer.NewCarInfo += max.NewCarIsHere;
		dealer.NewCar("Mercedes");
		dealer.NewCarInfo -= valtteri.NewCarIsHere;
		dealer.NewCar("Ferrari");
	}
}

運行這個程序,首先一輛名為Williams的新車出現了,並且通知了Valtteri。然后,Max訂閱了新車的消息通知,因此當新的Mercedes車到貨的時候,Valtteri和Max都得到了通知。再之后,Valtteri取消了對新車消息的關注,因此當Ferrari到貨的時候,只有Max得到了通知:

CarDealer, new car Williams
Valtteri: car Williams is new
CarDealer, new car Mercedes
Valtteri: car Mercedes is new
Max: car Mercedes is new
CarDealer, new car Ferrari
Max: car Ferrari is new

8.5 小結

本章介紹了委托,lambda表達式和事件的基礎知識。你已經學到了如何聲明一個委托,並如何將方法添加到委托隊列里。你也了解到如何使用lambda表達式來實現委托的方法調用,並且你還了解了為一個事件聲明處理器的過程,包括如何創建一個自定義的事件已經如何觸發這個事件。

在設計大型應用程序時使用委托和事件能大大降低各層之間的依賴關系。這使得你能開發高復用的程序組件。

lambda表達式是C#的語言特性,它是基於委托的,通過它,你可以省略不少代碼。lambda表達式不單只用在委托上,它還能用在LINQ中,我們將在第12章進行介紹。

下一章我們將介紹字符串的使用和正則表達式。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM