从编译器的角度来看,如何处理数组的引用,以及为什么不允许按值传递(不是衰减)?

From compiler perspective, how is reference for array dealt with, and, why passing by value(not decay) is not allowed?

正如我们所知,在 C++ 中,我们可以像 f(int (&[N]) 一样将数组的引用作为参数传递。是的,它是 iso 标准保证的语法,但我很好奇这里的编译器是如何工作的。我找到了这个 thread,但不幸的是,这并没有回答我的问题——编译器是如何实现这个语法的?

然后写了一个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

live demo

我承认我对汇编语言不熟悉,但是很显然,这三个函数的汇编代码是一样的!所以,在汇编代码之前必须发生一些事情。无论如何,与数组不同,指针对长度一无所知,对吧?

问题:

  1. 这里的编译器是如何工作的?
  2. 现在标准允许通过引用传递数组,这是否意味着实现起来很简单?如果是这样,为什么不允许按值传递?

对于Q2,我猜测是因为之前的C++和C代码的复杂度。毕竟函数参数中int[]等于int*已经是一种传统了。也许一百年后,它会被弃用?

在汇编语言中,对数组的 C++ 引用与指向第一个元素的指针相同。

甚至 C99 int foo(int arr[static 3]) 仍然只是 asm 中的一个指针。 static syntax 向编译器保证它可以安全地读取所有 3 个元素,即使 C 抽象机不访问某些元素,例如它可以使用无分支 cmov 作为 if.


调用者不会在寄存器中传递长度,因为它是一个编译时常量,因此在 运行 时不需要。

您可以按值传递数组,但前提是它们在结构或联合内。在那种情况下,不同的调用约定有不同的规则。 .

您几乎从不想要按值传递数组,因此 C 没有它的语法是有道理的,而且 C++ 也从未发明任何语法。通过常量引用(即 const int *arr)传递效率要高得多;只是一个指针参数。


通过启用优化消除编译器噪音:

我把你的代码放在 Godbolt 编译器资源管理器上,用 gcc -O3 -fno-inline-functions -fno-inline-functions-called-once -fno-inline-small-functions 编译以阻止它内联函数调用。这消除了来自 -O0 debug-build 和 frame-pointer 样板的所有噪音。 (我只是在手册页中搜索了 inline 并禁用了内联选项,直到我得到了我想要的。)

而不是 -fno-inline-small-functions 等等,您可以在函数定义中使用 GNU C __attribute__((noinline)) 来禁用特定函数的内联,即使它们是 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;
}

gcc7.3 -O3 -Wall -Wextra without function inlining, on Godbolt:由于我从您的代码中消除了未使用的参数警告,我们得到的唯一警告来自模板,而不是来自 foo_r :

<source>: In function 'int main()':
<source>:14:10: warning: array subscript is above array bounds [-Warray-bounds]
     foo_t(arr);
     ~~~~~^~~~~

汇编输出为:

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() 的调用仍然被优化掉了,可能是因为它什么也没做。 (我没有禁用过程间优化,甚至 noinlinenoclone 属性也没有阻止它。)将 *arr=0; 添加到函数体会导致从main(在 rdi 中传递一个指针,就像其他 2 个一样)。

注意 demangled 函数名称上的 clone .isra.0 注释:gcc 定义了一个函数,该函数采用指向 arr[4] 而不是指向基本元素的指针。这就是为什么有一个 lea rdi, [rsp+20] 来设置 arg,以及为什么商店使用 [rdi] 来取消引用没有位移的点。 __attribute__((noclone)) 会阻止它。

这种过程间优化非常简单,在这种情况下节省了 1 个字节的代码大小(只是克隆中寻址模式中的 disp8),但在其他情况下可能很有用。调用者需要知道它是函数修改版本的定义,如 void foo_clone(int *p) { *p = 42; },这就是为什么它需要在错位符号名称中对其进行编码。

如果您在一个文件中实例化模板并从另一个看不到定义的文件调用它,那么如果没有 link-time 优化,gcc 将不得不调用常规名称并通过一个指向数组的指针,就像所写的函数一样。

IDK 为什么 gcc 对模板而不是引用这样做。这可能与它警告模板版本而不是参考版本有关。或者可能与main推导模板有关?


顺便说一句,实际上会让 运行 稍微快一点的 IPO 是让 main 使用 mov rdi, rsp 而不是 lea rdi, [rsp+4]。即,将 &arr[-1] 作为函数参数,因此克隆将使用 mov dword ptr [rdi+20], 42.

但这只对像 main 这样的调用者有帮助,他们在 rsp 之上分配了一个数组 4 个字节,而且我认为 gcc 只是在寻找使函数本身更高效的 IPO,而不是一个特定调用者的调用顺序。

关于:

I admit that I am not familiar with the assembly language, but clearly, the three function's assembly codes are the same!

汇编代码可能完全相同,也可能不同——这取决于各个 C++ 实现(以及调用它们时使用的选项)。只要可观察行为(仔细定义)是维护。

你问题中的不同语法只是源代码级别和翻译过程中的语法和一些语义差异。它们中的每一个在标准中都有不同的定义——例如函数参数的确切类型会有所不同(如果您使用 boost::type_index<T>()::pretty_name() 之类的东西,您实际上会得到不同的机器代码和可观察到的输出)——但归根结底,整体代码是必要的为您的示例程序生成的实际上只是 main()return 0; 语句。 (从技术上讲,该语句对于 C++ 中的 main() 函数也是多余的。)

一切都是为了向后兼容。 C++ 从 C 获得数组,C 从 B 语言获得数组。而在 B 中,一个数组变量实际上 一个指针。丹尼斯·里奇 written about this.

数组参数衰减为指针帮助 Ken Thompson 在将 UNIX 迁移到 C 时重用他的旧 B 源代码。:-)

当后来它被认为可能不是最好的决定时,改变 C 语言反而被认为为时已晚。所以数组衰减被保留了下来,但是结构——后来添加的——是按值传递的。


结构的引入还为您确实想要按值传递数组的情况提供了一种解决方法:

Why declare a struct that only contains an array in C?