移动拷贝(移动构造)与移动赋值
一、先搞懂:移动语义到底是什么?
C++11 引入的移动语义(Move Semantics),核心是 **「资源所有权转移」**,而不是传统拷贝的「资源复制」。它解决两个完全不同的核心问题:
- 性能优化:避免临时对象、大型对象的无谓深拷贝,减少内存分配和数据复制的开销。
- 独占资源设计 :实现「只可移动、不可拷贝」的类型,比如文件句柄、线程、
std::unique_ptr这类天生不能被复制的资源,只能通过转移所有权来传递。
而移动拷贝(移动构造)和移动赋值,就是实现移动语义的两个核心接口。
二、先铺垫:移动语义的基础 ------ 右值引用
移动语义的语法基础是右值引用 &&,先搞懂三个概念:
| 类型 | 定义 | 例子 |
|---|---|---|
| 左值 | 有名字、能取地址的对象 | int a = 10; 中的 a |
| 右值 | 临时对象、即将被销毁的对象,不能取地址 | Person("temp", 20)(临时对象)、函数返回的局部对象 |
右值引用 && |
专门绑定到右值的引用,用来触发移动语义 | Person&& r = Person("temp", 20); |
补充两个关键误区:
std::move不是 "移动对象",只是把左值强制转换成右值引用,本身不修改对象,真正的资源转移是在移动构造 / 移动赋值里实现的。- 被
std::move后的对象,处于「有效但未指定」的状态,只能被赋值或析构,不能再依赖它的原始值(除非你手动置空了)。
三、移动拷贝(移动构造函数)详解
1. 定义与语法
移动构造函数 :用一个源对象(通常是右值)初始化一个新对象,过程是直接接管源对象的资源,源对象被置为合法的空状态,没有深拷贝。
语法原型:
cpp
class Person {
public:
// 移动构造函数:参数是右值引用&&,通常加noexcept
Person(Person &&other) noexcept;
};
2. 触发时机
-
用临时对象初始化新对象:
Person p = Person("temp", 20); -
函数返回局部对象(RVO 优化失效时):
cppPerson create() { Person p("Alice", 20); return p; } Person p = create(); // 触发移动构造 -
用
std::move强制左值触发:Person p2 = std::move(p1);
3. 核心实现逻辑(带堆资源的例子)
以带 char* name 成员的类为例,移动构造的关键是「零拷贝接管资源 + 源对象置空」:
cpp
Person(Person &&other) noexcept
: name(other.name), age(other.age) // 直接接管源对象的指针和数据
{
// 必须把源对象置空!防止析构时重复释放
other.name = nullptr;
other.age = 0;
}
对比拷贝构造:拷贝构造会 new 一块新内存,把 other.name 的内容复制过去;而移动构造直接拿指针,全程没有内存分配和数据复制,开销几乎为 0。
四、移动赋值运算符详解
1. 定义与语法
移动赋值运算符 :把一个源对象(通常是右值)的资源转移给一个已经存在的对象,同样是转移所有权,源对象置空。
语法原型:
cpp
class Person {
public:
// 移动赋值运算符:返回*this支持链式赋值,参数是右值引用&&,加noexcept
Person& operator=(Person &&other) noexcept;
};
2. 触发时机
- 用临时对象赋值给已有对象:
p = Person("temp", 20); - 用
std::move强制左值赋值:p2 = std::move(p1);
3. 核心实现逻辑(关键步骤)
移动赋值比移动构造多一步:释放目标对象自身的旧资源,防止内存泄漏。完整实现:
cpp
Person& operator=(Person &&other) noexcept {
// 1. 自移动判断:防止p = std::move(p); 这种自移动操作
if (this != &other) {
// 2. 释放目标对象自身的旧资源(必须!否则会内存泄漏)
delete[] name;
// 3. 接管源对象的资源
name = other.name;
age = other.age;
// 4. 把源对象置空,防止析构时重复释放
other.name = nullptr;
other.age = 0;
}
// 5. 返回*this,支持链式赋值:p1 = p2 = std::move(p3);
return *this;
}
五、移动语义 vs 拷贝语义:核心区别
用一张表把两者的本质差异讲透:
| 维度 | 拷贝构造 / 拷贝赋值 | 移动构造 / 移动赋值 |
|---|---|---|
| 核心行为 | 复制资源,创建独立副本 | 转移资源所有权,不复制 |
| 源对象状态 | 不变,仍然持有资源 | 被置空,不再持有资源 |
| 性能开销 | 高(深拷贝需要内存分配、复制) | 极低(仅指针赋值,零拷贝) |
| 适用对象 | 可共享所有权的对象 | 独占所有权 / 临时对象 |
| 资源管理 | 多个对象持有同一份资源的副本 | 同一时间只有一个对象持有资源 |
| 典型场景 | shared_ptr、普通值对象 |
unique_ptr、std::thread、临时对象 |
六、面试必考点:关键细节
1. 为什么要加 noexcept?
- 标准库容器(比如
vector)扩容时,会优先使用带noexcept的移动构造函数,来保证异常安全。 - 如果移动构造没有
noexcept,容器扩容时会直接「降级」使用拷贝构造,移动语义直接失效,优化白写。 - 原因:如果移动过程中抛出异常,容器无法安全回滚,所以必须用
noexcept保证移动构造不会抛出异常。
2. 编译器默认生成规则
C++11 及以后,编译器会默认生成移动构造和移动赋值,但有严格前提:
- 没有手动定义拷贝构造、拷贝赋值、析构函数中的任何一个;
- 类的所有非静态成员和基类都支持移动语义。
只要你写了拷贝相关的函数,编译器就不会默认生成移动版本,需要自己手动实现。
3. 自移动判断的必要性
移动赋值里的 if (this != &other),是为了防止 p = std::move(p); 这种自移动操作:
- 如果没有判断,会先
delete[] name(释放自己的资源),再接管other.name(其实就是自己的name),导致访问已释放的内存,直接崩溃。
4. 源对象置空的必要性
如果不把源对象的指针置空,源对象析构时会 delete[] name,而这个指针已经被新对象接管了,会导致double free ,程序崩溃。置空后,源对象析构时 delete nullptr 是安全的。
七、两种核心使用场景
场景 1:性能优化
对于大型可拷贝对象,比如 vector<string>、自定义的大型类,临时对象的深拷贝开销极大,移动语义可以直接转移资源,不用复制数据。比如:
cpp
// 函数返回大型vector,移动语义避免了拷贝
vector<int> func() {
vector<int> v(1000000, 0);
return v; // 触发移动构造,直接转移vector的底层数组
}
场景 2:独占资源设计
有些资源天生不能被复制,只能被转移所有权,这类类会禁用拷贝构造 / 拷贝赋值(=delete),只开放移动语义,实现「只可移动、不可拷贝」。典型例子:
std::unique_ptr:独占智能指针,禁用拷贝,只能移动,保证资源只有一个持有者。std::thread:线程只能被一个对象持有,不能复制,只能移动转移线程所有权。- 文件句柄、socket、管道:如果复制了,两个对象析构时会重复关闭,导致崩溃,所以只能移动。
这类场景下,移动不是「优化版的拷贝」,而是对象传递的唯一合法方式。
八、对比单例模式
单例模式和独占资源类的区别:
- 单例:既禁用拷贝,也禁用移动,确保全局只有一个实例,连所有权转移都不允许。
- 独占资源类(如
unique_ptr):禁用拷贝,允许移动,允许所有权转移,但不允许多个持有者。
九、完整可运行示例代码
下面是一个带堆资源的类,完整实现了拷贝、移动、析构,你可以跑一下,直观看到每个函数的调用时机:
cpp
#include <iostream>
#include <utility>
#include <cstring>
class Person {
private:
char* name;
int age;
public:
// 普通构造
Person(const char* name, int age)
: age(age) {
this->name = new char[strlen(name) + 1];
strcpy(this->name, name);
std::cout << "普通构造函数调用" << std::endl;
}
// 拷贝构造(深拷贝)
Person(const Person& other) {
age = other.age;
name = new char[strlen(other.name) + 1];
strcpy(name, other.name);
std::cout << "拷贝构造函数调用" << std::endl;
}
// 拷贝赋值(深拷贝)
Person& operator=(const Person& other) {
if (this != &other) {
delete[] name;
age = other.age;
name = new char[strlen(other.name) + 1];
strcpy(name, other.name);
}
std::cout << "拷贝赋值运算符调用" << std::endl;
return *this;
}
// 移动构造
Person(Person&& other) noexcept
: name(other.name), age(other.age) {
other.name = nullptr;
other.age = 0;
std::cout << "移动构造函数调用" << std::endl;
}
// 移动赋值
Person& operator=(Person&& other) noexcept {
if (this != &other) {
delete[] name;
name = other.name;
age = other.age;
other.name = nullptr;
other.age = 0;
}
std::cout << "移动赋值运算符调用" << std::endl;
return *this;
}
// 析构
~Person() {
delete[] name;
std::cout << "析构函数调用" << std::endl;
}
void print() const {
if (name) {
std::cout << "Name: " << name << ", Age: " << age << std::endl;
} else {
std::cout << "对象已被移动,资源为空" << std::endl;
}
}
};
// 测试函数:返回临时对象
Person createPerson() {
Person p("Alice", 20);
return p;
}
int main() {
std::cout << "=== 测试1:临时对象初始化,触发移动构造 ===" << std::endl;
Person p1 = createPerson();
p1.print();
std::cout << "\n=== 测试2:临时对象赋值,触发移动赋值 ===" << std::endl;
Person p2("Bob", 30);
p2 = Person("Temp", 18);
p2.print();
std::cout << "\n=== 测试3:std::move强制触发移动构造 ===" << std::endl;
Person p3 = std::move(p1);
p3.print();
p1.print(); // p1被移动后,资源为空
std::cout << "\n=== 测试4:std::move强制触发移动赋值 ===" << std::endl;
Person p4("Charlie", 40);
p4 = std::move(p3);
p4.print();
p3.print(); // p3被移动后,资源为空
return 0;
}
十、口述精简版
移动构造和移动赋值是 C++11 引入的移动语义的核心接口,基于右值引用实现,核心是资源所有权转移,而非复制。
- 移动构造:用源对象初始化新对象,直接接管源对象资源,源对象置空,无深拷贝;触发时机包括临时对象初始化、函数返回局部对象、std::move 强制左值。
- 移动赋值:把源对象资源转移给已存在的对象,需要先释放目标对象自身的旧资源,再接管源对象资源,源对象置空;触发时机包括临时对象赋值、std::move 强制左值赋值。
- 和拷贝的区别:拷贝是复制资源,创建独立副本;移动是转移所有权,源对象不再持有资源,开销极低。
- 核心用途:一是性能优化,避免大型对象、临时对象的无谓深拷贝;二是实现独占资源的传递,比如 std::unique_ptr、std::thread 这类只可移动不可拷贝的类型,保证资源不被重复持有。
- 关键细节:移动函数通常加 noexcept,否则标准库容器会降级使用拷贝;需要自移动判断和源对象置空,防止内存泄漏和 double free。