为什么定义数组之外的第一个元素默认为零?

cia*_*anc 92 c++ arrays initialization undefined-behavior zero-initialization

我正在准备 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] << " ";
}
Run Code Online (Sandbox Code Playgroud)

该问题的示例答案是:

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

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

Vla*_*cow 97

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

在这份声明中

int x[] = {120, 200, 16};
Run Code Online (Sandbox Code Playgroud)

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

也就是说,这个循环

 for (int i = 0; i < 4; i++)
 cout << x[i] << " ";
Run Code Online (Sandbox Code Playgroud)

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

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

int x[4] = {120, 200, 16};
Run Code Online (Sandbox Code Playgroud)

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

  • 所以答案是“纯粹是运气” (38认同)
  • @kdb 请注意,_实现定义的行为_在 C 和 C++ 标准的上下文中具有非常特定的含义,但事实并非如此。_未定义的行为_是一个更强烈的主张,具有更深远的影响。请参阅[此概述](/sf/answers/287358641/)。 (8认同)
  • @kdb:我们不使用术语“实现定义”来描述 UB 情况下实际发生的情况。显然,它实际上并不是鼻恶魔;而是鼻恶魔。相反,它取决于编译器生成的 asm 的详细信息以及之前内存中的内容。“实现定义”意味着实际的编译器实际上会注意确保您得到零,而不是让您读取一些仍被内核归零的堆栈内存(就像所有新页面一样,以避免泄漏内核)数据)。这可以解释为什么未优化的构建总是打印 0。 (4认同)
  • @lalala 从某种意义上说,但更具体地说,它可能是“实现定义的行为,依赖于编译器标志”。如果结果始终为零,则“某些东西”必须将其设置为零。 (3认同)

Chr*_*sMM 51

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

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

  • 我真的不喜欢像“或者导致你的计算机被格式化”这样的恐怖例子。虽然编译器假设未定义的行为不会发生确实会导致真正令人惊讶的结果,但仍然很难看出破坏计算机的代码如何神奇地出现。除非程序已经包含这样的代码,但这只是由于 UB 导致程序流程跳跃的问题,这并不是那么牵强。 (30认同)
  • 为了不吓到OP,虽然理论上它可以生成格式化您的计算机的代码,但通常发生的情况是您得到一个“随机”数字,这通常是内存在该位置包含的内容。如今,编译器保护程序员免受自身侵害。 (26认同)
  • @ilkkachu 你想象计算机有一个 MMU。如果您有内存映射 IO 并且没有内存保护,那么任何写入返回地址的溢出都可以跳转到任何地方并执行任何操作。写入控制磁盘的内存映射 IO 位置是一种肯定的可能性 - 我曾经遇到过一个错误,该错误导致间歇性中断,该错误将单个随机字符写入磁盘上的随机位置,因此一个文件中的一个字符经常会更改没有理由。 (15认同)
  • @DavidHammen,是的,如果实现忽略了 UB,或者只是假设 UB 不会发生(就像在著名的 Linux bug 中,他们在检查指针是否为 NULL 之前取消引用指针),那么它会执行_something_ ,可能有些_错误_,但是仅仅“因为标准允许”而插入具有破坏性的代码的实现是主动恶意的,并且问题不再在于有错误的代码。 (13认同)
  • 我的观点是,像这样具有奇幻结果的恐怖故事,作为迷因重复出现,并没有太多成效。关注现实或真实的问题,那些源于本身无辜甚至明智的逻辑的问题会更有用。(当然,在 Linux 的情况下,对于编译器逻辑是否“合理”,意见各不相同。) (8认同)
  • @ilkkachu 没有人说编译器(除非是 DS9K)故意将 UB 引入全面灾难是可以接受的。更重要的是,如果所有错误的情况都适用,那么这种情况就可能发生,无论可能性有多大。 (3认同)
  • @Offtkp当涉及到未定义的行为时,没有编译器可以始终保护所有程序员免受自身侵害。编译器检测不良行为代码的能力是有限的,隔离不良行为代码的影响的能力也是有限的,因此保护程序员免受程序员错误的后果的能力也是有限的。然而,程序员犯错误的能力是无限的。 (2认同)
  • @ilkkachu 鼻恶魔。(谷歌这个术语。)未定义的行为使实现全权委托做任何事情,但仍然被认为符合标准。在这种情况下,从实施者的角度来看,最容易做的事情就是忽略 UB。如果某个实现检测到 UB,那么如果该实现将其报告为问题就好了。但是,检测 UB 的实现也可以自由地发出代码,重新格式化您的硬盘驱动器,或者神奇地让恶魔从程序员的鼻子里跳出来。调用 UB 应该是很可怕的。 (2认同)

mar*_*inj 31

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

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

短输出是:

==9469==ERROR: AddressSanitizer: stack-buffer-overflow
Run Code Online (Sandbox Code Playgroud)

您可以在编译器资源管理器上使用未优化的 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
Run Code Online (Sandbox Code Playgroud)

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

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

    mov     DWORD PTR [rsp+4], 120
    mov     DWORD PTR [rsp+8], 200
    mov     DWORD PTR [rsp+12], 16
Run Code Online (Sandbox Code Playgroud)

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


Sha*_*tam 12

更正答案

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

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

引用示例答案:

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

这是有史以来最错误的说法。我猜代码中有一个错别字,他们想把它改过来

int x[4] = {120, 200, 16};
Run Code Online (Sandbox Code Playgroud)

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

为什么不是错误呢?

这不是一个错误,因为堆栈就是这样工作的。您的应用程序不需要在堆栈中分配内存来使用它,它已经是您的了。您可以按照自己的意愿对堆栈进行任何操作。当你像这样声明一个变量时:

int a;
Run Code Online (Sandbox Code Playgroud)

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

#include <stdio.h>

int main() {
    int a;
}
Run Code Online (Sandbox Code Playgroud)

集会:

    .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:
Run Code Online (Sandbox Code Playgroud)

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

所以,你可以看到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++ 编写的,以提高可读性。


dbu*_*ush 6

\n

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

\n
\n

这是完全错误的。来自C++17 标准第 11.6.1p5 节:

\n
\n

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

\n
int x[] = { 1, 3, 5 };\n
Run Code Online (Sandbox Code Playgroud)\n

将 x 声明并初始化为具有三个\n元素的一维数组,因为未指定大小并且存在三个初始值设定项。\n\xe2\x80\x94结束示例]

\n
\n

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

\n

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

\n