在C++中,当一个类管理着动态内存、文件句柄、网络连接等需要手动释放的资源时,拷贝控制相关函数的实现直接决定了程序的正确性和性能。三五法则和移动语义是处理这类问题的核心规范,理解它们的应用场景和实现方式能有效避免资源管理相关的常见错误。

拷贝控制与三五法则基础
拷贝控制涉及五个特殊成员函数:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。三五法则的核心内容是:如果一个类需要自定义其中任意一个函数,那么它大概率需要自定义全部五个函数,否则可能出现资源管理问题。
比如一个管理动态数组的简单字符串类,我们先看错误实现带来的问题:
#include <cstring>
#include <iostream>
class BadString {
private:
char* data;
int length;
public:
// 构造函数
BadString(const char* str = nullptr) {
if (str) {
length = std::strlen(str);
data = new char[length + 1];
std::strcpy(data, str);
} else {
length = 0;
data = new char[1];
data[0] = ' ';
}
}
// 只定义了析构函数,没有定义拷贝相关函数
~BadString() {
delete[] data;
}
};
int main() {
BadString s1("hello");
BadString s2 = s1; // 默认拷贝构造是浅拷贝,s1和s2的data指向同一块内存
return 0; // 程序结束时s1和s2都会调用析构函数,导致同一块内存被释放两次,触发崩溃
}
上面的例子中,类只定义了析构函数却没有定义拷贝构造和拷贝赋值,就会触发浅拷贝问题。按照三五法则,既然需要自定义析构函数来释放动态内存,就必须同时自定义拷贝构造和拷贝赋值,实现深拷贝。
三五法则的正确实现
我们修改上面的BadString类,补全所有需要的拷贝控制函数:
#include <cstring>
#include <iostream>
class GoodString {
private:
char* data;
int length;
public:
// 构造函数
GoodString(const char* str = nullptr) {
if (str) {
length = std::strlen(str);
data = new char[length + 1];
std::strcpy(data, str);
} else {
length = 0;
data = new char[1];
data[0] = ' ';
}
}
// 拷贝构造函数(深拷贝)
GoodString(const GoodString& other) {
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
}
// 拷贝赋值运算符
GoodString& operator=(const GoodString& other) {
if (this != &other) { // 处理自赋值
delete[] data; // 释放原有资源
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
}
return *this;
}
// 析构函数
~GoodString() {
delete[] data;
}
// 打印字符串内容
void print() const {
std::cout << data << std::endl;
}
};
int main() {
GoodString s1("hello");
GoodString s2 = s1; // 调用自定义拷贝构造,深拷贝
GoodString s3;
s3 = s1; // 调用自定义拷贝赋值,深拷贝
s1.print(); // 输出hello
s2.print(); // 输出hello
s3.print(); // 输出hello
return 0; // 三个对象各自释放自己的data,无问题
}
移动语义的应用场景
上面的实现解决了浅拷贝的问题,但在某些场景下,深拷贝是不必要的。比如当一个临时对象(右值)要被拷贝后就会被销毁,这时候直接转移它的资源所有权比拷贝资源更高效,这就是移动语义的作用。
移动语义通过右值引用和移动构造、移动赋值运算符实现,适用场景主要有以下几类:
- 函数返回局部对象时,编译器通常会触发移动构造而非拷贝构造,减少临时对象的资源拷贝
- 使用
std::move将左值转换为右值,主动触发资源转移,比如容器元素交换、对象所有权转移 - 标准库容器(如
std::vector)扩容时,会移动原有元素到新内存,而非拷贝
为GoodString添加移动语义支持
我们继续扩展GoodString类,添加移动构造和移动赋值运算符:
#include <cstring>
#include <iostream>
#include <utility>
class GoodString {
private:
char* data;
int length;
public:
// 构造函数
GoodString(const char* str = nullptr) {
if (str) {
length = std::strlen(str);
data = new char[length + 1];
std::strcpy(data, str);
} else {
length = 0;
data = new char[1];
data[0] = ' ';
}
}
// 拷贝构造函数(深拷贝)
GoodString(const GoodString& other) {
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
std::cout << "调用拷贝构造函数" << std::endl;
}
// 拷贝赋值运算符
GoodString& operator=(const GoodString& other) {
if (this != &other) {
delete[] data;
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
std::cout << "调用拷贝赋值运算符" << std::endl;
}
return *this;
}
// 移动构造函数
GoodString(GoodString&& other) noexcept : data(other.data), length(other.length) {
other.data = nullptr; // 转移资源后,将原对象的指针置空,避免析构时释放转移走的资源
other.length = 0;
std::cout << "调用移动构造函数" << std::endl;
}
// 移动赋值运算符
GoodString& operator=(GoodString&& other) noexcept {
if (this != &other) {
delete[] data; // 释放当前对象的原有资源
data = other.data;
length = other.length;
other.data = nullptr;
other.length = 0;
std::cout << "调用移动赋值运算符" << std::endl;
}
return *this;
}
// 析构函数
~GoodString() {
delete[] data;
}
void print() const {
if (data) {
std::cout << data << std::endl;
} else {
std::cout << "空字符串" << std::endl;
}
}
};
// 返回局部对象的函数,触发移动构造
GoodString createString() {
GoodString temp("world");
return temp;
}
int main() {
GoodString s1("hello");
// 场景1:用临时对象初始化新对象,触发移动构造
GoodString s2 = createString();
s2.print(); // 输出world
// 场景2:用std::move转移左值资源
GoodString s3;
s3 = std::move(s1); // 触发移动赋值
s3.print(); // 输出hello
s1.print(); // 输出空字符串,资源已被转移
return 0;
}
需要注意的是,移动构造和移动赋值运算符通常标记为noexcept,这样标准库容器在扩容等场景下才能安全使用移动操作,否则可能会 fallback 到拷贝操作。
三五法则与移动语义的协同
当类中需要管理资源时,完整的拷贝控制实现应该遵循以下规则:
- 如果需要自定义析构函数,那么必须同时定义拷贝构造、拷贝赋值、移动构造、移动赋值,满足三五法则
- 移动操作应该转移资源所有权,而不是拷贝资源,转移后原对象应该处于可安全析构的状态
- 如果类不需要拷贝操作,可以将拷贝构造和拷贝赋值标记为
=delete,此时移动操作仍然可以正常使用
比如一个只支持移动、不支持拷贝的资源管理类,可以这样实现:
#include <iostream>
#include <utility>
class OnlyMoveResource {
private:
int* handle; // 模拟资源句柄
public:
OnlyMoveResource(int val) : handle(new int(val)) {}
// 禁止拷贝
OnlyMoveResource(const OnlyMoveResource&) = delete;
OnlyMoveResource& operator=(const OnlyMoveResource&) = delete;
// 允许移动
OnlyMoveResource(OnlyMoveResource&& other) noexcept : handle(other.handle) {
other.handle = nullptr;
}
OnlyMoveResource& operator=(OnlyMoveResource&& other) noexcept {
if (this != &other) {
delete handle;
handle = other.handle;
other.handle = nullptr;
}
return *this;
}
~OnlyMoveResource() {
delete handle;
}
void print() const {
if (handle) {
std::cout << "资源值:" << *handle << std::endl;
} else {
std::cout << "资源已转移" << std::endl;
}
}
};
int main() {
OnlyMoveResource r1(10);
OnlyMoveResource r2 = std::move(r1); // 允许移动
r2.print(); // 输出资源值:10
// OnlyMoveResource r3 = r1; // 编译报错,拷贝构造被删除
return 0;
}
在C++11及之后的标准中,三五法则也扩展为五法则,即五个拷贝控制函数都需要根据类的资源管理需求合理实现,移动语义的加入让资源管理更加高效灵活。开发者在自定义类时,首先要明确类是否需要管理资源,再根据三五法则和移动语义的要求实现对应的特殊成员函数,才能写出正确且高效的代码。