在初始化的内存上使用放置新位置是否合法

Is it legal to use placement new on initialised memory?

本文关键字:位置 是否 新位置 内存 初始化      更新时间:2023-10-16

我正在探索在C++中实现真正的(部分)不可变数据结构的可能性。由于C++似乎没有区分变量和变量存储的对象,因此真正替换对象(无需赋值操作!)的唯一方法是使用放置 new:

auto var = Immutable(state0);
// the following is illegal as it requires assignment to
// an immutable object
var = Immutable(state1);
// however, the following would work as it constructs a new object
// in place of the old one
new (&var) Immutable(state1);

假设没有非平凡的析构函数要运行,这在C++中是合法的,还是我应该期待未定义的行为?如果它依赖于标准,哪个是我可以期望它工作的最小/最大标准版本?

附录:由于人们似乎在 2019 年仍然阅读本文,因此快速说明一下 - 这种模式实际上在现代(17 后)C++中使用std::launder()在法律上是可能的。

你写的东西在技术上是合法的,但几乎可以肯定是无用的。

假设

struct Immutable {
const int x;
Immutable(int val):x(val) {}
};

对于我们非常简单的不可变类型。

auto var = Immutable(0);
::new (&var) Immutable(1);

这是完全合法的。

而且没用,因为您不能使用var来引用放置后存储在其中的Immutable(1)的状态new. 任何此类访问都是未定义的行为。

您可以这样做:

auto var = Immutable(0);
auto* pvar1 = ::new (&var) Immutable(1);

获得*pvar1是合法的。 您甚至可以执行以下操作:

auto var = Immutable(0);
auto& var1 = *(::new (&var) Immutable(1));

但在任何情况下,你都不能在你放置新的之后提到var

C++中的实际const数据是对编译器的承诺,即您永远不会更改该值。 这是与对 const 的引用或对 const 的指针进行比较,这只是建议您不会修改数据的建议。

被宣布为const的结构的成员"实际上是恒常的"。 编译器会假设它们从未被修改过,并且不会费心去证明这一点。

您在旧实例实际上所在的位置创建新实例违反了此假设。

您可以这样做,但不能使用旧名称或指针来引用它。 C++让你搬起石头砸自己的脚。 去吧,我们敢。

这就是为什么这种技术是合法的,但几乎完全无用。 一个具有静态单一赋值的优秀优化器已经知道您将在此时停止使用var,并创建

auto var1 = Immutable(1);

它很可能重用存储。


在另一个变量之上放置新位置通常是定义的行为。 这通常是一个坏主意,而且很脆弱

这样做会结束旧对象的生存期,而无需调用析构函数。 如果某些特定的假设成立,则引用和指针以及旧对象的名称会引用新对象(完全相同的类型,没有常量问题)。

修改声明为 const 的数据或包含const字段的类会导致在引脚掉落时出现未定义的行为。 这包括结束声明为 const 的自动存储字段的生存期,并在该位置创建新对象。 旧名称、指针和引用使用起来不安全。

[Basic.life 3.8]/8:

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

  • (8.1) 新对象的存储恰好覆盖原始对象占用的存储位置, 和

  • (8.2) 新对象与原始对象的类型相同(忽略顶级 CV 限定符),并且

  • (8.3) 原始对象的类型不是常量限定的,如果是类类型,则不包含任何非静态 类型为常量限定或引用类型的数据成员,以及

  • (8.4) 原始对象是派生最多的对象 (1.8) 类型 T 而新对象是最派生的 类型的对象 T (也就是说,它们不是基类子对象)。

简而言之,如果您的不可变性是通过const成员编码的,则使用旧名称指向旧内容的指针是未定义的行为

您可以使用放置 new 的返回值来引用新对象,而不能使用其他任何内容。


异常可能性使得极难防止执行未定义行为或必须立即退出的代码。

如果需要引用语义,请使用指向 const 对象的智能指针或可选的 const 对象。 两者都处理对象生存期。 第一个需要堆分配,但允许移动(可能还有共享引用),第二个允许自动存储。 两者都将手动对象生存期管理移出业务逻辑。 现在,两者都可以为空,但无论如何,手动避免这种情况是很困难的。

还要考虑写入指针上的复制,这些指针允许逻辑上包含突变的数据,以提高效率。

来自 C++ 标准草案 N4296:

3.8 对象生存
期[...]
类型 T 对象的生存期在以下时间结束:
(1.3) — 如果 T 是一个类 使用非平凡析构函数 (12.4) 的类型,析构函数调用开始, or
(1.4) — 对象占用的存储被重用,或者 释放。
[...]
4 程序可以通过以下方式结束任何对象的生存期 重用对象占用的存储或通过显式调用 具有非平凡的类类型的对象的析构函数 破坏者。对于具有非平凡的类类型的对象 析构函数,程序不需要调用析构函数 在对象占用的存储被重用之前显式或 释放;但是,如果没有显式调用析构函数或 如果未使用删除表达式 (5.3.5) 释放存储,则 不得隐式调用析构函数,并且任何依赖于 析构函数产生的副作用具有未定义的行为。

所以,是的,你可以通过重用对象的内存来结束对象的生存期,即使是具有非平凡析构函数的内存,只要你不依赖于析构函数调用的副作用。

这适用于具有对象的非常量实例,例如struct ImmutableBounds { const void* start; const void* end; }

您实际上已经问了 3 个不同的问题:)

1. 不变性契约

只是 - 一个契约,而不是一个语言结构。

例如,在Java中,String类的实例是不可变的。但这意味着类的所有方法都旨在返回类的新实例,而不是修改实例。

因此,如果你想将Java的字符串变成一个可变的对象,你不能,而不访问它的源代码。

这同样适用于用C++或任何其他语言编写的课程。您可以选择创建包装器(或使用代理模式),但仅此而已。

2. 使用放置构造函数并分配到初始化的内存中。

这实际上就是他们最初被创造的目的。 放置构造函数最常见的用例是内存池- 预先分配一个大型内存缓冲区,然后将内容分配到其中。

所以是的 - 这是合法的,没有人不会介意。

3. 使用放置分配器覆盖类实例的内容。

别这样。

有一个特殊的构造来处理这种类型的操作,它称为复制构造函数