【问题标题】:Reinterpreting a union to a different union将联合重新解释为不同的联合
【发布时间】:2018-07-25 04:46:14
【问题描述】:

我有一个标准布局联合,其中包含一大堆类型:

union Big {
    Hdr h;

    A a;
    B b;
    C c;
    D d;
    E e;
    F f;
};

AF 类型中的每一个都是标准布局,其第一个成员是 Hdr 类型的对象。 Hdr 标识联合的活动成员是什么,所以这是变体。现在,我确定(因为我检查过)活跃成员是BC。实际上,我已将空间缩小到:

union Little {
    Hdr h;

    B b;
    C c;
};

现在,以下是明确定义还是未定义的行为?

void given_big(Big const& big) {
    switch(big.h.type) {
    case B::type: // fallthrough
    case C::type:
        given_b_or_c(reinterpret_cast<Little const&>(big));
        break;
    // ... other cases here ...
    }
}

void given_b_or_c(Little const& little) {
    if (little.h.type == B::type) {
        use_a_b(little.b);
    } else {
        use_a_c(little.c);
    }
}

Little 的目标是有效地充当文档,我已经检查过它是 BC,所以将来没有人添加代码来检查它是 A 或其他东西。

我将B 子对象作为B 阅读这一事实是否足以使其格式正确?在这里可以有意义地使用公共初始序列规则吗?

【问题讨论】:

  • 这个:“如果两个联合成员是标准布局类型,那么在任何编译器上检查它们的公共子序列都是明确定义的。” 但我认为我们需要更多信息来确定;来自:en.cppreference.com/w/cpp/language/union
  • 无论如何你只需要使用big.h.type,它已经被正式文档UB :)
  • @RbMm 使用big.h.type 是合法的。 OP提到的“通用初始子序列规则”强制它。
  • @RbMm 参见here,尤其是标准布局类型的特殊属性。
  • RbMm :你做错了。您在哪里可以找到标准中的措辞,即如果两个对象具有相同的地址,那么您可以重新解释转换指针?不要只考虑一个简单的编译器实现,而是一个高度优化的编译器,它假设您的代码不会调用未定义的行为。 (OP 并没有问“它可能会工作”,而是“必须它可以在任何符合要求的编译器上工作。)

标签: c++ c++11 language-lawyer


【解决方案1】:

为了能够获取指向 A 的指针,并将其重新解释为指向 B 的指针,它们必须是pointer-interconvertible

Pointer-interconvertible 是关于对象,而不是对象的类型

在 C++ 中,位置处有对象。如果您在某个特定位置有一个Big,并且至少存在一个成员,那么由于指针互转换性,在同一位置也有一个Hdr

但是,那个位置没有Little 对象。如果那里没有Little 对象,则它不能与不存在的Little 对象进行指针互转换。

它们似乎是布局兼容的,假设它们是平面数据(普通旧数据、可简单复制等)。

这意味着您可以复制它们的字节表示并且它可以工作。事实上,优化器似乎明白,一个 memcpy 到一个堆栈本地缓冲区,一个放置 new(使用微不足道的构造函数),然后一个 memcpy 返回实际上是一个 noop。

template<class T>
T* laundry_pod( void* data ) {
  static_assert( std::is_pod<Data>{}, "POD only" ); // could be relaxed a bit
  char buff[sizeof(T)];
  std::memcpy( buff, data, sizeof(T) );
  T* r = ::new( data ) T;
  std::memcpy( data, buff, sizeof(T) );
  return r;
}

上述函数在运行时是一个 noop(在优化的构建中),但它会将 data 的 T-layout 兼容数据转换为实际的 T

所以,如果我是对的并且当BigLittle 中的类型的子类型时BigLittle 是布局兼容的,您可以这样做:

Little* inplace_to_little( Big* big ) {
  return laundry_pod<Little>(big);
}
Big* inplace_to_big( Little* big ) {
  return laundry_pod<Big>(big);
}

void given_big(Big& big) { // cannot be const
  switch(big.h.type) {
  case B::type: // fallthrough
  case C::type:
    auto* little = inplace_to_little(&big); // replace Big object with Little inplace
    given_b_or_c(*little); 
    inplace_to_big(little); // revive Big object.  Old references are valid, barring const data or inheritance
    break;
  // ... other cases here ...
  }
}

如果Big 具有非平面数据(如引用或const 数据),则上述内容会严重中断。

注意laundry_pod 不做任何内存分配;它使用placement new 在data 使用data 的字节指向的地方构造一个T。虽然它看起来正在做很多事情(复制内存),但它优化到了一个 noop。


有一个“对象存在”的概念。对象的存在与物理或抽象机器中写入的位或字节几乎无关。您的二进制文件上没有与“现在存在对象”相对应的指令。

但是语言有这个概念。

无法与不存在的对象进行交互。如果这样做,C++ 标准不会定义程序的行为。

这允许优化器对您的代码做什么和不做什么以及哪些分支不能到达和哪些可以到达做出假设。它让编译器做出无锯齿假设;通过指针或对 A 的引用修改数据不能更改通过指针或对 B 的引用到达的数据,除非 A 和 B 以某种方式存在于同一位置。

编译器可以证明BigLittle 对象不能同时存在于同一个位置。因此,通过指针或对Little 的引用修改任何数据都不会修改Big 类型变量中存在的任何内容。反之亦然。

想象一下如果given_b_or_c 修改了一个字段。那么编译器可以内联given_biggiven_b_or_cuse_a_b,注意没有Big 的实例被修改(只是Little 的一个实例),并证明来自Big 的数据字段之前缓存过调用你的代码无法修改。

这为它节省了一条加载指令,优化器很高兴。但是现在您的代码如下:

Big b = whatever;
b.foo = 7;
((Little&)b).foo = 4;
if (b.foo!=4) exit(-1);

优化为

Big b = whatever;
b.foo = 7;
((Little&)b).foo = 4;
exit(-1);

因为它可以证明b.foo 一定是7 它被设置了一次并且从未被修改过。由于别名规则,通过Little 的访问无法修改Big

现在这样做:

Big b = whatever;
b.foo = 7;
(*laundry_pod<Little>(&b)).foo = 4;
Big& b2 = *laundry_pod<Big>(&b);
if (b2.foo!=4) exit(-1);

假设大数据没有改变,因为有一个 memcpy 和一个 ::new 可以合法地改变数据的状态。没有严格的别名违规。

它仍然可以跟随memcpy并消除它。

Live examplelaundry_pod 被优化掉。请注意,如果未对其进行优化,则代码必须具有条件和 printf。但正因为如此,它被优化为空程序。

【讨论】:

  • laundry_pod 中,表达式new (data) T; 只是结束了位于data 的对象的生命周期,该对象是函数given_big 的参数Big const&amp; big 所引用的对象。这真的很糟糕,因为函数given_big 的任何调用者都不会期望作为 const 参数传递的对象在此函数内终止。调用后对该对象的任何使用都将是 UB。
  • @oliv 是的,该引用已失效。它可以通过清洗 pod back 来恢复活力,只要您获得正确的(完整)类型,并且没有 const 或引用数据成员。请注意,我在示例代码中调用了inplace_to_big;在那里恢复了Big 对象。无法保证异常安全。
【解决方案2】:

我在n4296(C++14 标准草案)中找不到任何使这合法的措辞。更重要的是,我甚至找不到任何给定的措辞:

union Big2 {
    Hdr h;

    A a;
    B b;
    C c;
    D d;
    E e;
    F f;
};

我们可以将reinterpret_castBig 的引用转化为对Big2 的引用,然后使用该引用。 (注意BigBig2布局兼容的。)

【讨论】:

  • 在 [basic.lval]/10.6 中,据说Big2Little 类型的引用可用于访问Big 类型的对象,请参阅我的回答。
【解决方案3】:

这是省略的UB。 [expr.ref]/4.2:

如果E2是一个非静态数据成员并且E1的类型是“cq1 vq1 X”, E2 的类型是“cq2 vq2 T”,表达式 [E1.E2] 表示 第一个表达式指定的对象的命名成员。

在评估given_big 中的given_b_or_c 调用期间,little.h 中的对象表达式实际上并未指定Little 对象,因此没有这样的成员。因为标准“省略了任何明确的行为定义”,所以行为是未定义的。

【讨论】:

  • 为什么这不是 UB delctype(std::declval&lt;Little&amp;&gt;().h) x;?我不相信这个陈述强加了对象存在的必要性。无论存在的概念是什么都没有定义,但生命时间是。
  • @Oliv 为什么是1/0 UB 而不是decltype(1/0)
  • 问题在于对值的访问或评估的事实,而不是表达式本身。
  • 你的阅读内容太乱了。这一段标准只是定义了语义。不是评价(意义)。 decltype 告诉编译器只检查语法,而不是可评估性(含义)。如果定义语义的标准段落像您一样逐字解释,以指定可评估性,decltype 将没有用,并且可能表明 C++ 程序无法生成重要代码。
  • 在标准中没有任何暗示的情况下,访问标准布局结构或联合的成员将等效于形成该成员的地址,然后读取或写入来自该地址的有问题的对象。标准的其他部分可能会允许编译器以某种其他方式进行行为,以防编译器编写者判断此类行为会使客户受益,或者编译器编写者将其他事物置于客户利益之上,但该行为不会在任何情况下都是“未定义的遗漏”。
【解决方案4】:

我不确定这是否真的适用于此。在reinterpret_cast - Notes 部分中,他们讨论了指针可相互转换的对象。

来自[basic.compound]/4

两个对象 abpointer-interconvertible 如果:

  • 它们是同一个对象,或者
  • 一个是联合对象,另一个是该对象的非静态数据成员,或者
  • 一个是标准布局类对象,另一个是该对象的第一个非静态数据成员,或者,如果该对象没有非静态数据成员,则该对象的第一个基类子对象,或
  • 存在一个对象 c 使得 ac 是指针可互转换的,并且 c 和 b 是指针可相互转换的。

如果两个对象是指针可互转换的,那么它们具有相同的地址,并且可以通过reinterpret_­cast从指向另一个对象的指针中获得指向另一个对象的指针。

在这种情况下,我们将Hdr h; (c) 作为两个联合中的非静态数据成员,这应该允许(因为第二个和最后一个要点)

Big* (a) -> Hdr* (c) -> Little* (b)

【讨论】:

  • Little 对象在哪里?实际对象。 Pointer-interconvertible 是关于 objects,而不是 types
  • 我也认为类和对象之间存在混淆。否则,使用相同的推理并且该关系是自反的(至少,措辞表明它是自反的),这意味着您可以将 reinterpret_castLittle&amp; 转换为 Big&amp;(而不是相反),即使 BigLittle 有更严格的对齐要求。
  • @Yakk :我想问最后一个子弹的意义是什么?答案是它允许指针从基的第一个成员的基到外部对象的相互转换。
  • 关于我之前的评论,我的意思是“对称”而不是“自反”
猜你喜欢
  • 2021-12-26
  • 1970-01-01
  • 1970-01-01
  • 2010-10-22
  • 1970-01-01
  • 1970-01-01
  • 2020-12-24
  • 2019-05-10
  • 2015-06-08
相关资源
最近更新 更多