在具有引用字段的类上使用定位new

13

这是来自C++20规范的代码示例([basic.life]/8):

struct C {
  int i;
  void f();
  const C& operator=( const C& );
};

const C& C::operator=( const C& other) {
  if ( this != &other ) {
    this->~C();              // lifetime of *this ends
    new (this) C(other);     // new object of type C created
    f();                     // well-defined
  }
  return *this;
}

int main() {    
  C c1;
  C c2;
  c1 = c2;   // well-defined
  c1.f();    // well-defined; c1 refers to a new object of type C
}

以下操作是否合法或未定义行为:
struct C {
  int& i; // <= the field is now a reference
  void foo(const C& other) {
    if ( this != &other ) {
      this->~C();  
      new (this) C(other);  
    }
  }
};

int main() {
    int i = 3, j = 5;
    C c1 {.i = i};
    std::cout << c1.i << std::endl;
    C c2 {.i = j};
    c1.foo(c2);
    std::cout << c1.i << std::endl;
}

如果这是非法的,那么使用std::launder会使其合法吗?应该在哪里添加?
注意:p0532r0 (page 5)使用launder处理类似情况。
如果这是合法的,那么没有"指针优化障碍"(即std::launder)它是如何工作的?我们如何避免编译器缓存c1.i的值?
这个问题涉及到一个关于实现std::optional的旧ISO线程。
同样地,这个问题也适用于一个常量字段(即如果在struct C中上面的i是:const int i)。

编辑

正如@Language Lawyer在下面的一个回答中指出的那样, 看起来C++20规则已经改变,响应了RU007/US042 NB comments

C++17规范[ptr.launder](§21.6.4.4):--我强调--

[注:如果在已有对象占用的存储空间中创建了一个新对象,并且该类型的对象包含const或引用成员,则可以使用指向原始对象的指针引用新对象,否则,可以使用此函数获取可用于新对象的指针。...— end note ]

C++17 [ptr.launder]规范中的代码示例(§21.6.4.5):

struct X { const int n; };
X *p = new X{3};
const int a = p->n;
new (p) X{5}; // p does not point to new object (6.8) because X::n is const
const int b = p->n; // undefined behavior
const int c = std::launder(p)->n; // OK

C++20 [ptr.launder]规范(§ 17.6.4.5):

[注意:如果在已有对象的存储空间中创建一个新对象,可以使用指向原始对象的指针来引用新对象,除非它的完整对象是一个const对象或它是一个基类子对象;在后一种情况下,可以使用此函数来获得可用于新对象的指针。...— 结束注释]

请注意,C++17中出现的部分:

除非类型包含const或引用成员;

在C++20中被删除,并相应地更改了示例。

C++20 [ptr.launder]规范中的代码示例(§ 17.6.4.6):

struct X { int n; };
const X *p = new const X{3};
const int a = p->n;
new (const_cast<X*>(p)) const X{5}; // p does not point to new object ([basic.life])
                                    // because its type is const
const int b = p->n;                 // undefined behavior
const int c = std::launder(p)->n;   // OK

因此,显然在C++20中,所涉及的代码是合法的,而在C++17中,则需要在访问新对象时使用std::launder

问题概述:

  • 在C++14或之前的版本中(当std::launder不存在时),这种代码的情况是什么?可能是未定义行为 - 这就是为什么std::launder被引入的原因,对吗?

  • 如果在C++20中,我们不需要std::launder来处理这种情况,编译器如何理解正在操作引用而没有我们的帮助(即没有"指针优化障碍")以避免缓存引用值?


类似的问题这里, 这里, 这里这里得到了矛盾的答案,一些人认为这是有效的语法,但建议重写。我关注语法的有效性以及在不同的C++版本中是否需要std::launder


你不能在没有使用placement-new(即你第一个示例中的main()函数中的c1变量)构造的C对象上调用this->~C()。并且让operator=调用placement-new也无法让外部代码知道他们现在需要显式地调用~C()。所以你的代码充满了未定义行为,请不要这样做。最好让你的operator=使用拷贝-交换习惯用法,这将允许你通过为C定义一个适当的复制构造函数来正确处理引用成员。 - Remy Lebeau
@RemyLebeau 我相信来自C++规范的代码示例,如果不是用于展示未定义行为,就不会涉及到未定义行为。但你永远无法确定。 - Amir Kirsh
我不是标准规范的专家,但我严重怀疑它会推广像你展示的那样的例子,因为它们对对象进行了不当操作。 - Remy Lebeau
@RemyLebeau 我同意这是一种不正规的做法,但在一些罕见的情况下,绝望的时刻可能需要采取绝望的措施。_请参阅我在最后提到的ISO讨论。无论如何,我添加了适当的标签来引入_语言律师。 - Amir Kirsh
@RemyLebeau:“你不能在未使用placement-new构造的C对象上调用this->~C()。”这是真的吗?我一直认为只有当类需要洗涤时(例如具有成员引用),才可能会出现问题,因为之后您必须“洗涤”对象的每个使用。 - HolyBlackCat
一个成员 const int i; 也会有类似的问题 - 但是那里是否有相关的区别呢?乍一看,似乎编译器不能假设同一对象的引用成员指向相同的对象/函数,并且不能假设同一对象的 const 成员在函数调用之间具有相同的值而没有可见的定义。但我想这对于优化来说可能不太好。 - aschepler
2个回答

6

将对象替换为const限定符和引用非静态数据成员是合法的。现在,在C++20中,[指针|引用]原始对象的名称在替换后将指向新对象。这些规则已经根据RU007/US042 NB评论http://wg21.link/p1971r0#RU007进行了更改:

RU007. [basic.life].8.3 放宽指针值/别名规则

...

将 6.7.3 [basic.life] 中的第 8.3 条修改为:

如果在一个对象的生命期结束后,在该对象占用的存储器被重新使用或释放之前,在原对象所占据的存储位置上创建了一个新对象,则指向原对象的指针、引用引用原对象的引用,或原对象的名称都将自动引用新对象,并且一旦新对象的生存期开始,就可以用来操作新对象,如果:

  • ...

  • 原对象的类型不是const限定且,如果是类类型,则其不包含任何类型是const限定或引用类型的非静态数据成员原始对象既不是一个const限定的完整对象,也不是这样的对象的子对象,而且

  • ...


这在C++20中是新的,之前是非法的吗?而且它如何在没有“指针优化屏障”(即std::launder)的情况下工作? - Amir Kirsh
@AmirKirsh 在 C++20 之前是合法的,但名称/指针/引用没有重新绑定到新对象。 - Language Lawyer
那么在C++20之前,预期的结果是什么?引用字段会保留其初始值吗?还是在C++17中需要launder而在C++20中不需要?(在这种情况下,在C++14及以前是否有效或UB?)如何避免引用值的缓存,而不帮助编译器理解引用正在被操作? - Amir Kirsh
1
我对这对于std::launder或其他解决此类情况的方法意味着什么也很感兴趣。 - underscore_d
在对象模型的情况下,编译器代码不是标准的实现。相反,标准是尝试表达编译器的常见行为,而编译器则尝试确保向后兼容性,以便旧代码仍然可以编译,即使使用新标准。结果是,无论您在命令行上选择哪个标准,您都可以期望编译器遵循的对象模型更接近于c++20标准所表达的内容,而不是C++17或C++14标准。但有一个值得注意的例外:隐式生命周期对象。 - Oliv

1

回答当前的问题:

第一个问题:

  • 在C++14或之前(std::launder不存在时),这种代码的情况是什么(当std::launder不存在时)?可能是UB-这就是为什么引入std::launder的原因,对吗?

是的,它是UB。这在@Language Lawyer提到的NB问题中明确提到:

由于这个问题,所有标准库在广泛使用的类型中都存在未定义的行为。解决这个问题的唯一方法是调整生命周期规则以自动清理放置new。(https://github.com/cplusplus/nbballot/issues/7)

第二个问题:

如果在C++20中,我们不需要std::launder来处理这种情况,编译器如何理解引用正在被操作而没有我们的帮助(即没有“指针优化屏障”)以避免引用值的缓存?

编译器已经知道,如果在两次使用对象(或子对象)之间调用了非 const 成员函数,或者将该对象作为参数(通过引用传递)调用任何函数,则不能通过这种方式优化对象(或子对象)的值,因为这个值可能会被这些函数改变。这个标准的修改只增加了几个更多的情况,使得这种优化是不合法的。

1
正如所指出的参考文档,如果A具有非静态const或引用字段,则在C++14中vector<A>::data()理论上是未定义行为,并且在C++17中您可能需要清洗其返回值?听起来这个更改可能成为DR的候选项,就像http://wg21.link/p0593r6一样-显然这不是一个实际问题,但如果这不是DR,那么在C++17中是否正式要求清洗`vector<A>::data()的返回值,如果A`具有非静态const或引用字段? - Amir Kirsh
这基本上也是我的理解,但似乎即使使用launder也无济于事,因为放置new本身就是未定义行为,这就是为什么NB评论说唯一的解决方案是“自动清洗放置new”。 - Yehezkel B.
1
我相信答案很好地解释了情况并回答了我的问题。这就是为什么我接受并投票支持它的原因。然而,我看到有人投反对票,但没有留下评论...所以不清楚是否有人认为这个答案有问题或者只是因为某些不明确的原因不喜欢它。 - Amir Kirsh

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