使用单指针访问二维数组

7

有很多像这样的代码:

#include <stdio.h>

int main(void)
{
    int a[2][2] = {{0, 1}, {2, -1}};
    int *p = &a[0][0];

    while (*p != -1) {
        printf("%d\n", *p);
        p++;
    }
    return 0;
}

但是根据这个答案,行为是未定义的。
N1570. 6.5.6 p8:
当将整数类型的表达式添加到指针或从指针中减去时,结果具有指针操作数的类型。如果指针操作数指向数组对象的元素,并且该数组足够大,则结果指向相对于原始元素偏移量的元素,使得所得到的和原始数组元素下标之间的差等于该整数表达式。换句话说,如果表达式P指向数组对象的第i个元素,则表达式(P)+N(等效于N +(P))和(P)-N(其中N的值为n)分别指向数组对象的第i+n个和第i−n个元素(如果它们存在)。此外,如果表达式P指向数组对象的最后一个元素,则表达式(P)+1指向数组对象的最后一个元素之后的位置,如果表达式Q指向数组对象的最后一个元素之后的位置,表达式(Q)-1指向数组对象的最后一个元素。如果指针操作数和结果都指向同一数组对象的元素或超出数组对象的最后一个元素,则评估不得产生溢出;否则,行为是未定义的。如果结果指向数组对象的最后一个元素之后的位置,则不能将其用作评估的一元*运算符的操作数。

2
你在你的链接问题中看过Christoph的回答吗?https://dev59.com/62sz5IYBdhLWcg3wmpBb#7787436 我认为他解释得非常好。 - mch
3
没错,现在有很多存在未定义行为的代码。 - n. m.
1
将排名与向后遍历指针*直到其值小于第一个元素地址的数组并列。这种方法的使用频率非常惊人,几乎没有人知道他们在这样做时正在调用UB。 - WhozCraig
2
我在N1570中找不到任何东西来证明这个规则,除了“因为标准这样说”。似乎数组下标和sizeof规则防止在不同的数组维度之间有任何填充。我想知道是否有任何符合标准的系统会破坏上述代码。 - user694733
1
现在我想起来了,也许这个限制的目的是为了允许将子数组放置在不同的存储器中,就像PICs一样。所以a [0]和a [1]可能放置在不同的存储器中,而示例代码会失败,因为编译器假定在循环中没有必要使用分行指令。 - user694733
显示剩余5条评论
3个回答

9

指针变量p指向的数组是int[2]类型。这意味着只能在地址*p*(p+1)(或者用下标表示为p[0]p[1])处合法地解引用指针p。此外,p+2被保证是一个合法的地址值,并且可以与该序列中的其他地址进行比较,但不能进行解引用操作。这是一种越界的地址。

您发布的代码通过在所在数组的最后一个元素之后解引用p ,违反了越界规则。所在的数组紧挨着另一个相似维度的数组对于所引用的正式定义没有影响。

话虽如此,在实践中它是有效的,但常说到的观察到的行为不应被认为是定义良好的。仅仅因为它有效并不意味着它就是正确的。


谢谢你,whoz。我明白我可以检查 if (p + 3) {,但是我不能解引用 int x = *(p + 3);,对吗? - David Ranieri
2
不,即使是地址 value p+3,也不能用于评估、比较或解引用。它超出了 a[0] ... a[0]+2 地址范围(后者是 a[0]int[2] 数组的地址)。 - WhozCraig
@WhozCraig,我是在回答http://stackoverflow.com/questions/29666141/what-is-wrong-passing-a-2d-array-to-a-respective-pointer-argument时来的,我想知道谁是对的(因为有人在网上说错了! https://xkcd.com/386/)。是不是`a`没有保证占用连续的内存? 我可以通过char合法地迭代a。 对于int来说,也不存在别名问题,因为n1570在6.5,7中关于聚合的递归豁免。 那么你在哪里找到访问p+3是UB的措辞? iyo是否将a的地址直接强制转换为int*会有所区别? - Peter - Reinstate Monica

4
指针的对象表示在C语言中是不透明的。指针具有边界信息编码并没有被禁止,这是需要记住的一种可能性。更实际的是,基于诸如别名等规则所断言的假设,实现还能够实现某些优化。然后,还要保护程序员免受意外伤害。考虑以下代码,在函数体内:
struct {
    char c;
    int i;
  } foo;

char * cp1 = (char *) &foo;
char * cp2 = &foo.c;

考虑到这一点,cp1cp2 会被视为相等,但它们的边界仍然不同。 cp1 可以指向任何一个 foo 的字节,甚至是“超过” foo 的范围,但如果我们希望维护定义良好的行为,则 cp2 最多只能指向“超过” foo.c

在这个例子中,foo.cfoo.i 成员之间可能有填充。虽然该填充的第一个字节与“超过”foo.c成员相吻合,但 cp2 + 2 可能指向其他填充区域。编译器可以在翻译期间注意到这一点,而不是生成程序,它可以建议您正在做一些您没有想到的事情。

相比之下,如果您读取 cp1 指针的初始化器,则直观地表明它可以访问 foo 结构的任何字节,包括填充。

总之,在翻译期间(警告或错误)或程序执行期间(通过编码边界信息)可能会产生未定义行为;标准上没有区别:行为是未定义的。


1
我认为你的意思是“填充字节”。是的,但实现中没有区分这两种情况的要求。因此,如果它可以在一个示例中拒绝翻译并给出警告,那么它也可以在另一个示例中执行相同的操作。理念是:编写你想表达的程序,而不是可能有效或应该有效的程序。(int *) &a 将是没问题的。 - Shao

1
你可以将指针强制转换为指向数组的指针以确保正确的数组语义。
这段代码确实没有定义,但在今天常用的每个编译器中都提供了C扩展。
然而,正确的做法是将指针转换为数组指针,如下所示:
((int (*)[2])p)[0][0]
获取第零个元素或者说:
((int (*)[2])p)[1][1]
获取最后一个元素。
严格来说,我认为这是非法的原因是你正在破坏严格别名规则,指向不同类型的指针可能不指向相同的地址(变量)。
在这种情况下,你正在创建一个指向int数组的指针和一个指向int的指针,并将它们指向相同的值,这是不允许的,因为唯一可以别名另一个指针的类型是char*,即使这种情况很少被正确使用。

严格别名规则指出,一个类型的值表示不能被视为另一个类型的内存(除了一些允许的别名)。即使其中一个是聚合成员而另一个不是,读取int作为int总是可以的。关于指向不同类型的指针指向重叠存储的想法由restrict覆盖。 - M.M

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