news 2026/10/26 18:58:07

C++赋值函数+移动赋值函数+移动构造函数详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++赋值函数+移动赋值函数+移动构造函数详解

前言


一个管理资源的 C++ 类,通常需要五个特殊成员函数:析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。它们合称「规则五」(rule of five)。写全这五个函数,类的拷贝、移动、销毁才有明确的语义;写错其中任何一个,轻则多一次拷贝,重则重复释放内存。


常见误解集中在三处。第一,认为只要写了析构函数,编译器仍会自动生成移动操作。实际上,一旦用户声明了析构函数、拷贝构造或拷贝赋值中的任意一个,编译器就不会再隐式声明移动构造和移动赋值,此时对右值的「移动」会退化成拷贝。第二,认为移动之后源对象一定变成空对象。标准只保证源对象处于「有效但未指定」的状态,可以安全析构和重新赋值,但不能假设它的具体内容。第三,认为移动赋值不需要处理自赋值。x = std::move(x)是合法表达式,虽然结果未指定,但至少不能因此崩溃或泄漏。


本文讲清五个函数的生成规则、拷贝赋值与移动赋值的实现要点,并给出一个管理动态数组的完整示例。示例以 C++17 为准。


一、五个特殊成员函数与生成规则


编译器会在需要时隐式生成这些函数,但生成规则有联动关系:




用户声明了什么隐式生成的拷贝操作隐式生成的移动操作



什么都没声明逐个成员拷贝逐个成员移动

析构、拷贝构造、拷贝赋值之一仍生成(除非被删除)不生成

移动构造或移动赋值之一定义为删除只生成被声明的那类



这张表解释了两个常见现象。其一,给类加了一个空的析构函数,移动就悄悄消失了,之后std::vector扩容时可能改为拷贝元素。其二,只写了移动构造函数,拷贝构造会被定义为删除,类变成只能移动不能拷贝。


如果确实需要默认行为,可以用= default显式要求编译器生成,用= delete显式禁止。显式声明比依赖隐式规则更容易读懂,也更不容易在后续修改中意外改变。


二、拷贝赋值:先保证正确,再谈效率


拷贝赋值运算符的签名是T& operator=(const T& other)。返回引用是为了支持连续赋值a = b = c,参数用 const 引用是为了接受 const 对象和临时对象。


实现拷贝赋值有两个关键点。


第一是自赋值。a = a虽然罕见,但在别名场景下可能出现,例如通过两个引用操作同一个对象。如果实现是「先释放自己的资源,再从参数拷贝」,自赋值时参数引用的正是已被释放的资源,读到的是悬垂内存。处理方式有两种:在开头判断if (this != &other),或者采用「拷贝并交换」——先用参数构造一个临时副本,再用swap交换,临时对象在析构时释放旧资源。拷贝并交换天然处理自赋值,而且如果拷贝构造抛出异常,自身对象还没有被修改,满足强异常安全保证。


第二是资源释放的时机。直接delete[] data_;再new分配,如果分配抛出std::bad_alloc,对象已经丢了原有数据,处于不一致状态。拷贝并交换把「分配新资源」放在修改自身之前,异常发生时原对象保持不变。


// 适用于 C++17
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp(other); // 可能抛异常,此时 *this 未变
swap(tmp); // 不会抛异常
}
return *this;
}

swap应当提供noexcept版本,只交换指针和长度,不分配内存。


三、移动构造与移动赋值:接管资源


移动构造的签名是T(T&& other) noexcept,移动赋值是T& operator=(T&& other) noexcept。参数是非 const 右值引用,因为函数内部要修改源对象,把它的指针「偷」过来并置空。


移动构造做的事:把自己成员的指针直接赋成源对象的指针,然后把源对象的指针置空、长度置零。置空不是可选的礼貌行为,而是正确性要求:如果源对象仍持有同一块内存,它析构时会释放一次,目标对象析构时又释放一次,造成重复释放。


移动赋值比移动构造多一步:目标对象原本可能持有资源,必须先释放,再接管源对象的资源。顺序不能反,否则原资源泄漏。同时要判断自赋值,x = std::move(x)时如果不加检查,先delete[] data_会把即将接管的指针释放掉,随后把悬垂指针赋回自身。


noexcept在移动操作上不是装饰。std::vector扩容时需要把旧元素搬到新缓冲区,如果元素的移动构造声明为noexcept,标准库可以放心使用移动;否则为了满足强异常安全保证,它可能退回使用拷贝构造。一个没有标noexcept的移动构造,可能让整个容器的性能特征发生变化。


移动之后,源对象处于「有效但未指定」状态。可以析构它、给它赋新值,但不能读取它的业务数据并假定那是原来的值。本文的实现把指针置空、长度置零,这只是一种常见选择,不是标准强制要求。


四、完整示例


// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>

class Buffer {
public:
explicit Buffer(std::size_t n = 0)
: size_(n), data_(n ? new int[n] : nullptr) {}

~Buffer() { delete[] data_; }

// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: size_(other.size_),
data_(other.size_ ? new int[other.size_] : nullptr) {
std::copy(other.data_, other.data_ + other.size_, data_);
}

// 拷贝赋值:拷贝并交换,天然处理自赋值与异常
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp(other);
swap(tmp);
}
return *this;
}

// 移动构造:接管指针,源对象置空
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}

// 移动赋值:先释放自身,再接管
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
}
return *this;
}

void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}

std::size_t size() const noexcept { return size_; }

int* data() noexcept { return data_; }
const int* data() const noexcept { return data_; }

private:
std::size_t size_;
int* data_;
};

int main() {
Buffer a(3);
for (std::size_t i = 0; i < a.size(); ++i) {
a.data()[i] = static_cast<int>(i + 1);
}

Buffer b(a); // 拷贝构造
Buffer c(std::move(a)); // 移动构造,a 被置空
std::cout << b.size() << ' ' << c.size() << ' '
<< a.size() << '\n'; // 3 3 0

b = c; // 拷贝赋值
b = std::move(c); // 移动赋值,c 被置空
std::cout << b.size() << ' ' << c.size() << '\n'; // 3 0
return 0;
}

推演对象状态。a构造后长度为 3,数据为 1、2、3。b(a)深拷贝,得到独立的 3 个元素。c(std::move(a))接管a的指针,c长度为 3,a被置为长度 0、指针空;输出为3 3 0。


b = c走拷贝赋值,tmp复制c的数据,交换后b持有新副本,tmp析构释放b原来的内存。b = std::move(c)走移动赋值,先释放b自己的内存,再接管c的指针,c被置空;输出为3 0。程序结束时三个对象各自析构,每块内存只释放一次。


需要说明的是,示例中的Buffer只用于演示五个函数的写法。实际工程中,管理动态数组更推荐直接使用std::vector,它已经正确实现了这些语义,用户类可以采用「规则零」:不写任何特殊成员函数,让编译器生成,成员各自管理自己的资源。


五、选择:规则零、规则五与拷贝并交换


规则零(rule of zero)是首选:类的成员都是能正确管理自身的类型(std::string、std::vector、智能指针),不手写析构、拷贝、移动,编译器生成的版本就是正确的。这样代码最少,也最不容易出错。


当类必须直接持有资源(裸指针、文件描述符、系统句柄)时,才需要规则五。此时应当把资源封装到一个小的 RAII 类型里,让外层类回到规则零。例如把int*换成std::unique_ptr<int[]>,析构、移动自动正确,只剩拷贝需要显式处理(或者直接禁用拷贝)。


拷贝并交换适合拷贝赋值,代码短、异常安全、自赋值安全。它的代价是每次赋值都多构造一个临时对象,对性能敏感的赋值路径可以改为手写「分配新资源、成功后再释放旧资源」。移动赋值不适合拷贝并交换,因为它本来就要接管资源,多一次交换没有意义。




场景推荐做法



成员都能自我管理规则零,不写特殊成员

直接持有裸资源规则五,或封装成 RAII 成员

拷贝赋值实现拷贝并交换,或先分配后释放

移动操作接管指针并置空源对象,标noexcept

禁止拷贝= delete拷贝构造与拷贝赋值



常见坑点


坑点一:拷贝赋值不处理自赋值。


❌delete[] data_; data_ = new int[other.size_]; std::copy(other.data_, ...);,a = a时读到已释放内存。


✅ 开头判断this != &other,或使用拷贝并交换。


坑点二:移动构造后不置空源对象。


❌data_(other.data_)但不修改other.data_,两个对象析构时重复释放。


✅ 接管后把源对象的指针置空、长度置零。


坑点三:移动操作缺少noexcept。


❌Buffer(Buffer&& other),std::vector扩容时可能放弃移动、改用拷贝。


✅ 移动构造与移动赋值都标noexcept,且实现中不抛异常。


坑点四:移动赋值不释放自身原有资源。


❌ 直接data_ = other.data_;,目标对象原来的内存泄漏。


✅ 先delete[] data_;再接管,并检查自赋值。


坑点五:以为声明析构函数后仍有隐式移动。


❌ 写了~Buffer(),却依赖编译器生成移动构造,实际走的是拷贝。


✅ 需要移动时显式= default或手写五个函数。


坑点六:拷贝赋值返回void。


❌void operator=(const Buffer&),不支持连续赋值,也偏离惯例。


✅ 返回Buffer&,函数末尾return *this;。


坑点七:移动赋值参数写成const T&&。


❌Buffer& operator=(const Buffer&& other),无法修改源对象,也就无法置空。


✅ 参数为T&&,非 const 右值引用。


坑点八:移动后继续读取源对象的业务数据。


❌Buffer c(std::move(a)); use(a.data()[0]);,源对象处于有效但未指定状态。


✅ 移动后只对源对象析构或重新赋值,不读取旧内容。


总结




函数签名核心动作关键约束



析构函数~T()释放资源声明后不再隐式生成移动

拷贝构造T(const T&)深拷贝参数 const 引用

拷贝赋值T& operator=(const T&)释放旧资源并拷贝自赋值安全、异常安全

移动构造T(T&&) noexcept接管资源并置空源必须noexcept

移动赋值T& operator=(T&&) noexcept释放自身、接管、置空源自赋值检查、noexcept



五个函数的核心是「谁拥有资源、资源何时释放」。拷贝意味着两份独立资源,移动意味着所有权转移且源对象不再持有。把这两句话落实到每一个函数里,再配合规则零减少手写代码,资源管理的绝大多数问题都可以在编译期和代码审查阶段消除。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 18:58:05

Linux系统管理实战:从基础配置到内核调优

1. Linux系统学习日志&#xff1a;从入门到精通的实践指南 作为一名在Linux系统管理领域摸爬滚打多年的老鸟&#xff0c;我决定把日常工作中积累的实战经验整理成这份系统学习日志。不同于市面上那些照本宣科的教程&#xff0c;这里记录的每个命令、每个配置背后都有血淋淋的踩…

作者头像 李华
网站建设 2026/10/26 18:57:41

风电现货交易尾部风险管理与金融化实践

1. 风电现货交易中的尾部风险挑战现货电力市场中&#xff0c;风电出力固有的波动性一直是交易员最头疼的问题。去年冬天我在西北某风电场亲眼目睹了这样的场景&#xff1a;气象预报显示未来三天将持续大风&#xff0c;风电场按P50预测值申报了次日96点出力曲线。结果第二天午后…

作者头像 李华
网站建设 2026/10/26 18:56:58

智能农业四情监测系统:物联网技术在现代农业的应用

1. 智能农业四情监测系统&#xff1a;现代农业的神经末梢 去年夏天在山东寿光的蔬菜基地&#xff0c;我亲眼目睹了一场由传统农业向智慧农业转型的现场教学。一位种植大户老李指着大棚里闪烁的各类传感器说&#xff1a;"以前种菜全凭经验&#xff0c;现在这些电子眼能告诉…

作者头像 李华
网站建设 2026/10/26 18:56:51

边缘AI与Kubernetes:实现高效推理部署的实战指南

1. 边缘AI与Kubernetes的天然契合 当AI推理遇上边缘计算&#xff0c;我们面临的是一个充满矛盾的场景&#xff1a;既要保证低延迟响应&#xff0c;又要处理异构硬件&#xff1b;既要应对网络不稳定性&#xff0c;又要满足资源受限环境下的模型部署。这正是Kubernetes大显身手的…

作者头像 李华
网站建设 2026/10/26 18:56:17

SVN图形化界面忽略node_modules目录操作指南

1. SVN图形化界面忽略文件操作指南 在团队协作开发中&#xff0c;版本控制系统是必不可少的工具。SVN作为经典的集中式版本控制系统&#xff0c;虽然近年来逐渐被Git取代&#xff0c;但在许多传统企业项目中仍然广泛使用。对于前端开发者而言&#xff0c;node_modules这类依赖目…

作者头像 李华
网站建设 2026/10/26 18:56:05

Docker部署MinIO对象存储:从入门到生产实践

对象存储这件事&#xff0c;很多团队一开始都觉得“随便找个目录存文件”就够了&#xff0c;等到文件数量上了百万级、需要多副本容灾、要给前端直传签名 URL、还要按租户做配额隔离的时候&#xff0c;才发现自己造了个半成品。MinIO 就是在这个阶段被大量团队捡起来的方案——…

作者头像 李华