在 C++ 或其他编译型语言中,返回槽(Return Slot) 是编译器在调用函数时为其 返回值提前分配的一块内存空间。
函数执行完成后,它会把计算出来的返回值写入这块区域,然后控制权返回给调用者,调用者再从这块区域读取结果。
int apple() {
return 42;
}
int pear() {
return 1 + apple(); // apple 返回 42,pear 返回 43
}
这段代码你觉得看起来就是 apple()
返回 42,pear()
接收它,加 1,返回 43。
但底层实现并不是直接“传数字”,而是:
pear()
调用 apple()
时,准备一个返回槽(可能是寄存器或内存)apple()
把 42
写到返回槽里(比如 x86 平台用寄存器 EAX
/ RAX
)pear()
从返回槽里取出这个值,加 1,得到 43struct MyStruct {
int x, y, z;
};
MyStruct makeVec() {
return MyStruct{1, 2, 3};
}
这时候返回的是一个结构体,不适合放在寄存器里。
于是:
makeVec()
makeVec()
直接在这块内存中构造并返回结果场景 | 影响 |
---|---|
返回大对象 | 会使用返回槽,避免额外拷贝(也可能触发 Return Value Optimization) |
内联优化 / 性能调优 | 编译器能优化掉中间变量,前提是你写法配合(如使用 RVO) |
编写底层库 / 接口设计 | 理解返回槽是构建高性能泛型代码的基础 |
术语 | 含义 |
---|---|
返回槽 | 函数返回值写入的内存或寄存器区域 |
简单类型返回 | 通常使用寄存器(如 int 用 EAX ) |
复杂类型返回 | 调用者先分配空间并传给函数(SRet) |
RVO / NRVO | 返回值优化:复用返回槽避免拷贝 |
调用约定定义了函数之间是如何传递参数、返回值以及管理栈帧的。在 x86-64 系统中(比如 Linux 下的 clang/gcc,使用 System V ABI):
%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 |
call
和 ret
指令进行函数调用和返回。下面是逐行中文解析和原理讲解。int apple() {
return 42;
}
int pear() {
return 1 + apple();
}
_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 中
在 x86-64 System V ABI(Linux/macOS 常见) 调用约定中:
类型 | 返回值寄存器 |
---|---|
int 、float |
%eax / %xmm0 |
double |
%xmm0 |
指针或地址类型 | %rax |
所以: |
return 42;
→ 直接将 42 存到 %eax
中。
当执行:
callq _Z5applev
CPU 做了两件事:
retq
之后回到调用者。_Z5applev
的地址,即跳到 apple()
执行。apple()
中执行 retq
:“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
2605
)压入栈pear()
,执行 callq apple()
apple()
返回 42 → 存到 %eax
pear()
加 1 → %eax
变为 43retq
回到 2605
,继续执行概念 | 说明 |
---|---|
%eax |
是 x86-64 中函数返回 int 的返回值寄存器 |
callq 指令 |
将返回地址压栈,并跳转到目标函数 |
retq 指令 |
从栈中弹出返回地址,并跳回 |
“return slot” | 指用来存储函数返回值的空间(对标量就是 %eax ) |
函数返回值传递方式 | 标量类型用寄存器,大结构可能用内存 |
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 |
其他栈帧或数据 |
pear()
函数
2600
,执行:callq _Z4pearv
2605
压入栈顶(即 FEF8
位置),这是 pear()
返回后跳回的地址。pear()
函数入口(地址 1706
)。pear()
%eip
= 1706
,开始执行 pear()
。pear()
执行 callq _Z5applev
调用 apple()
函数。apple()
retq
返回地址 170b
压栈(栈顶将变),跳转到 apple()
。apple()
apple()
将常数 42
移动到返回值寄存器 %eax
:movl $42, %eax
retq
,从栈弹出返回地址 170b
,跳回 pear()
。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
用于存放整型函数的返回值pear()
,执行到 apple()
入口时:%eip = 04D0 // apple() 开始执行
%eax = xxxx // 还没设置返回值
%esp = FEF0 // 栈顶指针位置
栈内容:
FEF0: 170B // 栈顶是apple()返回地址(即pear()中调用apple()后下一条指令地址)
FEF8: 2605 // pear()返回地址(即调用pear()后下一条指令地址)
apple()
函数执行完设置返回值,马上要返回:%eip = 04D5 // apple() 即将执行 retq
%eax = 42 // apple() 函数返回值已经放入 eax
%esp = FEF0 // 栈顶仍是返回地址
栈内容不变
apple()
执行 retq
,跳回 pear()
的下一条指令地址:%eip = 170B // 回到pear(),紧接着调用apple()后执行 addl $1, %eax
%eax = 42 // apple()返回值仍在 eax
%esp = FEF8 // 栈顶向上移动,弹出了apple()的返回地址
栈内容:
FEF8: 2605 // pear()返回地址仍在
FEF0: 170B // 之前apple()返回地址已弹出
addl $1, %eax
,计算 1 + apple()
:%eip = 170E // pear() 执行完加法,准备返回
%eax = 43 // 返回值是 43
%esp = FEF8 // 栈顶仍指向 pear() 返回地址
pear()
执行 retq
,跳回主调代码:%eip = 2605 // 返回到主调代码的下一条指令(2600 call pear后的下一条指令)
%eax = 43 // pear() 的返回值
%esp = FF00 // 栈顶回到了主调状态
栈内容:
FEF8: 2605 // 已经弹出了pear()返回地址
callq
会把返回地址压栈(地址是调用指令的下一条指令)retq
会从栈顶弹出返回地址,跳转回去%eax
寄存器传递%esp
随着 call
和 ret
的压栈、弹栈动态变化%eip
随着程序执行移动,跟踪当前指令地址%eax
(或 %rax
,如果64位),寄存器返回,简单高效。%eax
返回。%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])里存着返回值
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
Fruit
为例)struct Fruit {
int data[5]; // 20 bytes
};
Fruit apple() {
return Fruit{1,2,3,4,5};
}
int
,pointer
)可以直接用寄存器 %eax
/ %rax
返回。subq $24, %rsp
— 给返回值开空间movq %rsp, %rdi
— 把返回槽地址放入 %rdi
,传给被调用函数。apple
):%rdi
收到返回槽地址。%rdi
指向的内存写数据。%rax
返回该地址,符合ABI约定。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}
%rdi
被用作“返回槽地址”,这是一个隐藏参数。%rax
返回的是返回槽地址,而非结构体本身。struct Fruit {
int data[5];
Fruit(Fruit&&); // 移动构造函数
};
Fruit apples_and_oranges(bool condition) {
Fruit x = ...;
Fruit y = ...;
return std::move(condition ? x : y);
}
x
或 y
的“移动版本”(std::move
)Fruit
)apples_and_oranges
Fruit
的移动构造函数,将 x
或 y
移动到返回槽里栈高地址 ↓ | 内容 | |
---|---|---|
… | … | |
返回地址 | ||
返回槽 (20 bytes) | ← 目标返回值存放处 | |
Fruit x (20 bytes) | ← 函数局部变量 | |
Fruit y (20 bytes) | ← 函数局部变量 | |
栈低地址 ↑ | … |
x
或 y
Fruit(Fruit&&)
移动构造函数,将选中的 Fruit
移动构造到返回槽return x;
,函数内部会先在栈上创建一个局部对象 x
,然后把它复制(或移动)到调用者传入的返回槽(return slot),也就是调用者预先分配的内存空间。x
,然后把它的数据复制到 %rdi
指向的返回槽中。x
构造在返回槽上,也就是直接在调用者的内存空间构造 x
,避免了复制。%rdi
(返回槽)上写数据,不再有额外复制。Fruit nothing_but_apples()
{
Fruit x = {1, 2, 3, 4, 5};
return x;
}
编译器优化后,会直接在返回槽上构造 x
,提高效率。
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));
lea rdi, [rbp - 24] ; ret 占 20 字节,分配在栈上
call nothing_but_apples
函数 nothing_but_apples
会把 Fruit
的值写入 [rbp - 24]
,而不是返回在寄存器里。
特性 | 说明 |
---|---|
拷贝消除 | 没有调用构造函数或移动,Fruit x 直接就地构造在返回槽里。 |
返回槽传递 | 编译器自动把返回槽地址作为隐藏参数传给函数(在 %rdi ) |
编译优化 | 函数内部使用 .L_ZZ18nothing_but_applesvE1x 静态初始化变量来填充数据 |
如果你自己实现了移动构造函数,这个优化 可能会被禁止,因为编译器不再认为它是无副作用构造 —— 这是 C++ 的“当有用户定义构造函数时,拷贝消除不再强制”的行为。 |
拷贝省略 是编译器在某些情况下可以避免调用拷贝构造函数或移动构造函数,直接在目标内存位置构造对象的优化。
比如:
Fruit make_fruit() {
return Fruit{1,2,3,4,5}; // 不会拷贝,直接构造在返回位置
}
标准版本 | Copy Elision | 说明 |
---|---|---|
C++03~14 | 允许(optional) | 编译器可以选择省略构造,但不是必须的。 |
C++17 | 强制(mandatory) | 在某些情况下必须省略,连拷贝/移动构造函数都不必生成。 |
C++20+ | 引入更多的构造规则 | 更进一步强化 return slot 优化与构造语义。 |
Fruit apples_to_apples(int i, Fruit x, int j) {
return x;
}
解释:
x
是作为参数传进来的,它的位置是固定的(通常位于栈某个区域)。%rdi
提供地址)。x
的内容从参数栈区复制到 return slot,不能省略!static Fruit x;
Fruit apples_to_apples() {
return x;
}
解释:
x
是在数据段的固定内存中。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; |
不可以(内存区域不同) |
struct Fruit {
int data[5]; // 20 bytes
};
struct Durian : Fruit {
double smell; // 再加 8 bytes,共 28~32 bytes
};
Fruit slapchop() {
Durian x = ...;
return x; // ← 这里发生了「切片」
}
当你把一个派生类对象(如 Durian
)赋值给一个基类对象(如 Fruit
)时:
smell
)会被「切掉」;Durian
切成 Fruit
—— 所以叫 Slicing。在 slapchop()
中:
Durian x = ...; // x 是个 32 字节的 Durian
return x; // 但函数返回的是 Fruit(只有 20 字节)
即使我们控制了 x 的位置(它是 local 变量),但:
x
是 Durian
类型;Fruit
,也就是说返回槽(return slot)只能容纳 20 字节的基类部分;x
,然后额外执行一次切片式拷贝。Fruit slapchop() {
Fruit x = ...;
return x; // 可以直接在 return slot 中构造,无需拷贝
}
情况 | 是否可以 elide? | 原因 |
---|---|---|
返回一个与函数返回值类型相同的本地变量 | 可以 | 编译器可以直接在 return slot 构造 |
返回一个派生类对象到基类返回值 | 不可以 | 会发生 slicing,类型不一致,内存布局不同 |
https://godbolt.org/z/5b8hz6Kqn |
Durian
返回给基类 Fruit
时发生了对象切片(object slicing),并且没有办法做 copy elision(拷贝省略),所以编译器必须显式地 构造 Durian
对象、再拷贝出 Fruit
部分。#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 字节
说明:
Durian
;Fruit
,只能返回 20 字节;Durian
中取前 20 字节赋值到返回槽 %rdi
;Durian
中的 smell
被丢弃了。因为:
x
是 Durian
类型;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 字节 |
发生了什么 | 返回 Durian 到 Fruit ,触发了切片 |
编译器行为 | 不能拷贝省略,显式在栈上构造 Durian ,再拷贝前 20 字节 |
汇编中表现 | memset 初始化 + mov 拷贝 3 段共 20 字节到 return slot |
为什么不能省略拷贝 | 类型不匹配(Durian vs Fruit ),不能在 return slot 上直接构造 |
当你返回一个**临时对象(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 构造函数)。
当你写:
Fruit make_fruit() {
Fruit x{1, 2, 3, 4, 5};
return x; // 编译器**尝试**优化:直接构造 x 在返回槽上(Named RVO)
}
这叫 Named Return Value Optimization。
return condition ? x : y;
⇒ 因为无法决定构造谁如果 NRVO 失败,也不用担心 —— C++11 起,编译器会自动尝试 move
std::string identity(std::string x) {
return x; // 如果 NRVO 失败,编译器会隐式对 x 做 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 |
你通过 Cexpr
类封装了一个 C 风格结构 cexpr*
,支持构造、复制、移动、析构,并派生出 CexprList
和 CexprInt
,最后写了个函数:
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)。
此时发生了所谓的:
CexprList
给一个 base 类型 Cexpr
CexprList
移动到 Cexpr
,只会调用 Cexpr(const Cexpr&)
cexpr_clone()
被调用!有几个方法:
Cexpr
return static_cast<Cexpr&&>(cfg);
或者更简单且安全:
return std::move(cfg); // 显式触发 Cexpr(Cexpr&&)
这里写 std::move(cfg)
是 必要的,因为 cfg
是 CexprList
类型,返回类型却是 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)”和性能损失。 |
return x;
,且 x
是一个局部变量时,编译器会尝试将 x
视为右值来调用移动构造函数(T(T&&)
)。x
类型的移动构造函数。x
的静态类型是派生类 Derived
,而返回类型是基类 Base
,移动构造函数参数是 Base&&
,Derived&&
,Cexpr as_cexpr() const override {
CexprList cfg;
// 构造 cfg
return cfg; // 这里 cfg 类型是 CexprList,而返回类型是基类 Cexpr
}
编译器尝试找 Cexpr(CexprList&&)
的构造函数,失败了,退而调用 Cexpr(const CexprList&)
(拷贝构造,且发生切片)。inplace_function
)且有隐式转换操作符时,隐式移动变得更加复杂,甚至会“意外地”调用转换操作符进行转换,增加复制负担。std::move
: 虽然文中提醒return std::move(x);
有时是伪优化,但在切片风险存在时,显式移动+更合适的设计是更安全的。[class.copy.elision] /3
的规则明确要求,返回局部变量时,编译器优先尝试调用接受“该局部变量确切类型右值引用”的构造函数。
Derived
,那么只有构造函数接受 Derived&&
才算匹配;如果构造函数只接受基类 Base&&
,则不算匹配。std::unique_ptr
、std::optional
、std::shared_ptr
、std::function
、std::variant
等)都依赖“转换构造函数”,即接受不同模板参数类型的右值引用构造函数。-Wmove
警告,提醒过度使用 return std::move(x);
可能会阻止拷贝消除(RVO),是个“伪优化”甚至“负优化”。return x;
实际上是拷贝,而非移动”,因为这是更隐晦的性能陷阱。-Wreturn-std-move
,用于检测函数返回局部变量时应该用 std::move()
以启用移动语义,但实际却直接返回,导致拷贝发生的情况,以及相关的开发流程和实现细节。std::move()
,有时候会调用拷贝构造而非移动构造,导致性能损失。标准的拷贝消除机制(NRVO)并不总是生效,所以想提醒程序员写 return std::move(x);
。DiagnosticGroups.td
和 DiagnosticSemaKinds.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">;
test/SemaCXX/warn-return-std-move.cpp
写大量测试代码,保证警告能正确触发,并支持自动检测 fix-it 建议。比如:Base test() {
Derived d2;
return d2; // expected-warning: will be copied...
}
lib/Sema/SemaStmt.cpp
中 PerformMoveOrCopyInitialization
函数。
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() + ")");
}
}
}
std::move
没区别。-Wall
。return x;
可能没达到预期的移动效果,提醒改成 return std::move(x);
。std::move
可能会阻止 NRVO,反而降低性能,所以 clang 也有 -Wpessimizing-move
,提醒你不要盲目用 std::move
。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提案想使其隐式移动 | 普通拷贝 | 目前标准不保证 |
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同上 |
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() | 否 | 允许 | 普通拷贝 | 同上 |
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支持 | 是 | 同上 |
Widget fourteen(Widget&& w) {
return w;
}
Widget fifteen(Widget& w) {
Widget&& x = std::move(w);
return x;
}
函数 | 拷贝消除 | 隐式移动 | 普通拷贝 | 备注 |
---|---|---|---|---|
fourteen() | 否 | 否 | 普通拷贝 | P0527提案希望改为隐式移动 |
fifteen() | 否 | 否 | 普通拷贝 | 同上 |
Widget sixteen(Widget w) {
w += 1;
return w;
}
Widget seventeen(Widget w) {
return w += 1;
}
函数 | 拷贝消除 | 隐式移动 | 普通拷贝 | 备注 |
---|---|---|---|---|
sixteen() | 否 | 否 | 否 | 暂无相关提案 |
seventeen() | 否 | 否 | 是 | 暂无相关提案 |