通过结构体对数组进行别名化

11

我正在阅读 ISO/IEC 9899: TC2 中第6.5节的第7段。

它通过以下方式支持对对象的lvalue访问:

包括其中一种类型的聚合体或联合体类型(包括递归地子聚集体或包含联合体的成员),

请参考文档以了解“前述”类型是什么,但它们肯定包括对象的有效类型。

这在一个被标注为:

此列表的目的是指定对象可能或不可能别名的情况。

我理解这是说(例如)以下定义是明确定义的:

#include <stdlib.h>
#include <stdio.h>

typedef struct {
    unsigned int x;
} s;

int main(void){
    unsigned int array[3] = {73,74,75};

   s* sp=(s*)&array; 

   sp->x=80;

   printf("%d\n",array[0]);

   return EXIT_SUCCESS;
}

这个程序应该输出80。

我并不认为这是一个好的(或非常有用的)想法,并且承认我之所以这样解释它,是因为我想不出别的意思,也不相信这是一个毫无意义的句子!

话虽如此,我看不出有很好的理由禁止它。我们知道的是,那个位置的对齐和内存内容与sp->x兼容,那么为什么不呢?

似乎甚至可以说如果我在结构体的末尾添加(比如)double y;,我仍然可以通过这种方式访问array[0],即sp->x

然而,即使数组大于sizeof(s),任何尝试访问sp->y的行为都是未定义的。

我可以礼貌地要求人们说出那句话支持什么,而不是陷入平淡无奇的旋转中,大声喊叫“strict aliasing UB strict aliasing UB”,似乎这些事情总是这样。


3
违反严格别名规则可能会导致高度优化的编译器生成与您意图不符的代码。我曾经看到读/写被重新排序(通过指针,它认为绝对不可能指向同一对象,但实际上却是这样),导致赋值似乎被“忽略”了。 - Jonathon Reinhart
1
你是否阅读并消化了《什么是严格别名规则?》以及从那里指向的链接?它们有帮助吗? - Jonathan Leffler
1
请注意,由于有一个已删除的回答提到了gcc-fstrict-aliasinggcc文档表示所有级别都存在各种程度的误报和漏报,因此不能真正作为代码是否违反严格别名规则的可靠指标。这些检查在许多简单的示例上都会失败。 - Shafik Yaghmour
1
阅读此内容的各个页面和帖子,似乎存在一个普遍误解,即如果X *Y *(不兼容)指向重叠的内存位置,则它们都不能用于访问任何子对象。然而,从标准选择的措辞来看,如果XY都包含相同类型的成员,则通过XY访问该成员不是别名违规。 - M.M
1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Shafik Yaghmour
显示剩余14条评论
3个回答

8
这个问题的答案在提案中有所涉及:修复基于类型别名的规则,很遗憾,在2010年提出这个提案时问题并没有得到解决,详见Hedquist, Bativa, November 2010 minutes。因此,C11没有解决N1520的问题,所以这是一个未解决的问题:

似乎没有任何方法能够在本次会议上解决这个问题。每个提议方法都引出更多问题。2010年11月4日星期四凌晨11:48。

行动——Clark继续努力。

N1520开头写道(以下为我强调部分):

Richard Hansen pointed out a problem in the type-based aliasing rules, as follows:

My question concerns the phrasing of bullet 5 of 6.5p7 (aliasing as it applies to unions/aggregates). Unless my understanding of effective type is incorrect, it seems like the union/aggregate condition should apply to the effective type, not the lvalue type.

Here are some more details:

Take the following code snippet as an example:

union {int a; double b;} u;
u.a = 5;

From my understanding of the definition of effective type (6.5p6), the effective type of the object at location &u is union {int a; double b;}. The type of the lvalue expression that is accessing the object at &u (in the second line) is int.

From my understanding of the definition of compatible type (6.2.7), int is not compatible with union {int a; double b;}, so bullets 1 and 2 of 6.5p7 do not apply. int is not the signed or unsigned type of the union type, so bullets 3 and 4 do not apply. int is not a character type, so bullet 6 does not apply.

That leaves bullet 5. However, int is not an aggregate or union type, so that bullet also does not apply. That means that the above code violates the aliasing rule, which it obviously should not.

I believe that bullet 5 should be rephrased to indicate that if the effective type (not the lvalue type) is an aggregate or union type that contains a member with type compatible with the lvalue type, then the object may be accessed.

Effectively, what he points out is that the rules are asymmetrical with respect to struct/union membership. I have been aware of this situation, and considered it a (non-urgent) problem, for quite some time. A series of examples will better illustrate the problem. (These examples were originally presented at the Santa Cruz meeting.)

In my experience with questions about whether aliasing is valid based on type constraints, the question is invariably phrased in terms of loop invariance. Such examples bring the problem into extremely sharp focus.

与此情况相关的示例是3,具体如下:

struct S { int a, b; };
void f3(int *pi, struct S *ps1, struct S const *ps2)
{
  for (*pi = 0; *pi < 10; ++*pi) {
      *ps1++ = *ps2;
  }
}

The question here is whether the object *ps2 may be accessed (and especially modified) by assigning to the lvalue *pi — and if so, whether the standard actually says so. It could be argued that this is not covered by the fifth bullet of 6.5p7, since *pi does not have aggregate type at all.

**Perhaps the intention is that the question should be turned around: is it allowed to access the value of the object pi by the lvalue ps2. Obviously, this case would be covered by the fifth bullet.

All I can say about this interpretation is that it never occurred to me as a possibility until the Santa Cruz meeting, even though I've thought about these rules in considerable depth over the course of many years. Even if this case might be considered to be covered by the existing wording, I'd suggest that it might be worth looking for a less opaque formulation.

下面的讨论和解决方案非常长,很难概括,但似乎最终会删除上述第五条要点,并通过调整6.5的其他部分来解决问题。但正如上面所述,这些问题是无法解决的,我也没看到后续的提议。
因此,标准措辞似乎允许OP演示的情况,尽管我的理解是这是无意的,因此我会避免它,并且在以后的标准中可能会发生变化以符合非一致性。

1
这个答案(尤其是示例3)确实讨论了通过具有int成员的结构类型来进行int别名。然而,OP的代码只将uint别名为uint。因此,我认为OP的问题仍未得到解答。(sp->x显然没有访问所有的*sp - M.M
1
@m.m 的第五个要点要求“包括其成员中提到的类型之一”,这适用于 OP 的代码,也是例子 3 的情况,并且我没有看到提案中有任何阻止它适用于此情况的内容。 - Shafik Yaghmour
1
@DanAllen 是的,优化是一个关键问题,即使语言支持代码,我也不清楚各种编译器实现对这样的代码做了什么。简单的例子并没有展示任何优化,但我不会指望它。 - Shafik Yaghmour
标准在各个地方对子对象不够清晰(另一个例子是指向2D数组的1D子对象的指针边界)。 - M.M
@ShafikYaghmour:如果取消基于类型的别名规则,程序员必须使用“restrict”来说明哪些东西会和不会别名,那么将失去多少优化机会?据我所知,如果这些规则在C89中存在任何理由,它们基本上已经随着C99的出现而消失了。我觉得奇怪的是,一些编译器越来越激进地使用基于类型的优化,因为程序员不使用“restrict”的地方很可能是速度最不受关注的地方。 - supercat
显示剩余8条评论

1
我认为这段文字不适用:

聚合类型或联合类型 中包含其中一种类型的成员(包括子聚合或包含联合的成员),

sp->x 的类型为 unsigned int,它不是聚合类型或联合类型。

在您的代码中,没有严格的别名违规:将 unsigned int 读取为 unsigned int 是可以的。

结构体可能具有与数组不同的对齐要求,但除此之外没有问题。

通过“聚合类型或联合类型”访问的方式是:

s t = *sp;

1
C语言并没有定义s指针必须表示char指针。以下情况是完全合法的:sizeof(s*) == 2,sizeof(unsigned int*) == 4,并且两者之间可能存在不同的对齐方式。 - this
1
如果所得指针未正确对齐于引用类型68),则其行为是未定义的。如果对齐方式不同,则会出现ub;这可能是因为大小不同或不一致造成的。 - this
1
@这个s包含了unsigned int类型的变量,因此可以使用别名将array[0]定义为s - M.M
1
@MattMcNabb 这就是问题所在,你误读了标准。这个例子清楚地展示了为什么会出现这种情况https://dev59.com/43VD5IYBdhLWcg3wE3Ro,而且它甚至使用了相同的结构设置。 - this
1
它是一个聚合类型,其中包括前面提到的类型之一(无符号整数)。这句话不能被理解为成员必须是聚合类型。我认为如果成员是聚合类型,则规则递归地适用。我可以有struct{ struct { struct { unsigned int x; } } },但我绝对不必进行这样的嵌套。 - Persixty
显示剩余23条评论

1
我承认,以这种方式将struct覆盖在本地定义的数组上的想法实际上是异国情调的。我仍然坚信C99和所有后续标准都允许这样做。事实上,很有争议的是,作为对象的成员本身在6.7.5的第一条要点中允许它:

与对象的有效类型兼容的类型

我认为这就是M.M的观点。从另一个角度看问题,让我们注意到(在一个严格符合环境中)将成员sp->x作为自己的对象别名是完全合法的。在我的OP代码的上下文中,考虑一个带有原型void doit(int* ip,s* sp);的函数,以下调用预期在逻辑上表现出来:
doit(&(sp->x),sp);

注意:程序逻辑可能(当然)不会按预期行事。例如,如果 doit 递增 sp-> x 直到超过 *ip ,那么就会出现问题!但是,在符合标准的编译器中不允许优化器忽略别名潜力引起的工件破坏结果。
我认为,如果语言要求我编写以下代码,则C将变得更加脆弱:
int temp=sp->x;
doit(&temp,sp);
sp->x=temp;

想象一下,在任何调用函数时,都必须监管对传递结构的任何部分进行潜在别名访问的情况。这样的语言可能无法使用。
显然,一个硬优化(即不符合规范)的编译器如果没有意识到ip可能是sp中间成员的别名,则可能会完全混淆doit()。但这与本讨论无关。
阐明编译器何时可以(和不能)做出这种假设被理解为标准需要在别名周围设置非常精确的参数的原因。这是为了给优化器一些条件来排除。在像'C'这样的低级语言中,合理(甚至可取)的做法可能是说,一个适当对齐的指向可访问有效位模式的指针可以用于访问值。
绝对确定的是,我OP中的sp->x指向一个正确对齐的位置,保存着有效的unsigned int。
智能关注点是编译器/优化器是否同意这是访问该位置的合法方式或者忽略为未定义行为。
正如doit()示例所显示的那样,结构可以被拆分并作为仅具有特殊关系的个体对象进行处理。
这个问题似乎是关于在一组具有特殊关系的成员中何时可以“覆盖”结构的情况。
我认为大多数人都会同意本答案底部的程序执行有效的、有价值的功能,如果与某些I/O库相关联,可以“抽象”大量读写结构所需的工作。
你可能认为有更好的方法来做这件事,但我不指望很多人认为这不是一个不合理的方法。
它通过确切的方式运作——逐个成员地构建结构,然后通过该结构访问它。
我怀疑一些反对OP代码的人会更放松一些。首先,它在从自由存储器中分配的内存上操作,作为“未类型化”的通用对齐存储。其次,它构建了一个完整的结构。在OP中,我指出规则(至少表面上允许)你可以排列结构的位,只要你只引用这些位,一切就没问题。

我有点赞同这种态度。我认为OP有点扭曲,语言拉伸在标准的糟糕写作角落里。不是值得信赖的事情。

然而,我绝对认为禁止以下技术是错误的,因为它们排除了一种逻辑上非常有效的技术,即识别结构可以像分解结构一样由对象构建。

然而,我要说的是,只有这样的方法才是我能想出来的,这种方法似乎值得尝试。但是另一方面,如果您不能将数据分解和/或组合在一起,那么您很快就会打破C结构是POD的概念-可能填充其部分的总和,仅此而已。

#include <stddef.h>
#include <stdlib.h>
#include <stdio.h>

typedef enum {
    is_int, is_double //NB:TODO: support more types but this is a toy.

} type_of;

//This function allocates and 'builds' an array based on a provided set of types, offsets and sizes.
//It's a stand-in for some function that (say) reads structures from a file and builds them according to a provided
//recipe. 
int buildarray(void**array,const type_of* types,const size_t* offsets,size_t mems,size_t sz,size_t count){
    const size_t asize=count*sz;
    char*const data=malloc(asize==0?1:asize);
    if(data==NULL){
        return 1;//Allocation failure.
    }
    int input=1;//Dummy...
    const char*end=data+asize;//One past end. Make const for safety!
    for(char*curr=data;curr<end;curr+=sz){
        for(size_t i=0;i<mems;++i){
            char*mem=curr+offsets[i];
            switch(types[i]){
                case is_int:
                    *((int*)mem)=input++;//Dummy...Populate from file...
                break;
                case is_double:
                    *((double*)mem)=((double)input)+((double)input)/10.0;//Dummy...Populate from file...
                    ++input;
                break;
                default:
                    free(data);//Better than returning an incomplete array. Should not leak even on error conditions.
                    return 2;//Invalid type!
            }
        }
    }
    if(array!=NULL){
        *array=data;
    }else{
        free(data);//Just for fun apparently...
    }
    return 0;
}

typedef struct {
    int a;
    int b;
    double c;
} S;

int main(void) {
    const type_of types[]={is_int,is_int,is_double};
    const size_t offsets[]={offsetof(S,a),offsetof(S,b),offsetof(S,c)};
    S* array=NULL;
    const size_t size=4;

    int err=buildarray((void **)&array,types,offsets,3,sizeof(S),size);
    if(err!=0){
        return EXIT_FAILURE;
    }
    for(size_t i=0;i<size;++i){
        printf("%zu: %d %d %f\n",i,array[i].a,array[i].b,array[i].c);
    }

    free(array);
    return EXIT_SUCCESS;
}

我认为这是一种有趣的张力。C语言旨在成为一种低级高级语言,几乎直接让程序员访问机器操作和内存。这意味着程序员可以满足硬件设备的任意需求并编写高效的代码。然而,如果程序员被给予绝对控制,比如我的观点关于别名的“如果适合就可以”的方法,那么优化器的游戏就会受到破坏。所以奇怪的是,值得稍微牺牲一点性能,以便从优化器中获得回报。C99标准的第6.5节试图(并没有完全成功地)规定了这个边界。

一个编译器如果不允许通过 sp->m 访问非字符结构成员,那它就太过于愚钝了,无论标准是否要求这样做。此外,标准的理由明确认识到,一个愚钝的编译器可能是符合标准的,但却是毫无用处的低质量产品。综上所述,与其试图思考 N1570 6.5p7 如何允许显然不应该允许的事情,我认为更有意义的是认识到在某些情况下它并没有这样做,但是良好的编译器仍会表现得好像它这样做了。 - supercat

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