系统中的动态资源、文件句柄(socket描述符、文件描述符)是有限的,在类中若涉及对此类资源的操作,但是未做到妥善的管理,常会造成资源泄露问题,严重的可能造成资源不可用,如申请内存失败、文件句柄申请失败;或引发未定义行为,进而引起程序崩溃、表现出意外的行为、损坏数据,或者可能看似正常工作但在其它情况下出现问题。
三之法则和五之法则可以很好地解决上述问题。它们帮助开发者理解和管理类的拷贝控制操作,避免常见的资源泄露、重复释放等问题,并优化程序的性能。零之法则关注类的特殊成员函数的声明和使用。在实际开发中,应根据类的具体需求来决定是否需要自定义这些特殊的成员函数。
学在前面
1、深拷贝和浅拷贝
深拷贝和浅拷贝是两种不同的对象复制方式,它们涉及到对象的内存管理和数据成员的处理方式。若类拥有资源类(指针、文件句柄)的成员,类的对象间进行复制时,若资源重新进行分配,为深拷贝;否则为浅拷贝。以下以类中包含指针数据成员为例进行描述。
1.1 概念
深拷贝:复制后新对象和旧对象的指针成员占用不同的内存空间。
浅拷贝:复制后新对象和旧对象的指针成员占用相同的内存空间。
1.2 特征对比
深拷贝
- 复制对象的所有成员值。
- 对于指针类型的成员,分配新的内存区域,并复制指针指向的实际数据。
- 修改源对象或新对象的指针指向的数据,不会影响另一个对象。
浅拷贝
- 复制对象的所有成员值。
- 对于指针类型的成员,仅复制指针值,而不复制指针指向的实际数据。
- 如果源对象或新对象在生命周期内修改了指针指向的数据,则另一个对象也会受到影响。
1.3 示例
1.3.1 浅拷贝
#include <iostream>
#include <cstring>class ShallowCopy {
public:explicit ShallowCopy(const char *str){data_ = new char[strlen(str) + 1];strcpy(data_, str);}ShallowCopy(const ShallowCopy &other){data_ = other.data_;}~ShallowCopy(){delete[] data_;}void MemberAddress(const std::string &item) const{if (data_ == nullptr) {std::cout << "data_ is NULL" << std::endl;}std::cout << item << &data_ << std::endl;}
private:char *data_;
};int main()
{ShallowCopy obj1("ShallowCopy");ShallowCopy obj2 = obj1;obj1.MemberAddress("old obj address ");obj2.MemberAddress("new obj address ");return 0;
}
思考:
1、打印的地址预期一致,实际一致吗?
一致。
因为两个对象的指针成员在复制时,只是进行指针地址的复制,指向同一块内存。
2、代码会执行成功吗?
不会。
在obj1、obj2的作用域结束后,会执行析构函数,首先释放obj1的成员data_的内存,再释放obj2的成员data_的内存,由于两个内存指向同一地址,所以同一内存会释放两次,引发core dump,异常退出。
old obj address 0x7fff3e60bab0
new obj address 0x7fff3e60bab8
free(): double free detected in tcache 2
Aborted (core dumped)
3、 ShallowCopy obj2 = obj1;替换为如下两行可以吗?
ShallowCopy obj2;
obj2 = obj1;
按照目前实现是不可以的,因为已自定义构造函数,默认的构造函数不会生成,obj2无对应的构造函数。
1.3.2 深拷贝
#include <iostream>
#include <cstring>
#include <string>class DeepCopy {
public:explicit DeepCopy(const char *str){data_ = new char[strlen(str) + 1];strcpy(data_, str);}DeepCopy(const DeepCopy &other){data_ = new char[strlen(other.data_) + 1];strcpy(data_, other.data_);}DeepCopy& operator=(const DeepCopy &other){if (this == &other) {return *this; // handle self-assignment}delete[] data_;data_ = new char[strlen(other.data_) + 1];strcpy(data_, other.data_);return *this;}~DeepCopy(){delete[] data_;}void MemberAddress(const std::string &item) const{if (data_ == nullptr) {std::cout << "data_ is NULL" << std::endl;} else {std::cout << item << ": " << &data_ << std::endl;}}private:char *data_;
};int main()
{DeepCopy copy1("Hello, World!");DeepCopy copy2 = copy1; // Use the copy constructorcopy1.MemberAddress("old obj address of data_ ");copy2.MemberAddress("new obj address of data_ ");return 0;
}
执行结果:
old obj address of data_ : 0x7fff918a0970
new obj address of data_ : 0x7fff918a0978
1.4 图示深浅拷贝差异
浅拷贝
深拷贝
2、RAII
2.1 概念
RAII是Resource Acquisition Is Initialization的缩写,它是一种管理资源的技术,其核心思想是将资源的获取与对象的初始化绑定在一起,并通过对象的生命周期来自动管理资源的释放。
2.2 特征
资源获取即初始化:当对象被创建时,自动获取所需的资源,通常在构造函数中完成。
析构函数管理资源释放:当对象被销毁时,析构函数用于释放资源。
异常安全性:RAII机制确保即使在发生异常的情况下,资源也能被正确释放。
2.3 示例
2.3.1 文件句柄类
class FileHandle {
public:FileHandle(const std::string &filename, std::ios_base::openmode mode) {file_.open(filename, mode);if (!file_.is_open()) {throw std::runtime_error("Failed to open file: " + filename);}}~FileHandle() {if (file_.is_open()) {file_.close();}}private:std::fstream file_;
};
2.3.2 RAII妙用——时间戳打点
class TimestampLogger {
public:TimestampLogger(const std::string &description): description_(description), start_(std::chrono::steady_clock::now()) {}~TimestampLogger() {auto end = std::chrono::steady_clock::now();std::chrono::duration<double> elapsed = end - start_;std::cout << description_ << " took " << elapsed.count() << " seconds.\n";}// 禁用拷贝构造函数和赋值运算符,防止资源泄露或重复打点TimestampLogger(const TimestampLogger &) = delete;TimestampLogger &operator=(const TimestampLogger &) = delete;private:std::string description_;std::chrono::steady_clock::time_point start_;
};
一、三之法则
1、概念
三之法则,也称为“三大定律”或“三法则”,它指出,如果类定义了以下三个特殊成员函数之一:析构函数、拷贝构造函数或拷贝赋值运算符,则开发者通常也需要定义其它两个特殊成员函数,以确保类的拷贝控制和资源管理行为的正确性。
2、使用场景
三之法则主要是为了避免资源泄露、重复释放或其它由于浅拷贝导致的错误。
默认情况下,编译器会为类生成默认的析构函数、拷贝构造函数和拷贝赋值运算符,但这些默认实现通常只进行浅拷贝,即只复制对象的成员变量的值,而不复制成员变量所指向的资源。如果成员变量是指针,并且指向动态分配的内存,则浅拷贝会导致两个对象共享同一块内存,从而在销毁时发生重复释放的错误。
3、如何实现
定义所有需要的特殊成员函数:如果类需要自定义其中一个特殊成员函数,那么通常也需要自定义其他两个成员函数,以确保对象的拷贝和赋值行为符合预期。
理解资源管理:了解类所管理的资源,并决定是否需要自定义特殊成员函数来管理这些资源的拷贝和赋值。
使用RAII:将资源的生命周期与对象的生命周期绑定,简化资源管理,降低资源泄露风险。
4、示例
见1.3.1
二、五之法则
1、概念
五之法则在C++11及以后版本引入,它在三之法则的基础上增加了两个新的特殊成员函数:移动构造函数和移动赋值运算符,以支持移动语义。
2、使用场景
五之法则的引入是为了进一步提高程序的性能,特别是在处理大型对象或资源密集型对象时。通过允许对象之间的资源移动而不是复制,可以减少不必要的内存分配和释放操作,从而提高程序的运行效率。
3、如何实现
定义所有五个特殊成员函数:如果类需要移动语义,则应该定义所有五个特殊成员函数(析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符)。
使用noexcept关键字:在C++11及以后版本中,移动构造函数和移动赋值运算符通常会被标记为noexcept,表明它们不会抛出异常。这有助于编译器优化代码,并允许在更多情况下使用移动语义。
理解移动语义:了解移动语义的工作原理,并决定何时以及如何使用它来优化程序的性能。
4、示例
4.1 socket描述符
class SocketDescriptor {
public:SocketDescriptor() : fd(-1) {}explicit SocketDescriptor(int socket_fd) : fd(socket_fd){if (fd == -1) {throw std::runtime_error("Invalid socket descriptor");}}~SocketDescriptor(){closeSocket();}SocketDescriptor(const SocketDescriptor &) = delete;SocketDescriptor &operator=(const SocketDescriptor&) = delete; SocketDescriptor(SocketDescriptor &&other) noexcept : fd(other.fd){other.fd = -1;}SocketDescriptor &operator=(SocketDescriptor&& other) noexcept{if (this != &other) {closeSocket();fd = other.fd;other.fd = -1;}return *this;}private:void closeSocket(){if (fd != -1) {::close(fd);fd = -1;}}private:int fd;
};
4.2 拷贝"大"数据
class LargeData {
public:LargeData() = default;explicit LargeData(size_t dataSize){try {data = new char[dataSize];this->dataSize = dataSize;std::fill_n(data, dataSize, 0);} catch (const std::bad_alloc&) {throw std::runtime_error("Memory allocation failed in LargeData constructor");}}~LargeData(){delete[] data;}LargeData(const LargeData& other) : dataSize(other.dataSize){try {data = new char[dataSize];std::copy(other.data, other.data + dataSize, data);} catch (const std::bad_alloc&) {throw std::runtime_error("Memory allocation failed in LargeData copy constructor");}}LargeData& operator=(const LargeData& other){if (this == &other) {return *this;}char* oldData = data;try {dataSize = other.dataSize;data = new char[dataSize];std::copy(other.data, other.data + dataSize, data);} catch (const std::bad_alloc&) {dataSize = 0;data = oldData;throw std::runtime_error("Memory allocation failed in LargeData copy assignment operator");}return *this;}LargeData(LargeData&& other) noexcept : dataSize(0), data(nullptr){*this = std::move(other);}LargeData& operator=(LargeData&& other) noexcept{if (this == &other) {return *this;}delete[] data;data = other.data;dataSize = other.dataSize;other.data = nullptr;other.dataSize = 0;return *this;}
private:size_t dataSize;char* data;
};
三、零之法则
1、概念
C++的零之法则是指,如果可能,类应该避免声明任何特殊成员函数。鼓励让编译器自动生成这些特殊成员函数,以简化类的设计和管理。
2、使用场景
简化设计:零之法则通过减少需要编写的代码量,简化类的设计。当类不需要显式管理资源时,遵循零之法则可以使类的接口更加清晰。
减少错误:手动编写特殊成员函数容易引入错误,特别是当类的成员变量较多或类型复杂时。编译器生成的特殊成员函数通常更加健壮。
利用标准库:零之法则鼓励使用标准库组件(如std::string、std::vector等)来管理资源。
提高可维护性:遵循零之法则的类更加简洁,更易于理解和维护。
3、如何实现
避免显式声明特殊成员函数:除非类需要显式管理资源,否则让编译器自动生成这些函数。
使用组合而非继承:组合优于继承是面向对象设计中的一个重要原则。通过组合,可以将其它类的实例作为当前类的成员变量,从而避免复杂的继承关系和虚函数的开销。
利用智能指针:对于需要动态分配内存的场景,使用C++11及以后版本中引入的智能指针(如std::unique_ptr、std::shared_ptr等)。这些智能指针可以自动管理内存,减少内存泄漏的风险。
4、示例
4.1 合理使用C++标准库管理内存
4.2中类使用标准库函数重新实现,对象的内存管理交由标准库进行处理。
class LargeData {
public:LargeData() = default;explicit LargeData(size_t dataSize) : data(dataSize, '\0') {}private:std::string data; // 使用std::string来存储数据
};