CppCon 2018 学习:Return Value Optimization

什么是 “返回槽”?

在 C++ 或其他编译型语言中,返回槽(Return Slot) 是编译器在调用函数时为其 返回值提前分配的一块内存空间
函数执行完成后,它会把计算出来的返回值写入这块区域,然后控制权返回给调用者,调用者再从这块区域读取结果。

举个简单例子:

int apple() {
    return 42;
}
int pear() {
    return 1 + apple();  // apple 返回 42,pear 返回 43
}

这段代码你觉得看起来就是 apple() 返回 42,pear() 接收它,加 1,返回 43。
底层实现并不是直接“传数字”,而是:

  1. pear() 调用 apple() 时,准备一个返回槽(可能是寄存器或内存)
  2. apple()42 写到返回槽里(比如 x86 平台用寄存器 EAX / RAX
  3. pear() 从返回槽里取出这个值,加 1,得到 43
    这个“返回槽”可以是:
  • 简单值:用寄存器返回(比如 int)
  • 复杂类型:使用内存地址传递(比如 struct/class)

对于复杂类型的返回

struct MyStruct {
    int x, y, z;
};
MyStruct makeVec() {
    return MyStruct{1, 2, 3};
}

这时候返回的是一个结构体,不适合放在寄存器里。
于是:

  • 编译器让调用方(caller)先分配好内存当作返回槽
  • 把这个返回槽的地址传给 makeVec()
  • makeVec() 直接在这块内存中构造并返回结果
  • 所以没有复制,只是在“调用方提供的地址”上直接构造对象

为什么你要关心返回槽?

场景 影响
返回大对象 会使用返回槽,避免额外拷贝(也可能触发 Return Value Optimization)
内联优化 / 性能调优 编译器能优化掉中间变量,前提是你写法配合(如使用 RVO)
编写底层库 / 接口设计 理解返回槽是构建高性能泛型代码的基础

小结

术语 含义
返回槽 函数返回值写入的内存或寄存器区域
简单类型返回 通常使用寄存器(如 intEAX
复杂类型返回 调用者先分配空间并传给函数(SRet)
RVO / NRVO 返回值优化:复用返回槽避免拷贝

你这段代码展示的是在 x86-64 调用约定(calling convention) 下,函数返回值是如何传递的。下面我用中文详细解释:

背景:什么是 x86-64 调用约定?

调用约定定义了函数之间是如何传递参数、返回值以及管理栈帧的。在 x86-64 系统中(比如 Linux 下的 clang/gcc,使用 System V ABI):

  • 前 6 个整型/指针参数通过寄存器传递:%rdi, %rsi, %rdx, %rcx, %r8, %r9
  • 函数的返回值用 %rax(32位整型时是 %eax)传递

你给出的代码分析:

int apple() {
    return 42;
}
int pear() {
    return 1 + apple();
}

编译指令:

clang -O0 -fomit-frame-pointer -S example.cpp

其中:

  • -O0:关闭优化,保留原始代码结构
  • -fomit-frame-pointer:省略帧指针
  • -S:生成汇编代码而不是目标文件

汇编分析

_Z5applev:           # 函数 apple()
  movl $42, %eax     # 把立即数 42 放到 %eax —— 这是返回值
  retq               # 返回
_Z4pearv:            # 函数 pear()
  callq _Z5applev    # 调用 apple(),其返回值保存在 %eax
  addl $1, %eax      # 在 %eax 上加 1,即返回 42 + 1 = 43
  retq               # 返回

关键点总结:

  • int 类型在 x86-64 下的返回值存储在 %eax(低 32 位寄存器)
  • apple() 的返回值直接写入 %eax
  • pear() 直接接着用 %eax 做加法,无需其他中间变量
  • 整个过程没有访问栈,也没有临时存储,非常高效

小技巧

你可以用如下命令查看对应汇编:

clang++ -std=c++17 -O0 -S -fomit-frame-pointer example.cpp -o -

总结

内容 说明
x86-64 调用约定 返回值用 %rax/%eax,参数前6个用寄存器传递
apple() 汇编实现 movl $42, %eax 是函数返回值的传递
pear() 汇编实现 call%eax 中的值被加 1,继续作为返回值
整个函数调用链 遵循约定,返回值高效传递,没进栈
https://godbolt.org/z/WTGTxe5zs

这段代码和注释讲解的是 x86-64 调用约定(calling convention) 的一个简单示例,解释了函数如何通过寄存器传递返回值、如何使用 callret 指令进行函数调用和返回。下面是逐行中文解析和原理讲解。

示例 C++ 代码:

int apple() {
    return 42;
}
int pear() {
    return 1 + apple();
}

汇编结果(使用 clang 编译,x86-64 架构):

_Z5applev:         ; apple 函数的符号名(Itanium ABI 下的 C++ 名字修饰)
 04d0: movl $42, %eax   ; 把 42 赋值给 %eax —— %eax 是返回值寄存器
 04d5: retq             ; 返回
_Z4pearv:         ; pear 函数的符号名
 1706: callq _Z5applev  ; 调用 apple(),返回值仍在 %eax 中
 170b: addl $1, %eax    ; 把 %eax 加上 1(即 1 + apple())
 170e: retq             ; 返回,结果仍在 %eax 中

关键点讲解:Calling Convention

1. 返回值传递:

x86-64 System V ABI(Linux/macOS 常见) 调用约定中:

类型 返回值寄存器
intfloat %eax / %xmm0
double %xmm0
指针或地址类型 %rax
所以:
return 42;

→ 直接将 42 存到 %eax 中。

2. 函数调用流程:

当执行:

callq _Z5applev

CPU 做了两件事:

  1. 把下一条指令地址(即 return address)压入栈顶
    用于 retq 之后回到调用者。
  2. 跳转到 _Z5applev 的地址,即跳到 apple() 执行。
    然后在 apple() 中执行 retq
  • 会从栈顶弹出之前的返回地址,并跳回。

示例中的“return slot”指什么?

“return slot”(返回槽)指的是:
用来临时存储函数返回值的位置。在 x86-64 中,如果返回的是简单类型(比如 int、float),就直接放在 寄存器中,而不是栈中。
apple() 中,返回值 42 就放在 %eax 中 → 不需要栈空间。
如果你返回的是大型结构体,就可能要用栈(或 caller 提供一个地址当作 return slot)。

图解示意:

当前状态

内容 地址 说明
2600 %eip 程序计数器 → 正在执行 call pear()
FF00 %esp 栈指针(指向栈顶)
%eax xxxx 用于函数返回值
栈内 FEF8 - FEE0 call 指令保存的返回地址和寄存器
当执行到:
2600: callq _Z4pearv
  • CPU 把下一条指令地址(2605)压入栈
  • 跳转到 pear(),执行 callq apple()
  • apple() 返回 42 → 存到 %eax
  • pear() 加 1 → %eax 变为 43
  • retq 回到 2605,继续执行

总结

概念 说明
%eax 是 x86-64 中函数返回 int 的返回值寄存器
callq 指令 将返回地址压栈,并跳转到目标函数
retq 指令 从栈中弹出返回地址,并跳回
“return slot” 指用来存储函数返回值的空间(对标量就是 %eax
函数返回值传递方式 标量类型用寄存器,大结构可能用内存

这段内容展示了 x86-64 调用约定中函数调用和返回的执行过程,以及寄存器和栈的状态,下面是详细的中文解释和流程说明:

程序代码回顾

int apple() {
    return 42;
}
int pear() {
    return 1 + apple();
}

汇编片段回顾

_Z5applev:
 04d0: movl $42, %eax
 04d5: retq
_Z4pearv:
 1706: callq _Z5applev
 170b: addl $1, %eax
 170e: retq
2600: callq _Z4pearv
2605: do something else

栈与寄存器状态说明

寄存器/内存 内容/含义 说明
%eip 1706 程序计数器(指令指针),当前指令地址,处于 pear() 函数入口
%eax xxxx(不确定) 函数返回值寄存器,目前是未知值(将被写入)
%esp FEF8 栈指针,指向当前栈顶
FF00 xxxx 栈中的某些数据(无具体展示)
FEF8 2605 栈顶位置,存储了 retq 返回地址 2605
其它栈地址 xxxx 其他栈帧或数据

流程解释

  1. 调用 pear() 函数
    • 程序执行到地址 2600,执行:
      callq _Z4pearv
      
    • CPU 做了两件事:
      • 把下一条指令的地址 2605 压入栈顶(即 FEF8 位置),这是 pear() 返回后跳回的地址。
      • 跳转到 pear() 函数入口(地址 1706)。
  2. 执行 pear()
    • 当前 %eip = 1706,开始执行 pear()
    • pear() 执行 callq _Z5applev 调用 apple() 函数。
  3. 调用 apple()
    • 同样,CPU 将 retq 返回地址 170b 压栈(栈顶将变),跳转到 apple()
  4. 执行 apple()
    • apple() 将常数 42 移动到返回值寄存器 %eax
      movl $42, %eax
      
    • 然后执行 retq,从栈弹出返回地址 170b,跳回 pear()
  5. 回到 pear() 继续
    • pear() 现在 %eax 已经是 42,执行:
      addl $1, %eax
      
      %eax 加 1,结果变成 43。
    • 执行 retq,返回地址 2605 从栈顶弹出,继续执行 do something else

总结

  • 栈的作用: callq 指令把返回地址压栈,retq 指令从栈弹出返回地址,实现函数调用与返回的跳转。
  • 寄存器作用: %eax 用于保存整型函数的返回值。
  • 程序计数器 %eip 指示当前正在执行的指令地址。
  • 栈指针 %esp 指示栈顶位置,随着函数调用和返回不断变化。
apple():                              # @apple()
        push    rbp        ; 保存调用者的基指针
        mov     rbp, rsp   ; 建立新的栈帧基址
        mov     eax, 42    ; 返回值 42 放入 eax 寄存器
        pop     rbp        ; 恢复调用者的基指针
        ret                ; 返回调用点
pear():                               # @pear()
        push    rbp        ; 保存调用者的基指针
        mov     rbp, rsp   ; 建立新的栈帧基址
        call    apple()    ; 调用 apple 函数
        add     eax, 1     ; 结果加 1
        pop     rbp        ; 恢复调用者的基指针
        ret                ; 返回调用点

你给的这组连续的状态快照非常好,完整地展现了 x86-64 调用约定下,函数调用、返回值传递以及栈和寄存器的变化流程。让我帮你用更清晰的顺序梳理整个过程,结合你给出的信息解释:

函数与调用约定简介

  • apple() 返回常量 42,返回值通过 %eax 传递
  • pear() 调用 apple(),然后对返回值加 1,最终返回结果
  • 函数调用时,call 指令会把 下一条指令地址(返回地址)压栈
  • 返回时,ret 指令从栈顶弹出返回地址跳回去
  • %eip 是指令指针寄存器,指向当前执行的指令地址
  • %esp 是栈顶指针,指向栈顶地址
  • %eax 用于存放整型函数的返回值

状态流程解释

1. 调用 pear(),执行到 apple() 入口时:

%eip = 04D0        // apple() 开始执行
%eax = xxxx        // 还没设置返回值
%esp = FEF0        // 栈顶指针位置
栈内容:
  FEF0: 170B       // 栈顶是apple()返回地址(即pear()中调用apple()后下一条指令地址)
  FEF8: 2605       // pear()返回地址(即调用pear()后下一条指令地址)

2. apple() 函数执行完设置返回值,马上要返回:

%eip = 04D5        // apple() 即将执行 retq
%eax = 42          // apple() 函数返回值已经放入 eax
%esp = FEF0        // 栈顶仍是返回地址
栈内容不变

3. apple() 执行 retq,跳回 pear() 的下一条指令地址:

%eip = 170B        // 回到pear(),紧接着调用apple()后执行 addl $1, %eax
%eax = 42          // apple()返回值仍在 eax
%esp = FEF8        // 栈顶向上移动,弹出了apple()的返回地址
栈内容:
  FEF8: 2605       // pear()返回地址仍在
  FEF0: 170B       // 之前apple()返回地址已弹出

4. 执行 addl $1, %eax,计算 1 + apple()

%eip = 170E        // pear() 执行完加法,准备返回
%eax = 43          // 返回值是 43
%esp = FEF8        // 栈顶仍指向 pear() 返回地址

5. pear() 执行 retq,跳回主调代码:

%eip = 2605        // 返回到主调代码的下一条指令(2600 call pear后的下一条指令)
%eax = 43          // pear() 的返回值
%esp = FF00        // 栈顶回到了主调状态
栈内容:
  FEF8: 2605       // 已经弹出了pear()返回地址

关键点总结

  • callq 会把返回地址压栈(地址是调用指令的下一条指令)
  • retq 会从栈顶弹出返回地址,跳转回去
  • 返回值通过 %eax 寄存器传递
  • 栈顶 %esp 随着 callret 的压栈、弹栈动态变化
  • %eip 随着程序执行移动,跟踪当前指令地址

你提到的这个“return slot”机制,是x86-64调用约定处理**较大返回值(比如结构体)**的典型方式。

小对象 VS 大对象 返回值区别

  • 小对象(如int、指针)
    返回值直接放 %eax(或 %rax,如果64位),寄存器返回,简单高效。
  • 大对象(如结构体、数组)
    返回值太大,寄存器放不下,不能直接通过 %eax 返回。

“return slot” 的解决方案

  • 调用者在自己的栈上分配一块内存区域(return slot),用于存放函数返回的结构体或大对象。
  • 调用者把这块内存的地址(return slot地址)通过隐藏参数传递给被调用函数,在 x86-64 Linux ABI 下,这个地址会放在 %rdi 寄存器里(第一个整型参数寄存器)。
  • 被调用函数 apple() 不再返回值,而是直接把结果写入这个由调用者传入的内存区域。
  • 函数执行结束后,调用者从这块内存中取到返回结果。

你给的汇编例子分析

_Z5applev:                   # apple()
  movq %rdi, %rax            # 把 return slot 的地址放到 %rax(也就是返回值的指针)
  movl $1, (%rdi)            # 把1写入return slot的第0个int位置
  movl $2, 4(%rdi)           # 把2写入return slot的第1个int位置
  movl $3, 8(%rdi)
  movl $4, 12(%rdi)
  movl $5, 16(%rdi)
  retq
  • %rdi 是调用者传入的return slot地址。
  • apple(){1,2,3,4,5} 写到这块内存。
  • 返回时 %rax 返回这块内存的地址(符合 ABI 约定)。
    调用者调用时:
subq $24, %rsp             # 在栈上分配24字节空间作为return slot
movq %rsp, %rdi           # 把return slot地址放到rdi寄存器,传给apple()
callq _Z5applev           # 调用apple()
# 现在 return slot 中的内存(rsp[0..20])里存着返回值

总结

  • 对于大对象返回,编译器自动添加一个“隐藏参数”(return slot地址)给被调用函数。
  • 被调用函数写入该内存,不通过寄存器返回值。
  • 调用者负责分配return slot,并传地址。
  • 这保证了返回大型对象的正确性和效率。
    如果你想,我可以帮你写一个完整的示例代码和它对应的汇编解释!或者帮你理解不同平台、编译器的返回值策略差异。需要吗?
    https://godbolt.org/z/oW96nd3rj
struct Fruit {
    int data[5];  // 20 bytes
};
Fruit apple()
{
    return Fruit{1,2,3,4,5};
}
int main(){
    apple();
}
apple():                              # @apple()
        push    rbp
        mov     rbp, rsp
        mov     rax, rdi
        mov     dword ptr [rdi], 1
        mov     dword ptr [rdi + 4], 2
        mov     dword ptr [rdi + 8], 3
        mov     dword ptr [rdi + 12], 4
        mov     dword ptr [rdi + 16], 5
        pop     rbp
        ret
main:                                   # @main
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        lea     rdi, [rbp - 24]
        call    apple()
        xor     eax, eax
        add     rsp, 32
        pop     rbp
        ret

总结和归纳一下这个 x86-64 返回大对象的调用约定核心思想,结合你贴的内容:

x86-64 对大对象返回值的处理机制(以结构体 Fruit 为例)

struct Fruit {
    int data[5];  // 20 bytes
};
Fruit apple() {
    return Fruit{1,2,3,4,5};
}

1. 为什么需要特殊处理?

  • 小类型(比如 intpointer)可以直接用寄存器 %eax / %rax 返回。
  • 大对象(> 16字节)返回值太大,寄存器放不下。
  • 所以采用**“返回槽(return slot)”机制**。

2. “返回槽(return slot)”机制

  • 调用者
    在自己的栈上分配一块内存(本例中分配24字节,刚好装下20字节数据+对齐),作为返回值的存放空间,称为返回槽。
    subq $24, %rsp — 给返回值开空间
    movq %rsp, %rdi — 把返回槽地址放入 %rdi,传给被调用函数。
  • 被调用函数 (apple):
    通过第一个参数 %rdi 收到返回槽地址。
    直接往 %rdi 指向的内存写数据。
    返回时 %rax 返回该地址,符合ABI约定。

3. 汇编流程回顾

2600:  subq $24, %rsp          # 调用者在栈上为返回值开空间
2604:  movq %rsp, %rdi        # 把返回槽地址传给apple() (%rdi)
2607:  callq _Z5applev        # 调用apple()
_Z5applev:
04d0:  movq %rdi, %rax        # 被调用函数返回槽地址放到%rax (返回地址)
04d3:  movl $1, (%rdi)        # 写入返回槽
04d9:  movl $2, 4(%rdi)
04e0:  movl $3, 8(%rdi)
04e7:  movl $4, 12(%rdi)
04ee:  movl $5, 16(%rdi)
04f5:  retq                   # 返回
260c:  # 返回后 %rsp[0..20] 有 {1,2,3,4,5}

4. 关键点总结

  • 返回槽由调用者分配和拥有
  • 被调用函数不直接返回结构体,而是写入调用者传递的返回槽。
  • 返回值传递时,第一个参数 %rdi 被用作“返回槽地址”,这是一个隐藏参数。
  • 返回时 %rax 返回的是返回槽地址,而非结构体本身。
  • 调用者调用结束后,从返回槽读取结果。

5. 为什么返回槽大小是24字节?

  • 结构体是20字节(5个int × 4字节)。
  • 栈一般需要16字节对齐。
  • 多留4字节保证对齐。
    这就是 x86-64 Linux ABI 规范中对于大结构体返回值的标准处理流程,编译器和链接器都会自动帮你生成对应代码,你只需要写正常的函数。

含有移动构造函数的复杂返回值场景下,如何利用“返回槽”进行返回的。

关键点梳理 — 结构体移动并返回到返回槽

struct Fruit {
    int data[5];
    Fruit(Fruit&&);  // 移动构造函数
};
Fruit apples_and_oranges(bool condition) {
    Fruit x = ...;
    Fruit y = ...;
    return std::move(condition ? x : y);
}

1. 为什么这里不直接拷贝返回?

  • 返回的是局部变量 xy 的“移动版本”(std::move
  • 如果直接拷贝可能开销大,移动构造可以更高效(比如内部有指针资源)
  • 编译器会利用移动构造函数,将数据“搬到”调用者的返回槽(栈上的内存)

2. 返回槽的作用不变

  • 调用者依旧先在栈上分配一块返回槽(20字节,存放最终返回的Fruit
  • 调用者把返回槽地址通过隐式参数传给 apples_and_oranges
  • 函数内部需要调用 Fruit 的移动构造函数,将 xy 移动到返回槽里

3. 栈布局示意

栈高地址 ↓ 内容
返回地址
返回槽 (20 bytes) ← 目标返回值存放处
Fruit x (20 bytes) ← 函数局部变量
Fruit y (20 bytes) ← 函数局部变量
栈低地址 ↑
  • 返回槽、局部变量x和y都在栈上分配,布局相邻
  • 返回槽由调用者拥有,函数通过移动构造将选中对象移动到这里

4. 函数行为逻辑

  • 函数接收返回槽地址参数(隐式传递)
  • 根据条件选择 xy
  • 调用 Fruit(Fruit&&) 移动构造函数,将选中的 Fruit 移动构造到返回槽
  • 返回时,返回槽中即为结果

5. 为什么返回槽特别重要?

  • 避免函数调用时返回临时对象的额外复制或移动
  • 函数直接“构造”到调用者提供的内存中,减少开销
  • 提升性能,符合现代C++的移动语义设计
    简单总结一下这里讲的:
  • 普通返回时,比如 return x;,函数内部会先在栈上创建一个局部对象 x,然后把它复制(或移动)到调用者传入的返回槽(return slot),也就是调用者预先分配的内存空间。
  • 汇编中表现为:先在栈上构造 x,然后把它的数据复制到 %rdi 指向的返回槽中。
  • **开启拷贝省略(Copy Elision)或返回值优化(RVO)**后,编译器直接把 x 构造在返回槽上,也就是直接在调用者的内存空间构造 x,避免了复制。
  • 汇编表现为:直接在 %rdi(返回槽)上写数据,不再有额外复制。
    所以,代码
Fruit nothing_but_apples()
{
    Fruit x = {1, 2, 3, 4, 5};
    return x;
}

编译器优化后,会直接在返回槽上构造 x,提高效率。

这段代码展示了 C++ 中一个小对象 Fruit 返回时如何通过“返回槽优化(Return Value Optimization, RVO)”直接在调用者栈上构造返回值,避免额外复制或移动。让我们一步步解析汇编代码行为:

https://godbolt.org/z/63En94sc1

#include 
struct Fruit {
    int data[5];
};
Fruit nothing_but_apples()
{
    Fruit x{1,3,4,5,6};
    return x;
}
int main(){
    Fruit ret = nothing_but_apples();
}
nothing_but_apples():                # @nothing_but_apples()
        push    rbp
        mov     rbp, rsp
        mov     rax, rdi
        mov     rcx, qword ptr [.L_ZZ18nothing_but_applesvE1x]
        mov     qword ptr [rdi], rcx
        mov     rcx, qword ptr [.L_ZZ18nothing_but_applesvE1x+8]
        mov     qword ptr [rdi + 8], rcx
        mov     edx, dword ptr [.L_ZZ18nothing_but_applesvE1x+16]
        mov     dword ptr [rdi + 16], edx
        pop     rbp
        ret
main:                                   # @main
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        lea     rdi, [rbp - 24]
        call    nothing_but_apples()
        xor     eax, eax
        add     rsp, 32
        pop     rbp
        ret
.L_ZZ18nothing_but_applesvE1x:
        .long   1                       # 0x1
        .long   3                       # 0x3
        .long   4                       # 0x4
        .long   5                       # 0x5
        .long   6                       # 0x6

结构体定义:

struct Fruit {
    int data[5];  // 共 5 * 4 = 20 字节
};

函数逻辑:

Fruit nothing_but_apples()
{
    Fruit x{1,3,4,5,6};
    return x;
}

你初始化了一个 Fruit x,然后返回它。因为没有自定义构造函数,编译器可以省略拷贝/移动构造,直接在返回槽中就地构造 x —— 这就是 copy elision(拷贝消除)。

汇编分析(重点):

函数调用栈图示:
main():
    lea     rdi, [rbp - 24]      # 把返回槽地址传给 rdi
    call    nothing_but_apples() # 调用函数

传入了返回槽地址在 [rbp - 24],这就是 ret 的内存空间。

nothing_but_apples() 函数汇编逻辑:
mov     rax, rdi     ; 返回槽地址复制到 rax 作为返回值
mov     rcx, qword ptr [.L_ZZ18nothing_but_applesvE1x]      ; data[0] 和 data[1]
mov     qword ptr [rdi], rcx
mov     rcx, qword ptr [.L_ZZ18nothing_but_applesvE1x+8]    ; data[2] 和 data[3]
mov     qword ptr [rdi + 8], rcx
mov     edx, dword ptr [.L_ZZ18nothing_but_applesvE1x+16]   ; data[4]
mov     dword ptr [rdi + 16], edx

这里 .L_ZZ18nothing_but_applesvE1x 是一个编译器生成的静态常量对象(即 Fruit x{1,3,4,5,6} 存在数据段里),通过 memcpy 把内容复制到调用者的返回槽 [rdi]
你可以认为它大致等价于:

memcpy(rdi, &x_static, sizeof(Fruit));

main 中如何接收返回值:

lea     rdi, [rbp - 24]   ; ret 占 20 字节,分配在栈上
call    nothing_but_apples

函数 nothing_but_apples 会把 Fruit 的值写入 [rbp - 24],而不是返回在寄存器里。

总结:

特性 说明
拷贝消除 没有调用构造函数或移动,Fruit x 直接就地构造在返回槽里。
返回槽传递 编译器自动把返回槽地址作为隐藏参数传给函数(在 %rdi
编译优化 函数内部使用 .L_ZZ18nothing_but_applesvE1x 静态初始化变量来填充数据
如果你自己实现了移动构造函数,这个优化 可能会被禁止,因为编译器不再认为它是无副作用构造 —— 这是 C++ 的“当有用户定义构造函数时,拷贝消除不再强制”的行为。

C++ 中的 Copy Elision(拷贝省略)规则 和例外情况,确实是现代 C++ 性能优化中非常重要的一块。下面是你给出的内容的中文解释和深入理解:

什么是 Copy Elision?

拷贝省略 是编译器在某些情况下可以避免调用拷贝构造函数或移动构造函数,直接在目标内存位置构造对象的优化。
比如:

Fruit make_fruit() {
    return Fruit{1,2,3,4,5}; // 不会拷贝,直接构造在返回位置
}

C++ 中 Copy Elision 的发展

标准版本 Copy Elision 说明
C++03~14 允许(optional) 编译器可以选择省略构造,但不是必须的。
C++17 强制(mandatory) 在某些情况下必须省略,连拷贝/移动构造函数都不必生成。
C++20+ 引入更多的构造规则 更进一步强化 return slot 优化与构造语义。

什么时候不能省略(不能 elide)?

情况一:x 和返回地址(return slot)物理位置不同

Fruit apples_to_apples(int i, Fruit x, int j) {
    return x;
}

解释:

  • x 是作为参数传进来的,它的位置是固定的(通常位于栈某个区域)。
  • 返回的对象则是构造在调用者提供的 return slot 中(通过寄存器 %rdi 提供地址)。
  • 所以,必须执行一次拷贝或移动,将 x 的内容从参数栈区复制到 return slot,不能省略

情况二:返回一个全局变量或静态变量

static Fruit x;
Fruit apples_to_apples() {
    return x;
}

解释:

  • 静态变量 x 是在数据段的固定内存中。
  • 返回值要写入调用者的 return slot(栈地址或寄存器指定的位置)。
  • 因此,也需要执行一次拷贝或移动操作 —— 不能 elide。

什么情况下可以 elide?

Fruit apples_to_apples() {
    Fruit x = Fruit{1,2,3,4,5};
    return x;
}

在这种情况下,编译器知道 x 的生命周期结束时正好是 return 时刻,且可以在 return slot 中直接构造 x,从而完全省略拷贝(甚至不需要生成拷贝构造函数!,尤其在 C++17)。

总结

情况 是否能 Copy Elide?
return Foo{...}; 可以
return local_variable; 如果变量是本地且没有逃逸
return function_parameter; 不可以(不同位置)
return static/global_variable; 不可以(内存区域不同)

提到的这一段是讲 “对象切片(Slicing)” 和它为什么阻止了 Copy Elision(拷贝省略)。下面我们来用中文详细解释这个机制和原因:

场景说明:

struct Fruit {
    int data[5];  // 20 bytes
};
struct Durian : Fruit {
    double smell; // 再加 8 bytes,共 28~32 bytes
};
Fruit slapchop() {
    Durian x = ...;
    return x;  // ← 这里发生了「切片」
}

什么是对象切片(Slicing)?

当你把一个派生类对象(如 Durian)赋值给一个基类对象(如 Fruit)时:

  • 只能复制基类部分的数据,派生类的额外成员(smell)会被「切掉」;
  • 就像用刀把 Durian 切成 Fruit —— 所以叫 Slicing

为什么不能发生拷贝省略?

slapchop() 中:

Durian x = ...;       // x 是个 32 字节的 Durian
return x;             // 但函数返回的是 Fruit(只有 20 字节)

即使我们控制了 x 的位置(它是 local 变量),但:

  • xDurian 类型;
  • 返回值类型是 Fruit,也就是说返回槽(return slot)只能容纳 20 字节的基类部分;
  • 我们 不能在 return slot 中构造一个 Durian 然后只拷贝其中一部分作为 Fruit
  • 所以只能在栈上构造 x,然后额外执行一次切片式拷贝

对比:可以省略的情况

Fruit slapchop() {
    Fruit x = ...;
    return x;  //  可以直接在 return slot 中构造,无需拷贝
}

总结

情况 是否可以 elide? 原因
返回一个与函数返回值类型相同的本地变量 可以 编译器可以直接在 return slot 构造
返回一个派生类对象到基类返回值 不可以 会发生 slicing,类型不一致,内存布局不同
https://godbolt.org/z/5b8hz6Kqn

这段代码和汇编展示的是 C++ 中一种非常典型的情况:派生类 Durian 返回给基类 Fruit 时发生了对象切片(object slicing),并且没有办法做 copy elision(拷贝省略),所以编译器必须显式地 构造 Durian 对象、再拷贝出 Fruit 部分

我们先简要分析代码逻辑

C++ 源码:

#include 
struct Fruit {
    int data[5];  // 20 bytes
};
struct Durian : Fruit {
    double smell; // 再加 8 bytes,共 28~32 bytes
};
Fruit slapchop() {
    Durian x{};
    return x;  // ← 返回时会发生切片
}
int main() {
    Fruit f = slapchop();  // 调用函数并接收切片结果
}

汇编结构分析(重点):

函数返回机制

我们看到这段关键代码:

mov     rax, rdi             ; 把返回槽的地址放入 rax
...
mov     qword ptr [rbp - 40], rdi   ; 把返回槽地址备份
...
call    memset              ; 初始化 Durian x 的内存
...
mov     rax, qword ptr [rbp - 32]   ; 读取 x 的前 20 字节
mov     rdx, qword ptr [rbp - 40]   ; 读取返回槽地址
mov     qword ptr [rdx], rax        ; 复制 x 的前 8 字节
mov     rax, qword ptr [rbp - 24]   ; 复制下一个 8 字节
mov     qword ptr [rdx + 8], rax
mov     ecx, dword ptr [rbp - 16]   ; 复制最后 4 字节
mov     dword ptr [rdx + 16], ecx   ; 共复制了 20 字节

说明:

  • 栈上构造了一个 32 字节的 Durian
  • 但由于返回类型是 Fruit,只能返回 20 字节;
  • 所以手动从 Durian 中取前 20 字节赋值到返回槽 %rdi
  • 这就是对象切片Durian 中的 smell 被丢弃了。

为什么不能拷贝省略?

因为:

  • xDurian 类型;
  • 返回值是 Fruit类型不完全匹配
  • 所以不能直接在 %rdi 指向的位置构造 x
  • 只能在栈上构造 x,然后拷贝 Fruit 部分出来。

main() 的作用

main:
    ...
    lea     rdi, [rbp - 24]   ; 为返回值准备 return slot(24 字节)
    call    slapchop()        ; 返回值写入 [rbp - 24]
  • main 负责给 slapchop() 提供返回槽(%rdi);
  • slapchop() 只返回 Fruit(不是整个 Durian);
  • 所以主函数只接收 Fruit 的那部分内容。

总结

项目 内容
结构体大小 Fruit = 20 字节,Durian = 28/32 字节
发生了什么 返回 DurianFruit,触发了切片
编译器行为 不能拷贝省略,显式在栈上构造 Durian,再拷贝前 20 字节
汇编中表现 memset 初始化 + mov 拷贝 3 段共 20 字节到 return slot
为什么不能省略拷贝 类型不匹配(Durian vs Fruit),不能在 return slot 上直接构造

这是理解 C++ 中返回值优化(RVO)的核心内容之一。我来帮你用中文清晰总结这段内容,并解释里面的重点规则和陷阱。

RVO:Return Value Optimization(返回值优化)

1⃣ URVO(Unnamed RVO)—— 总是会发生拷贝省略

当你返回一个**临时对象(prvalue)**时,比如:

Fruit make_fruit() {
    return Fruit{1, 2, 3, 4, 5};   //  编译器会直接构造在返回槽中
}

或者:

Fruit make_fruit() {
    return helper_function();     //  helper_function 返回值会直接构造在返回槽中
}

这叫 “Unnamed RVO”,根据 C++17 标准是 强制执行的必然发生 copy elision,无需 move/copy 构造函数)。

2⃣ NRVO(Named RVO)—— 返回局部变量的情况

当你写:

Fruit make_fruit() {
    Fruit x{1, 2, 3, 4, 5};
    return x;   //  编译器**尝试**优化:直接构造 x 在返回槽上(Named RVO)
}

这叫 Named Return Value Optimization

  • 大多数编译器都能成功执行 NRVO
  • 不是强制性的,某些复杂控制流会让 NRVO 失败
  • 比如 return condition ? x : y; ⇒ 因为无法决定构造谁

3⃣ 隐式 move(Implicit Move)

如果 NRVO 失败,也不用担心 —— C++11 起,编译器会自动尝试 move

std::string identity(std::string x) {
    return x;  //  如果 NRVO 失败,编译器会隐式对 x 做 move
}

这是通过函数参数为局部变量 x 启用的 隐式移动构造

4⃣ 不要写 return std::move(x);

有些人担心 return 时不 move,会写成:

return std::move(x);  //  其实是“优化悲化”

但这么写 可能会破坏 NRVO!你手动指定 std::move(x),就意味着你希望移动而非构造在返回槽上。于是:

  • 编译器不能对 x 应用 Named RVO
  • 必须执行一次移动构造函数
  • 效率反而下降

总结:三条黄金法则

情况 会发生 copy elision 吗? 是否需要写 std::move
return T{...}; 一定会发生 URVO 不要写 std::move
return x; 大概率发生 NRVO(C++17) 不要写 std::move
return condition ? x : y; NRVO 会失败,改用 move 可以写 std::move

这是一个非常典型的 C++ 隐式拷贝 vs 移动抽象基类返回值引发性能陷阱的真实案例。你已经走到返回值优化(RVO)、隐式移动、C API 封装等知识点的交叉口了。我们逐步理解你说的这段故事:

背景回顾:C++ 包装 C 接口

你通过 Cexpr 类封装了一个 C 风格结构 cexpr*,支持构造、复制、移动、析构,并派生出 CexprListCexprInt,最后写了个函数:

Cexpr as_cexpr() const override {
    CexprList cfg;
    cfg.append(CexprInt(17));
    cfg.append(CexprInt(42));
    cfg.append(CexprString("hike"));
    return cfg;
}

你希望的是:return cfg; 会被当成右值 —— 从而调用 Cexpr(Cexpr&&) 移动构造而不是拷贝。

但实际上 —— 发生了 cexpr_clone() 拷贝!

也就是说,调用的不是 Cexpr(Cexpr&&)(移动构造),而是 Cexpr(const Cexpr&)(复制构造),从而导致大量的 cexpr_clone(),这是性能瓶颈。

原因揭秘:返回类型是抽象基类,无法发生移动

来看这个定义:

class ConfigManager {
    virtual Cexpr as_cexpr() const = 0;
};

而你写的是:

Cexpr as_cexpr() const override {
    CexprList cfg;
    return cfg;
}

表面上你返回的是 cfg,它的静态类型是 CexprList,但函数签名的返回类型是 值传递返回一个 Cexpr(base class)
此时发生了所谓的:

Slicing(切片) + 拷贝构造

  • 返回一个派生类对象 CexprList 给一个 base 类型 Cexpr
  • C++ 不会尝试从 CexprList 移动到 Cexpr,只会调用 Cexpr(const Cexpr&)
  • 导致 cexpr_clone() 被调用!
    这是对象切片的经典场景之一。

怎么优化?

有几个方法:

方式一:返回派生类对象时,显式移动为 Cexpr

return static_cast<Cexpr&&>(cfg);

或者更简单且安全:

return std::move(cfg);  //  显式触发 Cexpr(Cexpr&&)

这里写 std::move(cfg)必要的,因为 cfgCexprList 类型,返回类型却是 Cexpr,编译器不会隐式调用 Cexpr(Cexpr&&)

这正是 C++ 隐式移动规则的一个重要例外当返回类型与变量类型不完全匹配(如基类 vs 派生类)时,不会自动触发移动

方式二:函数返回值改为派生类型

如果你能改动接口设计,像这样:

class ConfigManagerImpl {
    CexprList as_cexpr_list() const;
};

那么就可以直接利用 NRVO,甚至是 URVO,编译器会将构造结果直接放进返回槽,完全避免拷贝和移动。
但这通常不适用于多态接口(如抽象基类中返回 Cexpr 的情况),所以不太现实。

总结小结

情况 会自动触发 move 吗? 会触发 copy 吗?
return x; 返回本地变量(类型完全匹配) 是(C++11 起) 否(除非禁用移动)
return x; 返回基类 是(切片 + 拷贝)
return std::move(x); 返回基类 是(但手动)
这段内容深刻揭示了 C++11 引入的“隐式移动”(implicit move)机制背后的复杂细节,特别是返回局部变量时编译器尝试调用移动构造函数,但只有在移动构造函数的参数类型与被返回对象的确切类型”(exact class type)匹配时才会生效**。否则会退回为调用拷贝构造,造成“切片(slicing)”和性能损失。

核心点总结(理解版)

  1. 隐式移动规则
    当你 return x;,且 x 是一个局部变量时,编译器会尝试将 x 视为右值来调用移动构造函数(T(T&&))。
    这次的移动构造函数必须是“精确匹配”x 类型的移动构造函数
  2. 切片问题(Slicing)
    如果 x 的静态类型是派生类 Derived,而返回类型是基类 Base,移动构造函数参数是 Base&&
    但此时编译器“尝试移动构造函数匹配”的规则会失败,因为参数类型不是 Derived&&
    于是隐式移动失效,退回调用拷贝构造,导致派生类部分被切掉(slicing),而且拷贝代价更高。
  3. 修订后的规则(CWG1579)
    标准在 C++11 发布后进行了补丁,确保只有当移动构造函数的第一个参数是对返回变量的确切类型的右值引用时才会优先调用。
    这可以避免一些意外行为,但也导致上述的“切片”问题仍然存在。
  4. 实践中的例子
    Cexpr as_cexpr() const override {
        CexprList cfg;
        // 构造 cfg
        return cfg;  // 这里 cfg 类型是 CexprList,而返回类型是基类 Cexpr
    }
    
    编译器尝试找 Cexpr(CexprList&&) 的构造函数,失败了,退而调用 Cexpr(const CexprList&)(拷贝构造,且发生切片)。
  5. 转换构造和转换操作符的影响
    当使用模板类(例如 inplace_function)且有隐式转换操作符时,隐式移动变得更加复杂,甚至会“意外地”调用转换操作符进行转换,增加复制负担。

结论和实践建议

  • 避免对象切片: 尽量让返回类型和返回变量的类型匹配,或者返回智能指针/值语义封装,避免基类值返回派生类对象。
  • 显式使用 std::move 虽然文中提醒return std::move(x);有时是伪优化,但在切片风险存在时,显式移动+更合适的设计是更安全的。
  • 理解标准背后的细节规则: 这些规则保证移动语义仅在“类型匹配”的情况下生效,确保行为明确,但对多态设计带来挑战。
  • 注意模板和隐式转换操作符带来的额外移动/拷贝: 复杂泛型代码里,转换构造和转换操作符可能导致更多复制,需谨慎设计。

这段内容核心是讨论 为什么 C++ 标准特别强调在返回值优化(RVO)时,要优先匹配“接收精确类型的右值引用构造函数”,以及构造函数在这其中的关键作用。

重点解析:

  1. 为什么特别强调构造函数接收“精确类型的右值引用”?
    标准中 [class.copy.elision] /3 的规则明确要求,返回局部变量时,编译器优先尝试调用接受“该局部变量确切类型右值引用”的构造函数。
    • 精确类型:意味着如果变量是派生类 Derived,那么只有构造函数接受 Derived&& 才算匹配;如果构造函数只接受基类 Base&&,则不算匹配。
    • 这样设计,是为了防止意外调用错误的构造函数导致“切片”或语义错误。
  2. 构造函数的特殊地位
    • C++ 标准库里大量类型(std::unique_ptrstd::optionalstd::shared_ptrstd::functionstd::variant 等)都依赖“转换构造函数”,即接受不同模板参数类型的右值引用构造函数。
    • 标准针对这些“转换构造函数”的行为做了统一规范,保证移动语义在它们之间的传递正确且高效。
  3. 为什么在 C++11 里引入,并且是补丁形式后加上的?
    • 原始的 C++11 移动语义规则没考虑所有隐式转换的细节,可能导致部分代码隐式调用拷贝构造或转换操作符,带来额外复制和性能损失。
    • 后来修正为更加精细的匹配机制:只有确切类型的移动构造才被优先选中。
    • 这条规则重要到必须回溯修正 C++11,而不等到 C++14。
  4. 现实中的坑和复杂性
    • 当函数返回的类型是基类,但返回局部变量是派生类时,会导致隐式移动失效,回退到拷贝构造(切片)。
    • 转换构造和转换操作符带来的隐式类型转换更复杂,有时会导致“隐式移动”失效,调用了非预期的拷贝构造。
    • 这些问题不容易通过静态分析工具(如 clang-tidy)发现,因为它们依赖复杂的重载解析和类型转换规则。
  5. 工具支持与检测
    • clang++ 提供了 -Wmove 警告,提醒过度使用 return std::move(x); 可能会阻止拷贝消除(RVO),是个“伪优化”甚至“负优化”。
    • 但之前没有对应的警告提醒“写 return x; 实际上是拷贝,而非移动”,因为这是更隐晦的性能陷阱。
    • 作者甚至直接修改了 clang,加入了更深入的诊断机制,尝试检测并警告“隐式移动失效”的情况。

总结:

  • 构造函数尤其是“接收精确类型右值引用的构造函数”对现代 C++ 的性能和语义至关重要,因为移动语义和拷贝消除都严重依赖它。
  • 标准为了保证移动语义能够在大量标准库模板和用户代码中高效且正确地工作,专门引入了这条规则,并追溯到 C++11。
  • 实际开发中需要意识到隐式移动的局限,特别是返回基类对象时可能发生切片,以及转换构造和操作符带来的额外复制。
  • 静态分析工具虽然能帮忙,但因规则复杂,仍需开发者理解这些细节,才能写出既正确又高效的代码。

这段内容讲述的是作者如何给 Clang 编译器新增一个诊断(警告)功能——-Wreturn-std-move,用于检测函数返回局部变量时应该用 std::move() 以启用移动语义,但实际却直接返回,导致拷贝发生的情况,以及相关的开发流程和实现细节。

核心流程总结

  1. 为什么加这个诊断?
    在 C++ 中,返回局部变量时,如果不显式用 std::move(),有时候会调用拷贝构造而非移动构造,导致性能损失。标准的拷贝消除机制(NRVO)并不总是生效,所以想提醒程序员写 return std::move(x);
  2. 添加诊断的步骤
    • Step 1:添加命令行选项
      DiagnosticGroups.tdDiagnosticSemaKinds.td 中定义新的警告组和警告消息。
      例如:
      def ReturnStdMove : DiagGroup<"return-std-move">;
      def warn_return_std_move : Warning<
        "local variable %0 will be copied despite being %select{returned|thrown}1 by name">,
        InGroup<ReturnStdMove>, DefaultIgnore;
      def note_add_std_move : Note<
        "call 'std::move' explicitly to avoid copying">;
      
    • Step 2:添加测试用例
      test/SemaCXX/warn-return-std-move.cpp 写大量测试代码,保证警告能正确触发,并支持自动检测 fix-it 建议。比如:
      Base test() {
        Derived d2;
        return d2;  // expected-warning: will be copied...
      }
      
    • Step 3:实现诊断逻辑
      主要改动 lib/Sema/SemaStmt.cppPerformMoveOrCopyInitialization 函数。
      • 先尝试用正常的 NRVO 和移动构造初始化。
      • 如果失败,尝试用更宽松的“假装调用 std::move”的方式查找构造函数。
      • 如果找到更优匹配但没有用上,触发警告和 fix-it(自动建议加 std::move())。
        关键代码片段:
      if (Res.isInvalid()) {
        auto *FakeCandidate = getCopyElisionCandidate(QualType(), Value, CES_AsIfByStdMove);
        if (FakeCandidate) {
          ExprResult FakeRes = ExprError();
          AttemptMoveInitialization(*this, Entity, FakeCandidate, ResultType, Value, true, FakeRes);
          if (!FakeRes.isInvalid()) {
            Diag(Value->getExprLoc(), diag::warn_return_std_move)
                << Value->getSourceRange() << FakeCandidate->getDeclName() << IsThrow;
            Diag(Value->getExprLoc(), diag::note_add_std_move)
                << FixItHint::CreateReplacement(Value->getSourceRange(),
                                               "std::move(" + FakeCandidate->getDeclName().getAsString() + ")");
          }
        }
      }
      
    • Step 3b:细节处理
      • 不对 trivial(平凡)的构造函数提示,因为加不加 std::move 没区别。
      • 不对返回引用类型的局部变量提示,避免误导。
  3. Step 4:合入主干
    该功能在 2018 年 5 月合入 Clang trunk,且默认开启于 -Wall
  4. Step 5:发表标准提案
    David Stone 提出了相关标准提案(P1155R0 和 P0527R1)推动更智能的隐式移动机制。

额外说明

  • 这个诊断的目的是让程序员注意到写 return x; 可能没达到预期的移动效果,提醒改成 return std::move(x);
  • 但实际上,过早或不当使用 std::move 可能会阻止 NRVO,反而降低性能,所以 clang 也有 -Wpessimizing-move,提醒你不要盲目用 std::move
  • 这个警告与标准中对构造函数和移动语义的精细规定密切相关——因为标准复杂的规则,编译器自动识别这种隐式复制vs移动的情况不容易。

你给的这些例子是围绕 C++ 返回值优化(copy elision)、隐式移动(implicit move)和普通拷贝(plain copy)行为的不同情况,展示了在不同语境下标准和编译器的行为差异,以及新的提案(P1155、P0527)希望改进的点。

例子 1:

void five() {
  Widget w;
  throw w;
}
Widget six(Widget w) {
  return w;
}
void seven(Widget w) {
  throw w;
}
函数 是否允许拷贝消除 是否隐式移动 是否普通拷贝 备注
five() Clang/MSVC/Intel 支持
six() 同上
seven() 不确定 P1155提案想使其隐式移动 普通拷贝 目前标准不保证

例子 2:

struct From {
  From(Widget const&);
  From(Widget&&);
};
struct To {
  operator Widget() const&;
  operator Widget() &&;
};
From eight() {
  Widget w;
  return w;
}
Widget nine() {
  To t;
  return t;
}
函数 拷贝消除 隐式移动 普通拷贝 备注
eight() P1155提案希望使其隐式移动
nine() 普通拷贝 P1155同上

例子 3:

struct Fish {
  Fish(Widget const&);
  Fish(Widget&&);
};
struct Fowl {
  Fowl(Widget);
};
Fish ten() {
  Widget w;
  return w;
}
Fowl eleven() {
  Widget w;
  return w;
}
函数 拷贝消除 隐式移动 普通拷贝 备注
ten() GCC支持,P1155想使其隐式移动
eleven() 允许 普通拷贝 同上

例子 4:

struct Base {
  Base(Base const&);
  Base(Base&&);
};
struct Derived : Base {};
unique_ptr<Base> twelve() {
  unique_ptr<Derived> p;
  return p;
}
Base thirteen() {
  Derived d;
  return d;
}
函数 拷贝消除 隐式移动 普通拷贝 备注
twelve() GCC、Intel支持,P1155提案
thirteen() GCC、Intel支持 同上

例子 5:

Widget fourteen(Widget&& w) {
  return w;
}
Widget fifteen(Widget& w) {
  Widget&& x = std::move(w);
  return x;
}
函数 拷贝消除 隐式移动 普通拷贝 备注
fourteen() 普通拷贝 P0527提案希望改为隐式移动
fifteen() 普通拷贝 同上

例子 6:

Widget sixteen(Widget w) {
  w += 1;
  return w;
}
Widget seventeen(Widget w) {
  return w += 1;
}
函数 拷贝消除 隐式移动 普通拷贝 备注
sixteen() 暂无相关提案
seventeen() 暂无相关提案

你可能感兴趣的:(CppCon 2018 学习:Return Value Optimization)