CSharp-委托

委托是类

委托是类,C# 提供了 delegate 关键字,使得用户能简单的声明委托。编译器及 CLR 做了大量的工作来隐藏委托的复杂性。[1]

1
internal delegate void FeedBack(int value);

编译器为如上的委托声明定义一个完整的类:

1
2
3
4
5
6
7
8
9
10
11
12
internal class FeedBack : System.MulticastDelegate
{
// 构造器(Constructor)
public FeedBack(object @object, IntPtr method);

// 委托调用 [2]
public virtual void Invoke(int value);

// 委托异步调用
public virtual IAsyncResult BeginInvoke(int value, AsyncCallback asyncCallback, object @object);
public virtual void EndInvoke(IAsyncResult result);
}

可使用 ILDasm.exe 打开生成的程序集,查看这个自动生成的类。

编译器定义了 FeedBack 类,其派生自 FCL (Framework Class Library) 中的 System.MulticastDelegate 类 (所有的委托都派生自 MulticastDelegate,MulticastDelegate 派生自 Delegate)。

MulticastDelegate

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

字段 类型 说明
_target (Delegate 类的字段) System.Object 若委托对象包装静态方法时,此字段为 null
若委托对象包装实例方法时,此字段引用回调方法需要操作的对象 (实例方法所在的对象)
_methodPtr (Delegate 类的字段) System.IntPtr 根据平台而定的整数类型 (所以上文 ILDasm 中的显示是 native int),CLR 使用它标记需要回调的方法
_invocationList System.Object 此字段通常为 null。构造委托链时,引用一个委托数组

委托的构造器有两个参数,一个是对象引用 (System.Object),另一个则是根据平台而定的整型 (System.IntPtr)。C# 编译器构造委托时,会分析源码以确定引用的对象及方法。对象引用被传给构造器的 object 参数,标识方法的特殊 IntPtr 值 (从 MethodDef 或 MemberRef 元数据 token 获得) 被传给构造器的 method 参数。对于静态方法,为 object 参数传递 null。构造器将这两个实参分别保存于 _target 及 _methodPtr。此外,构造器将 _invocationList 设为 null。

委托链/多播

委托链是委托对象的集合

合并

调用 Delegate.Combine(Delegate a, Delegate b) 方法对两个委托进行合并。

1
fbChain = (Feedback)Delegate.Combine(fbChain, fb1);

移除

调用 Delegate.Remove(Delegate source, Delegate value) 方法对两个委托进行合并。

1
fbChain = (Feedback)Delegate.Remove(fbChain, fb1);

原理

详情请参阅 Reference Source

Combine

  • 若一个委托为 null

    返回非 null 委托

  • 若两个委托为 null

    返回 Combine 的第二个参数 (当然返回的也是 null)

  • 若都不为 null

    进行委托合并

    1. 判断两委托类型是否相同,不同则抛出 ArgumentException
    2. 合并两个委托对象中的委托 (包括委托链中的委托) (Object[] : resultList) (使用 for 遍历实现)
    3. 统计两个委托对象中的委托数 (int: invocationCount) (委托链不为空则统计委托链中的委托)
    4. 根据 resultList 及 invocationCount 构建新的委托对象并返回

Remove

注意,若 Remove 的目标为委托链,则该委托链需为当前操作委托对象委托链的连续子列表。[3]

  • 若指定需要去除的委托为空,直接返回当前委托
  • 若指定需要去除的委托不为空,在当前委托及其委托链中寻找目标委托或目标委托链,剔除并返回
    • 结果是委托链则构建新委托并返回
    • 结果是单一委托则直接返回该委托

不要定义过多的委托

Microsoft 在刚开始开发 .NET Framework 的时候引入了委托的概念。开发人员在 FCL 中添加类时,凡是有回调方法的地方都定义了新的委托类型。随时间的推移,他们定义的委托越来越多。仅在 MSCorLib.dll 中,就有接近 50 个委托类型,例如:

1
2
3
4
5
6
7
public delegate void TryCode(Object userData);
public delegate void WaitCallback(Object state);
public delegate void TimerCallback(Object state);
public delegate void ContextCallback(Object state);
public delegate void SendOrPostCallback(Object state);
public delegate void ParameterizedThreadStart(Object obj);
...

以上的示例委托,实际上都是一样的:这些委托引用的方法都是获取一个 Object 返回 void。没有必要定义这么多委托,定义一个就够了。

现在的 .NET Framework 支持泛型 (C# 2.0 版本引入),只需要几个泛型委托,就能表示多达16个参数的方法:

  • 从无参,到至多16个参数,返回值为 void 的 Action 委托:

    1
    2
    3
    4
    5
    public delegate void Action(); // (这个不是泛型委托)
    public delegate void Action<in T>(T obj);
    public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
    ...
    public delegate void Action<in T1, ... , in T16>(T1 arg1, ... , T16 arg16);
  • 从无参,到至多16个参数,返回值为 TResult 的 Func 委托:

    1
    2
    3
    4
    public delegate TResult Func<out TResult>();
    public delegate TResult Func<in T, out TResult>(T arg);
    ...
    public delegate TResult Func<in T1, ... , in T16, out TResult>(T1 arg1, ... , T16 arg16);

建议尽量使用以上的委托类型,而不是定义更多的委托类型。这样能减少系统中的类型数量,简化代码。

但若需使用 ref 或 out 关键字以传递引用的方式传递参数,可能不得自定义委托:

1
delegate void Foo(ref int bar);

event 关键字

event 关键字用于在发布类 (publisher class) 中声明事件。这是一种特殊的多播委托,仅能从声明事件的类或结构(发布类)中对其进行调用,否则产生编译器:event 的委托仅能作为 += 或 -= 的左值 (除非在其声明的类或结构中)。 如果其他类或结构订阅该事件,则在发布类引发该事件时,将调用其事件处理程序方法。 有关详细信息和代码示例,请参阅事件委托

1
public event Action action;

EventHandler

EventHandler 委托是一个预定义的委托,专门表示不生成数据的事件的事件处理程序方法。

1
public delegate void EventHandler(object? sender, EventArgs e);

如果事件生成数据,则必须使用泛型 EventHandler 委托类。

1
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

委托的简化语法

Combine 与 Remove 的简化

C# 为委托重载 += 调用 Combine ,重载 -= 调用 Remove ,简化了委托链的构造。[4]

1
2
3
4
action1 = (Action) Delegate.Combine(action1, action2);
action1 += action2;
Delegate.Remove(action1, action2);
action1 -= action2;

不需要显式构造委托对象

仅仅是为了指定委托地址就构建一个对象显得有些奇怪,实际上构建委托对象是 CLR 的要求,该对象是包装器,可保证被包装的方法只能以类型安全的方式调用。C# 简化了委托的构建过程,不需要用户显示的使用 new 关键字进行委托的构造。

  • 未显式构造委托对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static void Main(string[] args)
    {
    Action action = PrintAction;
    action.Invoke();
    }

    private static void PrintAction()
    {
    Console.WriteLine("Action");
    }
  • 显式构造委托对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // DON'T DO THIS
    public static void Main(string[] args)
    {
    Action action = new Action(PrintAction);
    action.Invoke();
    }

    private static void PrintAction()
    {
    Console.WriteLine("Action");
    }

对比显式构造与非显式构造的 IL code,他们都会构造一个 Action 委托实例。

不需要定义回调方法 (使用 lamdba 表达式)

不需要因构造委托而定义一个方法:

1
2
3
4
5
6
7
8
9
10
11
public static void Main(string[] args)
{
Action action = PrintAction;
action.Invoke();
}

// NOT NEED
private static void PrintAction()
{
Console.WriteLine("Action");
}

可以使用 lamdba 表达式简化回调:[5]

1
2
3
4
5
public static void Main(string[] args)
{
Action action = () => Console.WriteLine("Action");
action.Invoke();
}

局部变量不需要手动包装到类中即可传递给回调方法

有时可能希望回调代码引用类中定义的其他成员或方法中的局部参数:

1
2
3
4
5
6
7
8
9
10
11
internal class Program
{
private static int bar = 21;

public static void Main(string[] args)
{
int foo = 21;
Action action = () => Console.WriteLine(foo + bar); // Closure allocation: 'foo' variable
action.Invoke();
}
}

实际上 lamdba 表达式主体的代码在一个单独的方法中 (CLR 的要求)。C# 通过自动辅助类实现闭包 (closure) [6]。在辅助类中,为需要传递给回调的每个值都定义一个字段。将回调方法定义为其实例方法。

构建回调方法实际上也构造了辅助类实例,使用方法中的局部变量的值初始化该实例中的字段,最后构造委托对象并绑定到该辅助对象的实例方法。

委托与反射

开发者可以在不知道回调方法的原型时使用回调。使用 MethodInfo.CreateDelegate,可在编译期不知道委托的所有必要信息的情况下创建委托:

1
2
3
4
// 构造包含静态方法的委托
public virtual Delegate CreateDelegate (Type delegateType);
// 构造包含实例方法的委托 (target 引用 this 实参)
public virtual Delegate CreateDelegate (Type delegateType, object? target);

创建完成后可用 Delegate.DynamicInvoke(Object[]) 调用它们:

1
2
// 调用委托并传递参数
public object? DynamicInvoke (params object?[]? args);

使用反射 API 获取引用了回调方法的 MethodInfo 对象,调用 CreateDelegate 构造委托 (如果是实例方法则需要传递 target 参数,指定其 this 参数)。

使用 DynamicInvoke 方法对委托对象的回调方法进行调用。DynamicInvoke 可传递一组参数,其在内部保证传递的参数与回调方法期望的参数兼容,兼容则调用回调方法,否则抛出 ArgumentException。若参数数不匹配,则抛出 TargetParameterCountException

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
internal class Foo
{
public static void StaticMethod() => Console.WriteLine(42);

public void NonStaticMethod() => Console.WriteLine(42);

public static void MethodWithPara(int num) => Console.WriteLine(num);

}

internal class Program
{
public static void Main(string[] args)
{
Foo delegateReflectionTest = new Foo();
MethodInfo nonStaticMethodInfo = delegateReflectionTest.GetType().GetMethod("NonStaticMethod");
Delegate delegate1 = nonStaticMethodInfo?.CreateDelegate(typeof(Action), delegateReflectionTest);
delegate1?.DynamicInvoke();
MethodInfo staticMethodInfo = delegateReflectionTest.GetType().GetMethod("StaticMethod");
Delegate delegate2 = staticMethodInfo?.CreateDelegate(typeof(Action));
delegate2?.DynamicInvoke();
MethodInfo methodInfoWithPara = delegateReflectionTest.GetType().GetMethod("MethodWithPara");
Delegate delegate3 = methodInfoWithPara?.CreateDelegate(typeof(Action<int>));
delegate3?.DynamicInvoke(42);
}
}
}

参阅

Ildasm.exe(IL 反汇编程序)

一般,该工具位于 NETFX 4.7.2 Tools 中

C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\ildasm.exe

如何合并委托(多播委托)- C# 编程指南 | Microsoft Docs

- - 和 -= 运算符 - C# 参考 | Microsoft Docs

+ 和 += 运算符 - C# 参考 | Microsoft Docs

注释

[1] 因此,可以定义类的地方,就可以定义委托。

[2] 这里把 Invoke 翻译为调用。但是要清楚 Invoke 和 Call 的区别,执行委托方法不是直接执行目标方法,而是从委托处援引 (Invoke) 目标方法执行。

[3] 实现细节请参阅 Reference Source Multicastdelegate ,算法为移除目标数组中的连续子序列

- - 和 -= 运算符 - C# 参考 | Microsoft Docs (委托删除) 中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Action a = () => Console.Write("a");
Action b = () => Console.Write("b");

var abbaab = a + b + b + a + a + b;
var aba = a + b + a;

var first = abbaab - aba;
first(); // output: abbaab
Console.WriteLine();
Console.WriteLine(object.ReferenceEquals(abbaab, first)); // output: True

Action a2 = () => Console.Write("a");
var changed = aba - a;
changed(); // output: ab
Console.WriteLine();
var unchanged = aba - a2;
unchanged(); // output: aba
Console.WriteLine();
Console.WriteLine(object.ReferenceEquals(aba, unchanged)); // output: True

[4] 可通过查看 IL code 验证这点:

[5] 请参阅 => 运算符 - C# 参考 | Microsoft Docs (表达式主体定义)

[6] 捕获上下文的外部变量以在回调方法中使用。闭包有对外部变量的引用,所以可能导致外部变量所在的对象声明周期延长。