基于构造函数实现C++赋值运算符的可行性及相关问题咨询
假设你需要在C++中实现一个资源管理类,且无法使用Rule of Zero或Rule of Five Defaults,因此需要手动实现拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符和析构函数。此处以Box类为例,该实现逻辑可推广到多种类型。
// Store object on heap but keep value semantics // Moved-from state has null elem; otherwise should have value // Only valid operations on moved-from state are assignment and destruction // Assignment only offers a basic exception guarantee template <typename T> class Box { public: using value_type = T; // Default constructor Box() : elem{new value_type} {} // Accessor value_type& get() { return *elem; } value_type const& get() const { return *elem; } // Rule of Five Box(Box const& other) : elem{new value_type{*(other.elem)}} {}; Box(Box&& other) : elem{other.elem} { other.elem = nullptr; }; Box& operator=(Box const& other) { if (elem) { *elem = *(other.elem); } else { elem = new value_type{*(other.elem)}; } return *this; } Box& operator=(Box&& other) { delete elem; elem = other.elem; other.elem = nullptr; return *this; } ~Box() { delete elem; } // Swap friend void swap(Box& lhs, Box& rhs) { using std::swap; swap(lhs.elem, rhs.elem); } private: T* elem; };
注:更完善的Box实现会包含更多特性,比如noexcept、constexpr函数、基于value_type构造函数的explicit转发构造函数、分配器支持等;此处仅实现问题和测试所需的最小逻辑。使用std::unique_ptr可以简化代码,但会降低示例的清晰度。
可以看到,赋值运算符的实现与对应的构造函数、析构函数存在大量重复代码。如果不需要支持对被移动后的Box对象赋值,重复度会有所降低,但在更复杂的类中该问题会更加明显。
题外话:Copy-and-Swap
处理该问题的一种标准方案是使用Copy-And-Swap Idiom(该场景下也称为Rule of Four and a Half),如果swap是nothrow的,该方案还可以提供强异常保证:
// Copy and Swap (Rule of Four and a Half) Box& operator=(Box other) // Take `other` by value { swap(*this, other); return *this; }
该方案只需要编写一个赋值运算符(按值传参other时,编译器会在可行时将实参移动到形参,必要时自动完成拷贝),且赋值运算符逻辑非常简单(前提是已经实现了swap)。但该方案存在额外内存分配、操作过程中保留多份内容拷贝等问题。
此处提出一种暂称为Destroy-and-Initialize assignment operator的实现方案:既然所有构造逻辑已经在构造函数中实现,且赋值后的对象状态应该和拷贝构造的对象完全一致,可直接复用构造函数实现赋值逻辑,代码如下:
// Destroy-and-Initialize Assignment Operator template <typename Other> Box& operator=(Other&& other) requires (std::is_same_v<Box, std::remove_cvref_t<Other>>) { this->~Box(); new (this) Box{std::forward<Other>(other)}; return *std::launder(this); }
该方案和Copy-and-Swap一样存在额外分配,但仅在拷贝赋值场景下产生,移动赋值场景不会出现,且额外分配是在销毁原有T拷贝之后执行,因此在资源受限场景下不会出现分配失败的问题。
- 该方案是否已经被提出过?如果有,在哪里可以查阅相关资料?
- 该方案是否在某些场景下属于UB?比如当
Box是其他对象的子对象时,是否允许销毁并重建子对象? - 该方案是否存在未提及的缺点?比如不兼容
constexpr? - 当无法使用
= default生成赋值运算符时,除了本方案和Rule of Four and a Half之外,还有哪些方案可以避免赋值运算符的代码重复?
- 该方案早已在C社区被广泛讨论,通常被称为析构重建赋值或就地重构赋值,在C标准相关讨论、C技术书籍补充内容、主流C技术社区的分享中都能找到相关记录,并非全新提出的思路。
- 该方案存在多处触发UB的风险:
- 子对象场景限制:如果
Box是其他类的非静态数据成员、或者是基类子对象,销毁重建的行为有严格约束:若子对象包含const修饰的非静态成员、或引用类型成员,重建后的对象无法被合法使用;若上层父对象本身是const修饰的,销毁重建子对象的行为直接属于UB。 - 构造异常风险:如果新的
Box构造过程中抛出异常,原对象已经被提前析构,会处于完全无效的状态,后续任何访问都会触发UB,连基础异常保证都无法满足。 - 指针/引用失效:如果存在外部指针或引用指向当前
Box对象的内部成员,重建后这些指针和引用会全部失效,即便使用std::launder也无法恢复其合法性,访问即触发UB。
- 子对象场景限制:如果
- 该方案存在多个未提及的缺点:
- 确实不兼容
constexpr:C++20及之前的标准不允许在常量表达式中调用析构函数,也不允许使用placement new语法,因此该赋值运算符完全无法用于constexpr场景。 - 自赋值UB:如果发生自赋值,会先析构自身,后续构造时访问的是已经被析构的无效对象,直接触发UB,额外增加自赋值判断又会带来不必要的性能开销。
- 性能损耗:如果类可以复用现有资源(比如示例中
Box的elem指针非空时,赋值仅需要修改*elem的值即可,不需要释放再重新分配内存),该方案会额外执行一次释放和分配操作,性能比手写赋值运算符更差。 - 兼容性差:部分老旧编译器对
std::launder的支持不完善,可能导致编译失败或者运行时异常。
- 确实不兼容
- 还有两种常用的方案可以避免代码重复:
- 提取公共辅助函数:将拷贝资源、移动资源、清理资源的通用逻辑抽成类的私有辅助函数,所有构造函数、赋值运算符、析构函数都调用这些公共函数,避免重复编写逻辑。
- 拆分资源管理层:将需要手动管理的资源单独封装成一个仅负责资源生命周期的小型工具类,该工具类自行实现Rule of Five,上层业务类直接持有该工具类的对象,即可使用Rule of Zero,不需要手动编写赋值运算符,从根源上避免代码重复。
内容的提问来源于stack exchange,提问作者Daniel H

