#面向对象程序设计04
浅复制的问题
先看一个资源类:
1 | class Buffer { |
如果直接写:
1 | Buffer a(1024); |
在没有自定义复制构造函数时,编译器会生成默认复制构造,默认复制是逐成员复制:
data_复制指针值size_复制整数值
结果是 a.data_ == b.data_,两个对象指向同一块堆内存
它们析构时都会执行 delete[] data_,从而发生双重释放
这就是浅复制:复制了指针值,却没有复制指针背后的资源
对资源拥有者来说,浅复制通常是错误的
浅复制、深复制与移动
三种策略可以这样区分:
| 策略 | 本质 | 结果 | 复杂度 |
|---|---|---|---|
| 浅复制 | 复制成员值,指针只复制地址 | 共享同一资源,资源类上常常错误 | O(1) |
| 深复制 | 新建资源并复制内容 | 两个对象独立存在 | O(N) |
| 移动 | 转移资源所有权 | 目标接管资源,源对象被置为空壳 | O(1) |
深复制的目标是兑现“复制后独立存在”:
1 | class Buffer { |
此时 a 和 b 的内容相同,但各自拥有独立资源
修改 b 不影响 a,析构时也各自释放自己的内存
自赋值检查是为了防止
b = b这种情况,如果不检查,可能先释放b的内存,再从已经释放的内存复制,导致未定义行为
移动则不是复制,而是接管:
1 | class Buffer { |
临时对象马上就要销毁,重新分配再复制内容没有意义
更合理的做法是把临时对象持有的内存直接交给 b,再把临时对象置为空状态,让它安全析构
左值、右值与 std::move
左值和右值可以用一句话理解:
这个对象能否安全地被偷走资源?
- 左值有名字,通常之后还要继续使用,例如
a、x - 右值通常是临时对象,例如
Buffer(1024)、函数返回的临时值 - 右值引用用
T&&表示,可以绑定到右值
1 | Buffer a(1024); // a 是左值 |
std::move 本身不移动任何东西,只是一个类型转换:把左值转换成右值引用,告诉编译器“这个对象可以被移动”
1 | Buffer a(1024); |
真正执行移动的是移动构造函数或移动赋值运算符,不是 std::move
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 int a = 10; // a: 左值,10: 右值
int b = a; // b: 左值,a: 左值(但被读取)
int c = a + b; // a+b: 右值(临时结果)
// 取地址
int* p1 = &a; // 对 左值可取地址
int* p2 = &(a+b); // 错 右值不可取地址
// 赋值
a = 20; // 对 左值在左侧可被赋值
20 = a; // 错 右值不能在左侧
// 引用绑定
int& ref1 = a; // 对 左值引用绑定左值
int& ref2 = 20; // 错 左值引用不能绑定右值
const int& ref3 = 20; // 对 const左值引用可绑定右值(延长生命周期)
int&& ref4 = 20; // 对 右值引用绑定右值(C++11)
误区:
1 | void foo(Buffer&& b); |
具名的右值引用表达式是左值
是否为左值,不看类型写着 &&,而看表达式有没有可继续使用的身份
移动语义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 class Buffer {
char* data_;
size_t size_;
public:
// 移动构造函数(接受右值)
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 窃取资源
other.size_ = 0;
}
};
Buffer a(1024);
Buffer b = std::move(a); // 调用移动构造(a被"移动"到b)
// 现在 a 为空,b 拥有资源完美转发
1
2
3
4
5
6
7
8 template<typename T>
void wrapper(T&& arg) { // 万能引用(转发引用)
process(std::forward<T>(arg)); // 保持 arg 的左右值属性
}
int x = 10;
wrapper(x); // T = int&,转发为左值
wrapper(20); // T = int,转发为右值区分重载
1
2
3
4
5
6
7
8
9
10 void process(int& x) {
std::cout << "左值: " << x << "\n";
}
void process(int&& x) {
std::cout << "右值: " << x << "\n";
}
int a = 10;
process(a); // 调用左值版本
process(20); // 调用右值版本
四个特殊成员函数
复制和移动各自有“构造新对象”和“给已有对象赋值”两种情况:
| 类别 | 构造新对象 | 给已有对象赋值 |
|---|---|---|
| 复制 | 复制构造函数 | 复制赋值运算符 |
| 移动 | 移动构造函数 | 移动赋值运算符 |
复制构造函数
复制构造函数用于用一个已有对象构造新对象:
1 | class Buffer { |
它的参数必须是 const Buffer&。如果按值传参:
1 | Buffer(Buffer other); // 错误设计 |
为了调用这个构造函数,需要先复制实参生成 other,这又会调用复制构造函数,导致无限递归
使用引用可以避免再次复制;使用 const 表示复制源不会被修改,也允许从常量对象复制
常见触发场景:
1 | Buffer b2 = b1; |
std::memcpy 只适合 char、基本类型和 trivially copyable 类型
如果元素是复杂类类型,应使用 std::copy 或逐元素构造,不能绕过对象的复制语义
场景1:
explicit构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 class Buffer {
public:
explicit Buffer(size_t size); // explicit 阻止隐式转换
Buffer(const Buffer& other); // 拷贝构造函数
};
Buffer b1(1024);
Buffer b2 = b1; // 对 拷贝初始化(直接使用 b1,无需转换)
Buffer b3(b1); // 对 直接初始化
Buffer b4{b1}; // 对 列表初始化
Buffer b5 = 1024; // 错 错误:explicit 构造函数不能被隐式调用
Buffer b6(1024); // 对
Buffer b7{1024}; // 对场景2:
std::initializer_list冲突
1
2
3
4
5
6
7
8
9 class Vec {
public:
Vec(int x, int y) { /* ... */ }
Vec(std::initializer_list<int> list) { /* ... */ }
};
Vec v1(10, 20); // 调用 Vec(int, int)
Vec v2{10, 20}; // 调用 Vec(initializer_list)!优先
Vec v3 = {10, 20}; // 调用 Vec(initializer_list)场景3:返回值优化(RVO)
1
2
3
4
5
6 Buffer createBuffer() {
return Buffer(1024); // 直接初始化,可能触发 RVO
}
Buffer b1 = createBuffer(); // 拷贝初始化,可能触发移动语义
// 现代编译器会优化掉拷贝/移动
复制赋值运算符
复制赋值处理的是左边对象已经存在的情况:
1 | Buffer& operator=(const Buffer& other); |
目标对象已经持有资源,所以复制赋值比复制构造更麻烦,简单实现如下:
1 | Buffer& operator=(const Buffer& other) { |
这里必须处理自赋值:
1 | b = b; |
如果不判断 this == &other,可能先释放自己的内存,再从已经释放的同一块内存复制
但这个实现还有异常安全问题:如果 new char[other.size_] 抛异常,对象原资源已经被释放,可能进入无效状态
更推荐 copy-and-swap:
1 | class Buffer { |
先构造副本 tmp,如果复制失败,当前对象完全不变;复制成功后再交换资源
旧资源被交换到 tmp,函数返回时由 tmp 析构释放,这提供了强异常安全,也自然处理自赋值
赋值运算符返回 Buffer&,是为了支持链式赋值:
1 | a = b = c; |
b = c 返回 b 本身,然后再执行 a = b
移动构造函数
移动构造用一个右值对象构造新对象,负责接管资源:
1 | class Buffer { |
关键操作:
- 目标对象接管源对象的资源;
- 源对象被放入可析构、可重新赋值的状态
移动后的源对象不是“原来的对象还在,只是内容也复制了一份”
标准说法是:源对象有效但未指定,也就是说,它可以析构,可以重新赋值,但不应继续依赖它原来的值
1 | Buffer a(1024); |
移动赋值运算符
移动赋值处理的是目标对象已经存在、右边对象可以被接管的情况:
1 | Buffer& operator=(Buffer&& other) noexcept { |
移动赋值要先释放目标对象原来持有的资源,再接管源对象资源
最后必须把源对象置空,否则两个对象仍然指向同一资源,析构时会双重释放
编译器如何选择复制或移动
不同写法会触发不同特殊成员函数
1 | Buffer a(1024); |
函数返回值:
1 | Buffer makeBuffer() { |
这里可能发生返回值优化,也就是 RVO/NRVO
编译器可以直接在 x 的存储位置构造返回对象,从而连移动构造都省掉
C++17 起,一些临时对象返回场景有强制复制省略
因此不要为了“强行移动”局部返回值而写:
1 | return std::move(tmp); // 通常不推荐 |
这可能阻碍返回值优化。多数情况下直接 return tmp; 更好
noexcept 与标准库行为
移动操作通常应当写 noexcept,前提是它只做资源指针转移、置空、释放旧资源等不会抛异常的动作
1 | Buffer(Buffer&& other) noexcept; |
noexcept 会影响标准库容器的策略
以 std::vector 扩容为例,扩容时需要把旧存储区里的元素搬到新存储区:
- 如果元素移动构造是
noexcept,容器更敢于使用移动; - 如果移动可能抛异常,为了保持异常安全,容器可能退回复制。
所以对资源类来说,移动操作如果只是转移所有权,通常应该声明为 noexcept
三法则、五法则与零法则
三法则
如果一个类需要自定义以下任意一个:
- 析构函数;
- 复制构造函数;
- 复制赋值运算符;
通常这三个都要一起考虑
原因是:一旦类手动管理资源,编译器默认复制往往变成浅复制,既然负责析构释放资源,就也必须负责复制时资源如何被复制
五法则
C++11 引入移动语义后,还要把移动构造函数和移动赋值运算符纳入考虑:
- 析构函数;
- 复制构造函数;
- 复制赋值运算符;
- 移动构造函数;
- 移动赋值运算符。
这些特殊成员函数会相互影响
自定义析构或复制操作后,编译器可能不再自动生成移动操作;自定义移动操作后,复制操作也可能被删除。因此资源类应把五个函数作为整体设计
一个完整资源类大致如下:
1 | class Buffer { |
零法则
现代 C++ 更推荐零法则:尽量不要自己管理资源,而是让成员类型管理资源
1 | class Person { |
std::string 已经正确实现复制、移动和析构,int 不需要资源管理
因此 Person 不需要手写任何特殊成员函数
如果 Buffer 改成:
1 | class Buffer { |
复制、移动、析构都可以交给 std::vector
这比手写 new[]、delete[] 更安全,也更符合现代 C++ 风格
类型语义设计
成员类型会直接影响类的默认语义:
| 成员类型 | 推导出的语义 |
|---|---|
std::string、std::vector | 可复制,可移动 |
std::unique_ptr | 不可复制,可移动 |
std::mutex | 不可复制,不可移动 |
因此类是否支持复制和移动,应先从语义出发:
std::string、Buffer这类值对象,复制一份有意义,移动也有意义- 文件句柄、
std::unique_ptr这类唯一所有权对象,不应复制,但可以移动 std::mutex这类有身份的同步原语,不应复制,也不应移动
可以用 = delete、= default 和 explicit 明确表达设计:
1 | class FileHandle { |
文件句柄不能复制,因为复制一个文件所有权没有清晰语义;但可以移动,因为所有权转让有意义
std::move 的正确与错误用法
适合使用 std::move 的场景:
- 明确之后不再需要原对象
- 在移动构造或移动赋值中转移成员
- 按值传参后,局部副本不再需要
1 | std::vector<Buffer> pool; |
常见误用:
1 | const Buffer a(1024); |
std::move 不会去掉 const
移动通常需要修改源对象,把它置空;const 源对象不能被修改,因此很多时候无法真正移动
另一个误用是移动后继续读取源对象的业务值:
1 | Buffer a(1024); |
移动后的对象有效但未指定