在微优化领域之外,我通常会传递一个const 引用,因为您没有修改对象并且您希望避免复制。如果有一天您确实使用了构造成本高昂的T1 或T2,则副本可能是一个大问题:没有与传递 const 引用相当的大脚枪。因此,我将按值传递视为一种权衡非常不对称的选择,并且仅在我知道数据很小时才按值进行选择。
至于您具体的微优化问题,它基本上取决于调用是否完全内联以及您的编译器是否不错。
完全内联
如果您的f 函数的任一变体被内联到调用者中,并且启用了优化,那么您很可能会为任一变体获得相同或几乎相同的代码。我用inline_f_ref 和inline_r_val 调用来测试here。它们都从未知的外部函数生成pair,然后调用f 的按引用或按变体。
像f_val 这样(f_ref 版本只改变最后的调用):
template <typename T>
auto inline_f_val() {
auto pair = get_pair<T>();
return f_val(pair);
}
当T1 和T2 是int 时,以下是gcc 上的结果:
auto inline_f_ref<int>():
sub rsp, 8
call std::pair<int, int> get_pair<int>()
add rsp, 8
ret
auto inline_f_val<int>():
sub rsp, 8
call std::pair<int, int> get_pair<int>()
add rsp, 8
ret
完全一样。编译器可以看穿这些函数,甚至识别出std::pair 和mypair 实际上具有相同的布局,因此所有f 的痕迹都消失了。
这是一个版本,其中 T1 和 T2 是一个具有两个指针的结构,而不是:
auto inline_f_ref<twop>():
push r12
mov r12, rdi
sub rsp, 32
mov rdi, rsp
call std::pair<twop, twop> get_pair<twop>()
mov rax, QWORD PTR [rsp]
mov QWORD PTR [r12], rax
mov rax, QWORD PTR [rsp+8]
mov QWORD PTR [r12+8], rax
mov rax, QWORD PTR [rsp+16]
mov QWORD PTR [r12+16], rax
mov rax, QWORD PTR [rsp+24]
mov QWORD PTR [r12+24], rax
add rsp, 32
mov rax, r12
pop r12
ret
这是“ref”版本和“val”版本是相同的。这里编译器无法优化所有工作:在创建对之后,它仍然需要做很多工作来将 std::pair 内容复制到 mypair 对象(有 4 个存储共存储 32 个字节,即4 个指针)。所以再次内联让编译器优化版本到相同的东西。
您可能会发现情况并非如此,但在我的经验中它们并不常见。
没有内联
没有内联是一个不同的故事。你提到你所有的函数都是内联的,但这并不一定意味着编译器会内联它们。尤其是 gcc 比一般情况下更不愿意内联函数(例如,在没有 inline 关键字的情况下,它没有内联此示例中的 very 短函数 -O2)。
没有内联参数的传递和返回方式是由 ABI 设置的,因此编译器无法优化掉两个版本之间的差异。 const 引用版本相当于传递一个指针,因此无论T1 和T2 是什么,您都将在第一个整数寄存器中传递一个指向std::pair 对象的指针。
这是导致 T1 和 T2 在 Linux 上的 gcc 中为 int 时的代码:
auto f_ref<int, int>(std::pair<int, int> const&):
mov rax, QWORD PTR [rdi]
ret
std::pair 的指针在 rdi 中传递,因此函数的主体是从该位置移动到 rax 的单个 8 字节。 std::pair<int, int> 占用 8 个字节,因此编译器一次性复制整个内容。在这种情况下,返回值是在rax 中“按值”传递的,所以我们完成了。
这取决于编译器的优化能力和 ABI。例如,下面是 MSVC 为 64 位 Windows 目标编译的相同函数:
my_pair<int,int> f_ref<int,int>(std::pair<int,int> const &) PROC ; f_ref<int,int>, COMDAT
mov eax, DWORD PTR [rdx]
mov r8d, DWORD PTR [rdx+4]
mov DWORD PTR [rcx], eax
mov rax, rcx
mov DWORD PTR [rcx+4], r8d
ret 0
这里发生了两种不同的事情。首先,ABI 不同。 MSVC 无法在 rax 中返回 mypair<int,int>。相反,调用者将rcx 传递一个指针 到被调用者应该保存结果的位置。所以这个函数除了加载之外还有存储。 rax 加载了保存数据的位置。第二件事是编译器太笨了,无法将两个相邻的4字节加载和存储合并成8字节,所以有两个加载和两个存储。
第二部分可以由更好的编译器修复,但第一部分是 API 的结果。
这是这个函数的 by value 版本,在 Linux 上的 gcc 中:
auto f_val<int, int>(std::pair<int, int>):
mov rax, rdi
ret
仍然只有一条指令,但这次是一个 reg-reg 移动,它永远不会比加载更昂贵,而且通常便宜得多。
在 MSVC、64 位 Windows 上:
my_pair<int,int> f_val<int,int>(std::pair<int,int>)
mov rax, rdx
mov DWORD PTR [rcx], edx
shr rax, 32 ; 00000020H
mov DWORD PTR [rcx+4], eax
mov rax, rcx
ret 0
您仍然有两个存储,因为 ABI 仍然强制将值返回到内存中,但是负载已经消失,因为 MSVC 64 位 API 允许参数最大为 64 位在寄存器中传递。
然后编译器做了一件非常愚蠢的事情:从rax 中的std::pair 的64 位开始,它写出底部的32 位,将顶部的32 位移到底部,然后将它们写出。世界上最慢的简单写出 64 位的方法。不过,此代码通常会比引用版本更快。
在两个 ABI 中,按值函数都能够在寄存器中传递其参数。然而,这有其局限性。这是f 的引用版本,当T1 和T2 是twop - 一个包含两个指针的结构,Linux gcc:
auto f_ref<twop, twop>(std::pair<twop, twop> const&):
mov rax, rdi
mov r8, QWORD PTR [rsi]
mov rdi, QWORD PTR [rsi+8]
mov rcx, QWORD PTR [rsi+16]
mov rdx, QWORD PTR [rsi+24]
mov QWORD PTR [rax], r8
mov QWORD PTR [rax+8], rdi
mov QWORD PTR [rax+16], rcx
mov QWORD PTR [rax+24], rdx
这是按值的版本:
auto f_val<twop, twop>(std::pair<twop, twop>):
mov rdx, QWORD PTR [rsp+8]
mov rax, rdi
mov QWORD PTR [rdi], rdx
mov rdx, QWORD PTR [rsp+16]
mov QWORD PTR [rdi+8], rdx
mov rdx, QWORD PTR [rsp+24]
mov QWORD PTR [rdi+16], rdx
mov rdx, QWORD PTR [rsp+32]
mov QWORD PTR [rdi+24], rdx
虽然加载和存储的顺序不同,但两者做的事情完全相同:4 次加载和 4 次存储,从输入复制 32 个字节到输出。唯一真正的区别是,在按值的情况下,对象应该在堆栈上(因此我们从[rsp]复制),而在按引用的情况下,对象由第一个参数指向,因此我们从@复制987654376@]1.
所以有一个较小的窗口,其中 非内联 按值函数比按引用传递具有优势:它们的参数可以在寄存器中传递的窗口。对于 Sys V ABI,这通常适用于最多 16 个字节的结构,而在 Windows x86-64 ABI 上最多为 8 个字节。还有其他限制,因此并非所有这种大小的对象都总是在寄存器中传递。
1 你可能会说,嘿,rdi 接受第一个参数,而不是 rsi - 但这里发生的情况是返回值也必须通过内存传递,所以隐藏的第一个参数 - 指向返回值的目标缓冲区的指针 - 被隐式使用并进入 rdi。