对象生命周期、特殊成员函数与移动语义

对象生命周期、特殊成员函数与移动语义

时间:2026/04/09

关键词:生命周期、RAII、拷贝构造、拷贝赋值、移动构造、移动赋值、Rule of Zero/Five、std::move
核心目标:搞清楚一个对象从创建到销毁会经历什么,以及类该如何正确管理资源。


1. 对象生命周期是什么

一个对象通常会经历:

  1. 构造
  2. 使用
  3. 析构

最重要的实践原则是:

  • 对象一旦构造完成,就应该处于“可用且满足类不变量”的状态
  • 对象一旦析构完成,就不该再被访问

局部对象在离开作用域时析构:

1
2
3
void f() {
std::string s = "hello";
} // 这里自动析构

动态对象则由拥有者负责释放:

1
auto p = std::make_unique<int>(42);

2. RAII 是生命周期管理的核心

RAII 的意思是:

  • 构造时获取资源
  • 析构时释放资源

典型资源包括:

  • 动态内存
  • 文件句柄
  • socket
  • 线程句柄

RAII 的价值不是“语法优雅”,而是:

  • 不容易忘记释放
  • 异常发生时也能自动清理

3. 六个特殊成员函数

一个类最重要的 6 个函数是:

  1. 默认构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值运算符
  5. 移动构造函数
  6. 移动赋值运算符

它们决定了对象如何:

  • 创建
  • 复制
  • 转移资源
  • 销毁

4. 拷贝构造 vs 拷贝赋值

4.1 拷贝构造

用一个对象去初始化另一个“新对象”:

1
2
T b(a);
T c = a;

4.2 拷贝赋值

把一个已经存在的对象覆盖成另一个对象的状态:

1
2
T b;
b = a;

核心差异:

  • 拷贝构造是“从无到有”
  • 拷贝赋值是“已有对象被覆盖”

后者通常还要考虑:

  • 旧资源释放
  • 自赋值
  • 异常安全

5. 为什么移动语义很重要

如果一个类持有资源,单纯拷贝代价可能很大。

例如:

  • 动态数组
  • 大字符串
  • 文件句柄包装对象

移动语义的核心思想是:

  • 不复制资源内容
  • 直接转移所有权或资源句柄

这就需要:

  • 移动构造
  • 移动赋值

6. std::move 到底在做什么

std::move 本身不移动资源。
它只是把一个表达式转换成右值形式,让后续重载决议优先匹配移动版本。

1
2
std::string s = "hello";
std::string t = std::move(s);

真正移动的是:

  • std::string 的移动构造函数

不是 std::move 本身。


7. 一个最小资源类示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <algorithm>
#include <cstddef>

class Buffer {
public:
Buffer() = default;

explicit Buffer(std::size_t n)
: 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_ + size_, data_);
}

Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
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) return *this;
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_);
}

private:
std::size_t size_ = 0;
int* data_ = nullptr;
};

这个例子体现了:

  • 深拷贝
  • 资源转移
  • 移动后源对象置空

8. Rule of Three / Five / Zero

8.1 Rule of Three

如果类手写了下面之一,通常就要认真考虑另外两个:

  • 析构
  • 拷贝构造
  • 拷贝赋值

因为这通常意味着类在管理资源。

8.2 Rule of Five

C++11 以后再加上:

  • 移动构造
  • 移动赋值

如果类显式管理资源,通常要一起考虑这五个。

8.3 Rule of Zero

现代 C++ 更推荐:

  • 尽量不要自己手写资源管理
  • 把资源交给现成 RAII 类型

例如:

  • std::vector
  • std::string
  • std::unique_ptr

这样很多特殊成员函数甚至可以完全默认生成。


9. 默认生成和 = default / = delete

9.1 = default

显式告诉编译器:

  • 用默认生成版本
1
MyType() = default;

9.2 = delete

显式禁止某种操作:

1
2
MyType(const MyType&) = delete;
MyType& operator=(const MyType&) = delete;

这在:

  • 独占资源类型
  • 锁对象
  • 文件句柄包装类

里很常见。


10. 拷贝省略与返回值优化

现代 C++ 里:

1
2
3
4
Buffer make_buffer() {
Buffer b(1024);
return b;
}

很多情况下不会真的发生拷贝,甚至连移动都可能被省掉。
这就是:

  • RVO
  • NRVO
  • guaranteed copy elision

所以写代码时不要过度手工干预,先让编译器优化。


11. 常见坑

11.1 移动后还把源对象当原值使用

移动后的对象通常只保证:

  • 仍然有效
  • 可以析构或重新赋值

但不保证保留原内容。

11.2 手写资源类却只写析构,不写拷贝/移动

这很容易造成:

  • 双重释放
  • 浅拷贝问题

11.3 拷贝赋值没处理自赋值和异常安全

尤其是手动 deletenew 的写法,容易把对象弄到半残状态。

11.4 本来可以 Rule of Zero,却硬写五个函数

不必要的手写资源管理会增加 bug 面积。


12. 一页总结

这篇最重要的是记住三件事:

  1. 生命周期就是“构造到析构”的可控过程
  2. 管资源的类必须认真处理拷贝和移动
  3. 最好的实践通常不是手写五件套,而是尽量 Rule of Zero

如果只记一个工程结论:

能把资源交给现成 RAII 类型,就不要自己手写裸资源生命周期。


13. 建议继续补充的相关主题

  1. 智能指针与所有权
  2. 完美转发与引用折叠
  3. 异常安全保证
  4. noexcept move 与容器优化