C++ 面向对象_7

总结 C++ 中的 “三五法则”:析构、拷贝与移动语义

在 C++ 中,资源管理是一个核心问题,特别是当类中包含动态分配的资源(如堆内存)时,“三五法则” 提供了一个设计类时应该遵循的指导原则,以确保资源被正确管理。

这包括如下:

  1. 资源正确释放:当对象生命周期结束时,其管理的资源必须被释放(例如 delete 内存)。这通常由析构函数完成。

  2. 资源正确复制:当对象被复制时(通过拷贝构造或拷贝赋值),其管理的资源需要被正确处理。

  3. 资源正确移动:当对象被移动时(通过移动构造或移动赋值),其管理的资源需要被正确处理。

因此在设计 C++ 的类时,通常要遵循三五零法则。如果开发者不显式定义这些特殊成员函数,编译器会尝试自动生成它们。但编译器生成的版本可能并不符合我们的预期,尤其是在涉及资源管理时。

什么是类设计时的三五法则?

  1. 三法则(Rule of Three)
    如果需要定义析构函数(destructor),那么也需要定义拷贝构造函数(copy constructor)和拷贝赋值函数(copy assignment operator)

    • 析构函数 (~ClassName):对象销毁时调用,通常用于释放资源。

    • 拷贝构造函数 (ClassName(const ClassName& other)):用一个已存在的同类对象初始化一个新对象时调用。

    • 拷贝赋值运算符 (ClassName& operator=(const ClassName& other)):将一个已存在的同类对象的值赋给另一个已存在的同类对象时调用。

  2. 五法则(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)。

  1. 优先遵循零法则(Rule of Zero)

    尽量让类不直接管理任何需要手动释放的资源(如裸指针、文件句柄等)。

    这种实现原则将资源管理委托给专门的 RAII 类。对于动态内存,使用 std::unique_ptr, std::shared_ptr, std::vector, std::string 等。对于其他资源(文件、锁等),使用标准库或第三方库提供的 RAII 包装器(如 std::fstream, std::lock_guard)。

    如果类的成员只包含基本类型、引用或者本身就是正确管理资源的 RAII 类型成员,那么通常不需要自己编写析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符。编译器自动生成的版本会正确地调用其成员的相应函数,从而实现正确的资源管理和拷贝/移动语义。

  2. 如果零法则不适用,在考虑使用五法则

    在极少数情况下,可能确实需要手动管理资源(例如,实现一个自定义的底层数据结构或与 C API 交互)。

    如果需要显式声明析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符中的任何一个,那么就必须考虑并决定如何处理所有其他的函数。通常意味着需要显式地定义或禁用(使用 = delete)它们。

    析构函数 (~ClassName):释放类管理的资源。

    拷贝构造函数 (ClassName(const ClassName&):执行深拷贝,为新对象分配独立的资源副本。

    拷贝赋值运算符 (ClassName& operator=(const ClassName&):处理自赋值,释放旧资源,执行深拷贝。通常使用 copy-and-swap 技巧。

    移动构造函数 (ClassName(ClassName&&) noexcept):从源对象“窃取”资源,并将源对象置于有效的可析构状态(通常是空状态)。必须标记为 noexcept 以获得最佳性能和保证。

    移动赋值运算符 (ClassName& operator=(ClassName&&) noexcept):处理自赋值,释放旧资源,从源对象“窃取”资源,并将源对象置于有效的可析构状态。必须标记为 noexcept。

  3. 封装(Encapsulation):

    将类的数据成员显示声明为 private 或 protected。并提供 public 成员函数作为接口来访问和操作数据。这允许你改变内部实现而不影响类的使用者。

  4. 考虑 const 的作用

    对于不修改对象状态的成员函数,将其标记为 const。这使得这些函数可以被 const 对象调用,提高了代码的清晰度和安全性。

    const ClassName& 用于传递不需要修改的对象,避免不必要的拷贝。

等等。。。