C#如何返回结构体

6
结构体是值类型,因此每次对结构体进行操作时都会完全复制。由于它们是值类型,所以结构体在堆栈中分配,而不是在堆中分配。
我可以看出当结构体作为参数传递时,它们会被始终复制到堆栈中,特别是当它们具有许多内部字段时,这可能会降低方法的性能。
但我很好奇 C# 如何处理结构体的返回。
在 C 中,返回值是通过寄存器或使用堆引用来返回的,如果要返回的值太大,则使用堆。实际上,几乎所有 C# 结构体教程都说结构体存在于堆栈中,从不在堆中。
因此,在以下代码中:
MyStruct ms = GetMyValue();

其中GetMyValue()

MyStruct GetMyValue();

C#将如何处理ms变量的结构体返回?特别是如果它对于寄存器来说太大了怎么办?它是否会将其复制到堆中,然后再次复制回调用方法的位置并将其分配给ms?

编辑:

针对留在帖子中的评论:

  1. 在发布这篇文章之前,我已经阅读了一些有关C#结构体的教程,特别是this tutorial,其中使用的单词“stack”比我能数到的次数还要多。而this MSDN tutorial也谈到了堆栈,尽管它是来自2003年的,但我认为结构体从那时起并没有改变。

  2. 我知道这可能与C#无关,实际上可能是JIT编译器本身或CLR或其他我不知道的东西的问题。这就是我的问题的目的,即了解C#的内部工作原理,即使这实际上与语言本身无关。

  3. 有C函数调用约定,最好支持我的帖子是this StackOverflow post。当我第一次在这里发布它时,我只是说出了我记得的,但由于SO答案说:

    至于您的具体问题,它取决于ABI。有时,如果返回值大于4个字节但不大于8个字节,则可以将其拆分为EAX和EDX。但大多数情况下,调用函数将只分配一些内存(通常在堆栈上)并将指向该区域的指针传递给被调用函数。

    我可能是错的,我说“可能”,因为答案说“通常”。

  4. 我想了解结构体如何处理的真正原因是,我有一个项目需要多次读取串行端口以轮询数据,这些数据将通过方法返回。

    由于数据只是一些字节,所以我认为使用结构体而不是使用类来抽象串行端口传入的字节可以提高性能,但如果returnstruct作为堆分配传递,则我的性能期望可能是错误的。

    是的,我可以进行简单的测试并比较性能,我知道,但我想实际上学习它在幕后是如何完成的,而不仅仅是记忆我的模拟结果。我喜欢知道我使用的东西实际上是如何工作的,而不仅仅是学习如何使用它们。


1
几乎所有的C#结构体教程都说结构体存在于堆栈中,而不是堆中。但并非所有教程都这么说。任何这样说的教程都是严格错误的。在C语言中,返回值是通过寄存器或使用堆引用来传递的。不,堆永远不会用于将返回值传递给调用者。 - Servy
3
可能会便宜一点,但值类型始终会被复制以进行返回。要有效地使用结构体,需要使用 ref/out 参数。专业提示:使用类。如果不确定,可以对结构体进行基准测试。几乎不需要额外的时间成本。 - leppie
1
我不明白点踩的意义。当有人试图学习某些东西时,你只能这样做吗?即使他在许多方面都是错误的,你也不应该点踩。我不是说我知道这个问题的答案。我也在学习,但告诉下踩的原因会很好。 - M.kazem Akhgary
1
虽然不是完全相同的问题,但是这个相关问题上的答案会帮助你理清思路。 - H H
1
这个问题中几乎每个句子都是错误的。回答一个基于如此多谬误的问题是很困难的。 - Eric Lippert
显示剩余15条评论
2个回答

2
值类型不仅存在于堆栈中,还存在于字段和数组中。与引用类型的关键区别在于,值类型是按值复制的,没有标识符。堆栈与堆的概念是错误的。
在C语言中,返回值通过寄存器或使用堆引用进行返回,如果要返回的值太大,无法放入寄存器中,则需要使用堆。
堆不参与其中。调用者为返回值分配空间,并传递指向该空间的指针。被调用者可以填充该空间。.NET CLR也是如此。当然,这是一个实现细节。
学习是非常好的。你不能完全相信他人所说的话。你可能有糟糕的教程或者阅读不准确。
我认为这并不总是情况。我不太确定,但我认为JIT有时可以将结构体传递到寄存器中。.NET JIT并没有进行太多优化,但我认为这是一种在一定程度上有效的优化。可能是由于某些单字段结构体(例如DateTime)的存在。

是的,根据评论中发布的链接,似乎JIT可以根据结构体大小优化返回。感谢您的澄清。 - Michel Feinstein

0

结构体并不总是存在于栈上。如果你在函数内部分配一个结构体,它就存在于栈上。如果它是引用类型(类/数组),它就存在于堆上。至于它们如何返回,那可能取决于该CPU架构的ABI。

听起来,你从未涉及过IL/汇编/代码生成,所以让我们构建一个动态方法,它等效于MyStruct ms = GetMyValue() /编译器在单词“stack”的上下文中生成的内容。"things"实际上从未被返回。事物(以元组的意义)被推到堆栈上,然后发出返回指令,把返回值留给调用者。我们将假设GetMyValue()分配了一个新的MyStruct并将其赋给一个局部变量。生成的代码将类似于以下内容(我扩展了ILGenerator类):

ILGenerator generator = dynamicMethod.GetILGenerator();

generator
    .DeclareLocal(typeof(MyStruct))
    .EmitCall(OpCodes.Call, typeof(EncapsulatingClass).GetMethod("GetMyValue"))
    .Emit(OpCodes.Stloc, 0);

这里发生的事情是(其中一些是我对CLI运行时工作方式的假设):

  1. 调用函数在当前本地列表索引处保留了一个typeof(MyStruct)类型的插槽。

  2. 调用GetMyValue(),以与我们正在构建的方法相同的方式保留了一个MyStruct本地变量,发出OpCodes.Newobj,该指令分配并向下调整ESP(扩展堆栈指针)大小为sizeof(MyStruct),发出OpCodes.Stloc将ESP减去sizeof(MyStruct)存储到保留的本地索引中,对其字段进行一些操作,调用Emit(OpCodes.Ldloc, 0)将本地变量指向的地址推送到评估堆栈上,然后发出OpCodes.Ret返回。

  3. 调用函数发出OpCodes.Stloc将评估堆栈顶部指向的MyStruct内容(如何发生这种情况,我确定答案是取决于情况的),存储(复制)到本地索引0处。

我并不是CLI运行时构建方面的专家,所以很多都是基于假设的。请谨慎对待,并且我也不是CPU工程方面的专家。OpCodes.Ldloc、OpCodes.Ret、OpCodes.Stloc这些指令流段如何处理--ms = GetMyValue()--可能取决于JITer将IL转换为实际的CPU特定机器指令的方式,例如X86。决定结构体是否返回到寄存器中,可能仅限于一个寄存器,因此最大的寄存器是什么,以及任何结构体是否适合放在其中。我知道CPU可以将寄存器组合用于内存偏移量,但我不确定是否适用于在多个寄存器中返回结构体。还要记住的另一件事是,GetMyValue()已经超出了范围,这意味着在作用域意义上分配的结构体GetMyValue()不再存在,但在堆栈意义上(它被分配的地方)存在,因此JITer可以直接获取OpCodes.Ldloc推送到堆栈上的地址,并将其直接放置在调用者的本地索引0中。由于函数返回后不可能再复制它,使调用者成为结构体的新所有者。在这种特殊情况下,避免了任何复制和寄存器。这可能也是调用约定发挥作用的地方。问题在于,如果由于某种原因在GetMyValue()中分配了三个结构体,则返回第一个结构体之后返回任何结构体都会破坏该优化,这就是下一个优化——在寄存器中返回结构体(如果适合)的地方。将其内容纯粹复制到调用者的堆栈上。我可能是错的,欢迎任何人加入并纠正我。开始的好地方可能是GitHub,看看运行时如何处理结构体的OpCodes.Ldloc/Stloc。我想那可能是寻找所需答案的好地方。

编辑:任何你读过的教程都说结构体总是分配在栈上,那就让它们全部遭受 DDoS 攻击吧。


1
如果你在函数内部分配一个结构体,它的生命周期就在栈上。但是如果它是异步方法或迭代器块等,则不是这样;即使如此,这也是一种事实上错误的过度简化。 - Marc Gravell
如果它是在异步方法中分配的,那么它就是“引用类型内部的一部分”。我认为大多数人都理解TPL内部/任务排队。如果他不理解,我想这样说可能不正确。 - jeff cassar
它比那更复杂;构建的状态机至少在发布版本中是值类型。它是否最终进入堆取决于它是否曾经遇到不完整的 continuation。非常微妙。 - Marc Gravell
我们还可以处理其他边缘情况,比如“捕获变量”——有这么多的“except”。 - Marc Gravell

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