• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

C#高级编程第11版-第八章

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

导航

第八章 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章进行介绍。

下一章我们将介绍字符串的使用和正则表达式。


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap