将赋值运算符实现为"destroy + construct"是否合法?

Is it legal to implement assignment operators as "destroy + construct"?

本文关键字:是否 construct destroy 赋值运算符 实现      更新时间:2023-10-16

我经常需要为"原始"资源句柄(如文件句柄、Win32操作系统句柄等)实现C++包装器。在执行此操作时,我还需要实现move运算符,因为默认编译器生成的运算符不会清除moved-from对象,从而产生双重删除问题。

在实现move赋值操作符时,我更喜欢显式调用析构函数,并使用placement new就地重新创建对象。这样,我就避免了析构函数逻辑的重复。此外,我经常以复制+移动(在相关情况下)的方式实现复制分配。这导致以下代码:

/** Canonical move-assignment operator. 
Assumes no const or reference members. */
TYPE& operator = (TYPE && other) noexcept {
if (&other == this)
return *this; // self-assign
static_assert(std::is_final<TYPE>::value, "class must be final");
static_assert(noexcept(this->~TYPE()), "dtor must be noexcept");
this->~TYPE();
static_assert(noexcept(TYPE(std::move(other))), "move-ctor must be noexcept");
new(this) TYPE(std::move(other));
return *this;
}
/** Canonical copy-assignment operator. */
TYPE& operator = (const TYPE& other) {
if (&other == this)
return *this; // self-assign
TYPE copy(other); // may throw
static_assert(noexcept(operator = (std::move(copy))), "move-assignment must be noexcept");
operator = (std::move(copy));
return *this;
}

这让我觉得很奇怪,但我还没有在网上看到任何关于以这种"规范"方式实现move+copy赋值运算符的建议。相反,大多数网站倾向于以特定类型的方式实现赋值运算符,必须手动与构造函数保持同步;析构函数。

除了性能之外,还有什么反对实施move&以这种与类型无关的"规范"方式复制赋值运算符?

更新2019-10-08基于UB评论:

我已经通读了http://eel.is/c++draft/basic.life#8似乎涵盖了所讨论的案例。摘录:

如果在对象的生存期结束后。。。,一个新对象是在原始对象占用的存储位置创建,a指向原始对象的指针,引用到原始对象。。。将自动引用新对象。。。,可以用于操作新对象,如果。。。

之后有一些明显的条件与相同的类型和const/reference成员相关,但它们似乎是任何赋值运算符实现所必需的。如果我错了,请纠正我,但在我看来,我的"规范"样本表现良好,而不是UB(?)

更新2019-10-10基于复制和交换评论:

赋值实现可以合并到一个方法中,该方法采用值参数而不是引用。这似乎也消除了对static_assert和自分配检查的需要。我提出的新实施方案变成:

/** Canonical copy/move-assignment operator.
Assumes no const or reference members. */
TYPE& operator = (TYPE other) noexcept {
static_assert(!std::has_virtual_destructor<TYPE>::value, "dtor cannot be virtual");
this->~TYPE();
new(this) TYPE(std::move(other));
return *this;
}

有一个强有力的论据反对您的"规范"实现—这是错误的

结束原始对象的生存期,并在其位置创建一个对象但是,指向原始对象的指针、引用等不会自动更新为指向新对象—您必须使用std::launder(这句话对大多数类来说都是错误的;请参阅Davis-Hering的评论。)然后,析构函数会自动调用原始对象,从而触发未定义的行为。

参考:(强调矿)[class.dtor]/16

一旦为对象调用析构函数,该对象将不再存在;如果为生存期已结束的对象[nbsp;示例:如果析构函数对于一个自动对象,显式调用该块随后以通常调用隐式的方式离开对象的破坏,行为是未定义的。—nbspend;示例]

[basic.life]/1

[…]类型为T的对象o的生存期在以下情况下结束:

  • 如果T是具有非平凡析构函数([class.dtor])的类类型,则析构函数调用将启动,

  • 对象占用的存储被释放,或者被未嵌套在o([interro.object])中的对象重用。

(根据类的析构函数是否平凡,结束对象生存期的代码行是不同的。如果析构函数平凡,则显式调用析构函数将结束对象的生存期;否则,placement new将重用当前对象的存储,结束其生存期。无论哪种情况,当赋值ment运算符返回。)


您可能认为这是另一种"任何正常的实现都会做正确的事情"的未定义行为,但实际上许多编译器优化都涉及缓存值,这利用了此规范。因此,当代码在不同的优化级别下、由不同的编译器、使用同一编译器的不同版本编译时,或者当编译器刚刚度过了糟糕的一天,心情不好时,您的代码随时都可能中断。


实际的"规范"方式是使用复制和交换习惯用法:

// copy constructor is implemented normally
C::C(const C& other)
: // ...
{
// ...
}
// move constructor = default construct + swap
C::C(C&& other) noexcept
: C{}
{
swap(*this, other);
}
// assignment operator = (copy +) swap
C& C::operator=(C other) noexcept // C is taken by value to handle both copy and move
{
swap(*this, other);
return *this;
}

注意,在这里,您需要提供一个自定义的swap函数,而不是使用std::swap,正如HowardHinnant:所提到的那样

friend void swap(C& lhs, C& rhs) noexcept
{
// swap the members
}

如果使用得当,如果相关函数正确内联,复制和交换就不会产生任何开销(这应该很琐碎)。这个习语非常常用,一个普通的C++程序员应该很难理解它。与其害怕它会引起混乱,只需花2分钟学习它,然后使用它。

这一次,我们交换对象的值,对象的生存期不受影响。对象仍然是原来的对象,只是具有不同的价值,而不是全新的对象。这样想吧:你想阻止一个孩子欺负别人。交换价值观就像是对他们进行公民教育,而"摧毁+构建"就像是杀死他们让他们暂时死亡并给他们一个全新的大脑(可能有魔法的帮助)。至少可以说,后一种方法可能会产生一些不良副作用。

像任何其他习语一样,在适当的时候使用它—不要只是为了使用它而使用它。

我相信http://eel.is/c++draft/basic.life#8清楚地证明了赋值运算符可以通过inplace实现;破坏+构造";假设与非常量、非重叠对象等相关的某些限制。