93

我正在为我的 C++ 课程简介准备期末考试。我们的教授给我们练习了这个问题:

解释为什么代码会产生以下输出:120 200 16 0

using namespace std;
int main()
{
  int x[] = {120, 200, 16};
  for (int i = 0; i < 4; i++)
    cout << x[i] << " ";
}

该问题的示例答案是:

cout 语句只是循环遍历数组元素,其下标由 for 循环的增量定义。元素大小不是由数组初始化定义的。for 循环定义了数组的大小,恰好超过了初始化元素的数量,因此对于最后一个元素默认为零。第一个 for 循环打印元素 0 (120),第二个打印元素 1 (200),第三个循环打印元素 2 (16),第四个循环打印默认数组值 0,因为没有为元素 3 初始化任何内容。此时点 i 现在超过了条件并且 for 循环终止。

我有点困惑,为什么数组外的最后一个元素总是“默认”为零。只是为了实验,我将问题中的代码粘贴到我的 IDE 中,但将 for 循环更改为for (int i = 0; i < 8; i++). 然后输出变为120 200 16 0 4196320 0 547306487 32655。为什么尝试访问超出定义大小的数组中的元素时没有错误?程序是否只输出上次将值保存到该内存地址时存在的任何“剩余”数据?

4

5 回答 5

96

我有点困惑,为什么数组外的最后一个元素总是“默认”为零。

在这份声明中

int x[] = {120, 200, 16};

该数组x恰好具有三个元素。因此,访问数组边界之外的内存会调用未定义的行为。

也就是说,这个循环

 for (int i = 0; i < 4; i++)
 cout << x[i] << " ";

调用未定义的行为。数组最后一个元素之后的内存可以包含任何内容。

另一方面,如果数组被声明为

int x[4] = {120, 200, 16};

也就是说,如果有四个元素,那么数组中没有显式初始化器的最后一个元素确实会被初始化为零。

于 2021-12-13T20:53:38.210 回答
52

它不默认为零。示例答案是错误的。未定义的行为是未定义的;该值可能是0,也可能是100。访问它可能会导致seg错误,或者导致您的计算机被格式化。

至于为什么它不是错误,那是因为 C++ 不需要对数组进行边界检查。您可以使用向量并使用该at函数,如果超出范围会引发异常,但数组不会。

于 2021-12-13T20:52:42.590 回答
31

它导致未定义的行为,这是唯一有效的答案。编译器希望您的数组x恰好包含三个元素,您在读取第四个整数时在输出中看到的内容是未知的,并且在某些系统/处理器上可能会由于尝试读取不可寻址的内存而导致硬件中断(系统不知道如何访问该地址的物理内存)。编译器可能会从堆栈中保留x内存,或者可能使用寄存器(因为它非常小)。你得到 0 的事实实际上是偶然的。通过在 clang 中使用地址清理程序(-fsanitize=address 选项),您可以看到:

https://coliru.stacked-crooked.com/a/993d45532bdd4fc2

简短的输出是:

==9469==ERROR: AddressSanitizer: stack-buffer-overflow

您可以在编译器资源管理器上使用未优化的 GCC进一步研究它: https ://godbolt.org/z/8T74cr83z (包括 asm 和程序输出)
在该版本中,输出是120 200 16 3因为 GCCi在大批。

您将看到 gcc 为您的数组生成以下程序集:

    mov     DWORD PTR [rbp-16], 120    # array initializer
    mov     DWORD PTR [rbp-12], 200
    mov     DWORD PTR [rbp-8], 16
    mov     DWORD PTR [rbp-4], 0       # i initializer

所以,确实 - 有第四个元素的值为 0。但它实际上是i初始化器,并且在循环中读取时具有不同的值。编译器不会发明额外的数组元素;充其量在它们之后只会有未使用的堆栈空间。

请参阅此示例的优化级别 - 它-O0- 如此一致的调试最小优化;这就是为什么i保存在内存中而不是保留调用的寄存器中的原因。开始添加优化,假设-O1你会得到:

    mov     DWORD PTR [rsp+4], 120
    mov     DWORD PTR [rsp+8], 200
    mov     DWORD PTR [rsp+12], 16

更多优化可能会完全优化您的数组,例如展开并仅使用立即操作数来设置对cout.operator<<. 那时,编译器完全可以看到未定义的行为,并且它必须想出一些事情来做。(如果数组值仅由常量(优化后)索引访问,则数组元素的寄存器在其他情况下是合理的。)

于 2021-12-13T21:08:54.927 回答
12

更正答案

不,它不默认为 0。这是未定义的行为。在这种情况下,这种优化和这种编译器恰好是 0。尝试访问未初始化或未分配的内存是未定义的行为。

因为它实际上是“未定义的”并且标准对此无话可说,所以您的程序集输出不会是一致的。编译器可能会将数组存储在 SIMD 寄存器中,谁知道输出会是什么?

引用示例答案:

并且第四个循环打印默认数组值零,因为没有为元素 3 初始化任何内容

这是有史以来最错误的说法。我猜代码中有错字,他们想写

int x[4] = {120, 200, 16};

并误将其x[4]变为just x[]。如果不是,而且是故意的,我不知道该说什么。他们错了。

为什么不是错误?

这不是错误,因为堆栈就是这样工作的。您的应用程序不需要在堆栈中分配内存来使用它,它已经是您的了。你可以随心所欲地用你的堆栈做任何事情。当您声明这样的变量时:

int a;

你所做的只是告诉编译器,“我希望堆栈的 4 个字节用于a,请不要将该内存用于其他任何用途。” 在编译时。看看这段代码:

#include <stdio.h>

int main() {
    int a;
}

集会:

    .file   "temp.c"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6 /* Init stack and stuff */
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret /* Pop the stack and return? Yes. It generated literally no code.
           All this just makes a stack, pops it and returns. Nothing. */
    .cfi_endproc /* Stuff after this is system info, and other stuff
                 we're not interested. */
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 11.1.0-1ubuntu1~20.04) 11.1.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long   1f - 0f
    .long   4f - 1f
    .long   5
0:
    .string "GNU"
1:
    .align 8
    .long   0xc0000002
    .long   3f - 2f
2:
    .long   0x3
3:
    .align 8
4:

阅读代码中的注释以获得解释。

所以,你可以看到int x;什么都不做。如果我打开优化,编译器甚至不会费心制作堆栈并做所有这些事情,而是直接返回。int x;只是对编译器说的编译时命令:

x 是一个有符号整数的变量。它需要4个字节,请跳过这4个字节(和对齐)后继续声明。

(堆栈的)高级语言中的变量只是为了使堆栈的“分布”更加系统化并且以可读的方式存在。变量的声明不是运行时过程。它只是教编译器如何在变量之间分配堆栈并相应地准备程序。执行时,程序分配一个堆栈(这是一个运行时进程),但它已经硬编码了哪些变量获得了堆栈的哪个部分。例如。变量a可能会-0(%rbp)到达-4(%rbp)b到达-5(%rbp)-8(%rbp)这些值是在编译时确定的。变量的名称在编译时也不存在,它们只是教编译器如何准备程序以使用其堆栈的一种方式。

您作为用户可以随意使用堆栈;但你可能不会。您应该始终声明变量或数组以让编译器知道。

边界检查

在像 Go 这样的语言中,即使你的堆栈是你的,编译器也会插入额外的检查以确保你不会意外使用未声明的内存。出于性能原因,它没有在 C 和 C++ 中完成,它会导致可怕的未定义行为和分段错误更频繁地发生。

堆和数据部分

堆是存储大数据的地方。这里没有存储变量,只有数据;并且您的一个或多个变量将包含指向该数据的指针。如果您使用尚未分配的东西(在运行时完成),则会出现分段错误。

数据部分是另一个可以存储东西的地方。变量可以存储在这里。它与您的代码一起存储,因此超出分配非常危险,因为您可能会不小心修改程序的代码。由于它与您的代码一起存储,因此显然也是在编译时分配的。我实际上对数据部分的内存安全知之甚少。显然,您可以在操作系统不抱怨的情况下超过它,但我不知道更多,因为我不是系统黑客,也没有可疑的目的将其用于恶意意图。基本上,我不知道在数据部分超出分配。希望有人对此发表评论(或回答)。

上面显示的所有程序集都是在 Ubuntu 机器上由 GCC 11.1 编译的 C。它使用 C 而不是 C++ 来提高可读性。

于 2021-12-14T16:50:27.840 回答
7

元素大小不是由数组初始化定义的。for 循环定义了数组的大小,恰好超过了初始化元素的数量,因此对于最后一个元素默认为零。

这是完全不正确的。从C++17 标准的第 11.6.1p5 节开始:

使用包含初始化子句的大括号括起来的初始化列表初始化的未知边界数组 ,其中应大于零,被定义为具有 n 个元素 (11.3.4)。[ 示例n n

int x[] = { 1, 3, 5 };

将 x 声明并初始化为一个一维数组,该数组具有三个元素,因为没有指定大小并且存在三个初始化器。—结束示例]

因此,对于没有明确大小的数组,初始化程序定义了数组的大小。for循环读取数组的末尾,这样做会触发未定义的行为

0 正在为不存在的第四个元素打印这一事实只是未定义行为的表现。无法保证会打印该值。事实上,当我运行这个程序时,最后一个值在我编译时得到 3,在编译时得到-O00 -O1

于 2021-12-16T04:13:00.230 回答