总结 C++ 中的 “三五法则”:析构、拷贝与移动语义
在 C++ 中,资源管理是一个核心问题,特别是当类中包含动态分配的资源(如堆内存)时,“三五法则” 提供了一个设计类时应该遵循的指导原则,以确保资源被正确管理。
这包括如下:
-
资源正确释放:当对象生命周期结束时,其管理的资源必须被释放(例如 delete 内存)。这通常由析构函数完成。
-
资源正确复制:当对象被复制时(通过拷贝构造或拷贝赋值),其管理的资源需要被正确处理。
-
资源正确移动:当对象被移动时(通过移动构造或移动赋值),其管理的资源需要被正确处理。
因此在设计 C++ 的类时,通常要遵循三五零法则。如果开发者不显式定义这些特殊成员函数,编译器会尝试自动生成它们。但编译器生成的版本可能并不符合我们的预期,尤其是在涉及资源管理时。
什么是类设计时的三五法则?
-
三法则(Rule of Three)
如果需要定义析构函数(destructor),那么也需要定义拷贝构造函数(copy constructor)和拷贝赋值函数(copy assignment operator)-
析构函数 (~ClassName):对象销毁时调用,通常用于释放资源。
-
拷贝构造函数 (ClassName(const ClassName& other)):用一个已存在的同类对象初始化一个新对象时调用。
-
拷贝赋值运算符 (ClassName& operator=(const ClassName& other)):将一个已存在的同类对象的值赋给另一个已存在的同类对象时调用。
-
-
五法则(Rule of Five)
由 C++11 引入移动语义后,如果需要定义拷贝构造函数和拷贝赋值函数,那么通常也应该考虑定义移动构造函数(Move Constructor)和移动赋值函数(Move Assignment Operator)。-
移动构造函数 (ClassName(ClassName&& other) noexcept):用一个将要销毁的同类对象(右值)初始化一个新对象时调用。通常用于“窃取”源对象的资源(类浅拷贝),并将源对象置于可析构的空状态。noexcept 很重要,因为它允许标准库容器等进行优化。
-
移动赋值运算符 (ClassName& operator=(ClassName&& other) noexcept):将一个将要销毁的同类对象(右值)的值赋给另一个已存在的同类对象时调用。通常先释放目标对象的旧资源,然后“窃取”源对象的资源,并将源对象置于可析构的空状态。
-
为什么需要遵循这些规则?
通常,需要显示定义析构函数是因为类管理了需要手动释放的资源(如裸指针指向的内存)。如果此时依赖编译器生成的拷贝构造函数和拷贝赋值运算符,会发生浅拷贝 (Shallow Copy)。
浅拷贝只会复制对象裸指针成员的值,而不复制指针指向的数据。这会使得多个对象指向同一块内存。因而存在一些潜在的问题,比如:
-
悬垂指针 (Dangling Pointer):如果一个对象被析构并释放了资源,其他指向该资源的对象就会持有悬挂指针。访问悬挂指针是未定义行为。
-
二次释放 (Double Free):当多个对象析构时,它们会尝试释放同一块资源,导致程序崩溃或未定义行为。
-
资源泄漏 (Resource Leak):在拷贝赋值时,如果目标对象原来管理的资源没有被正确释放,就会发生资源泄漏。
细说为什么析构函数需要配套拷贝操作?
当一个类需要在析构函数中释放资源时,这通常意味着该类拥有某种资源(如动态分配的内存)。如果不定义自己的拷贝操作,编译器会自动生成默认版本,这些默认版本只会执行浅拷贝操作,比如:
class ClassName
{
private:
char* data;
public:
// 构造函数
ClassName(const char* str)
{
size_t size = strlen(str) + 1;
data = new char[size];
memcpy(data, str, size);
}
// 析构函数
~ClassName()
{
delete[] data;
}
// 缺少拷贝构造函数和拷贝赋值函数
};
上面设计的类存在潜在的问题:
ClassName s1("Hello");
ClassName s2 = s1; // 使用默认拷贝构造函数 会使用浅拷贝
// s1.data 和 s2.data 指向同一块内存
// 程序结束时,s1 和 s2 都会尝试释放同一块内存 多次释放会导致未定义行为
细说为什么拷贝构造需要配套拷贝赋值?
这两个操作在概念上是相似的,都涉及到对象的复制。如果一个需要自定义,另一个通常也需要自定义,如果只定义了其中一个,另一个编译器使用默认实现可能导致不一致的行为,为了保持构造对象的一致性:
// 只定义拷贝构造但没有定义拷贝赋值
ClassName s1("Hello");
ClassName s2("World");
s2 = s1; // 使用默认的拷贝赋值 会使用浅拷贝
// s1.data 和 s2.data 指向同一块内存,而且 s2 原来的内存没有被释放
// 程序结束时,s1 和 s2 都会尝试释放同一块内存
通过上面的示例,可以想到的解决方案就是使用深拷贝操作,通过一个示例来说明如何设计一个类:
Class ClassName
{
private:
char* data;
size_t len;
// 提供构造函数
explicit ClassName(const char* p = "")
{
std::cout << "Constructor called for: " << (p ? p : "nullptr") << std::endl;
len = (p ? std::strlen(p) : 0);
data = new char[len + 1];
if (p) {
std::strcpy(data, p);
} else {
data[0] = '\0';
}
}
// 提供析构函数
~ClassName()
{
std::cout << "Destructor called for: " << (data ? data : "nullptr") << std::endl;
delete[] data;
}
// 提供拷贝构造函数(深拷贝)
ClassName(const ClassName& other)
{
std::cout << "Copy Constructor called from: " << (other.data ? other.data : "nullptr") << std::endl;
len = other.len;
data = new char[len + 1];
std::strcpy(data, other.data);
}
// 提供拷贝赋值函数(深拷贝,使用 copy-and-swap 惯用法)
ClassName& operator=(const ClassName& other)
{
std::cout << "Copy Assignment Operator called from: " << (other.data ? other.data : "nullptr") << std::endl;
if (this != &other) { // 防止自赋值
ClassName temp(other); // 1. 使用拷贝构造函数创建临时副本 (深拷贝)
swap(*this, temp); // 2. 交换当前对象和临时副本的内容 (包括指针和长度)
} // 3. 临时副本 temp 在离开作用域时析构,自动释放原 *this 的资源
return *this;
}
// 提供 swap 辅助函数
friend void swap(ClassName& a, ClassName& b) noexcept
{
using std::swap;
swap(a.data, b.data);
swap(a.len, b.len);
}
};
为什么引入移动语义后需要移动构造和移动赋值?
如果设计的类有要管理的资源并定义了拷贝操作,移动语义可以优化性能(避免不必要的深拷贝)尤其是在处理临时对象或函数返回值这类右值时,如果不定义移动构造函数和移动赋值函数,会导致性能损失,当需要移动对象时(例如,从函数返回对象、存储在 std::vector 中并发生扩容),如果移动操作不可用,编译器会回退到使用拷贝操作(如果可用),导致不必要的资源分配和复制开销。编译错误,如果类只定义了移动操作(例如 std::unique_ptr),而没有拷贝操作,那么在需要拷贝的地方会编译失败。反之,如果类只定义了拷贝操作而没有移动操作,在某些强制要求移动语义的场景下也可能出问题或效率低下。
通过一个示例,说明设计类时的五法则:
class ClassName
{
private:
char* data;
size_t len;
// 构造函数
ClassName(const char* p = "") :
len(0), data(nullptr)
{
using namespace std;
cout << "Constructor called for: " << (p ? p : "nullptr") << endl;
if (p)
{
len = strlen(p);
data = new char[len + 1];
strcpy(data, p);
} else {
data = new char[1]; // 初始化保持空字符
data[0] = '\0';
}
}
// 析构函数
~ClassName()
{
using namespace std;
cout << "Destructor called for: " << (data ? data : "nullptr") << endl;
delete[] data;
}
// 拷贝构造函数(深拷贝)
ClassName(const ClassName& other) :
len(other.len), data(new char[other.len + 1])
{
std::cout << "Copy Constructor called from: " << (other.data ? other.data : "nullptr") << std::endl;
std::strcpy(data, other.data);
}
// 拷贝赋值函数
ClassName& operator=(const ClassName& other)
{
std::cout << "Copy Assignment Operator called from: " << (other.data ? other.data : "nullptr") << std::endl;
if (this != &other) {
ClassName temp(other);
swap(*this, temp);
}
return *this;
}
// 移动构造函数(转移资源)
ClassName(ClassName&& other) noexcept :
data(other.data), len(other.len) // 1.转移资源的所有权
{
std::cout << "Move Constructor called from: " << (data ? data : "nullptr") << std::endl;
// 2.将源对象置于有效的可析构状态
other.data = nullptr;
other.len = 0;
}
// 移动赋值函数
ClassName& operator=(ClassName&& other) noexcept
{
std::cout << "Move Assignment Operator called from: " << (other.data ? other.data : "nullptr") << std::endl;
if (this != &other) { // 防止自移动赋值
// 1.释放当前对象的资源
delete[] data;
// 2.转移所有权
data = other.data;
len = other.len;
// 3.将源对象置于有效的可析构状态
other.data = nullptr;
other.len = 0;
}
return *this;
}
// 辅助函数:交换 (noexcept)
friend void swap(ClassName& first, ClassName& second) noexcept {
using std::swap;
swap(first.len, second.len);
swap(first.data, second.data);
std::cout << "Swap executed." << std::endl;
}
};
现代 C++ 类设计时的零法则(Rule of Zero)
现代 C++ 类设计规范的核心原则是 RAII (Resource Acquisition Is Initialization) 和 “零法则” (Rule of Zero)。
-
优先遵循零法则(Rule of Zero)
尽量让类不直接管理任何需要手动释放的资源(如裸指针、文件句柄等)。
这种实现原则将资源管理委托给专门的 RAII 类。对于动态内存,使用 std::unique_ptr, std::shared_ptr, std::vector, std::string 等。对于其他资源(文件、锁等),使用标准库或第三方库提供的 RAII 包装器(如 std::fstream, std::lock_guard)。
如果类的成员只包含基本类型、引用或者本身就是正确管理资源的 RAII 类型成员,那么通常不需要自己编写析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符。编译器自动生成的版本会正确地调用其成员的相应函数,从而实现正确的资源管理和拷贝/移动语义。
-
如果零法则不适用,在考虑使用五法则
在极少数情况下,可能确实需要手动管理资源(例如,实现一个自定义的底层数据结构或与 C API 交互)。
如果需要显式声明析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符中的任何一个,那么就必须考虑并决定如何处理所有其他的函数。通常意味着需要显式地定义或禁用(使用 = delete)它们。
析构函数 (~ClassName):释放类管理的资源。
拷贝构造函数 (ClassName(const ClassName&):执行深拷贝,为新对象分配独立的资源副本。
拷贝赋值运算符 (ClassName& operator=(const ClassName&):处理自赋值,释放旧资源,执行深拷贝。通常使用 copy-and-swap 技巧。
移动构造函数 (ClassName(ClassName&&) noexcept):从源对象“窃取”资源,并将源对象置于有效的可析构状态(通常是空状态)。必须标记为 noexcept 以获得最佳性能和保证。
移动赋值运算符 (ClassName& operator=(ClassName&&) noexcept):处理自赋值,释放旧资源,从源对象“窃取”资源,并将源对象置于有效的可析构状态。必须标记为 noexcept。
-
封装(Encapsulation):
将类的数据成员显示声明为 private 或 protected。并提供 public 成员函数作为接口来访问和操作数据。这允许你改变内部实现而不影响类的使用者。
-
考虑 const 的作用
对于不修改对象状态的成员函数,将其标记为 const。这使得这些函数可以被 const 对象调用,提高了代码的清晰度和安全性。
const ClassName& 用于传递不需要修改的对象,避免不必要的拷贝。
等等。。。