为什么一个已定义数组范围以外的第一个元素默认值为零?

92

我正在为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),第四个循环打印数组的默认值为零,因为未对元素3进行初始化。此时i已经超过条件,for循环终止。

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


63
行为未定义,其他内容都无关紧要。 - HolyBlackCat
84
不默认为零。样例答案是错误的。未定义行为是未定义的。 - ChrisMM
99
把for循环用来定义数组的大小,没有默认为零的最后一个元素。请退学费。 - chux - Reinstate Monica
16
这两个陈述都是错误的。 "数组初始化未定义元素大小"和"for循环定义数组大小"都是错误的。 - Bob__
47
如果写成int x[4] = {120, 200, 16};会有意义。 - chux - Reinstate Monica
显示剩余17条评论
5个回答

97

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

在这个声明中:


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

在这个声明中:

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

数组x恰好有三个元素。因此,访问数组范围之外的内存将引发未定义行为。

也就是说,这个循环

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

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

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

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

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


38
答案是“纯粹依靠运气”。 - lalala
3
在某种意义上来说,但更具体地说,它很可能是“实现定义的行为,取决于编译器标志”。如果结果始终为零,则必须有某些内容将其设置为零。 - kdb
8
请注意,在C和C++标准的背景下,“实现定义的行为”有一个非常特定的含义,而本文并非如此。 “未定义的行为”是一种更强烈的说法,具有更广泛的后果。请参阅此概述。 - marcelm
4
@kdb:我们不使用“实现定义”的术语来描述在未定义行为情况下实际发生的事情。显然,它实际上不会是鼻子恶魔;相反,它取决于编译器所产生的汇编代码细节以及先前存储在内存中的内容。“实现定义”将意味着实际编译器确实会注意确保您获得零值,而不是恰好让您读取一些由内核仍然清零的堆栈内存(所有新页面都是这样做以避免泄漏内核数据)。这可以解释为什么未经优化的构建始终打印0。 - Peter Cordes
1
更强烈地说,整个程序具有未定义的行为。它不一定要打印4个数字,它可以打印3个、5个或者格式化您的硬盘。 - Caleth
显示剩余2条评论

51

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

至于为什么它不是一个错误,那是因为C++不需要对数组进行边界检查。你可以使用 vector 并使用 at 函数,如果越界就抛出异常,但数组不会。


26
为了不吓到OP,虽然它理论上可以生成格式化计算机的代码,但通常发生的情况是您会得到一个“随机”的数字,这通常是内存在该位置所包含的内容。现在的编译器能够保护程序员不犯低级错误。 - Offtkp
30
我非常不喜欢类似“使您的计算机被格式化”这样恐吓性的例子。虽然编译器假设未定义行为不会发生可能会导致非常惊人的结果,但很难看到摧毁计算机的代码如何神奇地出现。除非程序已经包含这样的代码,但那只是由于未定义行为导致程序流程跳转而已,这种情况相对较少发生。 - ilkkachu
13
@DavidHammen,是的,如果实现忽略了未定义行为,或者只是假设未定义行为不会发生而执行某些操作(例如在著名的Linux漏洞中,在检查是否为NULL之前对指针进行解引用),那么它会执行“某些”操作,可能是“错误”的操作。但是,如果一个实现插入有害代码“仅因为标准允许”,那么它就是有意恶意的,此时问题不再是出现错误的代码。 - ilkkachu
8
我的观点是,像那样带有奇幻结果的恐怖故事被重复传播成为模因,并不是非常有益的。更有用的是关注现实或真实的问题,这些问题源于逻辑本身的清白和甚至明智。(当然,在Linux的情况下,对于编译器逻辑是否“明智”,人们的意见各不相同。) - ilkkachu
15
@ilkkachu,你想象计算机有一个MMU。如果你有内存映射IO而没有内存保护, 那么任何溢出写入返回地址的情况都可能跳转到任何位置并执行任何操作。写入控制磁盘的内存映射IO位置是一种明确的可能性 - 我曾经遇到过一个漏洞,导致间歇性中断,将一个随机字符写入磁盘的随机位置,因此偶尔会因为无法预知的原因而更改一个文件中的一个字符。 - Jerry Jeremiah
显示剩余9条评论

31

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

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

短输出为:

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

您可以在编译器探索器上进行进一步调查,使用未优化的GCC进行测试: https://godbolt.org/z/8T74cr83z (包括汇编和程序输出)
在该版本中,输出是120 200 16 3,因为GCC将i放到数组之后的堆栈上。

您将看到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<<的调用。此时未定义行为将对编译器完全可见,它必须想出解决方法。(在其他情况下,如果数组值仅通过一个常量(在优化后)索引访问,则寄存器对数组元素是可行的。)


1
“堆栈内存”我不认为标准规定这样的声明必须在堆栈上,大多数编译器会将其放在堆栈上,但标准是模棱两可的。 - Sam
1
@sam 我同意,编译器可能会将这样的数组放入寄存器中 - 就像我在编译器资源管理器中展示的那样。我会澄清我的第一句话。 - marcinj
@Sam:确实,一些 C 和 C++ 实现根本不使用 asm “堆栈”,而是使用自动存储的动态分配(特别是 IBM zSeries:C 是否需要堆栈和堆才能运行?)。标准规定每个对象都有一个地址(除了 register 变量),但根据 as-if 规则允许将对象放入寄存器中。当然,所有这些都不意味着标准对此情况所需的任何行为;在错误访问之前或之后,整个程序都没有任何行为要求;这就是 UB 的全部含义。 - Peter Cordes
但是,编译器将把它编译成给定构建的某些具体行为;如果它们没有完全展开循环,那么肯定会有一个数组在内存中索引(因为您无法可变地索引寄存器)。如果它们在编译时没有发现UB,甚至可能预测一些可能发生的事情。如果他们注意到UB,你的编译器可能会停止为执行路径生成代码,例如让执行落入与main链接后的任何函数中。或者发出类似于x86“ud2”的非法指令。 - Peter Cordes
1
-O0下值为0的第四个元素实际上是变量i的初始值。 - ralphmerridew
@ralphmerridew:发现得好,我之前看这个答案的时候没有仔细阅读!实际上在Godbolt上链接的版本打印的是3而不是0。我编辑了这个答案来纠正关于Godbolt版本的部分,因为它从错误的事实中走向了错误的方向。编译器不会发明数组元素;最多只有在它们后面有填充空间,它们才不会被初始化。(有时会对它们造成损失,例如初始化一个16字节的数组比15字节的数组更便宜,在这种情况下,额外的字节只是填充:https://godbolt.org/z/Er5GWqhY4) - Peter Cordes

12

更正答案

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

因为它实际上是“未定义的”,标准对此无法再发表其他声明,你的汇编输出将不会一致。编译器可能会将数组存储在SIMD寄存器中,谁知道输出结果会是什么样子?

来自参考答案的引用:

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

这是最错误的陈述。我猜代码中有错别字,他们想要修改它。

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

并错误地将x[4]变成了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 ++。


我猜代码中有一个错别字,他们想要将其改为int x[4]...。他们还说:“for循环定义了数组的大小”,所以看起来这不是一个错别字,而是他们的错误理解。 - NotThatGuy
就我个人而言,那个后面的引用(“for循环定义了数组的大小”)是教师解决方案中最错误的陈述。它甚至根本没有任何意义。 - Daniel R. Collins
@DanielR.Collins 这是什么意思?它是否意味着数组就像一个列表,在每次迭代中添加数据?这是什么鬼东西? - Shambhav Gautam

6

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

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

An array of unknown bound initialized with a brace-enclosed initializer-list containing n initializer-clauses, where n shall be greater than zero, is defined as having n elements (11.3.4). [ Example:

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

declares and initializes x as a one-dimensional array that has three elements since no size was specified and there are three initializers. — end example ]

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

第4个元素不存在时打印0只是未定义行为的一种表现形式。不能保证该值将被打印。实际上,当我使用-O0编译时,最后一个值为3,而使用-O1编译时,最后一个值为0。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接