欢迎光临
我们一直在努力

关于函数返回值——一个重要问题

我的总结

对于“值类型返回”:

当返回值类型无法用寄存器传递时(一般为>16B),调用函数会给被调用函数的返回值预留一个存储空间,且预留的大小等于该类型的大小(考虑对齐)。对于能用寄存器返回的类型或void类型,不预留任何存储空间。

在执行函数调用时,该预留空间的地址会作为隐式参数传递给被调函数,被调函数在执行return语句时,将返回值存储在该预留空间。

对于同一被调函数,该预留空间的地址和生命周期并不固定,这取决于调用方如何使用该返回值。

情形一:调用方将多次返回值赋给同一对象

int x;
x = func(); //第一次调用
x = func(); //第二次调用

两次调用的预留空间地址都为&x,即预留空间就是x的存储空间,该预留空间的生命周期自然为x的生命周期。

情形二:调用方将多次返回值赋给不同对象

int x = func(); //第一次调用
int y = func(); //第二次调用

第一次调用的预留空间地址为&x,生命周期同x,

而第二次调用的预留空间地址为&y,生命周期同y。

情形三:调用方未使用或临时使用返回值

func(); //第一次调用
display(func().name); //第二次调用
sum = func() + 5; //第三次调用

在这种情形下,调用方在栈上分配一个临时的无名对象,而后在完整表达式结束时立即释放它。

情形四:在同一个表达式中多次调用

sum = func() + func(); //第一第二次调用
if(func().value > func().value) //第三第四次调用

每个func()调用都会产出一个独立的临时对象,而后在完整表达式结束时立即释放它。

对于“引用类型返回”:

函数只是返回一个已存在对象的别名(底层是地址)。编译器不创建新对象(不预留空间),只传递地址。要求该对象在函数返回后仍然有效(如全局、静态、堆上、或通过参数传入的引用)。

问题的产生以及AI的总结

值类型返回和引用类型返回有何区别?

返回引用实际上是不是返回地址?

是的,从底层实现角度看,返回引用本质上就是返回对象的地址,但语法和语义上与返回指针有明显区别。

具体解释:

  • 底层机制:引用在编译器内部通常用指针(地址)来实现。当函数返回一个引用时,编译器实际上返回的是该对象的地址(就像返回指针一样),但在调用方使用该返回值时,不需要用 * 解引用,编译器会自动处理。

  • 语法差异:

    • 返回指针:free_throws* clone(…),调用方需用 * 或 ->。

    • 返回引用:free_throws& clone(…),调用方可直接像使用原对象一样使用。

  • 语义差异:返回引用强调的是“返回对象本身”,而不是“返回地址”。这意味着函数返回值可以作为左值(可被赋值),且通常避免拷贝大对象。

所以,你可以认为 返回引用 = 隐式传递地址 + 自动解引用。这是一种更安全、更直观的写法,避免了显式指针操作可能带来的错误。

那书中说的将 free_throws & accumulate(free_throws & target, const free_throws & source); 改成 free_throws accumulate(free_throws & target, const free_throws & source); 会创建一个新结构 为什么不是返回引用对象这个结构

你提到的书中对比很关键。我来解释为什么改成 free_throws 返回值类型后会“创建一个新结构”,而不是返回引用对象本身。

核心区别

  • 返回引用 free_throws &:
    函数返回的是 target 所引用的原对象(例如调用时传入的 team)。
    没有创建新对象,只是返回了该对象的别名(底层是指针传递)。
    调用方得到的是原对象本身,对其修改会影响原对象。

  • 返回值 free_throws:
    函数返回的是原对象的一个副本(临时对象)。
    编译器会调用拷贝构造函数或移动构造函数,在函数返回时创建一个新的 free_throws 结构体,内容与原对象相同。
    这个新结构是独立的,修改它不会影响原对象。

为什么不是返回“引用对象这个结构”?

因为 free_throws 不是引用类型,它是值类型。
值类型的函数返回值总是产生一个新的对象(要么是局部变量的副本,要么是参数副本的副本)。
即使你写 return target;,其中 target 是引用,但函数返回类型是 free_throws(非引用),编译器也会复制 target 指向的对象,然后返回这个副本。
而返回类型 free_throws & 告诉编译器:不要复制,直接返回原对象的引用。

举例说明

cpp

free_throws team = {…};
free_throws & ref = accumulate(team, two); // 返回引用,ref 就是 team 本身
free_throws val = accumulate(team, two); // 返回值,val 是 team 的副本(新结构)

所以书中说“会创建一个新结构”,正是针对返回值版本;而返回引用版本则不会创建新结构。

意思是说,值类型的函数返回值在返回时都会创建一个新变量是吗?

是的,从概念上讲,当函数的返回类型是值类型(非引用)时,函数返回时会创建一个新对象。这个新对象由 return 语句中的表达式(如局部变量、参数或字面量)复制或移动初始化而来,它独立于函数内部的对象。

不过需要留意两点:

  • 实际优化:现代 C++ 编译器会执行返回值优化(RVO / NRVO)或利用移动语义,可能会直接在调用方接收的目标位置上构造结果,省略显式的复制/移动操作。但即便如此,最终被使用的对象依然是一个新创建的对象(只是没有多余的临时对象)。

  • 与引用返回的区别:返回引用时不会创建新对象,仅仅返回原有对象的别名(底层是地址)。修改引用会影响原对象,而修改值返回的新对象不会影响原对象。

  • 所以你的理解正确:值类型的返回值在返回时(至少在逻辑上)会新建一个变量。

    详细的、透彻的、深入的、直击根本原理的为我仔细讲讲为什么会这样

    一、核心结论(先给你一个总览)

    值类型返回:函数内部计算得到的对象(无论是局部变量、参数副本还是临时对象)在返回时,必须将它的“值”交给调用方。由于函数栈帧即将销毁,无法直接共享栈上对象的地址,因此需要把对象的内容“复制或移动到调用方的某个存储位置”中。这个存储位置通常是调用方预先在栈上分配的一个临时对象,或是由调用方提供的返回值接收变量。语义上,这就是“创建了一个新对象”。
    引用类型返回:函数只是返回一个已存在对象的别名(底层是地址)。编译器不创建新对象,只传递地址。要求该对象在函数返回后仍然有效(如全局、静态、堆上、或通过参数传入的引用)。

    二、从函数调用与栈帧的角度看根本原因

    1. 栈帧的生命周期

    每个函数调用时,编译器在栈上分配一块内存(栈帧),用于存放:

    • 函数的参数(部分通过寄存器)

    • 局部变量

    • 返回地址

    • 一些临时对象

    当函数返回时,该栈帧被销毁。所有在栈上分配的对象(局部非静态变量)自动释放其内存。如果你试图返回一个局部对象的引用或指针,就会导致悬垂引用——因为对象已经没了。

    2. 值类型返回的机制

    假设你有:

    cpp

    struct S { int a[1000]; };
    S func() {
    S local;
    // … 给 local 赋值
    return local; // 返回对象的值
    }

    底层做了什么?
    C++ 底层通常使用返回值对象优化和调用方传递隐藏参数的方式(参见 Itanium C++ ABI)。大致流程:

  • 调用方在调用 func() 时,会在自己的栈帧中预留一块空间(称为“返回值对象存储”),并将这块空间的地址作为隐藏参数传递给 func。

  • 在 func 内部,return local; 时,编译器会生成代码将 local 的内容拷贝或移动到隐藏参数指向的那个地址中(即调用方提供的存储空间)。

  • 函数返回后,调用方可以通过该存储地址访问返回值对象。

  • 关键点:

    • 即使 local 是函数局部对象,编译器也不会直接返回它的地址(因为栈帧即将销毁),而是把它的值转移到调用方的内存中。这个转移过程就会构造一个对象(拷贝或移动)。

    • 从语义看,调用方最终持有的对象是一个新对象,它的内容与 local 相同,但地址不同,生命周期独立。

    如果没有 RVO(返回值优化),这个过程会先创建局部对象,再拷贝到临时对象,再拷贝到最终变量(可能两次拷贝)。
    有了 RVO,编译器可以跳过局部对象的独立构造,直接在调用方提供的内存地址上构造局部对象——即 local 和最终结果对象是同一个内存位置。即使这样,最终被使用的对象仍然是一个“新对象”,只是它没有经过额外的拷贝构造。但逻辑上,你仍然得到了一个与函数内部对象不同的、属于调用方作用域的新对象。

    3. 引用类型返回的机制

    cpp

    S& func(S& param) {
    return param; // 返回引用
    }

    • 编译器只是传递 param 的地址(引用底层就是地址),不创建任何新对象。

    • 要求 param 指向的对象在函数返回后仍然存活(比如是调用方传入的、全局的、静态的、或用 new 分配的堆对象)。

    • 调用方拿到引用,可以直接操作原对象,没有拷贝,没有新对象。

    三、C++ 标准规定的语义

    根据 C++ 标准:

    • 值类型(prvalue)的返回值表达式创建了一个结果对象。这个结果对象在调用方的上下文中被初始化([basic.lval])。它的内容来自 return 语句中的表达式,但它是新创建的。

    • 引用类型(lvalue reference)的返回值是一个左值,它指代某个已存在的对象,不创建新对象。

    即使编译器使用 RVO 在调用方内存里直接构造局部对象,标准也允许这种 as-if 规则,但语义上结果对象仍然是“一个函数返回的值”,它和函数内部的对象在概念上可以视为同一个对象(因为 RVO 允许合并),但最终这个对象的存在位置属于调用方,从生命周期和独立性的角度看,它是一个不同于函数内部任何其他对象的实体——除非你返回的是传入的引用或全局对象。

    换句话说:值返回传递的是“内容”,引用返回传递的是“身份”。

    四、为什么必须“新创建”?—— 栈的不可逆性

    假设 C++ 允许值返回直接返回局部栈对象的地址(就像返回引用一样),那么调用方将得到一个指向已经销毁的栈帧内部的指针,这会导致未定义行为。
    因此语言强制规定:当返回值类型是值类型时,函数必须将结果放在调用方的生命周期内有效的存储中。怎么做到?要么在调用方的栈帧中(通过隐藏参数),要么在寄存器中(适用于小的 POD 类型,如 int、char、bool 甚至某些小结构体——但寄存器也只存放值,不是地址)。

    对于小对象(如 int),返回值可能直接通过 EAX/RAX 寄存器传递。调用方从寄存器里取出值并赋给一个变量,这也是“新创建”:寄存器里没有“对象”,只有比特位;调用方根据这些比特位在它的栈上构造一个对象(或者直接赋值给已有对象)。寄存器传值本质也是值拷贝。

    五、彻底对比:指针返回 vs 引用返回 vs 值返回

    返回类型底层操作是否创建新对象生命周期要求
    T 拷贝/移动对象到调用方提供的地址 无(返回值对象由调用方管理)
    T& 传递地址 原对象必须在返回后有效
    const T& 传递地址(只读) 原对象必须在返回后有效(临时可延长生命周期,特殊情况)
    T* 传递地址 否(仅指针本身是值) 原对象必须有效

    六、示例代码的深层含义

    cpp

    free_throws accumulate_by_value(free_throws& target, const free_throws& source) {
    // 修改 target
    target.made += source.made;
    target.attempts += source.attempts;
    return target; // 返回的是 target 对象的副本
    }

    调用:

    cpp

    free_throws team;
    free_throws result = accumulate_by_value(team, two);

    return target; 中 target 是 team 的引用,但返回类型是 free_throws(值),所以编译器会拷贝构造一个临时对象(或直接在 result 的内存中构造)。result 是一个全新的对象,与 team 独立。修改 result 不会影响 team。

    而引用版本:

    cpp

    free_throws& accumulate_by_ref(free_throws& target, const free_throws& source) {
    target.made += source.made;
    target.attempts += source.attempts;
    return target;
    }
    free_throws& ref = accumulate_by_ref(team, two);

    ref 就是 team 本身,没有新对象。

    七、为什么你说“值类型的函数返回值在返回时都会创建一个新变量”—— 严格来说是“新对象”,不一定是“变量”

    • 如果返回值被用作初始化某个变量(如 T obj = func();),那么该变量就是新创建的那个对象(可能通过 RVO 直接构造)。

    • 如果返回值被用作临时对象(如 func().some_method();),那么会创建一个临时的无名对象,在完整表达式结束后销毁。

    • 无论哪种情况,在对象模型层面,确实有一个新的对象被构造(即使是通过移动语义,对象的内容被转移,但目标对象是新的实例)。

    八、总结一句根本原理

    函数栈帧的不可恢复性要求:值类型返回时,不能暴露栈上对象的地址,必须把对象的值(内容)转移到调用方可控的存储区中。这一转移过程必然导致目标存储区中产生一个新对象(通过拷贝或移动)。而引用返回只是传递地址,不涉及对象内容的迁移,因此不创建新对象。

    这就是为什么“值类型返回总是创建一个新变量(新对象)”的本质。

    调用方函数把被调用方函数的返回值放在一个地址上,这个地址上的值会存储多久?如果我执行多次函数调用,后面几次函数调用的返回值会放在新的地址上吗?还是说后面几次调用的返回值也会放在第一次函数调用返回值的地址上,把它覆盖掉。同一个函数多次被调用,它的返回值地址是固定的,还是会在每次被调用时发生变化?

    一、先直接回答你的问题

    同一个函数多次被调用,它的返回值存储地址是固定的吗?

    不固定。 每次函数调用时,调用方会独立地决定返回值的存储位置。这个位置可以每次相同,也可以每次不同,取决于调用方如何使用返回值。

    • 如果调用方把返回值每次都存到同一个变量,则地址相同(覆盖原内容)。

    • 如果调用方存到不同变量或产生新的临时对象,则地址不同。

    • 从被调用函数的角度看,它每次被调用时收到的“返回值隐藏参数”地址可能完全不同——因为调用方栈帧布局、调用位置、优化策略都可能变化。

    这个地址上的值会存储多久?

    • 如果返回值存储在调用方定义的变量中,则该变量的生命周期由调用方作用域决定(直到变量超出作用域)。

    • 如果返回值存储在临时对象中(例如 func().method() 或 a = func() + b),则该临时对象存活到完整表达式结束时(即包含该调用的最外层表达式语句末尾)。

    • 如果调用方没有保存返回值(如直接调用 func(); 而不使用返回值),临时对象也会在表达式结束时销毁。


    二、深入原理:返回值存储的决策与生命周期

    1. 调用方如何决定返回值存储位置

    以 x86-64 Itanium C++ ABI 为例(Linux、macOS 常用),对于非平凡(non-trivial)的且大于 16 字节的结构体,函数返回时使用“隐藏参数”传递:

    • 调用方在栈上或寄存器中准备一块未初始化的内存,将其地址作为第一个隐藏参数(或额外参数)传递给函数。

    • 被调用函数直接在这块内存上构造返回值对象(拷贝或移动)。

    • 函数返回后,调用方持有该内存地址。

    关键:每次调用,调用方都可以选择不同的内存地址。

    场景 A:赋值给同一个变量

    cpp

    free_throws x;
    x = func(); // 第一次调用
    x = func(); // 第二次调用

    • 第一次调用:调用方传递 &x 作为隐藏参数,函数直接在 x 的内存上构造返回值(可能先析构旧值)。

    • 第二次调用:调用方再次传递 &x,覆盖 x 的内容。
      → 地址固定为 &x。

    场景 B:赋值给不同变量

    cpp

    free_throws a = func(); // 第一次调用:传递 &a
    free_throws b = func(); // 第二次调用:传递 &b

    → 地址不同(&a vs &b)。

    场景 C:使用临时对象(无具名变量)

    cpp

    func(); // 返回值丢弃
    display(func().name); // 临时对象用于成员访问
    free_throws c = func(); // 拷贝初始化,也可直接构造在 c 上

    • 直接调用 func();:调用方在栈上分配一个无名临时对象,函数构造它,然后立即(完整表达式结束)析构它。

    • display(func().name):先构造临时对象,然后访问其成员,表达式结束销毁临时对象。

    • free_throws c = func();:这其实是拷贝初始化,但编译器通常优化为直接构造 c(RVO),此时地址就是 &c。如果没有优化,会先构造临时再拷贝到 c,临时对象地址不同于 c。

    场景 D:同一个表达式中多次调用

    cpp

    if (func().value > func().value) …

    • 每个 func() 调用会产生独立的临时对象,地址不同(除非编译器激进复用——但标准不允许并发生存期重叠,因此地址必须不同)。两个临时对象在比较完成后销毁。

    2. 为什么“每次调用地址可能变化”?

    从 CPU/栈的角度看:

    • 每次函数调用,调用方在执行 call 指令之前,会在自己的栈帧中按需分配空间。如果调用方有多个局部变量、参数或临时对象,栈顶指针(RSP)会不断移动。

    • 即使同一行代码中两次调用同一个函数,编译器通常会为每个临时对象分配不同的栈槽(spill slot)或重用之前已销毁的临时对象的内存——但重用并不意味着固定地址,而是按需分配。

    根本原因:栈是动态的,每个函数调用帧独立;返回值存储位置由调用方在当前栈帧中当前可用的内存决定。两次调用之间可能有其他函数调用、变量声明等,改变栈布局,因此地址几乎必然变化(除非极度简单的场景且关闭优化)。

    3. 寄存器返回的小对象(如 int、指针)

    对于能放入寄存器(RAX、RDX等)的小对象,不涉及内存地址。返回值直接通过寄存器传递,调用方将寄存器的值拷贝到目标变量(或临时内存)中。
    这种情况下,“返回值地址”问题不存在——只有值的拷贝,没有长期存储的地址。但同理,调用方把值拷贝到不同变量时,变量地址不同;拷贝到同一变量,地址相同。


    三、“覆盖”现象什么时候发生?

    当调用方明确重用同一个变量来接收返回值时,后一次调用的返回值会覆盖前一次的存储内容。

    cpp

    free_throws result = func1(); // result 地址 A
    result = func2(); // 仍写入地址 A,覆盖 func1 的返回值

    如果是不同变量,则各自独立,互不影响。
    如果是临时对象,每次调用产生新的临时对象,不存在“覆盖”关系(临时对象在表达式结束后就销毁了,下一次调用产生全新的临时对象,即使重用同一块栈内存,也是新对象构造,语义上不是覆盖而是全新构造)。


    四、总结表

    调用方使用方式返回值存储地址变化?覆盖/生命周期
    T x = f(); 只调用一次 固定为 &x 存活至 x 作用域结束
    x = f(); 多次调用同一个 x 地址固定(&x) 每次覆盖 x 的值
    T a = f(); T b = f(); 地址不同(&a ≠ &b) 无覆盖,独立生命周期
    f(); (忽略返回值) 临时对象地址,每次调用可能相同或不同 表达式结束时销毁
    f().g() (使用临时对象) 临时对象地址,每次调用通常不同(栈指针变化) 完整表达式结束时销毁
    同一个表达式中多次调用 f() 每个临时对象地址不同(必须不同) 各自独立,在表达式结束时全部销毁

    最终本质:

    返回值存储地址由调用方在调用点决定的接收对象(变量或临时)的地址给出。同一函数多次被调用时,接收对象可以相同(地址固定)、不同(地址变化)或临时(地址动态分配),因此地址不固定。
    存储的时间则取决于该接收对象的生命周期——对于变量,至作用域结束;对于临时对象,至完整表达式结束。

    C++跟C语言都是这样吗?其它语言呢?

    一、C 语言的情况

    1. 与 C++ 值类型返回的本质相同

    C 语言中,函数返回值永远是值类型(没有引用类型)。返回一个结构体时,也是通过调用方提供的隐藏地址或寄存器来传递结果。机制与 C++ 的值返回几乎一致:

    • 对于能放入寄存器的小结构体(如两个 int 或更小,取决于 ABI),返回值通过寄存器传递(如 eax+edx)。调用方从寄存器取值后赋值给变量或临时对象。

    • 对于较大的结构体,调用方在栈上分配临时对象(或目标变量)并将地址作为隐藏参数传递给函数,函数直接将结果拷贝/构造到该地址上。

    生命周期:与 C++ 相同——若返回值存入变量,则变量生存期由作用域决定;若作为临时使用(如直接调用函数而不保存),临时对象在完整表达式结束时释放(C 语言中 struct 临时对象在栈上,离开表达式后不再可用)。

    多次调用地址:调用方是否重用同一地址取决于是否重用同一变量或临时槽位。与 C++ 完全一致:不是固定的,由调用方决策。

    2. C 与 C++ 的关键差异(不影响你的核心问题)

    • C++ 有移动语义和返回值优化(RVO/NRVO)甚至 C++17 强制临时对象实质化,但底层仍然是调用方提供存储位置,语义上产生新对象。

    • C 没有拷贝构造函数,只是简单的内存复制(memcpy 或逐成员复制),也没有 RVO 的标准保证,但现代编译器仍会做类似的优化(称为“返回结构体优化”)。

    结论:C 语言返回值存储地址的变化规律、生命周期与 C++ 的值返回完全一致,因为两者都基于栈帧和 ABI 约定。


    二、其他语言的情况

    不同语言的内存管理和返回值传递策略差异很大。我们按类别分析。

    1. Rust

    • 返回值:Rust 默认也是移动语义(对于非 Copy 类型),移动后原对象不再可用。函数返回值的存储位置由调用方决定,机制类似于 C++ 的 RVO(保证返回值优化)。

    • 生命周期:调用方必须提供一个存储位置(变量或临时)。临时对象在语句结束时销毁(Drop)。

    • 地址变化:每次调用可以指向不同变量或临时内存,因此地址不固定。

    • 关键区别:Rust 引用必须显式标注生命周期,但返回值本身(非引用)总是拥有所有权,新对象必然被创建(或移动),与 C++ 值返回类似。

    2. Go

    • 返回值:Go 函数可以返回多个值,且支持值语义。返回局部变量的地址是允许的(编译器会进行逃逸分析,将该变量分配到堆上)。

    • 存储位置:Go 编译器决定变量在栈还是堆。如果返回指向局部变量的指针,该变量会逃逸到堆,生命周期由 GC 管理。

    • 值返回:返回结构体时,会复制整个结构体。调用方栈上会分配接收变量,地址由调用方决定,也非固定。

    • 关键不同:Go 可以安全返回局部变量的指针,因为逃逸到堆;而 C/C++ 这样做是未定义行为。所以 Go 的返回值地址可能指向堆而非栈,但依然是新创建的对象(移动或复制)。

    3. Java / C# / Python / JavaScript

    这些语言中,函数返回值永远是引用(或指向对象的指针),但语义上是对象引用的值拷贝。具体:

    • Java:方法返回对象时,返回的是引用的副本,该引用指向堆上同一个对象。没有“新对象”被创建(除非你显式 new 或 clone)。基本类型返回值是值拷贝。

    • C#:引用类型同 Java,值类型(struct)返回时会复制内容(类似于 C++ 值返回)。

    • Python:一切皆对象,返回的是对象的引用(类似指针)。赋值不会复制对象,只是增加引用计数。因此函数返回时没有新对象产生(除了不可变对象可能被缓存,但通常也是同一对象)。

    • JavaScript:类似 Python,返回的是对象的引用,没有隐式复制。

    关键差异:在 GC 语言中,返回值通常不创建新对象,而是传递引用(或指针)。因此“返回值存储地址”对于引用类型而言,实际上是引用的存储位置,而引用本身的存储位置在栈上或寄存器,每次调用可以不同。但引用的目标对象(堆上)可能被多个引用共享。

    生命周期:由 GC 管理,只要还有引用指向对象,对象就存活。

    多次调用地址:对于基本类型(如 int),返回的是值,地址规律同 C++。对于对象引用,返回的“引用值”本身存放的位置(比如局部变量接收引用)可以不同,但引用指向的堆对象可以相同(如返回单例)或不同(如工厂方法创建新对象)。


    三、总结对比表

    语言/特性返回值为新对象?(语义上)返回值存储地址固定?能否安全返回局部变量地址?
    C/C++(值返回) 是(拷贝/移动) 不固定(取决于调用方) 否(栈销毁后非法)
    C++(引用返回) 指向原对象,地址就是原对象地址 否(必须保证原对象有效)
    Rust 是(移动或复制) 不固定 否(借用检查阻止)
    Go(返回结构体值) 是(复制) 不固定 否(局部变量逃逸到堆则安全,但返回的是指针时)
    Go(返回指针) 否(新对象?看情况:若返回指向新分配对象的指针,则是新对象;若返回指向已有对象的指针,则不是) 指向堆上对象,地址由分配决定 是(逃逸到堆)
    Java(返回对象) 否(返回引用,不复制对象) 引用的存储位置不固定,但目标对象地址不变(若同一对象) 不适用(局部对象在堆上)
    C#(值类型 struct) 是(复制) 不固定 否(值类型在栈,返回引用需注意)
    Python/JS 否(返回引用) 引用的存储位置不固定,目标对象地址由 GC 管理 安全(所有对象在堆)

    四、根本原则

    无论哪种语言,函数返回值如何存放,本质上由内存管理策略和调用约定决定:

    • 栈式语言(C/C++/Rust/Go 值类型):返回值存储位置由调用方动态决定(可能是寄存器、栈槽位、目标变量地址)。多次调用地址通常不同,除非调用方显式重用同一内存位置。

    • 垃圾回收语言(Java/C#/Python/JS):返回值如果是对象引用,则不复制对象,只复制引用值;引用值本身存储于临时位置(栈或寄存器),地址不固定,但引用指向的对象地址通常不变(除非新建对象)。

    • 返回值生命周期:在无 GC 的语言中,返回值对象的生命周期绑定到调用方提供的存储位置的生命周期;在 GC 语言中,对象生命周期由可达性决定,与函数调用的栈帧无关。

    所以,你最初关于 C++ 的观察(返回值地址不固定、覆盖取决于调用方变量重用)也适用于 C 以及 Rust、Go 等具有显式栈分配的语言。而对于 Java/Python 等语言,情况更复杂:返回的“值”是引用,引用的临时存储地址仍不固定,但被引用的对象可能唯一且持久。

    如果是void函数呢,调用函数还会给被调用函数预留存储空间吗

    直接结论

    对于 void 函数(不返回任何值),调用方不会为被调用函数预留返回值存储空间。
    因为没有返回值,也就不存在“将返回值放到某个地址”的步骤。调用方只负责传递参数(包括隐含的 this 指针、参数列表)、执行 call 指令,并在函数返回后恢复栈帧或处理副作用。


    1. 从调用约定与栈帧布局看

    以典型的 x86-64 System V ABI(Linux/macOS)为例:

    • 返回值存储在 rax/rdx 等寄存器或由调用方提供的隐藏地址。
      对于普通函数(非 void):

      • 如果返回值类型能放进寄存器(整数、指针、小结构体),直接使用 rax/rdx。

      • 如果返回值类型较大(如大结构体),调用方会在自己的栈帧中分配一块未初始化的内存,并将该内存的地址作为第一个隐藏参数传递给被调用函数。

    • 对于 void 函数:

      • 调用方不分配返回值存储。

      • 不设置隐藏参数。

      • 不关心 rax/rdx 的值(虽然被调用函数可能修改这些寄存器,但调用方会忽略它们)。

    汇编层面:
    非 void 函数调用前可能有额外指令(如 sub rsp, 64 分配空间,或 lea rdi, [rsp+offset] 传递隐藏地址)。
    void 函数调用前没有这些与返回值相关的操作,只压入/准备参数,然后 call。


    2. 特殊情况分析(看似需要空间,但并非为返回值)

    2.1 输出参数(Out Parameters)

    void 函数常常通过指针或引用参数“返回”数据。例如:

    cpp

    void get_value(int* out) { *out = 42; }

    调用方必须自己为 int 对象分配空间(栈上或堆上),并将该地址传递给 out 参数。
    这块空间不属于“函数返回值存储”,而是由调用方显式管理的参数空间。被调用函数只是往这块地址写入数据。

    2.2 异常处理与栈展开

    异常处理机制可能记录一些信息(如 unwind 表),但这些与返回值的存储空间无关。void 函数也可能抛出异常,但不会因此需要预留返回值空间。

    2.3 调试信息 / 检查点

    某些调试构建或插桩工具可能会在栈上预留额外空间(比如用于检查栈溢出),但这与返回值类型无关,对 void 和非 void 都可能有,不是针对返回值的。


    3. 对比表:void vs 非void

    项目void 函数非 void 函数(值返回)
    调用方是否分配返回值存储 是(寄存器或堆栈隐藏参数)
    是否传递隐藏返回值地址 是(大对象时)
    是否读取 rax 等寄存器 不读取(忽略) 是(小对象时)
    参数中的输出参数 调用方为参数分配空间(作为参数) 同样可以为参数分配空间,与返回值无关
    栈帧大小 仅包含参数、局部变量、对齐等 可能额外包含返回值存储槽(大对象时)

    4. 为什么会有这样的设计?

    • 性能:不为无用的存储浪费栈空间或寄存器。

    • 语义清晰:void 明确表示“不返回任何值”,自然不需要预留返回值位置。

    • ABI 简化:调用约定不需要处理不存在的返回值路径,减少复杂性。


    5. 深入例子(伪汇编)

    c

    // C 代码
    struct Big { int arr[1000]; };
    Big foo(int x) { … }
    void bar(int x) { … }

    调用 foo(5)(大结构体返回):

    asm

    sub rsp, 4000 ; 分配 Big 对象空间
    lea rdi, [rsp] ; 隐藏参数:返回地址
    mov esi, 5 ; 参数 x
    call foo
    ; 此时 rsp 处包含返回值

    调用 bar(5):

    asm

    mov edi, 5 ; 参数 x
    call bar
    ; 没有 rsp 调整用于返回值

    可以明显看到区别。


    最终总结

    void 函数调用时,调用方不会为被调用函数预留任何返回值存储空间。
    所有看起来像“存储”的操作,都是通过显式参数(指针/引用)由调用方管理的,与函数返回值无关。
    这一设计遵循了“最小惊讶原则”:没有返回值,就不分配返回值空间。

    调用函数为被调用函数预留的存储空间大小是不是被调函数返回值类型所要占用的空间大小

    结论

    不完全是,取决于返回值类型的大小和调用约定。

    • 对于能在寄存器中返回的小对象(如 int、char、指针、某些小结构体):调用方不在栈上预留空间,而是通过寄存器传递返回值。此时预留空间大小为 0(或说不涉及栈存储)。

    • 对于无法放入寄存器的大对象(通常指超过 16 字节或非平凡构造/析构的类型,依 ABI 而定):调用方会在自己的栈帧中预留一块连续内存,其大小恰好等于返回值类型所需的空间(可能还会考虑对齐)。这块内存的地址作为隐藏参数传给被调用函数。

    • 对于 void 函数:不预留任何返回值空间。

    因此,当需要预留时,预留空间大小确实等于返回值类型的大小(对齐后),但并非所有返回值类型都会导致调用方预留空间。


    详细解释

    1. 小对象:寄存器返回(无栈预留)

    以 x86-64 System V ABI(Linux、macOS、MinGW)为例:

    • 返回值类型若可容纳在 rax/rdx 中(如整数、指针、最多两个 64 位整数的小结构体),则直接使用寄存器。

    • 调用方不会在栈上分配额外的返回值存储槽。调用后直接从 rax 等寄存器中取值使用(如赋值给变量或临时对象)。

    例子:

    cpp

    int func(); // 返回 int
    struct Point { int x; int y; }; // 8 字节,放得进 rax+rdx
    Point getPoint();

    调用 func() 和 getPoint() 时,调用方不预先分配栈空间。

    2. 大对象:栈上预留(大小等于类型大小)

    如果返回值类型大于 16 字节(或具有非平凡拷贝构造函数、析构函数,即使较小),ABI 要求调用方提供存储空间:

    • 调用方在栈上分配足够大小(包含对齐 padding)的内存。

    • 将地址作为隐藏的第一个参数(通常在 rdi 中,对于 System V)。

    • 被调用函数将返回值直接构造/复制到该地址上。

    预留空间大小 = sizeof(return_type) 向上取整到栈对齐边界(通常 16 字节)。例如一个结构体大小为 24 字节,实际可能占用 32 字节栈空间(对齐到 16 的倍数)。

    例子:

    cpp

    struct Big { int arr[10]; }; // 40 字节
    Big makeBig();

    调用方会执行类似 sub rsp, 40(或对齐后更多),并将 rsp 作为隐藏参数。

    3. 特殊情况:C++ 返回值优化(RVO)与存储位置

    C++ 允许编译器省略拷贝/移动,直接在调用方提供的地址上构造返回值。此时预留空间仍然存在(大小同类型)。RVO 不影响“是否需要预留”的决定——只要类型按 ABI 判定为需要间接返回,调用方就必须提供空间。

    4. 寄存器返回但需要临时存储

    即使返回值通过寄存器传递,调用方也可能因为使用需要而创建临时变量(如 int x = func(); 中的 x 在栈上)。但这个栈空间不是“为被调用函数预留的返回值空间”,而是调用方自己的局部变量空间。被调用函数只负责把返回值放到寄存器,调用方再从寄存器拷贝到变量。


    对比表

    返回值类型是否需要调用方预留栈空间?预留空间大小返回方式
    int, char, 指针 0 寄存器(rax)
    小结构体(≤16字节,trivial) 否(或取决于 ABI) 0(寄存器) 寄存器(rax+rdx)
    大结构体(>16字节) sizeof(T) 对齐到栈边界 隐藏地址参数
    非平凡拷贝/析构的类型(即使小) 通常是(依 ABI) sizeof(T) 对齐 隐藏地址参数
    void 0

    总结一句话

    仅当返回值类型无法用寄存器传递时,调用方才会为被调用函数预留存储空间,且预留的大小等于该类型的大小(考虑对齐)。对于能用寄存器返回的类型,不预留任何空间。

    赞(0)
    未经允许不得转载:171主机测评 » 关于函数返回值——一个重要问题
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址