委托和事件

C#语言中委托(delegate)可以实现类似C/C++的函数指针的作用,而事件(event)实现了发布订阅设计模式,可以用于实现对特定动作进行一系列响应。在日常开发中,两者还经常结合使用,例如WinForms中就使用了委托和事件,实现为控件注册回调函数的机制。

这篇笔记我们详细介绍委托、内置委托、Lambda表达式和闭包,以及事件和标准事件模式的使用。

delegate 委托

如果熟悉C/C++我们应该了解函数指针,它能实现将函数作为参数传递或保存,C#语言中,我们主要使用委托(delegate)实现该功能。委托可以看作一种安全的函数指针对象,它是一个引用类型,能够将函数的调用包装为变量。此外,委托比函数指针更加灵活,委托不仅可以包装一个函数,还可以包装一组函数实现组合委托。下面代码实现了一个简单的回调函数机制。

delegate void Callback();

class Program
{
    static void Main()
    {
        void Foo()
        {
            Console.WriteLine("程序运行成功!");
        }
        Callback cb = new Callback(Foo);

        LoadData(cb);
    }

    static void LoadData(Callback cb)
    {
        Console.WriteLine("加载数据中...");
        cb();
    }
}

上面例子代码中,我们首先声明了一个委托类型Callback。委托的声明使用delegate关键字,后面部分则和函数声明非常类似,实际上它规定了委托可以关联的函数签名。在Main函数中,我们首先定义了一个局部函数Foo,然后创建委托对象cb,并将局部函数的函数名作为参数传入其中。最终,我们将委托对象传入了另一个方法中,此时就可以直接使用类似调用函数的方式调用委托对象cb了,函数Foo会被触发执行。

声明委托类型

委托类型的声明和普通方法声明类似,只是没有方法体,并且需要使用delegate关键字修饰。委托可以带参数和返回值,也可以不带。例如下面的Calculate委托表示接收两个int参数并返回int的方法。

delegate void Callback();
delegate int Calculate(int a, int b);

需要注意的是,每个委托类型本质上是一个派生自System.MulticastDelegate(进而派生自System.Delegate)的类。委托的签名决定了它可以关联哪些方法,方法的返回类型和参数列表必须与委托签名一致。

创建委托

上面代码我们使用了一个局部函数创建委托,实际上实例方法、静态方法都可以用来创建委托。

// 使用实例方法创建委托
Callback c1 = new Callback(demoClass.Foo);
// 使用静态方法创建委托
Callback c2 = new Callback(DemoClass.Bar);

更进一步,其实new Callback()也可以省略掉,这种写法叫做方法组转换(method group conversion),也是实际开发中最常见的写法,简化的语法如下。

// 使用实例方法创建委托
Callback c1 = demoClass.Foo;
// 使用静态方法创建委托
Callback c2 = DemoClass.Bar;

组合委托

实际上委托不仅能包含一个函数调用,也可以包含多个,这被称为组合委托(多播委托)。两个委托类型可以使用+运算符生成一个新的组合委托。

Callback c1 = DemoClass.Foo;
Callback c2 = DemoClass.Bar;

Callback c3 = c1 + c2;

调用组合委托c3时,会依次调用c1和c2。

委托也支持+=和-=运算符。

Callback c1 = DemoClass.Foo;
c1 += DemoClass.Bar;
c1 -= DemoClass.Foo;

这里要注意,委托类似于string,是一个不可变对象,然而C#又为委托提供了+=和-=运算符,大家不要感到迷惑,实际上这里是生成了新的委托对象,而非在原来的委托对象上修改。

关于组合委托还有两点需要了解:

  1. 如果委托带有返回值,调用组合委托时,实际返回的是最后一个被调用方法的返回值,中间的返回值会被丢弃,因此带返回值的委托一般不建议用于组合场景。
  2. 组合委托在底层维护了一个调用列表,可以通过GetInvocationList()方法获取其中的每个委托单独调用,这在需要处理某个回调抛出的异常时很有用。

匿名方法和Lambda表达式

初始化委托时我们需要传入一个函数,然而如果这个函数仅用于初始化委托,单独定义一个函数的写法就比较麻烦了。C# 2.0引入了匿名方法语法,用于简化这类代码的编写。

Callback c = delegate ()
{
    Console.WriteLine("hello");
};

上面代码中,Callback是一个委托类型,我们直接使用了一个匿名方法对其进行了初始化。匿名方法也是使用delegate关键字修饰,且不需要设定方法名(毕竟这个概念就叫做匿名方法),小括号内可以指定方法的参数,匿名方法声明中不需要指定返回值,但如果委托具有非void的返回值,匿名方法的方法体必须返回相同类型的数据。

实际上匿名方法的编写还是有一些麻烦,C# 3.0引入了Lambda表达式,能够进一步简化匿名方法的写法。实际开发中,我们能看到绝大多数都是使用Lambda表达式,而非上面匿名方法的写法。

// 语句体
Callback c1 = () =>
{
    Console.WriteLine("hello");
};
// 表达式体,只有一句表达式时可以省略大括号和return
Callback c2 = () => Console.WriteLine("hello");

// 带参数的Lambda表达式
Calculate add = (a, b) => a + b;

Lambda表达式使用=>分隔参数列表和方法体,左操作数是参数,右操作数是方法体,用法和匿名方法一致。

闭包

匿名方法和Lambda表达式还有一个重要特性,就是它可以访问其外部作用域内的变量,比如下面代码中,Lambda表达式体内就可以访问外部的变量x。

int x = 1;

Callback c = () =>
{
    Console.WriteLine("x={0}", x);
};

这种“方法捕获了其定义作用域中的变量”的机制叫做闭包(closure)。这里要注意一个容易踩坑的细节:闭包捕获的是变量本身而非变量的值,也就是说外部变量在Lambda执行前如果发生了改变,Lambda中使用到的是改变后的值。

int x = 1;
Callback c = () => Console.WriteLine(x);
x = 2;
c(); // 输出 2,而非 1

在循环中定义Lambda时尤其要注意这一点。C# 5.0起,foreach语句的迭代变量每次循环都会是一个新的变量,因此下面的代码会输出0、1、2,符合直觉。

foreach (int i in new[] { 0, 1, 2 })
{
    Callback c = () => Console.WriteLine(i);
    c();
}

但如果是传统的for循环,循环变量i始终是同一个变量,捕获后最终都会是循环结束时的值,这是早期很容易出错的地方。

内置委托:Action、Func和Predicate

自定义委托虽然灵活,但实际开发中我们通常不需要为每种签名都声明一个委托类型,.NET为我们预置了一组常用的泛型委托,使用它们可以省去大量重复的delegate声明。

内置委托 说明
Action 无参数、无返回值的方法
Action<T1, ...> 有参数、无返回值的方法,最多支持16个参数
Func<TResult> 无参数、有返回值的方法
Func<T1, ..., TResult> 有参数、有返回值的方法,最后一个类型参数是返回值类型
Predicate<T> 接收一个参数并返回bool的方法

可以看到,无返回值的叫Action,有返回值的叫Func,返回bool的叫Predicate,这个起名非常生动而符合语义,干活不说话叫Action,计算给结果叫Func,判断对不对叫Predicate。这些内置委托本身都是普通委托类型,我们直接使用它们即可,例如上面的Callback就可以用Action替代,代码可以简化成下面这样。

class Program
{
    static void Main()
    {
        Action cb = () => Console.WriteLine("程序运行成功!");
        LoadData(cb);
    }

    static void LoadData(Action cb)
    {
        Console.WriteLine("加载数据中...");
        cb();
    }
}

其它几种内置委托的用法示例如下。

// Action<T>:接收一个参数,无返回值
Action<string> log = msg => Console.WriteLine(msg);
// Func<T1, T2, TResult>:接收两个参数,返回一个值
Func<int, int, int> add = (a, b) => a + b;
// Predicate<T>:接收一个参数,返回bool
Predicate<int> isPositive = n => n > 0;

实际开发中,如果只是为了临时传递一个回调,我们一般直接使用Action、Func等,而不用单独定义委托类型。

委托的协变和逆变

委托还支持协变和逆变,这两个概念用于描述方法签名和委托签名之间允许的“宽松”匹配关系,虽然名字听起来有点高深,但实际上这两个概念是非常优雅而符合直觉的。

协变:方法的返回类型可以比委托声明的返回类型更具体(派生程度更高)。

逆变:方法的参数类型可以比委托声明的参数类型更宽泛(派生程度更低)。

class Animal { }
class Dog : Animal { }

delegate Animal AnimalFactory();
delegate void DogHandler(Dog dog);

static Dog CreateDog() => new Dog();
static void HandleAnimal(Animal animal) { }

// 协变:返回类型Dog比Animal更具体
AnimalFactory f = CreateDog;
// 逆变:参数类型Animal比Dog更宽泛
DogHandler h = HandleAnimal;

委托的协变和逆变特性在集合排序、比较器、事件处理等场景中都有重要应用。

event 事件

C#中事件可以实现发布订阅设计模式,简单来说就是注册一些回调函数,满足特定条件时回调函数执行,事件需要结合委托来实现。下面例子代码实现了事件的声明和使用。

class Demo
{
    public event Action? DataLoaded;

    public void LoadData()
    {
        Console.WriteLine("加载数据中...");
        DataLoaded?.Invoke();
    }
}

class Program
{
    static void Main()
    {
        Demo demo = new Demo();
        demo.DataLoaded += () =>
        {
            Console.WriteLine("hello");
        };
        demo.LoadData();
    }
}

代码中,类Demo中我们使用event关键字声明了一个事件,注意声明事件时需要使用委托类型,这里我们直接使用了内置委托Action。最后在Main方法中,我们实例化了这个类,并使用+=运算符为事件注册回调函数。调用LoadData方法后,回调函数被执行。

事件和委托的区别

事件本质上是对委托的一层封装,理解两者区别很重要:

对比项 委托 事件
本质 是一个类型(派生自MulticastDelegate) 是一个成员,基于某个委托类型声明
外部操作 可赋值、可调用、可+=/-= 外部只能+=/-=订阅,不能赋值或主动触发
触发时机 任何持有委托的地方都可调用 只能由声明事件的类内部触发

正是因为事件限制了外部只能“订阅/取消订阅”而不能随意调用,才能保证发布订阅模式的封闭性。像上面例子中,只有在Demo类内部通过DataLoaded?.Invoke()触发事件才是合法的。

使用?.Invoke()安全触发事件

上面触发事件时我们使用了DataLoaded?.Invoke(),而不是直接写DataLoaded(),这是因为如果事件没有任何订阅者,其底层委托就是null,直接调用会抛出NullReferenceException,使用空条件运算符?.Invoke()可以在没有订阅者时安全地跳过调用。

此外,这种?.Invoke()写法还能顺带缓解线程安全问题,它会先把委托读取到临时位置再调用,避免了多线程环境下订阅者列表在判断和调用之间发生变化的问题。

标准事件模式

虽然使用内置委托可以快速定义事件,但.NET约定了一套标准事件模式,即事件类型使用EventHandler或EventHandler<TEventArgs>,事件处理方法的签名固定为(object? sender, TEventArgs e),其中sender表示事件源,e表示事件携带的数据。TEventArgs需派生自EventArgs。

// 自定义事件参数类,派生自EventArgs
class DataEventArgs : EventArgs
{
    public string Url { get; }

    public DataEventArgs(string url)
    {
        this.Url = url;
    }
}

class Downloader
{
    // 使用标准的EventHandler<TEventArgs>委托声明事件
    public event EventHandler<DataEventArgs>? DataDownloaded;

    public void Download(string url)
    {
        Console.WriteLine("{0} 数据下载中...", url);
        // 触发事件
        OnDataDownloaded(new DataEventArgs(url));
    }

    // 约定:使用protected virtual的OnXxx方法触发事件,便于派生类扩展
    protected virtual void OnDataDownloaded(DataEventArgs e)
    {
        DataDownloaded?.Invoke(this, e);
    }
}

class Program
{
    static void Main()
    {
        Downloader downloader = new Downloader();
        downloader.DataDownloaded += (sender, e) =>
        {
            Console.WriteLine("{0} 数据下载完成", e.Url);
        };
        downloader.Download("ftp://aaa.com");
    }
}

使用标准事件模式的好处是事件处理方法签名统一,便于框架和派生类统一处理;OnXxx方法声明为protected virtual,派生类可以重写它以改变或拦截事件的触发行为,这也是WinForms等框架中控件的通用做法。

小结

委托是C#中实现回调、函数式编程风格的核心机制,它把方法包装成了可以传递和组合的对象;事件则是在委托之上实现的发布订阅模式,通过限制外部操作保证了封闭性。实际开发中,我们一般直接使用Action、Func等内置委托,配合Lambda表达式编写回调逻辑;而在对外暴露可订阅行为时,则推荐遵循标准事件模式。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。