未定义的对象(〔basic.life〕/8):为什么允许引用重新绑定(和常量修改)

Undead objects ([basic.life]/8): why is reference rebinding (and const modification) allowed?

本文关键字:引用 许引用 新绑定 修改 常量 绑定 basic 对象 life 未定义 为什么      更新时间:2023-10-16

"不死"子句

我把不死子句称为C++规则,即在对象被破坏后,如果在同一地址创建了一个新对象,有时它可以被视为与旧对象相同的对象。这个规则在C++中一直存在,但在附加条件上有一些变化。

这个问题使我不得不阅读最新的亡灵条款。Lifetime[basic.life]/8中的修订条件为:

(8.1)新对象的存储正好覆盖存储原始对象占用的位置,以及

嗯,嗯。不同地址的对象不会是同一个对象。

(8.2)新对象与原始对象的类型相同(忽略顶级cv限定符),以及

再说一次,duh。

(8.4)原始对象和新对象都不是潜在重叠的子对象([interro.object])。

它不能是基类、classic(或具有使其地址不唯一的特殊声明的成员)。再说一遍,啊。

(8.3)原始对象既不是一个完整的对象const限定也不是此类对象的子对象,以及

现在这很有趣。被替换的对象不能是:

  • 一个完整的const对象
  • 完整const对象的一部分

另一方面,被复活的对象可以是:

  1. 常量成员子对象
  2. 此类常量成员的子对象
  3. const对象数组中的元素

构造子对象

所以在我看来,所有这些对象x都可以复活:

构造成员子对象

struct CI {
const int x;
};
CI s = { 1 };
new ((void*)&s.x) int(2);
int r = s.x; // OK, 2

const成员的子对象:

struct T {
int x;
};
struct CT {
const T m = { 1 };
};
CT s;
new ((void*)&s.m.x) int (2);
int r = s.m.x;

常量对象数组中的元素:

const int x[1] = { 1 };
new ((void*)&x[0]) int (2);
int r = x[0];

具有const和引用成员的类

此外,类类型的对象中包含const或引用成员似乎并不被禁止;复活的对象仍然被称为CCD_ 2。

使用const成员类:

struct CIM {
CIM(int i): m(i) {}
const int m;
};
CIM x(1);
new ((void*)&x) CIM(2);
int r = x.m; // OK, 2

使用参考成员上课:

struct CRM {
CRM (int &r): m(r) {}
int &m;
};
int i=1,j=2;
CRM x(i);
new ((void*)&x) CRM(j);
int r = x.m; // OK, 2

问题

  1. 对该条款的解释正确吗
  2. 如果是,是否有其他条款禁止这些覆盖操作
  3. 如果是,这是有意的吗?为什么会改变
  4. 这对代码生成器来说是一个突破性的变化吗?所有编译器真的支持这一点吗?它们不是基于常量成员进行优化吗?数组的常量元素是不可变的,引用是不可重循环的
  5. 额外的问题:这会影响具有足够存储类(当然不是动态创建的对象)和足够初始化的常量对象的ROM能力吗

注意:我后来添加了奖金,因为在讨论中提到了将常数放入ROM。

如果标准中与对象生命周期相关的所有要求都不在[基本生命]中,那将是令人惊讶的。

在你引用的标准段落中,"完整"形容词被无意中添加到"对象"这个名字中的可能性很小。

在论文P0137中,人们可以阅读到这篇理性的文章(以下引用在@LanguageLawyer评论中的论文):

这对于允许std::optional等类型包含const子对象是必要的;现有的限制是为了允许只读性而存在的,因此只影响完整的对象。

为了让我们放心,我们可以验证编译器是否遵循了标准措辞:它们对完全常量对象执行常量优化,但对非常量完全对象的常量成员子对象不执行常量优化:

让我们考虑一下这个代码:

struct A{const int m;};
void f(const int& a);
auto g(){
const int x=12;
f(x);
return x;
}
auto h(){
A a{12};
f(a.m);
return a.m;
}

Clang和GCC在针对x86_64:时都会生成此程序集

g():                                  # @g()
push    rax
mov     dword ptr [rsp + 4], 12
lea     rdi, [rsp + 4]
call    f(int const&)
mov     eax, 12     ;//the return cannot be anything else than 12
pop     rcx
ret
h():                                  # @h()
push    rax
mov     dword ptr [rsp], 12
mov     rdi, rsp
call    f(int const&)
mov     eax, dword ptr [rsp]  //the content of a.m is returned
pop     rcx
ret

返回的值放置在寄存器eax中(根据ABI规范:System V x86处理器特定的ABI):

  • 在函数g中,编译器可以自由地假设x不能通过对f的调用来更改,因为x是一个完整的常量对象。因此,值12被直接作为立即值放置在eax寄存器中:mov eax, 12

  • 在函数h中,编译器不能自由地假设a.m不能通过对f的调用来更改,因为a.m不是完整const对象的子对象。因此,在调用f之后,必须将a.m的值从内存加载到eax:mov eax, dword ptr [rsp]

答案的大部分必要部分已经由其他用户给出,所以我将它们收集在社区wiki中,以便这个问题可以得到一个可接受的答案。

  1. 是的,除了关于const数组(正如T.C.的评论中所指出的)。元素为x0的数组本身就是const对象,因此如果它是完整的对象,那么它的元素就不能在原地销毁和重新创建(正如胡桃木的评论中指出的那样)。

  2. 没有。正如语言律师在评论中指出的那样,它被允许的事实是明确的。俄罗斯国家机构要求允许它,并且对C++20进行了相应的修改以允许它

  3. 请参见上文。

    • 正如Oliv的回答中所指出的,编译器确实意识到,他们不应该基于非const完整对象的const成员不能被替换的假设进行优化,因为标准规定它们可以被替换
    • OP还询问这是否是一个"错误";"断裂变化";。好吧,在什么意义上?由于C++17不允许这种形式的替换(如果尝试,可能会导致UB),而C++20允许,这意味着如果编译器的行为为了实现C++20行为而改变,那么UB就会减少,这不是一个突破性的改变
    • 编译器在C++17时代有没有进行过这种优化,后来被C++20禁止了?俄罗斯国家机构的评论暗示了一个合理的论点,即为什么答案可能是";否";。与之相关的美国国家机构的评论更为明确。在这个答案的末尾给出了一个可能被这种优化破坏的代码示例,以避免格式问题。这里,在v中创建的第一个S对象被销毁,然后在其位置创建新的S对象。v.data()是最初指向旧对象的指针。它现在指向新对象(导致打印2)还是不指向对象,导致UB?我认为我们可以放心地假设,没有一个广泛使用的编译器会将此代码视为UB,以便应用C++17中技术上允许的优化。(当然,也有一些注意事项:编译器本可以在标准库类中使用魔术来关闭这种优化,或者他们本可以让push_back操作始终用其自身的清洗版本替换存储的指针,但在实践中,这并没有做到。)总的来说,编译器似乎不必因为C++20中替换规则的放松而改变他们的行为
  4. 不,没有。显然,最初只有完整的const对象是可ROM的(而不是非const完整对象的const子对象),正如所讨论的,完整的const对象不受此更改的影响。

代码:

#include <iostream>
#include <vector>
struct S {
S(int x) : x(x) {}
const int x;
};
int main() {
std::vector<S> v;
v.push_back(S(1));
v.pop_back();
v.push_back(S(2));
std::cout << v.data()->x;
}