通过使用offsetof访问成员是否被充分定义?

6
使用offsetof进行指针算术运算时,将结构体地址加上成员偏移量并解引用该地址以访问底层成员的行为是否被良好地定义?以下是一个示例:
#include <stddef.h>
#include <stdio.h>

typedef struct {
    const char* a;
    const char* b;
} A;

int main() {
    A test[3] = {
        {.a = "Hello", .b = "there."},
        {.a = "How are", .b = "you?"},
        {.a = "I\'m", .b = "fine."}};

    for (size_t i = 0; i < 3; ++i) {
        char* ptr = (char*) &test[i];
        ptr += offsetof(A, b);
        printf("%s\n", *(char**)ptr);
    }
}

这应该在三行连续打印“there.”、“you?”和“fine.”,目前使用clang和gcc都可以,您可以在wandbox上验证。但是,我不确定这些指针转换和算术是否违反了某些规则,从而导致行为变得未定义。

@Someprogrammerdude 主要是出于好奇心。在编写代码时,这个想法突然浮现出来,它确实可能意味着一种(非常小的)优化,但我并没有真正要解决的问题。我特别对这个 offsetof 的用法感兴趣。 - Ben Steffan
1
当你提到“优化”时,我脑海中会响起警报。不要进行过早的优化,相反,首先编写简单、易读且最重要的是可维护的代码。然后记住,“足够好”通常就是足够好的。只有当程序的性能不符合您的要求时,您才需要测量、分析和基准测试以找到瓶颈,并仅修复其中最糟糕的(并附有大量注释和文档)。 - Some programmer dude
2
你违反了严格别名规则。 - Klas Lindbäck
1
@Someprogrammerdude 我非常清楚过早优化的危险性。也许我没有表达清楚,但实际上除非出现性能问题等情况,否则我不打算使用它。这只是我想到这个想法的背景。我问这个问题是因为我真的很好奇这是否可能,而不是因为我打算使用它。 - Ben Steffan
@BenSteffan 我甚至不确定手动完成这个操作在任何情况下都算是什么优化。使用 test[i].b 直接访问成员将会进行必要的指针算术运算,但是保证正确行为,而指针 hack 则不能保证。并且由于它是常见情况,编译器设计者会专注于对其进行优化,而可能无法有效地优化指针 hack。可以想象一种情况,即 test[i].b 编译为单个 load 指令,而手动算术则作为单独的步骤后跟随一个 load - zstewart
显示剩余4条评论
2个回答

1
据我所知,这是明确定义的行为。但是只有当您通过char类型访问数据时才是如此。如果您使用其他指针类型来访问结构体,则会违反“严格别名规则”。 严格来说,访问超出边界的数组是不明确定义的,但是使用字符类型指针从结构体中获取任何字节是明确定义的。通过使用offsetof,您保证该字节不是填充字节(这可能意味着您将获得一个不确定的值)。
但请注意,消除const限定符的转换确实会导致行为不良。
编辑
同样,强制转换(char**)ptr是无效的指针转换-仅此就违反了严格别名规则,因为它不管ptr指向什么,变量ptr本身被声明为char*,因此您不能欺骗编译器并说“嘿,这实际上是char**”,因为它不是。我相信没有行为不良的正确代码应该是这样的:
#include <stddef.h>
#include <stdio.h>
#include <string.h>

typedef struct {
    const char* a;
    const char* b;
} A;

int main() {
    A test[3] = {
        {.a = "Hello", .b = "there."},
        {.a = "How are", .b = "you?"},
        {.a = "I\'m", .b = "fine."}};

    for (size_t i = 0; i < 3; ++i) {
        const char* ptr = (const char*) &test[i];
        ptr += offsetof(A, b);

        /* Extract the const char* from the address that ptr points at,
           and store it inside ptr itself: */
        memmove(&ptr, ptr, sizeof(const char*)); 
        printf("%s\n", ptr);
    }
}

1
char *转换为char **在指向的值确实是char *时应该是有效的。否则,整个offsetof宏将很难正确使用(正如@cmaster所指出的那样,这仍然是可能的)。标准在哪里说它是无效的指针转换?至少6.3.2.3p7说,给定char *p;char *q =(char *)&p;是有效的,并且在此之后,(char **) q ==&p必须保持不变。 - user743382
@Lundin,你认为用等效的char* string; memcpy(&string, ptr, sizeof(string));替换强制转换(char**)ptr会使代码片段定义良好吗? - cmaster - reinstate monica
@cmaster 除了 const 的问题之外,那段代码也是良好定义的。 - Lundin
嗯,再思考一下... 我不确定的问题是 ptr 本身,而不是指向的数据。在原始代码中,ptr 明显是一个 char*,你无法告诉编译器 ptr 实际上是一个 char**。一个 char** 不能与一个 char* 别名 - 这才是实际的问题!不是指向的数据。我会更新答案。 - Lundin
@hvd 是的,我很清楚。您可以使用它来读取任何数据类型的二进制表示(在这种情况下,您实际上需要unsigned charuint8_t)。但这不是这里的用法!要做到这一点,您必须创建一些非便携式的怪物,例如const char* p = ptr[0] << 24 | ptr[1] << 16,这假定您知道特定指针表示和其字节序。 - Lundin
显示剩余11条评论

1
给定
struct foo {int x, y;} s;
void write_int(int *p, int value) { *p = value; }

在标准中没有任何区别:

write_int(&s.y, 12); //Just to get 6 characters

并且

write_int((int*)(((char*)&s)+offsetof(struct foo,y)), 12);

标准可以这样理解,即上述两种情况都违反了左值类型规则,因为它没有指定一个结构体的存储值可以使用成员类型的左值进行访问,要求想要访问结构体成员的代码必须编写成:
void write_int(int *p, int value) { memcpy(p, value, sizeof value); }

我个人认为这是荒谬的;如果不能使用&s.y来访问类型为int的lvalue,那么为什么&运算符会产生一个int*
另一方面,我也认为标准规定并不重要。除非使用-fno-strict-aliasing调用,否则clang和gcc都无法可靠地处理与指针有关的任何“有趣”的代码,即使在标准明确定义的情况下。编译器只要真正努力避免在至少某些合理的标准解读下定义的情况下进行任何错误的别名“优化”,就不会有问题处理使用offsetof的代码,其中所有将使用该指针(或从中派生的其他指针)进行的访问都在通过其他方式访问对象之前进行。

你会推荐始终使用-fno-strict-aliasing吗?在我看来,这似乎是唯一明智的选择。您是否知道所有当前编译器是否提供类似的标志?如果没有,还有什么其他选项? - GermanNerd
@GermanNerd:寻求销售编译器的人会设计它们以满足客户的需求。一些专业的编译器可能没有提供禁用晦涩“优化”的选项,这些“优化”会破坏常见的结构,但任何为低级编程而设计的高质量编译器都将提供一种模式,可以处理gcc/clang优化器无法处理的结构。要在标准中获得有用的东西,需要三个派别达成共识:那些希望允许实现进行激进优化的人,... - supercat
那些想要在需要时使用低级结构的人,以及那些反对通过识别旨在某些目的的实现来提供实际上无法支持其他目的的语义而“分裂语言”的人。在我看来,如果第三组可以被排除在外,标准将是最有用的,但是一个排除了其他一组并被认为没有努力满足该组需求的标准,仍然比我们现在拥有的更好。 - supercat
感谢您的阐述。我个人认为,为了方便优化而对语言引入限制是灾难性的,完全违背了C语言的精神。更不用说SAR基本上是在邀请程序员陷入精心准备的陷阱......最糟糕的是,这些陷阱可能会展现出只有理解特定编译器优化才能追踪的行为。这是要实现可移植性的吗?更具体地说:如果我分配一些内存(malloc),我希望那块内存是我的,可以按照我的意愿使用它。这非常有用... - GermanNerd
@GermanNerd:N1570 6.5p7本来很好,但有一个词不妥:“by”。如果将其替换为“...与可见关系一起...”,并在脚注中明确指出识别关系的能力是实现质量问题,那么规则就没问题了。我和你一样,对于破坏语言的破坏者感到不满。 - supercat
让我们在聊天中继续这个讨论 - GermanNerd

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