深入了解C#委托的底层原理

853 阅读8分钟

代理在C#(以及一般的.NET)中被广泛使用。无论是作为事件处理程序、回调,还是作为被其他代码使用的逻辑(如LINQ)。

尽管它们被广泛使用,但对于开发者来说,委托的实例化并不总是那么明显。在这篇文章中,我将展示委托的各种用法以及它们产生的代码,这样你就可以看到在代码中使用它们的相关成本。

显式实例化

在C#语言的整个发展过程中,委托调用一直在不断发展新的模式,而没有打破之前存在的模式。

最初(1.0和1.2版本),唯一可用的实例化模式是用一个方法组显式调用委托类型构造器:

delegate void D(int x);

class C
{
    public static void M1(int i) {...}
    public void M2(int i) {...}
}

class Test
{
    static void Main() {
        D cd1 = new D(C.M1);        // static method
        C t = new C();
        D cd2 = new D(t.M2);        // instance method
        D cd3 = new D(cd2);         // another delegate
    }
}

隐式转换

C# 2.0引入了方法组转换,其中存在一个从方法组(Expression分类)到兼容的委托类型的隐式转换(Implicit conversions)。

这允许对委托进行简短的实例化:

delegate string D1(object o);

delegate object D2(string s);

delegate object D3();

delegate string D4(object o, params object[] a);

delegate string D5(int i);

class Test
{
    static string F(object o) {...}

    static void G() {
        D1 d1 = F;            // Ok
        D2 d2 = F;            // Ok
        D3 d3 = F;            // Error -- not applicable
        D4 d4 = F;            // Error -- not applicable in normal form
        D5 d5 = F;            // Error -- applicable but not compatible

    }
}

d1 的赋值隐含地将方法组F 转换为一个类型为D1 的值。

d2 的赋值显示了如何创建一个具有较少派生(contravariant)参数类型和较多派生(covariant)返回类型的方法的委托。

d3 的赋值表明,如果方法不适用,则不存在转换。

d4 的赋值显示了该方法必须在其正常形式下适用。

d5 的赋值显示了委托和方法的参数和返回类型是如何被允许仅在引用类型中不同的。

编译器会将上述代码翻译成:

delegate string D1(object o);

delegate object D2(string s);

delegate object D3();

delegate string D4(object o, params object[] a);

delegate string D5(int i);

class Test
{
    static string F(object o) {...}

    static void G() {
        D1 d1 = new D1(F);            // Ok
        D2 d2 = new D2(F);            // Ok
        D3 d3 = new D3(F);            // Error -- not applicable
        D4 d4 = new D4(F);            // Error -- not applicable in normal form
        D5 d5 = new D5(F);            // Error -- applicable but not compatible

    }
}

与所有其他隐式和显式转换一样,cast操作符可以用来显式地进行方法组转换。因此,这段代码:

object obj = (EventHandler)myDialog.OkClick;

将被编译器转换为:

object obj = new EventHandler(myDialog.OkClick);

这种实例化模式可能会在循环或频繁调用的代码中产生性能问题。

这段看起来代码:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
Sort(lines, StringComparer.OrdinalIgnoreCase.Compare);
...

将被转化为:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
Sort(lines, new Comparison<string>(StringComparer.OrdinalIgnoreCase.Compare));
...

这意味着每次调用时都会创建一个委托的实例。这个委托实例以后必须由垃圾收集器(GC)收集。

避免这种重复实例化委托的方法之一是预先将其实例化:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
private static Comparison<string> OrdinalIgnoreCaseComparison = StringComparer.OrdinalIgnoreCase.Compare;
...

...
Sort(lines, OrdinalIgnoreCaseComparison);
...

这将被编译器翻译成:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
private static Comparison<string> OrdinalIgnoreCaseComparison = new Comparison<string>(StringComparer.OrdinalIgnoreCase.Compare);
...

...
Sort(lines, OrdinalIgnoreCaseComparison);
...

现在,将只创建一个委托的实例。

匿名函数

C# 2.0还引入了匿名方法表达式的概念,作为编写未命名的内联语句块的一种方式,可以在委托调用中执行。

像方法组一样,匿名函数表达式可以隐含地转换为兼容的委托。

C# 3.0引入了使用lambda表达式来声明匿名函数的可能性。

作为一个新的语言概念,允许编译器设计者以新的方式解释这些表达式。

如果表达式没有外部依赖,编译器可以生成一个静态方法并优化委托的创建。

这段代码:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

...
var r = ExecuteOperation(2, 3, (a, b) => a + b);
...

将被翻译成:

[Serializable]
[CompilerGenerated]
private sealed class <>c
{
      public static readonly <>c <>9 = new <>c();

      public static Func<int, int, int> <>9__4_0;

      internal int <M>b__4_0(int a, int b)
      {
            return a + b;
      }
}
...

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

...
var r = ExecuteOperation(2, 3, <>c.<>9__4_0 ?? (<>c.<>9__4_0 = new Func<int, int, int>(<>c.<>9.<M>b__4_0)));
...

现在,编译器足够 "聪明",只在第一次使用时实例化该委托。

正如你所看到的,由C#编译器生成的成员名并不是有效的C#标识符。不过,它们是有效的IL标识符。编译器产生这样的名字的原因是为了避免与用户代码发生名称冲突。没有办法写出的C#源代码会有带有<> 的标识符。

这种优化之所以能够实现,是因为该操作是一个静态函数。如果相反,代码是这样的:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;

...
var r = ExecuteOperation(2, 3, (a, b) => Add(a, b));
...

我们就会回到每次调用都要进行委托实例化:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;

int <M>b__4_0(int a, int b) => Add(a, b);

...
var r = ExecuteOperation (2, 3, new Func<int, int, int> (<M>b__4_0));
...

这是由于该操作依赖于调用该操作的实例。

另一方面,如果该操作是一个静态函数:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
var r = ExecuteOperation(2, 3, (a, b) => Add(a, b));
...

编译器就会很聪明地优化代码:

[Serializable]
[CompilerGenerated]
private sealed class <>c
{
      public static readonly <>c <>9 = new <>c();

      public static Func<int, int, int> <>9__4_0;

      internal int <M>b__4_0(int a, int b)
      {
            return Add(a, b);
      }
}
...

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
var r = ExecuteOperation(2, 3, <>c.<>9__4_0 ?? (<>c.<>9__4_0 = new Func<int, int, int>(<>c.<>9.<M>b__4_0)));
...

闭包

每当lambda(或匿名)表达式引用表达式外的一个值时,总是会创建一个闭包类来保存该值,即使该表达式在其他情况下是静态的。

这段代码:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
var o = GetOffset();
var r = ExecuteOperation(2, 3, (a, b) => Add(a, b) + o);
...

将导致编译器生成这段代码:

[CompilerGenerated]
private sealed class <>c__DisplayClass4_0
{
      public int o;

      internal int <N>b__0(int a, int b)
      {
            return Add(a, b) + o;
      }
}

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
<>c__DisplayClass4_0 <>c__DisplayClass4_ = new <>c__DisplayClass4_0();
<>c__DisplayClass4_.o = GetOffset();
ExecuteOperation(2, 3, new Func<int, int, int>(<>c__DisplayClass4_.<M>b__0));
...

现在,不仅仅是一个新的委托将被实例化,还有一个类的实例来保存依赖值。这个编译器生成的用于捕获变量的字段在计算机科学中被称为闭包

闭包允许生成的函数在它们被定义的范围内访问变量。

然而,通过捕获本地环境或上下文,闭包可以意外地持有对资源的引用,否则会很快被收集,导致它们被提升到更高的世代,因此,由于垃圾收集器(GC)需要执行回收该内存的工作,会产生更多的CPU负荷。

静态匿名函数

因为很容易写出一个一开始想成为静态的lambda表达式,但最后却不是静态的,所以C# 9.0引入了静态匿名函数,允许将static 修改器应用于lambda(或匿名)表达式,以确保该表达式是静态的:

var r = ExecuteOperation(2, 3, static (a, b) => Add(a, b));

如果做了上述同样的修改,现在编译器会 "抱怨":

var o = GetOffset();
var r = ExecuteOperation(2, 3, static (a, b) => Add(a, b) + o); // Error CS8820: A static anonymous function cannot contain a reference to 'o'

解决办法

开发者可以做什么来避免这些不需要的实例化呢?

我们已经看到了编译器的做法,所以,我们也可以这样做。

通过对代码的这个小改动:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;
Func<int, int, int> addDelegate;

...
var r = ExecuteOperation(2, 3, addDelegate ?? (addDelegate = (a, b) => Add(a, b));
...

编译器现在唯一要做的就是添加委托实例,但同一委托实例将在包围类型的整个生命周期内使用:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;
Func<int, int, int> addDelegate;

...
var r = ExecuteOperation(2, 3, addDelegate ?? (addDelegate = new Func<int, int, int>((a, b) => Add(a, b)));
...

结尾

我们已经看到了使用委托的不同方式和编译器生成的代码及其副作用。

委托具有强大的功能,如捕获局部变量。虽然这些功能可以使你的工作效率更高,但它们并不是免费的。意识到生成的代码中的差异,就可以做出明智的决定,对于你的应用程序的特定部分,你更重视什么。

更频繁地实例化一个委托,会因为分配更多的内存而产生性能损失,这也会增加CPU的负载,因为垃圾收集器(GC)需要执行回收内存的工作。

出于这个原因,我们已经看到了如何以最适合我们性能需求的方式来控制编译器生成的代码。