百科问答小站 logo
百科问答小站 font logo



C++中函数返回的过程是什么样的? 第1页

  

user avatar   bei-ji-85 网友的相关建议: 
      
是不是运行完return语句就发生了赋值或拷贝的行为?将x*2的结果
赋值给一个无名的临时对象(假如叫temp)?

如果你不想了解编译器的具体细节,这样理解是没问题的。具体到编译器-汇编语言层面上,不同的编译器和开发环境里,底层的行为并不是完全一样的。

比如,这个temp是谁准备的?这个temp具体放在那里?如果temp是一个比较大的数据结构,而不是一个常见的int/long这种类型的数据,那么temp如何保存?

这个东西叫做ABI(Application binary interface),不同的编译器,不同的操作系统,32、64位不同环境下,不同的硬件环境(ARM/X86/PPC),ABI的行为不完全相同。

通常情况下,在Windows、Linux环境里,是用EAX/RAX寄存器来保存返回值,因为高级语言里并不能直接看到寄存器(内联汇编除外),所以这个寄存器可以理解为你所说的temp,但如果返回值比较大,寄存器里放不下这么大的数据,比如返回一个结构体,或者返回浮点类型的数据,那么具体行为就要看编译器实现了。


user avatar   Kurenai_Misuzu 网友的相关建议: 
      

C++ 中从函数返回(不考虑异常、 longjmp 等非正常退出方式)的过程是个稍微有点长的话题…初学阶段可能不必纠结。这段代码不需要无名的临时对象,也没有赋值。

总结

正常来说你只需要普通地写函数,返回一个同返回类型的局部变量时不用 move ,尽可能避免悬垂指针/引用就行了。多数时候不用意识到后述的细节。

如果要返回指针/引用,就尽可能让它指代函数外部就创建好,并且不随函数退出而失效的对象,或函数体内的 static 对象;也可以返回 nullptr 以表示某种失败。

比较特殊的情况有针对调用约定优化返回类型的设计、为了优化抑制 NRVO [1]等等。这些算是相当进阶,甚至在业务中罕见的内容。

标准语义部分

首先初始化返回值,这里做的事和返回类型有关:

  • 返回类型为(可能有 cv 限定的) void 。则这步不做任何事
  • 返回类型为引用。则这步进行一次引用绑定,而此绑定不会延长临时对象生存期。故如果绑定到了临时对象(以及默认情况下函数内定义的局部对象)则必然会产生悬垂引用
  • 返回类型为对象类型,则这步有不同的情况:
    • 一般从 return 的操作数复制初始化产生一个纯右值,语义上通常没有临时对象。但如果返回类型是一个比较“平凡”(见后述)的类类型,则语义上可以有临时对象[2]
    • 如果 return 的操作数是一个忽略顶层 const 后与函数返回类型相同的,非 staticthread_local ,且不是形参的局部变量[3],则标准允许编译器令这个局部变量与函数返回的结果最终形成的对象成为同一对象[4][5]。这里最终形成的对象可能是用函数返回值初始化的同类型变量,或者函数返回值形成的临时对象等等。
    • 如果 return (也包括 C++20 协程的 co_return )的操作数是一个对象类型或右值引用类型的变量,包括形参,但不是 staticthread_local (这里也包括上一种情况中没有消除复制/移动时做的选择)[6],则首先把该操作数当作右值(即优先进行移动操作);如果不能选择正确的函数,再当作左值。

初始化完返回值后,操作依次是:

  1. 按创建顺序的逆序析构 return 语句中产生的临时对象;
  2. 按通常离开作用域规则的规则,析构其他函数体内(非 staticthread_local )的局部变量。(这里“析构”的说法不太严谨,对于非类类型可以认为就是使对象不再存在了。)

然后在标准语义中,控制已经离开了该函数,而函数调用表达式的类型和值类别按以下方式确定:

  • 若函数是析构函数或返回(可以有 cv 限定的) void 的函数,则表达式为 void 类型纯右值。不考虑构造函数,因为不存在直接调用构造函数的函数调用表达式[7]
  • 若函数返回类型为左值引用或右值引用,则表达式分别为左值或亡值,类型为被引用的类型[8]
  • 若函数返回类型为对象类型,则表达式为纯右值,类型为返回类型;但如果返回类型不是类类型,则表达式类型需要去掉 cv 限定(例如即使函数返回类型是 const int ,函数调用表达式的类型也还是 int )。

就传递/返回目的而言平凡的类型

函数返回类型如果是满足下列条件的类类型[9],则即使从 C++17 起,返回时语义上也可以有临时对象:

  • 拥有平凡的析构函数;
  • 拥有至少一个可调用[10]的复制构造函数或移动构造函数;
  • 所有可调用的复制构造函数或移动构造函数均为平凡[11][12]

形参对象的析构

按照标准,如果形参如果是具有非平凡析构函数的类类型对象(注意不是引用),则由实现选择二种策略之一,具体如何选择是实现定义的:

  • 从函数返回后立即析构这些形参;
  • 在含有函数调用的全表达式末尾析构这些形参。

这两种策略有一些细微的区别,前者会在更多情况下悬垂引用或悬垂指针。不过无论如何,都尽可能不要既令函数形参为对象类型,又令返回的引用或指针指代形参对象。

虽然标准中形参对象的析构是在离开函数之后,但选择前者的实现(例如 MSVC )可能会在汇编/机器码层次上离开被调用函数之前就进行析构。

实现与调用约定

实现上,调用一个未被内联的函数时,往往需要在栈上记录控制流在调用函数后待返回的位置。而函数正常返回时,控制流会取得栈上记录的地址,并跳转到该地址以进行后续操作。

除了这一通用部分,从函数返回的实现会依赖调用约定。各种平台的调用约定非常复杂[13],不过我们可以把返回的行为归纳为两个部分:返回方式与栈清理。

通过寄存器或内存返回

多数调用约定会允许使用一个或多个通用寄存器返回,而引用、指针不长于通用寄存器的整数会通过寄存器返回。浮点类型可能会通过专门的浮点寄存器返回。

在有些调用约定上,对于一个就传递/返回目的而言平凡[14]的类类型,若它大小和组成[15]满足一定要求,则它可以通过寄存器返回。

对于大小比较大,或返回会涉及非平凡特殊成员函数的类类型(以及一些编译器提供的较大的非标准类型),调用约定会选择通过内存返回(有些调用约定上任何类类型都要通过内存返回),具体做法是:

  1. 调用函数时,先确定返回的对象所在的地址,将它作为一个隐藏的参数传给函数。
  2. 在初始化返回值时,在这个返回地址上初始化对象。
  3. (不一定必须)为了方便链式调用,离开函数时在专门的通用寄存器中也存储该地址。

实际上,通过内存返回的机制构成了允许函数返回时不产生临时对象,以及允许编译器令局部变量和函数返回值最终形成对象等同的基础——前者需要继续向内传递最终形成的对象的地址,而后者需要在返回所用的地址上创建局部变量。

栈清理

除了控制流待返回的位置,调用约定还还需要通过寄存器和栈传递参数 。有些调用约定只会通过栈传递,而有些是在传参用的寄存器不够时才通过栈传递。无论是哪种调用约定,都需要在离开函数后使栈记录回到原来的位置,通常通过将栈指针恢复为原先的值。

不同的调用约定在栈清理的表现上不同:

  • 有的让调用方(caller)负责调节栈指针。
  • 另外的让被调用方(callee)负责调节栈指针。

默认的调用约定(如 __cdecl 以及 x64 调用约定)上往往由调用方清理栈。这种设计对于 C 的变长实参函数(例如 printf ,注意它与 C++ 的形参包无关)更合理,同时也允许了 C++ 中在全表达式末尾析构形参对象。

另外的一些调用约定(如 32 位 Windows 的 __stdcall ) 上由被调用方清理栈。这种设计较不容易兼容变长实参函数(例如 __stdcall 就不支持),也使得这种调用约定不能允许在全表达式末尾析构形参对象。由 callee 清理栈目的可能是减少机器码大小:清理栈的机器码只需在被调用的函数内存在一次,而无需在每个 caller 处都重复。

参考

  1. ^ https://quuxplusone.github.io/blog/2021/03/07/copy-elision-borks-escape-analysis/
  2. ^ C++17 前若返回类型是类类型,则这步同样产生纯右值,但语义上总是有临时对象。
  3. ^ 拥有自动存储期。
  4. ^ 这种操作被称为“具名返回值优化(named return value optimization, NRVO)”。尽管它经常被称为“优化”,但这种优化会跳过构造函数和析构函数的调用,从而可能改变标准意义上的可观察行为。无论是否进行 NRVO ,均要求适合的构造函数可调用。
  5. ^ C++17 前类类型纯右值必须为立即成为临时对象。在 return 操作数为一个相同类类型(同样忽略 const )纯右值临时对象时,使该临时对象直接变成函数返回的临时对象的操作也可能改变可观察行为。这种操作被称为“返回值优化(return value optimization, RVO)”。
  6. ^这种变量被称为“隐式可移动实体(implicitly movable entity)”。隐式可移动实体的要求在应用到 C++11 的缺陷报告 P1825R0 中放宽,此前( C++11 起)它必须是与返回类型相同类型(忽略顶层 const )的自动存储期的局部变量。 https://wg21.link/p1825r0
  7. ^ ClassType{args} 这种表达式会调用构造函数,但它不属于函数调用表达式。
  8. ^ 标准说法中表达式的类型不能是引用类型,但引用作为返回类型会影响函数调用表达式的值类别。
  9. ^C++17/20 中对此的规定有缺陷,目前尚未解决,见 CWG 2434 。 https://wg21.cmeerw.net/cwg/issue2434
  10. ^ 这里标准用词是 eligible (合格的),具体来说和可调用有些区别,譬如不顾及访问权限。
  11. ^ 这蕴含了这些构造函数都会进行逐位复制,但具体要求更严格一些。
  12. ^ 这里的要求和“可平凡复制(trivially copyable)”类型的要求很类似,但不考虑赋值运算符。
  13. ^Calling convention - Wikipedia https://en.wikipedia.org/wiki/Calling_convention
  14. ^有些调用约定,如 MS x64 调用约定有更严格的限制。 https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-160
  15. ^组成要求的一个例子是 System-V ABI 。 https://uclibc.org/docs/SysV-ABI.pdf



  

相关话题

  左移40位为什么不能写成1<<40ll? 
  C++需要反射吗? 
  C++中函数返回的过程是什么样的? 
  这个如此诡异的C语言「怪事」是怎么回事? 
  为什么很多程序无法计算负数的立方根? 
  代码之间为什么要加空格? 
  C++20有哪些让你激动不已的新特性? 
  网上有对于C++编程要避免使用cin、cout、fstream;而是使用scanf、printf、FILE *的说法, 请问是正确的吗? 
  C++ 学到什么程度才能叫精通? 
  为什么C++中virtual要翻译为虚函数? 

前一个讨论
2021年了,你还在玩游戏《闪耀暖暖》么,为什么?
下一个讨论
星特朗70400能看到什么?





© 2024-05-21 - tinynew.org. All Rights Reserved.
© 2024-05-21 - tinynew.org. 保留所有权利