为啥委托是引用类型?

Posted

技术标签:

【中文标题】为啥委托是引用类型?【英文标题】:Why are delegates reference types?为什么委托是引用类型? 【发布时间】:2011-12-15 21:33:34 【问题描述】:

关于已接受答案的快速说明:我不同意Jeffrey's answer 的一小部分,即由于Delegate 必须是引用类型,因此所有代表都是引用类型。 (多级继承链排除值类型是不正确的;例如,所有枚举类型都继承自 System.Enum,而 System.ValueType 又继承自 System.ValueType,后者继承自 System.Objectall 引用类型。)但是我认为,从根本上说,所有委托实际上不仅继承自 Delegate,而且继承自 MulticastDelegate,这是这里的关键实现。正如Raymond points out 在对他的 答案的评论中,一旦你承诺支持多个订阅者, 对委托本身使用引用类型真的没有意义,考虑到某个地方需要一个数组。


见底部更新。

如果我这样做,我总是觉得奇怪:

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;

这个问题有意义吗?在我看来,有两种可能的解释:

将委托正确地实现为值类型需要额外的工作/复杂性,因为支持诸如 确实修改局部变量值的闭包之类的东西无论如何都需要编译器生成的引用类型。 还有一些其他原因,说明在底层,委托不能被实现为值类型。

最后,我并没有为此失眠;这只是我一直好奇的事情。


更新:为了回应 Ani 的评论,我明白了为什么上面示例中的 CompilerGenerated 类型也可能是引用类型,因为如果委托将包含一个函数指针和一个对象指针它无论如何都需要一个引用类型(至少对于使用闭包的匿名函数,因为即使你引入了一个额外的泛型类型参数——例如,Action<TCaller>——这也不会涵盖无法命名的类型! )。 然而,这一切都让我后悔将编译器生成的闭包类型问题带入讨论!我的主要问题是关于 delegates,即 with 函数指针和对象指针的东西。在我看来, 仍然可能是一种值类型。

也就是说,即使这样……

Action foo = () =>  obj.Foo(); ;

...需要创建一个引用类型的对象(支持闭包,并给delegate一些引用),为什么需要创建两个(支持闭包的对象加上Action委托)?

*是的,是的,实现细节,我知道!我真正的意思是短期记忆存储

【问题讨论】:

第一个可能的解释对我来说已经足够了。 好的,假设您想将委托实现为具有函数指针和对象指针的值类型。在您的闭包示例中,对象指针指向哪里?您几乎可以肯定需要将 CompilerGenerated 结构实例装箱并将其放在堆上(通过转义分析,在某些情况下可以避免这种情况)。 @Ani:啊,我明白你的意思了。也许您可以以答案的形式扩展该评论​​? 你真的想使用 Nullable 吗? @Ani:如果委托是一个包含函数指针和对象指针的结构,则构造闭包只需要创建一个新的堆对象而不是两个。如果委托是接口类型(我认为它们应该是),闭包只需要创建一个堆对象来保存闭包数据及其方法。 【参考方案1】:

问题归结为:CLI(公共语言基础结构)规范说委托是引用类型。为什么会这样?

一个原因在今天的 .NET Framework 中显而易见。在最初的设计中,有两种委托:普通委托和“多播”委托,它们的调用列表中可以有多个目标。 MulticastDelegate 类继承自 Delegate。由于不能从值类型继承,Delegate 必须是引用类型。

最后,所有实际的委托最终都成为了多播委托,但在这个过程的那个阶段,合并这两个类已经太晚了。看到这个blog post关于这个确切的主题:

我们放弃了 Delegate 和 MulticastDelegate 之间的区别 在V1结束时。那时,它会是一个巨大的 更改为合并两个类,所以我们没有这样做。你应该 假装它们被合并并且只存在 MulticastDelegate。

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

【讨论】:

我有点明白你的意思,但严格来说,仅仅因为Delegate 是引用类型,所有委托都必须是引用类型,这并不完全正确,对吧?我的意思是,考虑System.Enum:它是一个引用类型,所有实际的枚举类型都继承自它;然而枚举是值类型。这在 CLI 中是合法的,并且在编译器端显然是可能的。因此,所有委托类型都是引用类型的决定仍然必须有进一步的原因。 System.Enum不是值类型!它是一个抽象类;亲眼看看:msdn.microsoft.com/en-us/library/system.enum.aspx. 此外,您可以对所有值类型进行多级继承,因为它们都继承自 System.ValueType(具有讽刺意味的是,这是一个引用类型)。 @DanTao 你说得对。我的错。不过也有区别。枚举只能有一个(非常量)实例字段,并且使用起来很像底层整数类型。委托要重得多,至少有 4 个实例字段。大小事项。 (见我修改后的答案。) 我看不出有任何理由 Delegate 需要两个以上的字段(保存方法和目标)。给定两个“单播”委托,可以通过让Target 引用包含其他两个委托的数组来形成多播委托,并且Method 指向一个静态方法,将这样的数组作为其第一个参数并调用其中的委托。请注意,使用此类多播委托的MethodTarget 构建委托将产生格式正确的多播委托——与目前的情况不同。另请注意...【参考方案2】:

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

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

【讨论】:

这个答案给出了最真实的理由。如果不考虑这个问题,代表可能真的是值类型。还有类似的类型,比如TypedReferenceArgIterator,还有各种句柄,也代表了对某物的引用,都是值类型。【参考方案3】:

想象一下,如果委托是值类型。

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 的副本,这意味着将创建一个全新的CompilerGeneratedhandler 的副本)并传递给@987654328 @。 SignalTwice 将执行委托两次,这会将副本中的counter 增加两次。然后SignalTwice返回,函数打印0,因为原来没有修改。

【讨论】:

但这里实际上有两件事。我同意你的观点,我错误地建议 CompilerGenerated 在这种情况下可能是一个值类型。但是仍然存在委托本身的问题。在您的示例中,而不是Notify handler = new CompilerGenerated(),“真实”输出会不会更像Notify handler = new CompilerGenerated().Execute?在这种情况下,我试图理解的是,即使CompilerGenerated 需要是引用类型,Notify不是Notify 实例可以指向 CompilerGenerated 和函数 (Execute),仅此而已。 由于一个委托可以有多个订阅者,并且一个结构必须是固定大小的,你要么必须对可以多播的订阅者数量有一个硬性限制(如果你选择一个数字太大,那么您的委托变得相当大),或者您必须将订阅者保存在一个单独的对象中,例如一个数组(在这种情况下,您无法避免创建引用类型)。 委托没有理由必须是引用类型才能允许多订阅工作。如果委托是一个结构,它组合了一个对象引用和一个指向接受此类对象的方法的指针,则两个 Action(integer) 委托可以组合成一个 Action(integer) 数组以及一个指向 ExecuteAllActionsInArray 方法的指针(如果委托以 ExecuteAllActionsInArray 作为其方法,附加数组中的委托可以复制到新数组中)。 @Raymond Chen:从线程安全的角度来看,委托必须是类类型。如果线程安全不是问题,委托可以是一个不可变结构,具有对象类型的字段和指向可以作​​用于该类型对象的方法的指针。构造两个独立的委托不需要创建任何堆对象。将它们组合成一个多播委托将需要创建一个堆对象来保存原始文件,以及一个结构,该结构将保存对该堆对象的引用和一个指向将运行该对象中保存的委托的方法的指针。 @Raymond Chen:在实践中创建的大多数代表都只有一个目标。尽管有必要为具有多个目标的多播委托进行堆分配,但避免为每个委托分配堆将是一个重大的胜利。实际上,我怀疑将每个单目标委托减少为包含一个对象引用和一个函数指针的单个堆对象会比实际存在的更好。【参考方案4】:

这是一个不知情的猜测:

如果委托被实现为值类型,则复制实例的成本会非常高,因为委托实例相对较重。也许 MS 认为将它们设计为不可变的 reference 类型会更安全——将机器字大小的引用复制到实例相对便宜。

委托实例至少需要:

对象引用(包装方法的“this”引用,如果它是实例方法)。 指向包装函数的指针。 对包含多播调用列表的对象的引用。请注意,根据设计,委托类型应支持使用相同委托类型的多播。

让我们假设值类型委托以与当前引用类型实现类似的方式实现(这可能有点不合理;很可能选择了不同的设计来减小大小)来说明。使用 Reflector,以下是委托实例中所需的字段:

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

如果实现为结构(无对象标头),这些将在 x86 上增加 24 个字节,在 x64 上增加 48 个字节,这对于结构来说是巨大的。


另一方面,我想问一下,在您提议的设计中,将 CompilerGenerated 闭包类型设为结构有何帮助。创建的委托的对象指针指向哪里?在没有适当的逃逸分析的情况下将闭包类型实例留在堆栈上将是非常冒险的事情。

【讨论】:

我已经回复了您关于将 CompilerGenerated 设为值类型的评论:您是对的,它没有帮助。但问题仍然在于代表本身。我认为您对这里推理的有根据的猜测是有道理的。 实际上,委托只需要两个字段——目标对象和执行它的函数。可以通过创建一个包含它们的数组并将该数组连同指向 ExecuteAllDelegatesInArray 方法的指针一起放入一个新的委托来组合两个或多个委托。【参考方案5】:

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

想象一下,Delegate 是一个由以下组成的结构体,比方说: 对象目标; 指向方法的指针

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

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

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


附带说明,方差不要求 Delegate 是引用类型。委托的参数应该是引用类型。毕竟,如果你传递一个字符串是需要一个对象(用于输入,而不是 ref 或 out),那么就不需要强制转换,因为字符串已经是一个对象。

【讨论】:

【参考方案6】:

我在网上看到了这段有趣的对话:

不可变并不意味着它必须是值类型。还有一些东西 是值类型不需要是不可变的。两人经常去 手拉手,但它们实际上不是一回事,而且有 实际上是 .NET Framework 中每个的反例(字符串 类,例如)。

答案是:

不同之处在于,虽然不可变引用类型是 相当普遍且完全合理,使值类型可变 几乎总是是个坏主意,并且可能会导致一些非常混乱 行为!

取自here

因此,在我看来,这个决定是由语言可用性方面做出的,而不是编译器技术上的困难。我喜欢可为空的代表。

【讨论】:

只需在此处或 eric lipperts 博客上搜索可变值类型是邪恶的。有很多关于这个问题的讨论可用。它们令人困惑通常归结为编译器在您不期望的地方创建了值类型的副本,然后您只改变了副本。 CLR 内存管理器进行了优化,以适应小型、短期对象的频繁分配,使得引用类型与值类型的性能问题大多是虚幻的。在几乎所有情况下,在表示给定的不可变数据时,引用类型优于值类型。值类型的最大好处是当您需要在大型集合中紧凑存储数据时。 @CodeInChaos:据我所知,只有blogs.msdn.com/b/ericlippert/archive/2011/03/14/… 中描述的转换为接口的示例对我来说似乎特别奇怪。 using 制作副本,obj.valueTypeInstance 制作副本,从制作副本的列表中获取 SpinLock(值类型)等等,所有这些似乎都完全符合值类型的语义,因为我已经将它们记在脑海中。这使得许多人还没有完全理解价值语义。 我不同意您在此答案中所说的任何内容。但是——也许只有我一个人——我认为这一切都与我的要求无关。也就是说,我并不是要建议“因为委托是不可变的,它遵循它们应该是值类型”;相反,我只提到了它们的不变性作为它们不必必须 成为引用类型的原因。选择价值而不是参考的主要好处是(在我看来)记忆的回报。 另外:你可能喜欢可以为空的委托,但似乎(对我来说)一个简单的 default(Action) 什么都不做(甚至抛出一个 NullReferenceException 因为函数指针会指向任何东西)会工作得很好。【参考方案7】:

我想一个原因是支持多转换委托 多转换委托比简单地指示目标和方法的几个字段更复杂。

只有在这种形式下才有可能的另一件事是委托方差。这种差异需要两种类型之间的引用转换。

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

【讨论】:

F# 的 FastFunc 类型也是类。

以上是关于为啥委托是引用类型?的主要内容,如果未能解决你的问题,请参考以下文章

委托Lambda表达式和事件

C#委托学习

C#的委托事件总结

c# 值类型与引用类型 值传递与引用传递

服务引用 - 为啥在引用的程序集中重用类型

为啥泛型类型只能引用类型,而不能是基本类型