【问题标题】:How does "struct inheritance" not violate the strict aliasing rule?“结构继承”如何不违反严格的别名规则?
【发布时间】:2020-04-14 14:48:46
【问题描述】:

C 中的“结构继承技术”(as described in this question) 之所以成为可能,是因为 C 标准保证结构的第一个成员在它之前永远不会有任何填充 (?),并且第一个成员将始终等于结构本身的地址。

这允许如下用法:

typedef struct {
    // some fields
} A;

typedef struct {
    A base;
    // more fields
} B;

typedef struct {
    B base;
    // yet more fields
} C;

C* c = malloc(sizeof(C));
// ... init c or whatever ...
A* a = (A*) c;
// ... access stuff on a etc.
B* b = (B*) c;
// ... access stuff on b etc.

这个问题有两个部分:

A.在我看来,这种技术打破了严格的别名规则。我错了吗?如果是,为什么?

B.假设这种技术确实是合法的。在这种情况下,如果 A: 我们首先将对象存储在其特定类型的左值中,然后向下或向上转换为不同的类型,或者 B: 如果我们直接将其转换为当前所需的特定类型,而不首先将其存储在特定类型的左值中?

例如,这三个选项是否都同样合法?

选项 1:

C* make_c(void) {
    return malloc(sizeof(C));
}    

int main(void) {
    C* c = make_c(); // First store in a lvalue of the specific type
    A* a = (A*) c;
    // ... do stuff with a
    C* c2 = (C*) a; // Cast back to C
    // ... do stuff with c2

    return 0;
}

选项 2:

C* make_c(void) {
    return malloc(sizeof(C));
}    

int main(void) {
    A* a = (A*) make_c(); // Don't store in an lvalue of the specific type, cast right away
    // ... do stuff with a
    C* c2 = (C*) a; // Cast back to C
    // ... do stuff with c2

    return 0;
}

选项 3:

int main(void) {
    A* a = (A*) malloc(sizeof(C)); // Don't store in an lvalue of the specific type, cast right away
    // ... do stuff with a
    C* c2 = (C*) a; // Cast to C - even though the object was never actually stored in a C* lvalue
    // ... do stuff with c2

    return 0;
}

【问题讨论】:

  • 所以对于问题 A,在您的示例中,您是否看到一个对象的存储值被不属于 6.5.7 中列出的类型之一的左值表达式访问(在 N2176 中) ?
  • @NateEldredge 所以让我们以A* a = (A*) c; // ... access stuff on a etc. 为例。假设我们用a->x = 10; 代替注释(假设(A 有一个int x 字段)。我们是否认为a->... 等同于取消引用a,这会产生A 类型的左值,而底层字节的有效类型是int,或者C?
  • 几年前,我在某处与几位 C 语言律师一起参与了一个关于此的长线程。抱歉我现在找不到了。结果是您建议的是调用未定义的行为。唯一完全一致的转换是从第一个元素到封闭结构。不是其他方式。结果是您需要在每个“子类”中声明相同的第一个字段(逻辑上指示其实际类型)并将指向该字段的指针作为基类引用传递。
  • 这种情况下指针的转换与严格的别名规则无关。给定c 指向一些初始化的C,考虑c->base 访问C 中的A 对象,并且在A *a = &c->base; 之后,使用a->SomeField 是完全正常的,没有混叠冲突。 A *a = (A *) c 获得与A *a = &c->base; 相同的指针,只是以另一种方式。不管这个a是如何获得的,它都是指向一个已建立结构中的已建立成员,并且使用它符合访问左值的规则。
  • 类似地,在C *c2 = (C *) a; 之后,我们有一个c2 指向一个已建立的C指针来自一个指向A的指针这一事实并不能否定我们现在有一个指向C的指针这一事实,它实际上指向一个C,即,它指向有效类型为C的内存,因此使用c2访问C符合访问左值的规则。

标签: c struct standards


【解决方案1】:

A.在我看来,这种技术打破了严格的别名规则。我错了吗?如果是,为什么?

是的,你错了。我会考虑两种情况:

案例 1:C 已完全初始化

就是这样,例如:

C *c = malloc(sizeof(*c));
*c = (C){0};  // or equivalently, "*c = (C){{{0}}}" to satisfy overzealous compilers

在这种情况下,C 表示的所有字节都被设置,并且包含这些字节的对象的有效类型C。这来自标准的第 6.5/6 段:

如果一个值通过 lvalue 的类型不是字符类型,然后是 左值成为该访问的对象的有效类型 以及不修改存储值的后续访问。

但结构和数组类型是聚合类型,这意味着这种类型的对象中包含其他对象。特别是,每个C 都包含一个B,标识为它的成员base。因为此时分配的对象实际上是C,它包含一个实际上是B 的子对象。引用B 的左值的一种语法是c->base。该表达式的类型是B,因此使用它访问它所引用的B 符合严格别名规则。必须没问题,否则结构(和数组)将根本不起作用,无论是否动态分配。*

但是,正如my answer to your previous question 中所讨论的,(B *)c 保证等于(在值 类型中)&c->base。因此*(B *)c 是另一个左值,指的是B,它是*c 的第一个成员。该表达式的语法与我们考虑的先前左值的语法不同是没有关系的。它是B 类型的左值,与B 类型的对象相关联,因此使用它来访问它所引用的对象是SAR 允许的情况之一。

这与静态和自动分配的案例没有任何不同。

案例 2:C 未完全初始化

可能是这样的:

C *c = malloc(sizeof(*c));
*(B *)c = (B){0};

因此,我们通过B 类型的左值将分配对象的初始B 大小部分分配给,因此该初始部分的有效类型是B。此时分配的空间不包含(有效)类型C 的对象。我们可以通过引用它们的任何可接受类型的左值访问B 及其成员,读取或写入,如上所述。但是我们有一个严格的别名违规,如果我们

  • 尝试整体读取*c例如C c2 = *c;);
  • 尝试读取C 以外的base 成员(例如 X x = c->another;);或
  • 尝试通过大多数不相关类型的左值读取分配的对象(例如Unrelated_but_not_char u = *(Unrelated_but_not_char *) c;

前两种情况在这里很有趣,它们在动态分配的对象方面是有意义的,当解释为 C 时,没有完全初始化。自动分配的对象也会出现类似的不完全初始化情况;它们也会产生未定义的行为,但遵循不同的规则。

但是请注意,对分配空间的任何写入都不会违反严格的别名冲突,因为任何此类写入都会(重新)分配(至少)写入区域的有效类型。

这将我们带到主要的诡计位。如果我们这样做会怎样:

C *c = malloc(sizeof(*c));
c->base = (B){0};

?或者这样:

C *c = malloc(sizeof(*c));
c->another = 0;

分配的对象在第一次写入之前没有任何有效类型(特别是它没有有效类型C),那么通过*c 写入成员表达式是否有意义?它们定义明确吗?标准的文字可能支持他们不支持的论点,但没有实现采用这种解释,并且没有理由认为任何人都会这样做。

最符合标准和通用实践的解释是,通过成员访问左值写入构成同时写入成员及其主机聚合,从而设置整个区域的有效类型,即使只写入一个成员的值。当然,这仍然无法读取尚未写入值的成员——因为它们的值是不确定的,而不是因为 SAR。

剩下的就是这种情况:

C *c = malloc(sizeof(*c));
*(B *)c = (B){0};
B b2 = c->base;            // What about this?

也就是说,如果分配空间的初始区域的有效类型是B,我们可以使用基于类型C的成员访问左值来读取该B区域的存储值吗?同样,有人可能会争辩说没有实际的C,但在实践中,没有实现做出这种解释。正在读取的对象的有效类型——分配空间的初始区域——与用于访问的左值的类型相同,因此从这个意义上说,不存在 SAR 违规。主机C 完全是假设性的,主要是语法的问题,而不是语义问题,因为同一个区域肯定可以通过替代表达式被读取为同类型的对象。


* 但是 SAR 通过提供“在其成员中包含上述类型之一的聚合或联合类型(递归地,包括子聚合的成员或包含的联合)”是可以访问的类型之一。这消除了围绕位置的任何歧义,即访问成员也构成访问包含它的任何对象。

【讨论】:

  • 感谢您的回答。我花了一些时间来处理这一切。为了验证我对文本的一些理解:是否可以安全地说对该标准的常见有效解释,是否执行MyStruct my_struct->a = 10; 会做两件事:它将&my_struct->a 的内存(对于sizeof(int) 字节)设置为有效类型 int,并且 also 将整个内存设置为 &my_struct(对于 sizeof(MyStruct) 字节)为有效类型 MyStruct? (其中 MyStruct 的字段不止 int a)。
  • 换句话说:在我的代码库中,我经常创建像这样的对象:Something* something_new(int a, float b) { Something* s = malloc(sizeof(Something)); s->a = a; s->b = b; return s; }。如您所见,我从不初始化s 本身的内存——我只初始化成员。我从您的回答中了解到,对该标准的合理常见解释是,这在 SAR 和一般规则方面是合法的,因为s->a = a; 会将整个内存区域设置为s 为有效类型Something .我说的对吗?
  • @AvivCohn,是的,这是对我的 cmets 的准确描述。您描述的通过仅分配给子对象而不是整个对象来初始化已分配对象的行为非常常见。 (有时也使用memset(),但这对有效类型没有帮助。)不会接受任何做错事的实现。从那里向后工作,我们来解释我提出的有效类型分配,你准确地概括了它。
  • 我明白了。我在我的程序中做的另一件常见的事情如下(ObjectSpecificThing 中的第一个字段):Object* make_object(size_t size) { Object* o = malloc(size); o->x = 10; return o; } SpecificThing* make_thing(void) { SpecificThing* thing = (SpecificThing*) make_object(sizeof(SpecificThing)); thing->y = 20; return thing; }。我认为这个代码是合法的。您是否介意验证我的理解是否正确,为什么会这样? [...]
  • make_object内,o->x = 10将整个区域的有效类型设置为Object(即使sizeof(Object) < size)。从make_object返回后,在make_thing里面,我们有SpecificThing* thing = (SpecficThing*) make_object(sizeof(SpecificThing));。此时,thing 处的有效类型仍然是 Object,而不是 SpecificThing。这是合法的,因为 SAR 允许进行我们想要的转换 - SAR 只处理 取消引用。之后,通过thing->y = 20;,我们现在将整个区域的有效类型重新分配SpecificThing。对吗?
【解决方案2】:

我相信来自 C11(ISO/IEC 9899:2011 §6.5 7)的这句话应该可以回答您的一些问题(我的重点已添加):

对象的存储值只能由具有以下类型之一的左值表达式访问:
— 与对象的有效类型兼容的类型,
— 与对象的有效类型兼容的类型的限定版本,
— 对应于对象有效类型的有符号或无符号类型,
— 有符号或无符号类型,对应于对象有效类型的限定版本,
一种聚合或联合类型,其中包括上述类型之一 成员(递归地包括子聚合或包含联合的成员),或
— 一种字符类型。

然后可以回答更多问题(ISO/IEC 9899:2011 §6.7.2.1 15):

一个指向结构对象的指针,经过适当的转换,指向它的初始成员(或者如果该成员是位域,则指向它所在的单元),反之亦然。结构对象中可能有未命名的填充,但不是在其开头。

其余的可以通过这个 sn-p 来回答(ISO/IEC 9899:2011 §7.22.3 1):

连续调用分配的存储的顺序和连续性 aligned_alloccallocmallocrealloc 函数未指定。如果分配成功,则返回的指针经过适当对齐,以便可以将其分配给具有基本对齐要求的任何类型对象的指针,然后用于访问分配的空间中的此类对象或此类对象的数组(直到空间被显式释放)。

总结:

A.你错了。推理见第一个和第二个引号。

B.不,这没有什么区别。请参阅第三个引用(也许是第一个)以了解推理。

【讨论】:

    【解决方案3】:

    是的,结构的第一个元素之前没有任何填充。

    其次,当匿名字段的类型是结构或联合的 typedef 时,代码可以使用 typedef 的名称来引用该字段。 这是 GCC 手册中的一个很好的做法:

    typedef struct {
        // some fields
    } A;
    
    typedef struct {
        A;
        // more fields
    } B;
    
    typedef struct {
        B;
        // yet more fields
    } C;
    
    B get_B (struct C *c) { return c->B; } /* access B */ 
    

    请查看Unnamed Structure and Union Fields

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2017-02-25
      • 1970-01-01
      • 2020-04-11
      • 2015-01-16
      • 1970-01-01
      • 2016-02-24
      相关资源
      最近更新 更多