为什么委托是引用类型?

39

关于被接受的答案的快速注释: 我不同意Jeffrey的回答中的一个小部分, 即Delegate必须是引用类型,因此所有委托都是引用类型。这个观点并不正确,多级继承链并没有排除值类型的可能性;例如,所有枚举类型都继承自System.Enum,后者又继承自System.ValueType,后者再继承自System.Object全部是引用类型。然而,我认为重要的认识实际上是所有委托不仅从Delegate继承,而且从MulticastDelegate继承。正如Raymond在对他的答案的评论中指出,一旦你决定支持多个订阅者,对于委托本身,没有使用引用类型的必要,考虑到数组的需要。


请注意底部的更新。

如果我这样做:

Action foo = obj.Foo;

我每次都创建一个新的Action对象。虽然成本很小,但这涉及到分配内存,稍后需要进行垃圾回收。

考虑到委托本身是不可变的,我想知道它们为什么不能成为值类型?那么像上面那行代码只会产生一个简单的堆栈内存地址分配。

即使考虑匿名函数,似乎(对于来说)这也可以实现。请考虑以下简单示例:

Action foo = () => { obj.Foo(); };
在这种情况下,foo确实构成了一个闭包。在许多情况下,我想这确实需要一个实际的引用类型(例如当局部变量被关闭并在闭包内修改时)。但在某些情况下,它不应该。例如,在上面的情况下,似乎支持闭包的类型可以像这样:我收回我原来的看法。下面确实需要是一个引用类型(或者说,它不需要是,但如果它是一个struct,它最终将被装箱)。因此,请忽略下面的代码示例。我只是为了提供有关专门提到它的答案的背景而保留它。
struct CompilerGenerated
{
    Obj obj;

    public CompilerGenerated(Obj obj)
    {
        this.obj = obj;
    }

    public void CallFoo()
    {
        obj.Foo();
    }
}

// ...elsewhere...

// This would not require any long-term memory allocation
// if Action were a value type, since CompilerGenerated
// is also a value type.
Action foo = new CompilerGenerated(obj).CallFoo;
这个问题有两种可能的解释:
1. 正确实现委托作为值类型需要额外的工作/复杂度,因为支持修改本地变量值的闭包等内容需要编译器生成的引用类型。
2. 委托不能作为值类型实现的其他原因。
总之,这不会影响我的睡眠,只是我一直很好奇而已。
更新:作为回应Ani的评论,我明白了上面示例中的CompilerGenerated类型为什么也可以是引用类型,因为如果委托将包括函数指针和对象指针,则无论如何都需要引用类型(至少对于使用闭包的匿名函数而言,因为即使您引入一个附加的泛型类型参数,例如Action,这也无法涵盖无法命名的类型!)。 然而,所有这些只是让我有点后悔把闭包的编译器生成类型的问题带入讨论! 我的主要问题是关于委托的,即具有函数指针和对象指针的东西。 对我来说,它似乎仍然可以是值类型。 换句话说,即使是这样...
Action foo = () => { obj.Foo(); };

如果闭包需要创建一个引用类型对象(来支持闭包,并提供给委托器一个引用对象),那么为什么需要创建两个对象(一个支持闭包的对象加上Action委托器)?

*是的,是的,这是实现细节,我知道!我真正想说的是短期内存存储


好的,假设您想使用函数指针和对象指针将委托实现为值类型。在您的闭包示例中,对象指针将指向哪里?您几乎肯定需要将“CompilerGenerated”结构实例装箱并放置在堆上(通过逃逸分析,在某些情况下可以避免这种情况)。 - Ani
@Ani:啊,我明白你的意思了。也许你可以把那个评论扩展成一个回答? - Dan Tao
你真的想要使用 Nullable<Action> 吗? - Amy B
@DavidB:你真的想要使用null吗?空值并不总是理想的选择。 - user395760
2
@Ani:如果委托是一个包含函数指针和对象指针的结构体,那么构建闭包只需要创建一个新的堆对象而不是两个。如果委托是接口类型(我认为它们应该是这样),那么闭包只需要创建一个堆对象来保存闭包数据和其方法。 - supercat
显示剩余2条评论
7个回答

17
问题归结为:CLI(通用语言基础结构)规范指出委托是引用类型。为什么会这样?
原因之一在于.NET Framework中很明显。在最初的设计中,有两种委托:普通委托和“多路广播”委托,其调用列表中可以有多个目标。 MulticastDelegate类继承自Delegate。由于无法从值类型继承,Delegate必须是引用类型。
最终,所有实际委托都成为了多路广播委托,但在该过程的那个阶段,合并两个类已经太迟了。关于这个确切主题,请参见blog post

我们在V1末期放弃了Delegate和MulticastDelegate之间的区别。当时,将两个类合并将是一个巨大的变化,所以我们没有这样做。您应该假装它们已经合并,并且只存在MulticastDelegate。

此外,委托当前具有4-6个字段,所有字段都是指针。 16字节通常被认为是节省内存仍然胜过额外复制的上限。64位MulticastDelegate占用48字节。鉴于此,以及他们使用继承的事实,类是自然选择。

3
我大概明白你的意思,但并不是所有委托都必须是引用类型仅仅因为Delegate是引用类型,对吧?我的意思是,考虑一下System.Enum:它是一个引用类型,而所有实际的枚举类型都继承自它;然而枚举是值类型。这在CLI中是合法的,并且编译器也能清楚地实现。所以,决定所有委托类型都是引用类型肯定有更进一步的原因。 - Dan Tao
2
System.Enum 不是值类型!它是一个抽象类;请自行查看:http://msdn.microsoft.com/en-us/library/system.enum.aspx。 - Dan Tao
1
此外,您可以使用多级继承来处理所有值类型,因为它们都继承自 System.ValueType(具有讽刺意味的是,它是一个引用类型)。 - Dan Tao
3
我不认为一个“委托”需要超过两个字段(用于保存方法和目标)。给定两个“单播”委托,可以通过使“目标”引用包含另外两个委托的数组,并使“方法”指向一个接受此类数组作为其第一个参数并调用其中委托的静态方法,来形成一个多播委托。请注意,使用此类多播委托的“方法”和“目标”建立委托将产生正确形式的多播委托 - 不像目前的情况。同时请注意... - supercat
1
“Delegate.Combine” 可以检查任何委托的“Method”,以确定它是否为多路广播调用程序,如果是,则生成一个组合调用列表。 - supercat
显示剩余5条评论

11

Delegate需要成为一个类的原因只有一个,但是这个原因很重要:尽管委托可能足够小,可以作为值类型进行高效存储(32位系统上为8字节,64位系统上为16字节),但它不可能足够小,以便在一个线程尝试写入委托时,另一个线程尝试执行它时,后者不会最终调用新目标上的旧方法或旧目标上的新方法。允许这样的事情发生将成为一个重大安全漏洞。使委托成为引用类型避免了这种风险。

实际上,比使委托成为结构类型更好的做法是使它们成为接口。创建闭包需要创建两个堆对象:一个编译器生成的对象来保存任何关闭的变量,以及一个委托来调用该对象上的适当方法。如果委托是接口,那么保存闭包变量的对象本身可以用作委托,而无需其他对象。


2
这个答案给出了最真实的原因。如果不考虑这个问题,委托可能真的是值类型。还有类似的类型,比如TypedReferenceArgIterator和各种句柄,它们也代表对某些东西的引用,它们都是值类型。 - IS4
只读值类型委托。 - Gavin Williams
我是在回应你提出的原因,即它们不能是结构体。一个只读的结构体将是线程安全的。而且现在我们有了委托*(函数指针),我倾向于它们是值类型。能够使用值类型真的很重要。我认为这更多取决于开发人员的意愿,而不是任何真正的限制。 - Gavin Williams
1
@GavinWilliams:如果一个只读的结构类型包含多个字段,那么结构赋值将被视为一系列单独的字段赋值,即使这个结构类型声称是“只读”的。 - supercat
@GavinWilliams: .NET 设计理念的一个基本方面是,即使线程不安全的代码也无法违反基本不变性。因此,它不能提供具有“相信我”语义的委托结构类型。 - supercat
显示剩余2条评论

7
想象一下,如果委托是值类型。
public delegate void Notify();

void SignalTwice(Notify notify) { notify(); notify(); }

int counter = 0;
Notify handler = () => { counter++; }
SignalTwice(handler);
System.Console.WriteLine(counter); // what should this print?

根据您的提议,这将在内部转换为

struct CompilerGenerated
{
    int counter = 0;
    public Execute() { ++counter; }
};

Notify handler = new CompilerGenerated();
SignalTwice(handler);
System.Console.WriteLine(counter); // what should this print?

如果delegate是值类型,那么SignalEvent将会获得handler的副本,也就是说一个全新的CompilerGenerated会被创建(即handler的一个副本),并传递给SignalEventSignalTwice将执行两次委托,这会使得副本中counter的值增加两次。然后SignalTwice返回,并打印0,因为原始值没有被修改。

6
由于委托可以有多个订阅者,而结构体必须是固定大小的,因此你必须对可以进行多路广播的订阅者数量设定一个硬性限制(如果选择一个数值过大,则委托对象会变得非常庞大),或者将订阅者保存在一个单独的对象中,例如数组(这种情况下,就无法避免创建引用类型)。 - Raymond Chen
1
我认为你的后续评论真正抓住了问题的核心。代表允许多个订阅者似乎是这里潜在的“根本原因”;至少这是对我来说最有意义的解释。顺便说一句,冒着听起来很老套的风险,让我说在StackOverflow上见到你真是太棒了,而且我感到非常荣幸能收到你的答复! - Dan Tao
3
委托不必是一个引用类型,以便多事件订阅工作。如果委托是一个结构体,它结合了对象引用和一个接受这样的对象的方法的指针,两个Action(integer)委托可以与指向ExecuteAllActionsInArray方法的数组组合在一起(如果任一委托具有ExecuteAllActionsInArray作为其方法,则连接的数组中的委托可以复制到新数组中)。 - supercat
2
@Raymond Chen:从线程安全性的角度来看,委托必须是类类型。如果不考虑线程安全性,委托可以是一个不可变结构体,其中包含一个对象类型的字段和一个指向可以操作该类型对象的方法的指针。构造两个独立的委托不需要创建任何堆对象。将它们合并成多路广播委托需要创建一个堆对象来保存原始委托,以及一个结构体,该结构体将持有对该堆对象的引用和指向运行该对象中持有的委托的方法的指针。 - supercat
2
@Raymond Chen:实际上,大多数实际创建的委托都只有一个目标。尽管对于具有多个目标的多路广播委托需要进行堆分配,但避免为每个委托进行堆分配将是一个重大的胜利。实际上,我怀疑将每个单目标委托减少到一个堆对象,其中包括一个对象引用和一个函数指针,会比实际存在的更好。 - supercat
显示剩余2条评论

4

以下是一种不成熟的猜测:

如果将委托实现为值类型,则实例复制的代价可能会很高,因为委托实例相对较重。也许 MS 认为将其设计为不可变的引用类型更为安全-复制机器字大小的实例引用相对便宜。

委托实例至少需要:

  • 一个对象引用(如果包装的方法是实例方法,则为“this”引用)。
  • 指向包装函数的指针。
  • 对象引用列表的引用,该委托类型应通过设计支持使用相同委托类型进行多路广播。

让我们假设值类型委托以类似于当前引用类型实现的方式实现(这或许有些不合理;可能会选择不同的设计来减小大小),这里是委托实例所需的字段:

System.Delegate: _methodBase, _methodPtr, _methodPtrAux, _target
System.MulticastDelegate: _invocationCount, _invocationList

如果以结构体形式实现(没有对象头),这些在x86上将增加24个字节,在x64上将增加48个字节,对于结构体来说这是非常大的。
另外,我想问一下,在您提出的设计中,将CompilerGenerated闭包类型设置为结构体有什么好处。创建的委托对象指针将指向哪里?未经适当逃逸分析而将闭包类型实例保留在堆栈上将是极其危险的。

1
我已回复了你关于将“CompilerGenerated”作为值类型的评论:你是对的,它并没有帮助。但问题仍然存在于委托本身。我认为你对这里的推理的猜测是有道理的。 - Dan Tao
2
实际上,委托只需要两个字段——目标对象和要执行的函数。可以通过创建一个包含这些委托的数组,并将该数组与指向ExecuteAllDelegatesInArray方法的指针一起放入新的委托中,来组合两个或更多的委托。 - supercat

1

我在互联网上看到了这个有趣的对话:

不可变并不意味着它必须是值类型。而且,一个值类型并不一定是不可变的。这两者经常联系在一起,但实际上它们并不是同一回事,在.NET Framework中也有相应的反例(例如String类)。

而答案是:

区别在于,虽然不可变的引用类型相当普遍且完全合理,但使值类型可变几乎总是一个坏主意,并且可能导致一些非常令人困惑的行为!

摘自此处

因此,在我看来,这个决定是基于语言可用性方面做出的,而不是编译器技术难度。我喜欢可空委托。


这并不是问题的重点,但是:我不明白可变值类型有什么令人困惑的地方。你能举个例子吗?显而易见的例外情况是那些从未(正确地)理解过值类型语义以及某些情况下不明显的值类型。但这两种情况都可以和应该被解决... - user395760
1
只需在此处或Eric Lippert的博客上搜索可变值类型的邪恶之处即可。有很多关于这个问题的讨论。它们通常令人困惑是因为编译器会在你意料不到的地方创建值类型的副本,然后你只能改变副本。 - CodesInChaos
3
CLR内存管理器针对频繁分配小、短寿命对象进行了优化,因此引用类型与值类型之间的性能差异大多是虚假的。在几乎所有情况下,引用类型比值类型更适合表示给定的不可变数据。值类型的最大优势在于需要在大型集合中紧凑存储数据时。 - Dan Bryant
1
根据我所发现的,只有在 http://blogs.msdn.com/b/ericlippert/archive/2011/03/14/to-box-or-not-to-box-that-is-the-question.aspx 中描述的将值类型转换为接口的例子似乎特别奇怪。使用 using 会复制,obj.valueTypeInstance 会复制,从列表中获取 SpinLock(值类型)也会复制等等,这些都与我在头脑中理解的值类型语义完全一致。这表明许多人尚未完全掌握值类型的语义。 - user395760
1
我不反对你在这个答案中说的任何事情。但是——也许只是我自己——我认为这些都与我的问题相当离题。也就是说,我并不是想暗示“因为委托是不可变的,所以它们应该是值类型”;相反,我只是提到了它们的不可变性作为它们不需要成为引用类型的原因之一。选择值类型而不是引用类型的主要好处(在我的看法中)将是内存回报。 - Dan Tao
2
此外:你可能喜欢可空委托,但在我看来,一个简单的 default(Action) 什么也不做(甚至抛出 NullReferenceException,因为函数指针将指向空)就可以很好地工作。 - Dan Tao

1

我可以告诉你,将委托作为引用类型是一个绝对糟糕的设计选择。它们可以是值类型,仍然支持多播委托。

想象一下,Delegate是一个由以下内容组成的结构体: object target; 指向方法的指针

它可以是一个结构体,对吧? 只有当目标是结构体时,装箱才会发生(但委托本身不会被装箱)。

你可能认为它不支持MultiCastDelegate,但我们可以: 创建一个新对象来保存普通委托的数组。 返回一个委托(作为结构体)到那个新对象,它将实现Invoke迭代所有的值并在它们上调用Invoke。

因此,对于从不调用两个或更多处理程序的普通委托,它可以作为一个结构体工作。 不幸的是,在.Net中这种情况不会改变。


顺带一提,方差不需要委托是引用类型。委托的参数应该是引用类型。毕竟,如果您传递一个字符串作为需要对象的输入(非 ref 或 out),则不需要进行转换,因为字符串已经是一个对象。

0

我猜一个原因是支持多播委托。多播委托比仅有几个字段指示目标和方法更为复杂。

另一个只有在这种形式下才可能的事情是委托的变化。这种变化需要两种类型之间的引用转换。

有趣的是,F#定义了自己的函数指针类型,类似于委托,但更轻量级。但我不确定它是值类型还是引用类型。


4
F# 的 FastFunc<> 类型也是类。 - Jeffrey Sax

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接