【问题标题】:Is it legal to use placement new on initialised memory?在初始化内存上使用新位置是否合法?
【发布时间】:2017-08-17 06:29:12
【问题描述】:

我正在探索在 C++ 中实现真正(部分)不可变数据结构的可能性。由于 C++ 似乎没有区分变量和变量存储的对象,因此真正替换对象(无需赋值操作!)的唯一方法是使用placement 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() 是合法可行的。

【问题讨论】:

  • 你能澄清一下“区分变量和变量存储的对象”是什么意思吗?因为对象肯定不同于一般的变量(例如不对应于变量的对象,或者通过指针间接)。
  • 为什么要重新分配给var?为什么不把它作为一个指针,可以根据需要设置为Immutable不同实例的地址?
  • 如果你需要改变它,它不是一成不变的。
  • @PeteBecker 这是不正确的。变量(绑定)的可变性和对象本身的可变性是有区别的。出于安全和效率的考虑,通常希望防止对象的可变性,但仍允许变量本身被反弹到不同的对象。请注意,我不是在改变对象,而是完全丢弃旧对象并绑定新对象。
  • C++ 引用语义是指针或引用。变量是值。以及如何实现不变性很重要。

标签: c++ memory language-lawyer placement-new


【解决方案1】:

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

假设

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

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

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

这是完全合法的。

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

你可以这样做:

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

并且访问*pvar1 是合法的。你甚至可以这样做:

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

但在任何情况下,在您将 new'd 放置在 var 上之后,您都不能提及它。

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

声明为const 的结构的成员“实际上是常量”。编译器会假定它们从未被修改过,也不会费心去证明它。

您在旧实例所在的位置创建一个新实例违反了这一假设。

您可以这样做,但您不能使用旧名称或指针来引用它。 C++ 让你在脚下开枪。去吧,我们敢。

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

auto var1 = Immutable(1);

它可以很好地重复使用存储。


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

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

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

[Basic.life 3.8]/8:

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

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

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

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

  • (8.4) 原始对象是类型最衍生的对象(1.8) 吨 并且新对象是最衍生的 类型的对象 吨 (也就是说,它们不是基类子对象)。

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

您可以使用placement new 的返回值来引用新对象,仅此而已。


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

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

为了提高效率,还考虑允许在逻辑上带有突变的 const 数据的写指针复制。

【讨论】:

  • 完全同意你的第一段。但是考虑到我的特定用例(这是为了实现可以抽象底层存储的库内部胖迭代器/访问器模式),使用这种对象替换大大简化了语义,实际上使代码更安全。特别是,迭代器不能被隐式复制,只能在非常有限的条件下移动。不能存在对迭代器的引用。这一切都确保了编译器可以使用积极的优化(并且在迁移到这种模式后,我看到了 10+% 的改进)。
  • @MrMobster 除了拥有const 的成员之外,您几乎可以肯定通过“创建新对象”计划执行未定义的行为。您看到的优化是编译器假设 const data 从不改变;你正在改变它,违反了这些假设。添加了标准报价。这是唯一允许您在销毁旧值后使用创建新值的变量名称的子句。就像我说的,坏主意。
  • @MrMobster 我已经明确直接地解决了我怀疑您的用例是什么,以及为什么您的用例会导致未定义的行为,而您在 OP 中的问题却不会。
  • 好的,感谢您的澄清!如果我理解正确,它的第 8.3 点使我想使用的东西非法。因此,毕竟在 C++ 中,没有办法与不可变对象进行可变绑定。这是一种耻辱,但我可以忍受它。
【解决方案2】:

来自 C++ 标准草案 N4296:

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

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

这适用于你有像struct ImmutableBounds { const void* start; const void* end; }这样的对象的非常量实例时

【讨论】:

  • 修改实际上是 const 的东西有特定的规则
  • 感谢您的评论,@Yakk。我将 OP 的不可变对象解释为具有像他commented here 这样的 const 成员的对象。对于这些,我认为对象生命周期的正常规则适用。当然对于const T t 这不起作用,但我认为 OP 理解这一点并且没有询问它。
  • @alain 不,成员变量为const 不会使再次使用变量名合法。好吧,OP 没有做错任何事,只是在调用 new 之后,他们永远不能在代码中提及 var,这似乎是他们接下来想做的事情。
  • 是的,不知何故我错过了重用var 是重点,只关注“重用初始化存储”。你很好地回答了“更广泛”的问题,很高兴 OP 接受了你的回答:-)
【解决方案3】:

你实际上问了 3 个不同的问题 :)

1.不变合约

就是这样 - 合同,而不是语言结构。

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

所以如果你想把 Java 的 String 变成一个可变对象,你不能,因为你不能访问它的源代码。

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

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

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

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

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

不要那样做。

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

【讨论】:

  • 1. const 是一种语言结构。另一个问题是,在 C++ 中,不可变性通常由合同提供(即使用可变的内部对象,只允许用户访问外部的类似不可变的包装 API)。 2.我认为你把初始化和分配混淆了。在您的示例中,内存已分配但未初始化。 3. 复制构造函数如何解决我的问题?它允许我复制一个对象,但不能重新分配一个对象——这就是需要赋值运算符的地方。而赋值运算符是我要明确避免的。
  • 1. const 适用于方法或变量 - 而不是对象的实例。它建立并帮助执行不变性合同——但它仍然只是一个合同。 2 - 你不需要初始化你分配到的内存,它可以包含垃圾。你需要分配它。 3 - 我的印象是您试图更改实例所在的内存区域。而不是使用placement-new用新的对象实例覆盖它,为什么不简单地复制值(你可以更好地控制你可以做什么)
  • struct ImmutableBounds { const void* start; const void* end; } 你会如何改变这个?但也许我们只是对合同的概念有不同的理解。
  • 现在我明白你的意思了 - 最好将该定义添加到问题中。如果创建者将这些字段标记为“const”,那么他有充分的理由不更改它们。所以真正的问题是——你为什么要他们变异?这就是我说的合同。如果不从语言中删除这些“conts”,就不会让你修改它们的值。
  • Piotr,这正是重点:我不希望它们变异!这就是我所说的 C++ 没有正确区分变量和对象的意思。我不想改变对象本身,但我确实想将不同的对象绑定到变​​量。查看ImmutableBounds 结构,假设您有一个算法需要选择要使用的边界(唯一的方法是迭代)。您实际上并不想更改任何界限。传统上,这是指针的用例,只是我想避免引用语义。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-09-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多