二维数组是否实现为连续的一维数组?

6

我有一个关于二维数组的内存布局的问题。 当我们定义一个二维数组,就像 int a[3][4],这个数组的内存是否是连续分配的?

换句话说,二维数组是作为一个连续的一维数组实现的吗?

如果答案是肯定的,那么访问a[0][6]是否等同于访问a[1][2]

我写了下面这个C程序。

#include <stdio.h>
int main(){
    int a[3][4] = {{1, 2, 3, 4},
                   {5, 6, 7, 8},
                   {9, 10, 11, 12}};
    printf("%d %d\n", a[0][6], a[1][2]);
    return 0;
}

我发现输出结果为7 7

a[0][6]看起来是非法的,但它指向了a[1][2],我想知道为什么以及这样的操作是否合法?


10
事实证明这是一个灰色地带。是的,C中的所有数组都是连续的,因此二维数组肯定是一维数组的连续数组。然而,虽然似乎这意味着a[0][6]等同于a[1][2],但已经确定,根据标准,尝试访问a[0][6]确实是未定义的。它可能看起来可以工作(正如它确实对您起作用),但不能保证其可行性。 - Steve Summit
逆矩阵是否未定义?您能将长度为1的一维数组作为2D(或更多)数组访问吗?一个包含12个元素的数组可靠地被视为2x6,3x4等吗?我的直觉是可以,但这并不一定明显。 - abelenky
@abelenky 不经过显式转换,至少也要进行一定程度的操作,否则索引操作将无法知道使用哪些步长。 - Karl Knechtel
@abelenky 如果不使用强制转换和指针(因此我们不再使用_array_访问),则可以通过将数组作为union成员来获取通过1D数组和2D数组(或不同的2D数组)进行访问。 - chux - Reinstate Monica
3个回答

8
这是一个有趣的案例。C标准第6.2.5p20节定义了数组如下:
数组类型描述了一组具有特定成员对象类型(称为元素类型)的连续分配的非空对象集合。每当指定数组类型时,元素类型必须是完整的。数组类型由其元素类型和数组中元素的数量所特征化。如果其元素类型是T,则称数组类型派生自其元素类型,有时将其称为‘‘T的数组’’。从元素类型构造数组类型称为‘‘数组类型派生’’。”
因此,数组是一组连续的特定类型的对象。在 int a[3][4] 的情况下,它是一个大小为3的数组,其对象也是数组。子数组的类型是 int [4],即一个大小为4且类型为 int 的数组。
这意味着一个二维数组,或更准确地说是一个数组的数组,确实将内部数组的所有单个成员连续布置。
然而,这并不意味着上述数组可以像您的示例中那样访问为a[0][6]。
有关数组下标运算符[]的第6.5.2.1p2节规定:
定义下标运算符[]的方式是E1[E2]等同于(*((E1)+(E2)))
有关指针操作数的加号运算符+的第6.5.6p8节规定:
当将整型表达式添加到指针或从指针中减去时,结果具有指针操作数的类型。如果指针操作数指向数组对象的元素,并且数组足够大,则结果指向距离原始元素偏移量为整型表达式的元素,即得到的数组元素下标与原始数组元素下标之差等于该整型表达式。换句话说,如果表达式P指向数组对象的第i个元素,则表达式(P)+N(等效于N+(P))和(P)-N(其中N的值为n)分别指向数组对象的第i+n个和第i-n个元素,前提是它们存在。此外,如果表达式P指向数组对象的最后一个元素,则表达式(P)+1指向数组对象的最后一个元素之后的一个元素,如果表达式Q指向数组对象的最后一个元素之后的一个元素,则表达式(Q)-1指向数组对象的最后一个元素。如果指针操作数和结果都指向同一数组对象的元素或数组对象的最后一个元素之后的一个元素,则评估不应产生溢出;否则,行为未定义。如果结果指向数组对象的最后一个元素之后的一个元素,则不应将其用作计算的一元*运算符的操作数。
这里需要吸收很多内容,但重要的是,给定大小为X的数组,有效的数组索引范围从0到X-1,尝试使用其他任何索引会触发 未定义行为。特别地,由于a [0]的类型为int [4],尝试访问a [0] [6]超出了数组a [0]的边界。
因此,虽然在实践中a [0] [6]可能会正常工作,但C标准并不保证它会正常工作。考虑到现代优化编译器将积极地假设程序中不存在未定义的行为并基于该事实进行优化,您可能会遇到某些问题,并且不知道原因。
总之:是的,2D数组是以这种方式实现的,但是您不能像那样访问它们。

非常好的写作。我喜欢“鉴于现代优化编译器…”和“…而且不,你不能像那样访问它们”。这里有龙。 - chux - Reinstate Monica
这是一个有趣的案例,更重要的是——以前从未考虑过。 - Language Lawyer
@LanguageLawyer 关于“以前从未考虑过”的问题:在C11中,可以参考J.2的“(如在lvalue表达式a[1][7]中给出的声明int a[4][5])”。 - pmor
@pmor 这被称为“讽刺”。这里有数十个类似的问题,但人们没有将其关闭为重复,而是继续回答它们。‍♂️ - Language Lawyer

4

@dbush写了一个好的、正确的答案,解释了什么是保证和允许的。简而言之:是连续的,但仍然没有任何保证可以让您可靠地访问任何(子)数组越界。任何指向项的指针都需要指向有效的数组,才能在其上使用指针算术或[]运算符。

这个答案添加了一些可能的解决方法来解决这个问题。

一个解决方法是使用一个union和一个2D数组和1D数组之间的“类型游戏”:

#include <stdio.h> 

typedef union
{
  int arr_2d [3][4];
  int arr_1d [3*4];
} arr_t;

int main (void)
{
  arr_t a = 
  { 
    .arr_2d =  
    {
      { 1, 2, 3, 4},
      { 5, 6, 7, 8},
      {9, 10, 11, 12}
    }
  };

  printf("%d %d\n", a.arr_1d[6], a.arr_1d[(1*4)+2]); // well-defined, union type punning
  return 0;
}

另一个通用的解决方法是,可以使用字符类型来检查 C 中的任何变量作为原始数据块,这样你可以将整个变量视为字节数组。
#include <stdio.h>
int main (void){
    int a[3][4] = {{1, 2, 3, 4},
                   {5, 6, 7, 8},
                   {9, 10, 11, 12}};
    unsigned char* ptr = (unsigned char*)a;

    // well-defined: byte-wise pointer arithmetic on a character type
    // well-defined: dereferencing as int, since the effective type of the memory location is int:
    int x = *(int*)&ptr[sizeof(int)*6];
    int y = *(int*)&ptr[sizeof(int[4])*1 + sizeof(int)*2];

    printf("%d %d\n", x, y);
    return 0;
}

或者如果你是一个饱受争议的宏迷,可以重写上一个例子(但并不推荐):

#include <stdio.h>

#define GET_ITEM(arr, x, y) \ // see 1)
  _Generic( **(arr),        \
            int:  *(int*) &((unsigned char*)(arr))[sizeof(*arr)*(x) + sizeof(int)*(y)] ) 

int main (void){
    int a[3][4] = {{1, 2, 3, 4},
                   {5, 6, 7, 8},
                   {9, 10, 11, 12}};
    unsigned char* ptr = (unsigned char*)a;

    // well-defined:
    printf("%d %d\n", GET_ITEM(a,0,6), GET_ITEM(a,1,2));
    return 0;
}

1) 说明:_Generic用于类型安全。将其转换为字符类型,根据int型一维数组x的大小和int型y的大小进行按字节指针算术运算。优先级为[]大于&。&是为了获取地址,然后将其转换为int*并取消引用。该宏将返回该值。


2
@TH讠NK 是的,根据字符指针的特殊规则允许这样做,C17 6.3.2.3:“当将一个指向对象的指针转换为指向字符类型的指针时,结果指向对象的最低寻址字节。从结果的连续增量,直到对象的大小,得到指向对象剩余字节的指针。” - Lundin
有一个很好的问题,即 int *flat = &a; 是否可以像 char 类型一样用于线性访问。 - tstanisl
1
@tstanisl 不被允许,因为上面的特殊规则只适用于字符类型。您不能使用 int* 进行指针算术加解除引用操作,超出第一个 int [4] 数组。而且,int*int(*)[3][4]int(*)[4] 不兼容,因此该赋值是一个约束违规。 - Lundin
@Lundin,它适用于int *flat = a [0];int *flat =&a [0] [0];。在int *flat =(int *)&a;中,没有将a [0]int [4]对象的“标识”传输到flat。如果是这种情况,则int(* a2)[3] [4] =(void *)(int *)&a;将是非法的。 - tstanisl
@tstanisl 没关系。只要你使用 [] 或指针算术运算,就会调用二进制 + 运算符,并且如 dbush 的答案所述:“如果指针操作数和结果都指向同一数组对象的元素,或者指向数组对象的最后一个元素之一,则评估不应产生溢出;否则,行为是未定义的。”。除非指针指向数组,否则不能在 C 中使用指针算术运算。 如果您将其转换为 uintptr_t 并在整数类型上执行所有算术运算,那么这是另一回事。然后它不一定是 UB,而只是 impl.defined。 - Lundin
显示剩余8条评论

0
在编译器开始对做(很可能)愚蠢的事情发出诊断之前,这种代码并不罕见。请注意:不要这样做。
#include <stdio.h>

int main(int argc, char* argv[]) {
    int a[3][4] = {{1, 2, 3, 4},
                   {5, 6, 7, 8},
                   {9, 10, 11, 12}};

    // treat the array as a single, contiguous arrangement
    // will throw compiler warning/error
    int *p = &a; // <=== VERY BAD, invalid pointer assignment!!

    printf("%d %d\n", p[6], a[1][2]);
    return 0;
}

这表明数组实际上是按行主序连续排列的。这是关于C语言的一些事情,如果你真的想要它,它会让你直接把自己脚打中。


我想了解 &a 的类型,int(*)[3][4]是什么? - TH讠NK
int (*)[3][4] 表示 "指向维度为 [3][4] 的整数数组的指针"。 - Mark Benningfield
你说得对。我写了int (*p)[3][4] = &a;,它成功编译了。更具体地说,(*p) [1] [2]在我的实验中等价于a [1] [2],因此我认为&a的类型是int (*) [3] [4] - TH讠NK
有趣的是,如果使用char而不是int,代码将被定义 - tstanisl
我认为这不是别名违规。严格的别名规则仅适用于左值表达式,而a[1]不是左值。它会自动转换为指针。因此,在p[6]a[1][2]中,都将int作为int左值访问。 - tstanisl
1
“回到野蛮的时代”,具体是多久以前,这种怀旧之旅对于OP有什么兴趣呢?因为自1989年以来,这不再是有效的C语言。它从来不是别名违规,而是赋值的约束违规。严格别名在C99之前并没有真正被规范化/搞砸,但是这段代码在那之前就已经是无效的C语言了。 - Lundin

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