c++ - 从编译器的角度来看,如何处理数组的引用,为什么不允许按值传递(而不是衰减)?
问题描述
众所周知,在 C++ 中,我们可以将数组的引用作为参数传递,例如f(int (&[N])
. 是的,它是由 iso 标准保证的语法,但我很好奇编译器在这里是如何工作的。我找到了这个线程,但不幸的是,这并没有回答我的问题——编译器如何实现这种语法?
然后我写了一个demo,希望能从汇编语言中看到一些东西:
void foo_p(int*arr) {}
void foo_r(int(&arr)[3]) {}
template<int length>
void foo_t(int(&arr)[length]) {}
int main(int argc, char** argv)
{
int arr[] = {1, 2, 3};
foo_p(arr);
foo_r(arr);
foo_t(arr);
return 0;
}
最初,我猜它仍然会衰减到指针,但会通过寄存器隐式传递长度,然后转回函数体中的数组。但是汇编代码告诉我这不是真的
void foo_t<3>(int (&) [3]):
push rbp #4.31
mov rbp, rsp #4.31
sub rsp, 16 #4.31
mov QWORD PTR [-16+rbp], rdi #4.31
leave #4.32
ret #4.32
foo_p(int*):
push rbp #1.21
mov rbp, rsp #1.21
sub rsp, 16 #1.21
mov QWORD PTR [-16+rbp], rdi #1.21
leave #1.22
ret #1.22
foo_r(int (&) [3]):
push rbp #2.26
mov rbp, rsp #2.26
sub rsp, 16 #2.26
mov QWORD PTR [-16+rbp], rdi #2.26
leave #2.27
ret #2.27
main:
push rbp #6.1
mov rbp, rsp #6.1
sub rsp, 32 #6.1
mov DWORD PTR [-16+rbp], edi #6.1
mov QWORD PTR [-8+rbp], rsi #6.1
lea rax, QWORD PTR [-32+rbp] #7.15
mov DWORD PTR [rax], 1 #7.15
lea rax, QWORD PTR [-32+rbp] #7.15
add rax, 4 #7.15
mov DWORD PTR [rax], 2 #7.15
lea rax, QWORD PTR [-32+rbp] #7.15
add rax, 8 #7.15
mov DWORD PTR [rax], 3 #7.15
lea rax, QWORD PTR [-32+rbp] #8.5
mov rdi, rax #8.5
call foo_p(int*) #8.5
lea rax, QWORD PTR [-32+rbp] #9.5
mov rdi, rax #9.5
call foo_r(int (&) [3]) #9.5
lea rax, QWORD PTR [-32+rbp] #10.5
mov rdi, rax #10.5
call void foo_t<3>(int (&) [3]) #10.5
mov eax, 0 #11.11
leave #11.11
ret #11.11
我承认我对汇编语言不熟悉,但是很明显,这三个函数的汇编代码是一样的!因此,在汇编代码之前必须发生一些事情。无论如何,与数组不同,指针对长度一无所知,对吧?
问题:
- 编译器如何在这里工作?
- 现在标准允许通过引用传递数组,这是否意味着实现起来很简单?如果是这样,为什么不允许按值传递?
对于 Q2,我的猜测是之前的 C++ 和 C 代码的复杂性。毕竟,在函数参数int[]
中等int*
于是一种传统。也许一百年后,它会被弃用?
解决方案
在汇编语言中,对数组的 C++ 引用与指向第一个元素的指针相同。
甚至 C99int foo(int arr[static 3])
仍然只是 asm 中的一个指针。该static
语法向编译器保证即使 C 抽象机器不访问某些元素,它也可以安全地读取所有 3 个元素,因此例如它可以使用无分支cmov
的if
.
调用者不会在寄存器中传递长度,因为它是编译时常量,因此在运行时不需要。
您可以按值传递数组,但前提是它们位于结构或联合中。在这种情况下,不同的调用约定有不同的规则。 根据 AMD64 ABI,什么样的 C11 数据类型是数组。
您几乎从不想按值传递数组,因此 C 没有语法是有道理的,而且 C++ 也从未发明过任何语法。通过常量引用(即const int *arr
)传递效率更高;只是一个指针 arg。
通过启用优化来消除编译器噪音:
我把你的代码放在 Godbolt 编译器资源管理器上,用它编译gcc -O3 -fno-inline-functions -fno-inline-functions-called-once -fno-inline-small-functions
以阻止它内联函数调用。这消除了-O0
调试构建和帧指针样板的所有噪音。(我只是在手册页中搜索inline
并禁用内联选项,直到我得到我想要的。)
而不是-fno-inline-small-functions
等等,您可以__attribute__((noinline))
在函数定义上使用 GNU C 来禁用特定函数的内联,即使它们是static
.
我还添加了对没有定义的函数的调用,因此编译器需要arr[]
在内存中具有正确的值,并在其中两个函数中添加了存储arr[4]
。这让我们可以测试编译器是否警告超出数组边界。
__attribute__((noinline, noclone))
void foo_p(int*arr) {(void)arr;}
void foo_r(int(&arr)[3]) {arr[4] = 41;}
template<int length>
void foo_t(int(&arr)[length]) {arr[4] = 42;}
void usearg(int*); // stop main from optimizing away arr[] if foo_... inline
int main()
{
int arr[] = {1, 2, 3};
foo_p(arr);
foo_r(arr);
foo_t(arr);
usearg(arr);
return 0;
}
-Wall -Wextra
在 Godbolt上没有函数内联的gcc7.3 -O3:由于我从您的代码中消除了未使用的参数警告,因此我们得到的唯一警告是来自模板,而不是来自foo_r
:
<source>: In function 'int main()':
<source>:14:10: warning: array subscript is above array bounds [-Warray-bounds]
foo_t(arr);
~~~~~^~~~~
asm 输出为:
void foo_t<3>(int (&) [3]) [clone .isra.0]:
mov DWORD PTR [rdi], 42 # *ISRA.3_4(D),
ret
foo_p(int*):
rep ret
foo_r(int (&) [3]):
mov DWORD PTR [rdi+16], 41 # *arr_2(D),
ret
main:
sub rsp, 24 # reserve space for the array and align the stack for calls
movabs rax, 8589934593 # this is 0x200000001: the first 2 elems
lea rdi, [rsp+4]
mov QWORD PTR [rsp+4], rax # MEM[(int *)&arr], first 2 elements
mov DWORD PTR [rsp+12], 3 # MEM[(int *)&arr + 8B], 3rd element as an imm32
call foo_r(int (&) [3])
lea rdi, [rsp+20]
call void foo_t<3>(int (&) [3]) [clone .isra.0] #
lea rdi, [rsp+4] # tmp97,
call usearg(int*) #
xor eax, eax #
add rsp, 24 #,
ret
调用foo_p()
仍然得到优化,可能是因为它没有做任何事情。(我没有禁用过程间优化,甚至noinline
andnoclone
属性也没有阻止它。)添加*arr=0;
到函数体会导致调用它(就像其他 2 一样main
传递一个指针)。rdi
请注意clone .isra.0
解构函数名称上的注释:gcc 对函数进行了定义,该函数接受一个指向arr[4]
而不是指向基本元素的指针。这就是为什么lea rdi, [rsp+20]
要设置 arg,以及为什么 store 使用[rdi]
deref 没有位移的点。 __attribute__((noclone))
会阻止的。
这种过程间优化非常简单,在这种情况下节省了 1 个字节的代码大小(只是disp8
克隆中的寻址模式),但在其他情况下可能很有用。调用者需要知道它是函数的修改版本的定义,例如void foo_clone(int *p) { *p = 42; }
,这就是为什么它需要将它编码到损坏的符号名称中。
如果您在一个文件中实例化了模板并从另一个看不到定义的文件中调用它,那么如果没有链接时优化,gcc 将不得不调用常规名称并将指针传递给数组,就像函数一样书面。
IDK 为什么 gcc 为模板而不是参考这样做。这可能与它警告模板版本的事实有关,但与参考版本无关。或者可能与main
推导模板有关?
顺便说一句,实际上会使其运行速度稍快的 IPO 将是 let main
usemov rdi, rsp
而不是lea rdi, [rsp+4]
. 即&arr[-1]
作为函数 arg,所以克隆将使用mov dword ptr [rdi+20], 42
.
但这仅对main
那些在上面分配了 4 个字节的数组的调用者有帮助rsp
,而且我认为 gcc 只是在寻找使函数本身更高效的 IPO,而不是在一个特定的调用者中的调用序列。
推荐阅读
- spring - 如何解决 Spring Boot 中的 Whitelabel 错误页面
- r - 对于分布不是高斯分布的混合模型,您如何执行预期的最大化算法?
- python - psycopg2.ProgrammingError:无法在 odoo 14 中调整类型“NewId”
- c# - 需要澄清帮助 - C# / OOP / 继承 / 多态性 / 多重继承
- mysql - SQL 从两个不同的表中选择相同的列
- php - Laravel 8.0 Blade 嵌套 @extends @section 和 @yield 不起作用
- html - 这是匹配 Vim 中每个英文单词的正确方法吗?(Vim 正则表达式)
- java - 如何使用 JDA getUserById 函数
- python - 如何单击 browser.page_source 中不存在的 Selenium 按钮
- java - 有没有更简单的方法来编写这个equalsIgnoreCase 检查链?