C++_拷贝控制
拷贝控制
当定义一个类时,实际上也在定义:
- 对象如何拷贝
- 如何赋值
- 如何移动
- 如何销毁
这些由一组特殊成员函数控制,称为:
拷贝控制成员
共 5 个:
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
五个特殊成员函数
拷贝构造函数
控制:
用同类型另一个对象初始化新对象时做什么
例如:
1 | T a; |
拷贝赋值运算符
控制:
一个已存在对象被赋值为另一个同类型对象时做什么
例如:
1 | T a, b; |
移动构造函数
控制:
用一个**将要被销毁的对象(右值)**初始化新对象时做什么
例如:
1 | T a = T(); // 可能调用移动构造 |
移动赋值运算符
控制:
把一个右值赋给已存在对象时做什么
析构函数
控制:
对象销毁时做什么
常用于:
- 释放动态内存
- 关闭文件
- 释放锁
- 归还系统资源
拷贝、赋值与销毁
这是拷贝控制的基础部分。
拷贝构造函数
定义
如果一个构造函数:
- 第一个参数是本类类型的引用
- 且其余参数都有默认值
那么它就是拷贝构造函数
例如:
1 | class Foo { |
为什么参数必须是引用
如果写成:
1 | Foo(Foo); |
那么为了把实参传给这个构造函数,就要先拷贝一次对象,
而拷贝本身又要调用拷贝构造函数,形成无限递归。
所以拷贝构造函数的参数必须是引用。
编译器合成的拷贝构造函数
如果类没有自己定义拷贝构造函数,编译器通常会合成一个。
这个合成版本会:
逐成员拷贝
即:每个非 static 成员各自按其类型的拷贝方式复制。
拷贝初始化与直接初始化
这是考试和理解中很容易混的地方。
直接初始化
1 | string s1("hello"); |
特点:
- 直接调用某个构造函数
- 由编译器做普通重载匹配
拷贝初始化
1 | string s3 = s1; |
特点:
- 形式上用了
= - 本质上不是赋值,而是初始化
- 通常调用拷贝构造函数(或移动构造函数)
常见会发生拷贝初始化的场景
用 = 定义变量
1 | T a = b; |
将对象作为实参传给非引用形参
1 | void f(T x); // 调用时会拷贝初始化 x |
函数返回非引用类型对象
1 | T f() { |
用花括号列表初始化数组元素或容器元素时的某些情况
注意
初始化 ≠ 赋值
T a = b;是初始化a = b;是赋值
前者调构造函数,后者调赋值运算符。
拷贝赋值运算符
作用
控制已存在对象如何接受另一个同类型对象的值:
1 | Foo a, b; |
形式
赋值运算符是名为 operator= 的成员函数:
1 | class Foo { |
通常:
- 参数是
const Foo& - 返回
Foo&
为什么返回引用
为了与内置类型行为一致,支持连续赋值:
1 | a = b = c; |
所以一般写成:
1 | Foo& operator=(const Foo&); |
编译器合成版本
如果未定义,编译器也会合成一个逐成员赋值的版本。
析构函数
作用
析构函数在对象销毁时自动调用,用来:
- 释放资源
- 做清理工作
形式:
1 | class Foo { |
特点:
- 没有返回值
- 没有参数
- 不能重载
析构函数做什么
构造函数负责“建立对象”,
析构函数负责“清理对象”。
例如,若类中有动态内存:
1 | class Str { |
析构时成员也会自动销毁
析构函数函数体执行完后,编译器还会自动销毁对象的成员:
- 类类型成员:调用它们自己的析构函数
- 内置类型成员:无需额外操作
注意:
内置指针成员本身销毁时不会自动
delete它指向的对象
这正是很多资源管理类要自己定义析构函数的原因。
析构函数调用时机
对象被销毁时,析构函数自动执行。
常见情况:
局部对象离开作用域
1 | { |
对象的成员随对象一起销毁
容器销毁时,元素被销毁
动态对象被 delete 时
1 | Foo *p = new Foo; |
临时对象在完整表达式结束时销毁
引用和指针本身离开作用域,不会销毁对象
1 | Foo *p = new Foo; |
三法则 / 五法则
这是拷贝控制最重要的总结之一。
三法则(Rule of Three)
如果一个类需要自定义以下三个中的一个:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
那么它通常也需要自定义另外两个。
为什么
因为这类类往往管理某种资源,如:
- 动态内存
- 文件句柄
- 网络连接
- 锁
如果只定义析构函数而不定义拷贝操作,默认拷贝往往只是浅拷贝,会导致:
- 多个对象指向同一资源
- 重复释放
- 悬空指针
五法则(Rule of Five)
C++11 后多了移动语义,因此扩展为“五法则”:
如果一个类需要自定义以下任意一个:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
那么通常应该认真考虑是否需要同时定义全部五个。
怎么记
简版记忆:
- 管理资源的类,一般要考虑五个特殊成员函数
- 定义了一个,通常说明默认行为不够了
=default
如果你希望:
明确告诉编译器“请使用默认合成版本”
可以写:
1 | class Foo { |
作用
- 保留编译器默认行为
- 同时让代码意图更清晰
- 有时可控制函数是否被声明/定义
注意
只能对这些“可合成的特殊成员函数”使用 =default:
- 默认构造函数
- 拷贝构造
- 拷贝赋值
- 移动构造
- 移动赋值
- 析构函数
=delete:阻止拷贝或赋值
如果希望某个操作根本不能用,可将其声明为删除函数:
1 | class NoCopy { |
含义
- 函数被声明了
- 但不能调用
这样编译器会在使用时报错。
常见用途
禁止拷贝类
例如:
- 互斥锁类
- 文件句柄类
- 单例类
注意点
=delete 必须写在第一次声明处
析构函数一般不能轻易删除
如果析构函数被删除,则该类型对象根本无法正常销毁。
旧式做法:private 拷贝控制
在 C++11 前,常通过把拷贝构造和拷贝赋值声明为 private 来禁止拷贝:
1 | class NoCopy { |
现代 C++ 更推荐:
1 | = delete |
因为:
- 更直观
- 错误信息更清楚
- 语义更明确
拷贝控制与资源管理
这是理解“为什么要自己写拷贝控制”的核心。
类大体有两种设计风格:
类值行为(像值)
拷贝后,新对象和原对象彼此独立。
例如 string、vector:
1 | string s1 = "abc"; |
修改 s2 不影响 s1。
这种类的拷贝通常要:
- 拷贝底层数据
- 各自独立管理资源
类指针行为(像指针)
拷贝后,新旧对象共享底层状态。
例如某些智能指针、句柄类:
1 | shared_ptr<int> p1 = make_shared<int>(42); |
修改共享对象会被双方看到。
复习重点
设计类时要先想清楚:
这个类是“像值”还是“像指针”?
不同设计,拷贝控制实现完全不同。
对象移动
C++11 引入移动语义,目的是:
避免不必要的深拷贝,提高效率
尤其对以下类型很重要:
stringvector- 资源管理类
右值引用
移动语义的基础是:
右值引用 &&
定义
右值引用只能绑定到右值:
1 | int &&rr = 42; // 正确 |
左值和右值
左值
有名字、可取地址、持久存在的对象:
1 | int x = 10; |
右值
临时结果、字面值、即将销毁的对象:
1 | 42 |
基本例子
1 | int i = 42; |
为什么右值适合“移动”
因为右值通常是:
- 临时对象
- 即将销毁
- 没有其他稳定使用者
所以可以“偷走”它的资源,而不必完整拷贝。
注意:右值引用变量本身是左值
1 | int&& rr1 = 42; |
虽然 rr1 的类型是右值引用,但表达式 rr1 本身是左值。
std::move
要把一个左值“显式地当作右值使用”,用:
1 | std::move(x) |
头文件:
1 |
作用
move 不会真的移动任何东西,它只是:
把一个左值转换成对应的右值引用
例如:
1 | string s = "hello"; |
此后 s 成为移后源对象。
使用 move 后的对象
规则:
- 仍然有效
- 可以析构
- 可以重新赋值
- 但其值不应再作任何假设
也就是:
1 | string s = "hello"; |
移动构造函数
形式
1 | class Foo { |
参数是:
- 本类类型的右值引用
本质
移动构造不是复制资源,而是:
接管源对象的资源
例如:
1 | class StrVec { |
为什么源对象要置空
因为资源所有权已经转移给新对象。
如果源对象还保留旧指针,析构时会重复释放。
所以移动后应保证源对象处于:
有效、可析构、但值未指定 的状态
noexcept
移动操作通常应该标记为:
1 | noexcept |
因为很多标准库容器在移动元素时,只有确认“不会抛异常”才愿意优先使用移动而不是拷贝。
所以复习时记:
移动构造 / 移动赋值通常应写
noexcept
移动赋值运算符
形式
1 | Foo& operator=(Foo&&) noexcept; |
实现思路
移动赋值一般要做三件事:
- 释放左侧对象原有资源
- 接管右侧对象资源
- 将右侧对象置于可析构状态
示例
1 | Foo& Foo::operator=(Foo&& rhs) noexcept { |
注意自赋值
移动赋值也要考虑自赋值:
1 | a = std::move(a); |
虽然这种写法少见,但实现时最好仍能正确处理。
编译器何时合成移动操作
这是高频易混点。
基本结论
编译器不会总是自动生成移动构造和移动赋值。
一般来说,只有当:
- 类没有自定义拷贝控制成员
- 且所有成员都支持移动
时,编译器才可能合成移动操作。
重要影响
如果类定义了:
- 拷贝构造函数
- 拷贝赋值运算符
- 析构函数
中的任意一个,
那么编译器通常不会再自动帮你生成移动操作。
所以很多资源管理类若自己写了析构函数,往往还要自己把移动操作补上。
拷贝与移动的选择规则
可以简单记为:
移动右值,拷贝左值
左值优先拷贝
1 | Foo a; |
右值优先移动
1 | Foo b = Foo(); // 若有移动构造,则优先移动 |
如果没有移动操作
那就退回去用拷贝操作。
即:
没有移动构造时,右值也可能被拷贝
移动迭代器
标准库提供了移动迭代器适配器:
1 | make_move_iterator(it) |
作用:
- 普通迭代器解引用得到左值
- 移动迭代器解引用得到右值引用
这样算法在处理元素时会优先移动而不是拷贝。
例如:
1 | vector<string> src = {"a", "b", "c"}; |
此时元素会从 src 移动到 dst。
不要滥用 std::move
这是实践中非常重要的一点。
std::move 的含义是:
我保证这个对象接下来不再按原值使用,可以把它的资源拿走
所以:
- 不能对还要正常使用的对象随便
move move后只能安全地:- 析构
- 重新赋值
- 少量不依赖原值的操作
右值引用与成员函数重载
除了构造和赋值,普通成员函数也可以同时提供:
- 左值版本
- 右值版本
常见例子就是容器的 push_back:
1 | void push_back(const T&); // 拷贝 |
记忆方式
通常成对出现:
const T&:接受左值,也能接受右值T&&:专门接受可移动右值
引用限定符
成员函数还可以限制:
只能被左值对象调用,或只能被右值对象调用
写法是在参数列表后加:
&&&
例子
1 | class Foo { |
这里的 & 表示:
- 该赋值运算符只能用于左值对象
即:
1 | Foo a, b; |
但:
1 | Foo() = b; // 错误,左侧是右值 |
为什么有用
可以防止一些无意义或危险的调用,比如:
- 给临时对象赋值
- 对右值调用只适合持久对象的成员函数
const 与引用限定符顺序
如果同时写,顺序必须是:
1 | func() const & |
而不是:
1 | func() & const // 错 |
注意
如果某个成员函数有引用限定符,那么同名同参数列表的其他重载通常也应使用引用限定符,保持一致。
一个典型资源管理类要做什么
假设类中有动态内存:
1 | class MyStr { |
复习时应知道这五个函数的职责:
- 析构函数:释放
data - 拷贝构造:分配新内存并拷贝内容
- 拷贝赋值:先释放旧资源,再深拷贝
- 移动构造:直接拿走指针
- 移动赋值:释放旧资源,再接管指针
易错点总结
T a = b; 是初始化,不是赋值
调用的是拷贝/移动构造,不是赋值运算符。
拷贝构造函数参数必须是引用
否则会无限递归。
析构函数不会自动 delete 指针成员指向的对象
如果类自己管理资源,必须手动释放。
定义了析构函数的类,通常也要考虑拷贝构造和拷贝赋值
这就是三法则的核心。
定义了拷贝控制成员后,编译器可能不再自动生成移动操作
std::move 只是类型转换,不是真的移动
真正移动发生在移动构造/移动赋值内部。
移后源对象仍然有效,但值不确定
只能安全地析构或重新赋值。
右值引用变量本身是左值
1 | T&& rr = ...; |
赋值运算符通常返回左侧对象引用
1 | T& operator=(const T&); |
noexcept 对移动操作很重要
特别是标准库容器会依赖它决定是否优先移动。
小结
拷贝控制的主线可以概括为:
- 类通过五个特殊成员函数控制:
- 拷贝
- 赋值
- 移动
- 销毁
- 若不自定义,编译器会尽量生成合成版本
- 合成版本通常是逐成员操作
- 管理资源的类通常不能直接依赖默认行为
- 三法则:析构、拷贝构造、拷贝赋值常常需要一起考虑
- 五法则:再加上移动构造和移动赋值
- 右值引用和
std::move是移动语义的基础 - 移动的本质不是复制资源,而是转移资源所有权
注
如果类管理资源,就必须认真设计拷贝、赋值、移动和析构;否则默认行为很容易出错。




