C#

C#事件与委托

Posted by LudoArt on July 2, 2023

C#事件与委托

事件

定义了事件成员的类型能提供以下功能:

  • 方法能登记它对事件的关注
  • 方法能注销它对事件的关注
  • 事件发生时,登记了的方法将收到通知

CLR事件模型以委托为基础。委托是调用(Invoke)回调方法的一种类型安全的方式。

编译器如何实现事件

如:

public event EventHandler<NewMailEventArgs> NewMail;

C#编译器编译时把它转换为以下3个构造:

// 1. 一个被初始化为null的私有委托字段
private EventHandler<NewMailEventArgs> NewMail = null;

// 2. 一个公共add_Xxx方法(其中Xxx是事件名)
// 允许方法登记对事件的关注
public void add_NewMail(EventHandler<NewMailEventArgs> value)
{
    // 通过循环和对CompareExchange的调用,可以以一种线程安全的方式向事件添加委托
    EventHandler<NewMailEventArgs> prevHandler;
    EventHandler<NewMailEventArgs> newMail = this.NewMail;
    do
    {
        prevHandler = newMail;
        EventHandler<NewMailEventArgs> newHandler = (EventHandler<NewMailEventArgs>) Delegate.Combine(prevHandler, value);
        newMail = Interlocked.CompareExchange<EventHandler<NewMailEventArgs>>(ref this.NewMail, newHandler, prevHandler);
    } while (newMail != prevHandler);
}

// 3.一个公共remove_Xxx方法(其中Xxx是事件名)
// 允许方法注销对事件的关注
public void remove_NewMail(EventHandler<NewMailEventArgs> value)
{
    // 通过循环和对CompareExchange的调用,可以以一种线程安全的方式向事件移除委托
    EventHandler<NewMailEventArgs> prevHandler;
    EventHandler<NewMailEventArgs> newMail = this.NewMail;
    do
    {
        prevHandler = newMail;
        EventHandler<NewMailEventArgs> newHandler = (EventHandler<NewMailEventArgs>) Delegate.Remove(prevHandler, value);
        newMail = Interlocked.CompareExchange<EventHandler<NewMailEventArgs>>(ref this.NewMail, newHandler, prevHandler);
    } while (newMail != prevHandler);
}
  • 第一个构造是具有恰当委托类型的字段。该字段是对一个委托列表的头部的引用。
  • 第二个构造是一个方法,允许其他对象登记对事件的关注。生成的代码总是调用 System.Delegate 的静态 Combine 方法,它将委托实例添加到委托列表中,返回新的列表头(地址),并将这个地址存回字段。
  • 第三个构造也是方法,允许对象注销对事件的关注。方法中的代码总是调用 System.Delegate 的静态 Remove 方法,将委托实例从委托列表中删除,返回新的列表头(地址),并将这个地址存回字段。

PS:

试图删除从未添加过的方法,DelegateRemove 方法在内部不做任何事情。也就是说,不会抛出任何异常,也不会显示任何警告;事件的方法集合保持不变。

在类型中定义事件时,事件的可访问性决定了什么代码能登记和注销对事件的关注。如上例中,因为源代码将事件声明为 publicaddremove 方法的可访问性都是 public

除了生成上述3个构造,编译器还会在托管程序集的元数据中生成一个事件定义记录项。这个记录项包含了一些标志(flag)和基础委托类型(underlying delegate type),还引用了 addremove 访问器方法。这些信息的作用就是建立”事件“的抽象概念和它的访问器方法之间的联系。

// 使用C#的+=操作符登记它对NewMail事件的关注
mm.NewMail += FaxMsg;

// C#编译器内建了对事件的支持,会将+=操作符翻译成以下代码来添加对象对事件的关注
mm.add_NewMail(new EventHandler<NewMailEventArgs>(this.FaxMsg));

如果对象不再希望接收事件通知时,应注销对事件的关注,如果类型要实现 IDisposableDispose 方法,就应该在实现中注销对所有事件的关注。

// 使用C#的-=操作符注销它对NewMail事件的关注
mm.NewMail -= FaxMsg;

// C#编译器内建了对事件的支持,会将-=操作符翻译成以下代码来移除对象对事件的关注
mm.remove_NewMail(new EventHandler<NewMailEventArgs>(this.FaxMsg));

委托

定义一些代码,随后用到:

internal delegate void Feedback(Int32 value);

private static void Counter(Int32 from, Int32 to, Feedback fb)
{
    for (Int32 val = from; val <= to; val++)
    {
        // 如果指定了任何回调,就调用它们
        if (fb != null)
            fb(val);
    }
}

private static void FeedbackToConsole(Int32 value)
{
    Console.WriteLine("Item=" + value);
}

private static void FeedbackToMsgBox(Int32 value)
{
    MessageBox.Show("Item=" + value);
}

private void FeedbackToFile(Int32 value)
{
    using (StreamWriter sw = new StreamWriter("Status", true))
    {
        sw.WriteLine("Item=" + value);
    }
}

用委托回调静态方法

private static void StaticDelegateDemo()
{
    Console.WriteLine("----- Static Delegate Demo -----");
    Counter(1, 3, null);
    Counter(1, 3, new Feedback(FeedbackToConsole)); // 委托对象是方法的包装器,使方法能通过包装器来间接回调
    Counter(1, 3, new Feedback(FeedbackToMsgBox));
    Console.WriteLine();
}

将方法绑定到委托时,C#和CLR都允许引用类型的协变性(covariance)和逆变性(contravariance)。协变性是指方法能返回从委托的返回类型派生的一个类型,逆变性是指方法获取的参数可以是委托的参数类型的基类。注意,只有引用类型才支持协变性和逆变性,值类型或void不支持。

用委托回调实例方法

private static void InstanceDelegateDemo()
{
    Console.WriteLine("----- Instance Delegate Demo -----");
    Program p = new Program();
    Counter(1, 3, new Feedback(p.FeedbackToFile)); // 导致委托包装对FeedbackToFile方法的引用,这是一个实例方法
    Console.WriteLine();
}

委托揭秘

重新审视这一行代码:

internal delegate void Feedback(Int32 value);

编译器实际会像下面这样定义一个完整的类:

internal class Feedback : System.MulticaseDelegate
{
    // 构造器
    public Feedback(Object @object, IntPrt method);
    
    // 这个方法的原型和源代码指定的一样
    public virtual void Invoke(Int32 value);
    
    // 以下方法实现对回调方法的异步回调
    public virtual IAsyncResult BeginInvoke(Int32 value, AsyncCallback callback, Object @object);
    public virtual void EndInvoke(IAsyncResult result);
}

注:System.MulticaseDelegate 派生自 System.Delegate ,后者又派生自 System.Object

由于所有委托类型都派生自MulticaseDelegate ,所以它们继承了MulticaseDelegate 的字段、属性和方法。在所有这些成员中,有三个非公共字段是最重要的。

字段 类型 说明
_target System.Object 当委托对象包装一个静态方法时,这个字段为null。当委托对象包装一个实例方法时,这个字段引用的是回调方法要操作的对象。换言之,这个字段指出要传给实例方法的隐藏参数this的值。
_methodPtr System.IntPtr 一个内部的整数值,CLR用它标识要回调的方法。
_invocationList System.Object 该字段通常为null。构造委托链时它引用一个委托数组。

所有委托都有一个构造器,它获取两个参数:一个是对象引用,另一个是引用了回调方法的整数。

对象引用被传给构造器的object参数,标识了方法的一个特殊 IntPtr 值(从MethodDefMemberRef元数据token获得)被传给构造器的method参数。对于静态方法,会为object参数传递null值。在构造器内部,这两个实参分别保存在_target_methodPtr私有字段中。除此之外,构造器还将_invocationList字段设为null

例如,在执行了一下两行代码后:

Feedback fbStatic = new Feedback(Program.FeedbackToConsole);
Feedback fbInstance = new Feedback(new Program().FeedbackToFile);

fbStaticfbInstance变量将引用两个独立的、初始化好的Feedback委托对象,如图所示:

image-20230703000927898

用委托回调多个方法(委托链)

private static void ChainDelegateDemo1(Program p)
{
    Console.WriteLine("----- Chain Delegate Demo 1 -----");
    Feedback fb1 = new Feedback(FeedbackToConsole);
    Feedback fb2 = new Feedback(FeedbackToMsgBox); 
    Feedback fb3 = new Feedback(p.FeedbackToFile);
    
    Feedback fbChain = null;
    fbChain = (Feedback) Delegate.Combine(fbChain, fb1);
    fbChain = (Feedback) Delegate.Combine(fbChain, fb2);
    fbChain = (Feedback) Delegate.Combine(fbChain, fb3);
    Counter(1, 2, fbChain);
    
    Console.WriteLine();
    fbChain = (Feedback) Delegate.Remove(fbChain, new Feedback(FeedbackToMsgBox));
    Counter(1, 2, fbChain);
}

在上述代码中,构造了三个委托对象并让变量 fb1fb2fb3 分别引用每个对象,如图所示:

image-20230703001449109

在第一次 Delegate.Combine时,发现试图合并的是 nullfb1,在内部,Combine 直接返回 fb1 中的值,所以 fbChain 变量现在引用 fb1 变量所引用的委托对象,如图所示:

image-20230703001643483

添加第二个委托时,在内部 Combine 方法发现 fbChain 已引用了一个委托对象,所以 Combine 会构造一个新的委托对象。新委托对象对它的私有字段 _target_methodPtr 进行初始化,同时 _invocationList 字段被初始化为引用一个委托对象数组。数组的第一个元素被初始化为引用包装了 FeedbackToConsole 方法的委托,第二个元素被初始化为引用包装了 FeedbackToMsgBox 方法的委托。最后 fbChain 被设为引用新建的委托对象,如图所示:

image-20230703002101012

在添加第三个委托时,过程与添加第二个委托时类似,需要注意的是,之前新建的委托及其_invocationList 字段引用的数组现在可以进行垃圾回收了。最终状态如图所示:

image-20230703002237003

在调用 Counter 方法时,会在 fbChain 引用的委托上调用 Invoke 方法,该委托发现私有字段_invocationList 不为 null ,所以会执行一个循环来遍历数组中的所有元素,并依次调用每个委托包装的方法。FeedbackInvoke 方法的伪代码大致如下:

// 无返回值
public void Invoke(Int32 value)
{
    Delegate[] delegateSet = _invocationList as Delegate[];
    if (delegateSet != null)
    {
        // 这个委托数组指定了应该调用的委托
        foreach (Feedback d in delegateSet)
            d(value); // 调用每个委托
    }
    else
    {
        // 否则不是委托链
        // 在指定的目标对象上调用这个回调方法
        _methodPtr.Invoke(_target, value);
    }
}

// 有返回值
public Int32 Invoke(Int32 value)
{
    Int32 result;
    Delegate[] delegateSet = _invocationList as Delegate[];
    if (delegateSet != null)
    {
        // 这个委托数组指定了应该调用的委托
        foreach (Feedback d in delegateSet)
            result = d(value); // 调用每个委托
        // 循环完成后,result变量只包含调用的最后一个委托的结果
    }
    else
    {
        // 否则不是委托链
        // 在指定的目标对象上调用这个回调方法
        result = _methodPtr.Invoke(_target, value);
    }
    return result;
}

还可调用 Delegate 的公共静态方法 Remove 从链中删除委托。Remove方法被调用时,它扫描第一个实参所引用的那个委托对象内部维护的委托数组(从末尾向索引0扫描)。Remove查找的是其 _target_methodPtr 字段与第二个实参中的字段匹配的委托。

  • 如果找到匹配的委托,并且在删除之后数组中只剩余一个数据项,就返回那个数据项;
  • 如果找到匹配的委托,并在数组中还剩余多个数据项,就新建一个委托对象——其中创建并初始化的_invocationList数组将引用原始数组中的所有数据项——并返回对这个新建委托对象的引用;
  • 如果从链中删除了仅有的一个元素,Remove会返回null

注意:每次Remove方法调用只能从链中删除一个委托,它不会删除有匹配的 _target_methodPtr 字段的所有委托。

C#编译器自动为委托类型的实例重载了+=和-=操作符。这些操作符分别调用 Delegate.CombineDelegate.Remove。可用这些操作符简化委托链的构造,如下示例代码 ChainDelegateDemo1ChainDelegateDemo2 方法生成的 IL 代码完全一样。

private static void ChainDelegateDemo2(Program p)
{
    Console.WriteLine("----- Chain Delegate Demo 2 -----");
    Feedback fb1 = new Feedback(FeedbackToConsole);
    Feedback fb2 = new Feedback(FeedbackToMsgBox); 
    Feedback fb3 = new Feedback(p.FeedbackToFile);
    
    Feedback fbChain = null;
    fbChain += fb1;
    fbChain += fb2)
    fbChain += fb3;
    Counter(1, 2, fbChain);
    
    Console.WriteLine();
    fbChain -= new Feedback(FeedbackToMsgBox);
    Counter(1, 2, fbChain);
}

委托类型的 Invoke 方法包含了对数组中的所有项进行遍历的代码,足以应付很多情形,但也有它的局限性:

  • 除了最后一个返回值,其他所有回调方法的返回值都会被丢弃;
  • 如果被调用的委托中有一个抛出了异常或阻塞了相当长一段时间,链中后续的所有对象都调用不了;

所以 MulticaseDelegate 类提供了一个实例方法 GetInvocationList,用于显示调用链中的每一个委托,并允许使用需要的任何算法:

public abstract class MulticaseDelegate : Delegate
{
    // 创建一个委托数组,其中每个元素都引用链中的一个委托
    public sealed override Delegate[] GetInvocationList();
}

在内部,GetInvocationList 构造并初始化一个数组,让它的每个元素都引用链中的一个委托,然后返回对该数组的引用。

委托定义不要太多(泛型委托)

.Net Framework 现在支持泛型,所以实际只需几个泛型委托(在System命名空间中定义)就能表示需要获取多达16个参数的方法:

public delegate void Action();
public delegate void Action<T>(T arg);
public delegate void Action<T1, T2>(T1 arg1, T2 arg2);
...
public delegate void Action<T1, ..., T16>(T1 arg1, ..., T16 arg16);

此外,还提供了17个 Func 函数,允许回调方法返回值:

public delegate TResult Func<TResult>();
public delegate TResult Func<T, TResult>(T arg);
public delegate TResult Func<T1, T2, TResult>(T1 arg1, T2 arg2);
...
public delegate TResult Func<T1, ..., T16, TResult>(T1 arg1, ..., T16 arg16);

然而,如需使用 refout 关键字以传引用的方式传递参数,就不得不定义自己的委托。

C#为委托提供的简化语法

button.Click += new EventHandler(button_Click);

上一行代码的思路是向按钮控件等级 button_Click 方法的地址,以便在按钮被单击时调用方法。构造 EventHandler 委托对象是CLR要求的,因为这个对象提供了一个包装器,可确保(被包装的)方法只能以类型安全的方式调用,这个包装器还支持调用实例方法和委托链。

简化语法1:不需要构造委托对象

C#允许指定回调方法的名称,不必构造委托对象包装器,如:

public static void CallbackWithoutNewingADelegateObject()
{
    ThreadPool.QueueUserWorkItem(SomeAsyncTask, 5);
}

private static void SomeAsyncTask(Object o)
{
    Console.WriteLine(o);
}

当然,当代码编译时,C#编译器还是会生成IL代码来新建委托对象。

简化语法2:不需要定义回调方法(lambda表达式)

前面的代码还可以这样重写:

public static void CallbackWithoutNewingADelegateObject()
{
    ThreadPool.QueueUserWorkItem(obj => Console.WriteLine(obj), 5);
}

C#编译器会将这些代码改写为下面这样:

// 创建该私有字段是为了缓存委托对象
// 优点:CallbackWithoutNewingADelegateObject 不会每次调用都新建一个对象
// 缺点:缓存的对象永远不会被垃圾回收
[CompilerGenerated]
private static WaitCallback <>9__CacheAnonymousMethodDelegate1;

public static void CallbackWithoutNewingADelegateObject()
{
    if (<>9__CacheAnonymousMethodDelegate1 == null)
    {
        // 第一次调用时,创建委托对象,并缓存它
        <>9__CacheAnonymousMethodDelegate1 = new WaitCallback(<CallbackWithoutNewingADelegateObject>b__0);
    }
    ThreadPool.QueueUserWorkItem(<>9__CacheAnonymousMethodDelegate1, 5);
}

[CompilerGenerated]
private static void <CallbackWithoutNewingADelegateObject>b__0(Object obj)
{
    Console.WriteLine(obj);
}

lambda表达式的优势在于,它从源代码中移除了一个“间接层”,或者说避免了迂回。如果只需在代码中引用这个主体一次,那么lambda表达式允许直接内联那些代码,不必为它分配名称,从而提高了变成效率。

简化语法3:局部变量不需要手动包装到类中即可传给回调方法

internal sealed class AClass
{
    public static void UsingLocalVariablesInTheCallbackCode(Int32 numToDo)
    {
        // 一些局部变量
        Int32[] squares = new Int32[numToDo];
        AutoResetEvent done = new AutoResetEvent(false);
        
        // 在其他线程上执行一系列任务
        for (Int32 n = 0; n < squares.Length; n++)
        {
            ThreadPool.QueueUserWorkItem(
            	obj => {
                    Int32 num = (Int32) obj;
                    
                    // 该任务通常更耗时
                    squares[num] = num * num;
                    
                    // 如果是最后一个任务,就让主线程继续运行
                    if (Interlocked.Decrement(ref numToDo) == 0)
                        done.Set();
             }, n);
        }
        
        // 等待其他所有线程结束运行
        done.WaitOne();
        
        // 显示结果
        for (Int32 n = 0; n < squares.Length; n++)
        {
            Console.WriteLine("Index {0}, Square={1}", n, squares[n]);
        }
    }
}

方法定义了一个参数 numToDo 和两个局部变量 squaresdone,lambda表达式的主体引用了这些变量。

C#编译器实际是像下面这样重写了代码:

internal sealed class AClass
{
    public static void UsingLocalVariablesInTheCallbackCode(Int32 numToDo)
    {
        // 一些局部变量
        WaitCallback callback1 = null;
        
        // 构造辅助类的实例
        <>c__DisplayClass2 class1 = new <>c__DisplayClass2();
        
        // 初始化辅助类的字段
        class1.numToDo = numToDo;
        class1.squares = new Int32[numToDo];
        class1.done = new AutoResetEvent(false);
        
        // 在其他线程上执行一系列任务
        for (Int32 n = 0; n < squares.Length; n++)
        {
            if (callback1 == null)
            {
                // 新建的委托对象绑定到辅助对象及其匿名实例方法
                callback1 = new WaitCallback(class1.<UsingLocalVariablesInTheCallbackCode>b__0);
            }
            
            ThreadPool.QueueUserWorkItem(callback1, n);
        }
        
        // 等待其他所有线程结束运行
        done.WaitOne();
        
        // 显示结果
        for (Int32 n = 0; n < squares.Length; n++)
        {
            Console.WriteLine("Index {0}, Square={1}", n, squares[n]);
        }
    }

	// 为避免冲突,辅助类被指定了一个奇怪的名称,而且被指定为私有,禁止从AClass类外部访问
	[CompilerGenerated]
	private sealed class <>c__DisplayClass2 : Object
	{
    	// 回调代码要使用的每个局部变量都有一个对应的公共字段
    	public Int32 numToDo;
    	public Int32[] squares;
    	public AutoResetEvent done;
    
    	// 公共无参构造器
    	public <>c__DisplayClass2 { }
    
    	// 包含回调代码的公共实例方法
    	public void <UsingLocalVariablesInTheCallbackCode>b__0(Object obj)
    	{
        	Int32 num = (Int32)obj;
        	squares[num] = num * num;
        	if (Interlocked.Decrement(ref numToDo) == 0)
            	done.Set();
    	}
	}   
}