我可以将一个二维数组视为连续的一维数组吗?

39

考虑以下代码:

int a[25][80];
a[0][1234] = 56;
int* p = &a[0][0];
p[1234] = 56;

第二行代码是否会引发未定义行为?第四行呢?


由于数组边界未经检查,因此不应出现任何错误! - teacher
int a[25][80] 分配了 80254 字节的内存,它是一个连续的内存分配,所以如果你访问 a[0][1234],实际上你正在访问从基地址开始的 1234 内存!第 4 行没有给你错误是因为 a[0][1234] = *((*a)+1234) =p[1234]; - teacher
@老师 — 实际上,int a[25][80] 的大小为 80*25*sizeof(int) 字节,在大多数系统上可能是 80*25*4 字节。 - Todd Lehman
@ThomasWeller 填充仅适用于结构体和联合体,而不适用于数组。关于 sizeof 的标准: “当应用于具有数组类型的操作数时,结果是数组中的总字节数。 当应用于具有结构或联合类型的操作数时,结果是该对象中的总字节数,包括内部和尾部填充。” - fredoverflow
@fredoverflow:是的,4将是byte [3]数组中总字节数。这并不矛盾。 - Thomas Weller
显示剩余2条评论
6个回答

10

两行代码会导致未定义的行为。

下标运算被解释为指针加法后跟随一个间接引用,也就是说,a[0][1234]/p[1234] 等同于 *(a[0] + 1234)/*(p + 1234)。根据[expr.add]/4(这里我引用了最新的草案,在 OP 提出时可以参考这个评论,结论是一样的):

如果表达式 P 指向具有 n 个元素的数组对象 x 的元素 x[i],则表达式 P + JJ + P(其中 J 的值为 j)指向(可能是虚构的)元素 x[i+j],如果 0≤i+j≤n,则行为定义;否则,行为未定义。

由于 a[0](衰减为指向 a[0][0] 的指针)/p 指向 a[0](作为一个数组)的一个元素,而 a[0] 只有大小为 80,因此行为未定义。


正如语言律师在评论中指出,下面的程序无法编译

constexpr int f(const int (&a)[2][3])
{
    auto p = &a[0][0];
    return p[3];
}

int main()
{
    constexpr int a[2][3] = { 1, 2, 3, 4, 5, 6, };
    constexpr int i = f(a);
}

编译器在常量表达式中发现这样的未定义行为时会进行检测。

1
@xskxzr 预C++17编译器能否优化 int f() { int i = 2; g(); return i; }int f() { g(); return 2; }?所有合理的实现都可以这样做,即使 g 可以猜测 i 的地址并访问它,因为_无论值是如何获得的_。但_无论值是如何获得的_并不是字面上的解释。这是一个不幸的措辞。 - Language Lawyer
@xskxzr GCC 5版本之前的常量表达式中存在可怕的UB诊断。不幸的是,这种措辞是为了解决CWG73,当标准要求完整对象之间的填充时添加的。措辞已经改变,但不准确,因此无意中可以“洗涤”指针超过末尾指向多维数组中的下一行。C++17措辞正确地表达了最初的意图。没有禁止使用的段落,但有历史和背景知识。 - Language Lawyer
@LanguageLawyer:作为一个简单的例子,比如代码想要读取或写入数组中的所有数字,或者对数组中的所有项执行某些操作?可以使用嵌套循环,但即使编译器能够将其优化为不劣于使用单个循环,似乎最好还是一开始就将代码编写为单个循环。 - supercat
@supercat 在现代社会,人们缺乏痛苦。C++来解决这个问题。 - Language Lawyer
@LanguageLawyer:在C++中,是否有一种“定义”的方法可以编写一个函数,如果它被传递一个指向数组的指针以及其维度,就可以作用于任意大小的二维数组的所有元素?我认为数组边界的规则并不是为了使这成为不可能,但当标准首次编写时,作者们并没有想到在这种情况下实际定义行为是否重要,因为以一种明显有用的方式行事对于编译器来说比做其他任何事情都更容易。不幸的是,这样的技术债务从未得到解决。 - supercat
显示剩余15条评论

6
这是需要翻译的内容:

这取决于解释。虽然数组的连续性要求在多维数组的布局方面不容易想象(这已经被指出过了),但请注意,当您执行p[1234]时,您正在索引仅有80列的第零行的第1234个元素。有些人认为只有0..79是有效的索引(&p[80]是一个特殊情况)。

来自C FAQ的信息,它是与C相关的Usenet收集智慧。(我认为C和C++在这个问题上没有区别,这非常相关。)


1
我认为C和C++之间存在显着差异,因为C99包括6.5.6#8中的文本:“如果结果指向数组对象的最后一个元素之一,则不得将其用作求值的一元*操作符的操作数”。 因此,&a[0][0] + 80在语义上与&a[1][0]不同,因为前者不能通过*解引用,但后者可以。C ++没有这个文本,并且(据我所知)似乎没有回答过程式何时可以解引用越界迭代器的问题。 - M.M
2
通常仅了解布局是不够的。由于可能存在分段内存模型,因此还需要安全地派生指针。例如,相对于另一个完全独立的数组进行索引容易出错,如 b [a_minus_b + i]。实现可以(例如在像x86这样的分段内存机器上)为任何单个对象设置最大大小,使指针数学只能在seg:off地址的偏移部分上工作,并且无法从b的段到达a有关UB的更多信息 - Peter Cordes

1
在描述标准的语言中,调用以下函数不会有任何问题:
最初的回答
void print_array(double *d, int rows, int cols)
{
  int r,c;
  for (r = 0; r < rows; r++)
  {
    printf("%4d: ", r);
    for (c = 0; c < cols; c++)
      printf("%10.4f ", d[r*cols+c]);
    printf("\n");
  }
}

在一个double[10][4],或者double[50][40],或者任何其他大小的数组上,只要数组中元素的总数小于rows*cols。实际上,保证T[R][C]的行跨度等于C * sizeof (T)是为了使编写能够处理任意大小的多维数组的代码成为可能。
另一方面,标准的作者们认识到当实现给出以下内容时:
double d[10][10];
double test(int i)
{
  d[1][0] = 1.0;
  d[0][i] = 2.0;
  return d[1][0]; 
}

允许他们生成代码,假设d [1] [0]return执行时仍然保持为1.0,或者允许他们生成代码,如果i大于10,则会陷入困境,这些都比要求它们在调用i == 10时无声地返回2.0更适合某些目的。

标准中没有区分这些情况。虽然标准可以包括规则,如果i >= 10,则第二个示例会调用UB而不影响第一个示例(例如说将[N]应用于数组不会导致其衰减为指针,而是产生必须存在于该数组中的第N个元素),但标准依赖于实现即使不需要这样做也可以以有用的方式运行,并且编译器编写者应该能够识别像第一个示例这样的情况,当这样做将有利于他们的客户时。

由于标准从未试图完全定义程序员需要对数组执行的所有操作,因此不应将其视为指导优质实现应支持哪些构造的指南。

原始回答


C-faq中的接受答案说,你的第一个示例不符合ANSI C标准的严格一致性要求。 - xskxzr
@xskxzr:我说的是用来描述标准的语言。标准的作者从未努力详尽地记录和强制支持所有现有实现有用地处理的结构,以及他们期望未来的实现也会同样处理无论是否被强制执行。他们认为强制执行只有在编译器编写者合理判断其客户可以从中获益时才会起作用,在这种情况下,允许其他行为比禁止它更好。 - supercat
@xskxzr:标准的作者们认识到,鉴于 int a[10][10]; ... a[i][j]=23;,如果 j 超过了9,则允许编译器发出警报在某些情况下是有用的。这绝不意味着他们不同样地认识到,能够对像上面的第一个例子这样的代码进行有意义的处理也同样有用。从根本上说,他们认识到程序员(并通过其扩展认识到,尊重 C 精神“信任程序员”的编译器编写者)比委员会更适合知道每种方法何时最好帮助他们“做需要做的事情”。 - supercat

-2

由于下标越界(第2行)和类型不兼容(第3行),您的编译器将抛出一堆警告/错误,但只要实际变量(在这种情况下是int)是内置基本类型之一,这在C和C++中是安全的。 (如果变量是类/结构体,在C中可能仍然有效,但在C++中则无法确定。)

为什么要这样做... 对于第一个变量:如果您的代码依赖于这种摆弄,它将很容易出错,并且长期维护困难。

我可以看到第二个变量有一些用途,当性能优化2D数组循环时,通过用1D指针运行数据空间来替换它们,但是一个好的优化编译器通常也会自动完成此操作。 如果循环体非常大/复杂,编译器无法优化/替换循环以进行1D运行,则手动执行此操作带来的性能提升可能也不会显著。


2
有时候(虽然不是经常)你需要将数据重塑为不同类型的数组,这并非出于性能原因,而是基于算法背后的数学解释。你可以不使用强制转换来实现这一点,但使用它们可能会导致更易于阅读的代码。这种类型的强制转换与 MATLAB 的 reshape 函数非常接近。 - Christopher Creutzig

-3

你可以自由地重新解释内存。只要多个不超过线性内存即可。你甚至可以将a移动到12、40并使用负索引。


-3
a 所引用的内存既是一个 int[25][80] 也是一个 int[2000]。 标准中说:3.8p2:

【注:数组对象的生存期从获得适当大小和对齐方式的存储开始,到其占用的存储被重用或释放为止。 12.6.2 描述了基类和成员子对象的生存期。—注解结束】

a 具有特定类型,是类型为 int[25][80] 的 lvalue。 但是,p 只是一个 int*。 它并不是指向 int[80] 或类似内容的 "int*"。 因此,实际上指向的 int 是名为 aint[25][80] 的元素,也是占用同一空间的 int[2000] 的元素。

由于pp+1234都是同一个int[2000]对象的元素,指针算术运算是明确定义的。而且,由于p[1234]意味着*(p+1234),它也是明确定义的。

这个数组生命周期规则的影响是,您可以自由地使用指针算术来遍历完整的对象。


由于在评论中提到了std::array

如果有std::array<std::array<int, 80>, 25> a;,那么就不存在std::array<int, 2000>。但是存在一个int[2000]。我正在寻找任何需要sizeof (std::array<T,N>) == sizeof (T[N])(和== N * sizeof (T))的内容。如果没有这个,你必须假设可能会有间隙,这会破坏嵌套std::array的遍历。


4
一个int[25][80]的元素是int[80]。不是 int[2000]对象。我不确定你引用的文本与此有何关联;一个聚合及其子对象都同时分配和释放。 - M.M
@MattMcNabb:当规则表明生命周期已经开始时,你怎么能说没有int[2000]对象呢?(显然,“获得了适当大小和对齐的存储空间”已经得到满足) - Ben Voigt
@Matt:p 的类型是 int*,记得吗?而且一个 int 对象肯定占据完全相同的存储空间。第4点排除了基类子对象,但不排除成员子对象或数组元素。 - Ben Voigt
这里似乎与严格别名无关;它只涉及 glvalue ,而衰减的数组是 prvalue。因此,严格别名仅适用于 int 子对象,因此不存在严格别名违规问题。 - M.M
@Deduplicator:那是一个已经修复的漏洞 - Davis Herring
显示剩余10条评论

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