struct
即使函数声明表明按值传递,有效地按引用传递也是一种常见的优化:只是它通常通过内联间接发生,因此从生成的代码中并不明显。
但是,要做到这一点,编译器需要知道被调用者在编译调用者时不会修改传递的对象。否则,它将受到平台/语言 ABI 的限制,该 ABI 准确地规定了值如何传递给函数。
即使没有内联也可能发生!
尽管如此,即使在没有内联的情况下,一些编译器也确实实现了这种优化,尽管情况相对有限,至少在使用 SysV ABI(Linux、OSX 等)的平台上,由于堆栈布局的限制。考虑以下简单示例,直接基于您的代码:
__attribute__((noinline))
int foo(S s) {
return s.i + s.j + s.k + s.l + s.m + s.n + s.o + s.p;
}
int bar(S s) {
return foo(s);
}
在这里,在语言级别bar
调用foo
具有 C++ 所需的按值传递语义。但是,如果我们检查gcc 生成的程序集,它看起来像这样:
foo(S):
mov eax, DWORD PTR [rsp+12]
add eax, DWORD PTR [rsp+8]
add eax, DWORD PTR [rsp+16]
add eax, DWORD PTR [rsp+20]
add eax, DWORD PTR [rsp+24]
add eax, DWORD PTR [rsp+28]
add eax, DWORD PTR [rsp+32]
add eax, DWORD PTR [rsp+36]
ret
bar(S):
jmp foo(S)
请注意,bar
只是直接调用foo
,而不制作副本:bar
将使用s
传递给bar
(在堆栈上)的相同副本。特别是它不会像语言语义所暗示的那样进行任何复制(忽略as if)。所以 gcc 已经完全按照您的要求进行了优化。但是 Clang 并没有这样做:它在它传递给foo()
.
不幸的是,这可以工作的情况相当有限:SysV 要求这些大型结构在堆栈中的特定位置传递,因此只有当被调用者期望对象位于完全相同的位置时,才能进行这种重用。
这在foo/bar
示例中是可能的,因为 bar 以S
与 相同的方式将其作为第一个参数foo
,bar
并进行尾调用,foo
避免了隐式返回地址推送的需要,否则会破坏重用堆栈参数的能力。
例如,如果我们简单地将 a 添加+ 1
到对 的调用中foo
:
int bar(S s) {
return foo(s) + 1;
}
这个技巧被破坏了,因为现在的位置与它所期望bar::s
的位置不同,我们需要一个副本:foo
s
bar(S):
push QWORD PTR [rsp+32]
push QWORD PTR [rsp+32]
push QWORD PTR [rsp+32]
push QWORD PTR [rsp+32]
call foo(S)
add rsp, 32
add eax, 1
ret
这并不意味着调用者bar()
必须完全微不足道。例如,它可以在传递它之前修改它的 s 副本:
int bar(S s) {
s.i += 1;
return foo(s);
}
...并且优化将被保留:
bar(S):
add DWORD PTR [rsp+8], 1
jmp foo(S)
原则上,这种优化的可能性在使用隐藏指针传递大型结构的 Win64 调用约定中要大得多。这为重用堆栈或其他地方的现有结构提供了更大的灵活性,以便在幕后实现传递引用。
内联
然而,除此之外,这种优化发生的主要方式是通过内联。
例如,在-O2
编译时,所有 clang、gcc 和 MSVC都不会复制 S 对象1。clang 和 gcc 都没有真正创建对象,只是或多或少地直接计算结果,甚至没有引用未使用的字段。MSVC 确实为副本分配了堆栈空间,但从不使用它:它只填写 only 的一个副本S
并从中读取,就像传递引用一样(在这种情况下,MSVC 生成的代码比其他两个编译器差得多)。
请注意,即使foo
被内联到main
编译器中,也会生成该foo()
函数的单独独立副本,因为它具有外部链接,因此可以被此目标文件使用。在此,编译器受应用程序二进制接口的限制:SysV ABI(用于 Linux)或 Win64 ABI(用于 Windows)根据值的类型和大小准确定义了必须如何传递值。大型结构由隐藏指针传递,编译器在编译时必须尊重这一点foo
。它还必须尊重编译某些调用者foo
何时 foo 无法看到:因为它不知道foo
会做什么。
因此,编译器几乎没有什么窗口可以进行有效的优化,将按值传递转换为按引用传递,因为:
1)如果它可以同时看到调用者和被调用者(main
在foo
你的例子中),如果被调用者足够小,很可能会被内联到调用者中,并且随着函数变大并且不可内联,效果诸如调用约定开销之类的固定成本变得相对较小。
2) 如果编译器不能同时看到调用者和被调用者2,它一般要根据平台ABI 分别编译。由于编译器不知道被调用者将做什么,因此在调用站点没有优化调用的范围,并且在被调用者内部没有优化的范围,因为编译器必须对调用者做了什么做出保守的假设。
1我的示例比您的原始示例稍微复杂一些,以避免编译器完全优化所有内容(特别是,您访问未初始化的内存,因此您的程序甚至没有定义的行为):我填充了一些s
字段argc
这是编译器无法预测的值。
2编译器可以“同时”看到两者通常意味着它们要么在同一个翻译单元中,要么正在使用链接时间优化。