自学内容网 自学内容网

类的特殊成员函数——三之法则、五之法则、零之法则

        系统中的动态资源、文件句柄(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 constructor
    copy1.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来存储数据
};

原文地址:https://blog.csdn.net/gaopeng1111/article/details/142733161

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!