问题

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

回答
在C++中,函数返回并不是一个简单地“跳出去”的操作,它涉及到多个步骤,并且与值的传递方式、调用栈以及编译器优化等因素紧密相关。我们来详细拆解一下这个过程,力求还原真实的执行场景。

核心概念:调用栈 (Call Stack)

要理解函数返回,就必须先理解调用栈。当你调用一个函数时,程序会在调用栈上为这个函数分配一块内存空间,这块区域被称为“栈帧”(Stack Frame)。栈帧包含了函数运行所需的所有信息,包括:

局部变量 (Local Variables): 函数内部声明的所有变量都存储在这里。
函数参数 (Function Arguments): 传递给函数的参数。它们可以按值传递、按引用传递或按指针传递,这会影响它们在栈帧中的表现。
返回地址 (Return Address): 这是关键!当当前函数执行完毕后,程序需要知道回到哪里继续执行(即调用该函数的位置)。这个地址被保存在栈帧中。
保存的寄存器 (Saved Registers): 在函数调用前,某些CPU寄存器的状态可能会被保存下来,以便函数执行完毕后恢复,避免影响调用方的执行环境。

函数返回的执行流程:

当函数执行到 `return` 语句(或者到达函数末尾而没有显式的 `return` 语句,在这种情况下,如果有非 `void` 返回类型,行为是未定义的)时,一系列操作会发生:

1. 计算返回值:
如果 `return` 语句后面是一个表达式,编译器会先计算这个表达式的值。
对于基本类型(如 `int`, `float`, `char` 等),返回值通常会放在一个特定的CPU寄存器中(比如 `eax` 或 `rax`)。这是最快的方式。
对于更复杂的类型(如 `struct`, `class` 对象),情况会更复杂。根据返回值大小和编译器优化,可能会有几种处理方式:
寄存器传递 (Register Passing): 如果对象较小,它也可能被打包进寄存器(可能需要多个寄存器)。
栈上传递 (Stack Passing) / 返回值优化 (Return Value Optimization RVO): 这是最常见的优化。编译器会“欺骗”你,让你觉得返回值是直接从函数里出来的,但实际上,它在调用函数时就指定了一个内存位置(通常是调用方栈帧上的一个区域,或者通过一个隐藏的“this”指针指向的内存),函数直接将结果构建到那个预先分配好的位置上。这避免了创建临时对象和拷贝。
移动语义 (Move Semantics): 如果返回的对象支持移动构造函数,并且编译器能够识别,那么会将对象的状态(资源指针等)“移动”到调用方,而不是进行昂贵的拷贝。这在 C++11 及以后版本中非常常见。
传统拷贝/移动 (Copy/Move): 如果以上优化无法进行,那么会按照对象的拷贝构造函数或移动构造函数来处理返回值。

2. 恢复调用方的上下文:
CPU会根据栈帧中的信息,将之前保存的寄存器值恢复。
返回地址会被取出,CPU指令指针会跳转到这个地址,继续执行调用方函数中的下一条指令。

3. 清理当前栈帧:
当函数执行完毕并返回后,其栈帧就不再需要了。栈指针会自动回退,将函数占用的栈空间释放。注意,这并不是说栈帧中的数据被“销毁”了,而是说那块内存空间现在可以被后续的函数调用重复使用。局部变量的析构函数(如果它们是对象且有定义)会在这个阶段被调用(通常是在栈帧被“弹出”之前)。

举例说明:

假设我们有这样一个简单的场景:

```c++
int add(int a, int b) {
int result = a + b;
return result;
}

int main() {
int x = 5;
int y = 10;
int sum = add(x, y); // 调用 add 函数
// ... main 函数继续执行
return 0;
}
```

1. `main` 调用 `add`:
`main` 的栈帧已经存在。
`x` 和 `y` 的值被传递给 `add`。根据调用约定(Calling Convention),这些参数可能通过寄存器(如 `rdi`, `rsi`)或栈传递给 `add`。
一个指向 `main` 中 `add(x, y);` 之后指令的返回地址被压入栈中。
`add` 的栈帧被创建。

2. `add` 函数执行:
`a` 和 `b`(可能是通过寄存器传入的 `x` 和 `y` 的值)在 `add` 的栈帧中被访问。
`result` 变量被分配在 `add` 的栈帧中。
`a + b` 计算得到 `15`。
这个值 `15` 被放入一个寄存器(例如 `eax`)。

3. `add` 函数返回:
`return result;` 执行。
`result` 的值 (`15`) 已经存在于 `eax` 寄存器中。
`add` 的栈帧被“弹出”(栈指针移动)。局部变量 `result` 的存储空间被释放。
`add` 函数中的局部变量(如果有析构函数)会被销毁。
CPU读取保存在栈中的返回地址,跳转到 `main` 函数中下一条指令的位置。

4. `main` 函数继续执行:
`add` 函数返回后,`main` 函数会从 `eax` 寄存器中获取返回值 `15`。
这个值 `15` 被赋给 `main` 中的变量 `sum`。
`main` 函数继续执行。

返回值优化 (RVO) 和命名返回值优化 (NRVO) 的影响:

在上面的例子中,如果 `result` 是一个类对象,并且我们返回的是 `result`,那么编译器很可能会使用 RVO/NRVO。这意味着:

在 `main` 函数中,为 `add` 函数的返回值预留了一个空间。
`add` 函数不会创建一个新的对象来存放计算结果,而是直接将计算结果构造到 `main` 为它预留的空间里。
这样就省去了创建临时对象和进行拷贝或移动的操作,大大提高了效率。

总结来说,C++函数返回是一个涉及调用栈管理、寄存器使用、以及可能的拷贝/移动操作的复杂过程。编译器会进行大量的优化,使得这个过程尽可能高效。理解这些底层机制,有助于写出更高效、更健壮的代码。

网友意见

user avatar
是不是运行完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

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

类似的话题

  • 回答
    在C++中,函数返回并不是一个简单地“跳出去”的操作,它涉及到多个步骤,并且与值的传递方式、调用栈以及编译器优化等因素紧密相关。我们来详细拆解一下这个过程,力求还原真实的执行场景。核心概念:调用栈 (Call Stack)要理解函数返回,就必须先理解调用栈。当你调用一个函数时,程序会在调用栈上为这个.............
  • 回答
    在 C 中,函数(或方法)的参数是沟通信息、传递指令给函数的核心方式。理解参数的各种行为和特性,对于编写清晰、高效且易于维护的代码至关重要。让我们深入探讨一下 C 中函数参数的方方面面。 1. 按值传递(Pass by Value) 默认行为当你声明一个函数参数时,如果没有特别指定,它默认就是按值.............
  • 回答
    关于C++自定义函数写在 `main` 函数之前还是之后的问题,这涉及到C++的编译和链接过程,以及我们编写代码时的可读性和维护性。理解这一点,对你写出更健壮、更易于理解的代码非常有帮助。总的来说, 将自定义函数写在 `main` 函数之前通常是更推荐的做法,尤其是对于项目中主要的、被 `main`.............
  • 回答
    在 C++ 中,为基类添加 `virtual` 关键字到析构函数是一个非常重要且普遍的实践,尤其是在涉及多态(polymorphism)的场景下。这背后有着深刻的内存管理和对象生命周期管理的原理。核心问题:为什么需要虚析构函数?当你在 C++ 中使用指针指向一个派生类对象,而这个指针的类型是基类指针.............
  • 回答
    在C++开发中,我们习惯将函数的声明放在头文件里,而函数的定义放在源文件里。而对于一个包含函数声明的头文件,将其包含在定义该函数的源文件(也就是实现文件)中,这似乎有点多此一举。但实际上,这么做是出于非常重要的考虑,它不仅有助于代码的清晰和组织,更能避免不少潜在的麻烦。咱们先从根本上说起。C++的编.............
  • 回答
    在C语言中,你提到的 `main` 函数后面的那对圆括号 `()` 并非只是一个简单的装饰,它们承载着至关重要的信息:它们表明 `main` 是一个函数,并且是程序的可执行入口点。要理解这个 `()` 的作用,我们需要先理清C语言中关于“函数”的一些基本概念。 函数是什么?在C语言中,函数就像一个独.............
  • 回答
    好的,我来详细解释一下 C 和 C++ 中 `malloc` 和 `free` 函数的设计理念,以及为什么一个需要大小,一个不需要。想象一下,你需要在一个储物空间里存放物品。`malloc`:告诉空间管理员你要多大的箱子当你调用 `malloc(size_t size)` 时,你就是在对内存的“管理.............
  • 回答
    在 C 中,`typeof()` 严格来说 不是一个函数,而是一个 类型运算符。这很重要,因为运算符和函数在很多方面有着本质的区别,尤其是在 C 的类型系统和编译过程中。让我来详细解释一下:1. 编译时行为 vs. 运行时行为: 函数(Method):函数通常是在程序运行时执行的代码块。你调用一.............
  • 回答
    在 C 中,`async` 和 `await` 关键字提供了一种优雅的方式来编写异步代码,但它们并非直接等同于多线程。理解这一点至关重要。异步并非强制多线程,但常常借助它首先,我们要明确一个核心概念:异步编程的本质是为了提高程序的响应性和吞吐量,而不是简单地将任务并行执行。 异步的目的是让程序在等待.............
  • 回答
    在 C++ 中,并没有一个直接叫做 `realloc()` 的函数的新版本。C++ 作为 C 语言的超集,依然继承了 `realloc()` 的存在,你仍然可以在 C++ 程序中使用它。但是,C++ 提供了一套更强大、更安全、更符合面向对象思想的内存管理机制,这使得在大多数情况下,直接使用 C++ .............
  • 回答
    在C++的世界里,“virtual”这个词被翻译成“虚函数”,这可不是随意为之,而是因为它精确地抓住了这种函数在继承和多态机制中的核心特征。理解“虚”这个字的关键,在于它暗示了一种“不确定性”,或者说是一种“在运行时才确定”的行为。设想一下,你有一系列动物,比如猫、狗,它们都属于一个更大的“动物”类.............
  • 回答
    在 C++ 中,直接在函数中传递数组,或者说以“值传递”的方式将整个数组复制一份传递给函数,确实是行不通的,这背后有几个关键的原因,而且这些原因深刻地影响了 C++ 的设计理念和效率考量。首先,我们要理解 C++ 中数组的本质。当你声明一个数组,比如 `int arr[10];`,你实际上是在内存中.............
  • 回答
    在 C++ 中,构造函数和析构函数确实存在一些关于异常处理的限制,这背后有深刻的技术原因和设计哲学。理解这些限制,需要我们深入 C++ 的内存管理、对象生命周期以及异常安全性的几个关键概念。首先,我们来聊聊构造函数。构造函数的核心任务是确保一个对象在被创建出来时,处于一个 有效且完整 的状态。所谓有.............
  • 回答
    要深入理解 `math.h` 中那些看似简单的数学函数(比如 `sin`, `cos`, `sqrt`, `log` 等)在计算机上究竟是如何工作的,我们需要绕开直接的函数列表,而是去探究它们背后的原理。这实际上是一个涉及数值分析、计算机体系结构以及编译链接等多个层面的复杂话题。想象一下,我们想要计.............
  • 回答
    在 C 语言的世界里,指针是必不可少的工具,它们就像是内存地址的“指示牌”,让我们能够更灵活地操作数据。而当我们将指针与数组、函数结合起来时,就诞生了一系列强大而又容易让人困惑的概念:指针数组、数组指针、函数指针,以及指向函数的指针。别担心,今天我们就来把它们掰开了揉碎了,让你彻底搞懂它们到底是怎么.............
  • 回答
    在 C++ 面向对象编程(OOP)的世界里,理解非虚继承和非虚析构函数的存在,以及它们与虚继承和虚析构函数的对比,对于构建健壮、可维护的类层级结构至关重要。这不仅仅是语法上的选择,更是对对象生命周期管理和多态行为的一种深刻设计。非虚继承:追求性能与简单性的默认选项当你使用 C++ 的非虚继承(即普通.............
  • 回答
    结构体变量的读写速度 并不比普通变量快。这是一个常见的误解。事实上,在很多情况下,访问结构体成员的开销会比直接访问普通变量稍微 大一些,而不是更小。要详细解释这一点,我们需要深入理解 C++ 中的变量、内存模型以及编译器的工作方式。 1. 普通变量的读写首先,我们来看看一个简单的普通变量,例如:``.............
  • 回答
    在C++中,表达式 `unsigned t = 2147483647 + 1 + 1;` 的求值过程,既不是UB(Undefined Behavior),也不是ID(ImplementationDefined Behavior),而是一个有明确定义的整数溢出(Integer Overflow)行为。.............
  • 回答
    在 C++ 中讨论 `std::atomic` 是否是“真正的原子”时,我们需要拨开表面的术语,深入理解其底层含义和实际应用。答案并非一个简单的“是”或“否”,而是取决于你对“原子”的理解以及在什么上下文中去考量。首先,让我们明确一下在并发编程领域,“原子性”(Atomicity)通常指的是一个操作.............
  • 回答
    在 C++ 中,将 `std::string` 类型转换为 `int` 类型有几种常见且强大的方法。理解它们的原理和适用场景对于编写健壮的代码至关重要。下面我将详细介绍几种常用的方法,并分析它们的优缺点: 方法一:使用 `std::stoi` (C++11 及以后版本)这是 最推荐 的方法,因为它提.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有