我正在探索在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 ++中是合法的还是我应该期望未定义的行为?如果它依赖于标准,那么我可以期待这个最小/最大标准版本能够工作吗?
答案 0 :(得分:8)
你所写的内容在技术上是合法的,但几乎肯定没用。
假设
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
后提及const
。
C ++中的实际const
数据是对编译器的承诺,您永远不会更改该值。这与const或const指针的引用相比,这只是一个你不会修改数据的建议。
声明为var
的结构成员是"实际上是const"。编译器会认为它们永远不会被修改,并且不会费心去证明它。
您在旧实例的位置创建新实例会违反此假设。
您可以这样做,但您不能使用旧名称或指针来引用它。 C ++可以让你自己射击。走吧,我们敢于你。
这就是为什么这种技术合法,但几乎完全没用。具有静态单一赋值的优秀优化器已经知道您将在此时停止使用auto var1 = Immutable(1);
并创建
const
它可以很好地重用存储。
在另一个变量之上调用新位置通常是定义的行为。这通常是个坏主意,而且脆弱。
这样做可以在不调用析构函数的情况下结束旧对象的生命周期。如果某些特定的假设成立(完全相同的类型,没有const问题),则引用和指向旧对象的名称和引用旧对象的名称。
修改声明为const的数据或包含const
字段的类会导致引脚丢失时出现未定义的行为。这包括结束声明为const的自动存储字段的生命周期并在该位置创建新对象。旧名称,指针和引用使用起来不安全。
如果在对象的生命周期结束之后以及对象占用的存储之前被重用或者 释放后,在原始对象占用的存储位置创建一个新对象,指针即可 指向原始对象,引用原始对象或原始对象的名称 对象将自动引用新对象,并且一旦新对象的生命周期开始,就可以 用于操纵新对象,如果:
(8.1) 新对象的存储完全覆盖原始对象占用的存储位置, 以及
(8.2) 新对象与原始对象的类型相同(忽略顶级cv限定符),
(8.3) 原始对象的类型不是const限定的,如果是类类型,则不包含任何非静态类型 类型为const限定的数据成员或引用类型,
(8.4) 原始对象是类型的派生程度最高的对象(1.8) Ť 并且新对象是派生最多的 对象类型 Ť (也就是说,它们不是基类子对象)。
简而言之,如果您的不变性是通过{{1}}成员进行编码的,则使用旧名称或指向旧内容的指针是未定义的行为。
您可以使用placement new的返回值来引用新对象,而不是其他任何内容。
异常可能性使得极难阻止代码导致未定义的行为或必须总结退出。
如果您想要引用语义,请使用指向const对象的智能指针或可选的const对象。两者都处理对象的生命第一个需要堆分配,但允许移动(可能是共享引用),第二个允许自动存储。两者都将手动对象生命周期管理移出业务逻辑。现在,两者都可以为空,但无论如何都要手动避免这种做法很难。
还要考虑复制写指针,这些指针允许逻辑上带有突变的const数据以提高效率。
答案 1 :(得分:2)
来自C ++标准草案N4296:
3.8对象生命周期
[...]
类型T的对象的生命周期在以下情况下结束:
(1.3) - 如果T是一个类 输入一个非平凡的析构函数(12.4),析构函数调用启动, 或
(1.4) - 对象占用的存储器被重用或 释放。
[...]
4程序可以结束任何对象的生命周期 重用对象占用的存储空间或通过显式调用 具有非平凡类的类类型的对象的析构函数 析构函数。对于具有非平凡的类类型的对象 析构函数,程序不需要调用析构函数 显式地在对象占用的存储之前重新使用或 公布;但是,如果没有显式调用析构函数或 如果没有使用delete-expression(5.3.5)来释放存储,那么 不应该隐式调用析构函数和任何依赖的程序 关于析构函数产生的副作用有不明确的行为。
所以是的,只要不依赖于析构函数调用的副作用,你就可以通过重用它的内存来结束一个对象的生命周期,即使是一个非平凡的析构函数也是如此。
当您有struct ImmutableBounds { const void* start; const void* end; }
等对象的非常量实例
答案 2 :(得分:0)
您实际上已经提出了3个不同的问题:)
<强> 1。不变性合同
只是 - 合同,而不是语言结构。
例如,在Java中,String类的实例是不可变的。但这意味着该类的所有方法都被设计为返回类的新实例而不是修改实例。
因此,如果您希望将Java的String变为可变对象,则无法访问其源代码。
同样适用于用C ++或任何其他语言编写的类。您可以选择创建包装(或使用代理模式),但这就是它。
<强> 2。使用放置构造函数并分配到已初始化的内存中。
这实际上是他们创造的第一件事。 放置构造函数最常见的用例是内存池 - 您预先分配一个大内存缓冲区,然后将内容分配到其中。
所以是的 - 这是合法的,没有人会在意。
第3。使用放置分配器覆盖类实例的内容。
不要这样做。
有一个特殊的构造来处理这种类型的操作,它被称为一个复制构造函数。