MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++类缺省函数的生命周期管理

2022-05-302.1k 阅读

C++类缺省函数的概念与基本形式

在C++ 中,当我们定义一个类时,如果没有显式地定义某些特殊成员函数,编译器会为我们生成一些缺省的函数。这些缺省函数对于类对象的生命周期管理起着至关重要的作用。常见的缺省函数包括默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符重载函数以及C++11 引入的移动构造函数和移动赋值运算符重载函数。

默认构造函数

默认构造函数是用于创建类对象的特殊成员函数,它在没有提供任何实参的情况下被调用。如果类中没有显式定义构造函数,编译器会生成一个隐式的默认构造函数。这个默认构造函数会按成员声明顺序对类的非静态数据成员进行默认初始化。

例如,考虑如下简单的类 MyClass

class MyClass {
    int data;
public:
    // 编译器会生成默认构造函数
};

在上述代码中,编译器生成的默认构造函数会默认初始化 data 成员。对于 int 类型,默认初始化意味着值是未定义的。

如果类包含自定义类型的成员,默认构造函数会调用这些成员的默认构造函数。例如:

class InnerClass {
public:
    InnerClass() {
        std::cout << "InnerClass default constructor" << std::endl;
    }
};

class OuterClass {
    InnerClass inner;
public:
    // 编译器生成的默认构造函数会调用 InnerClass 的默认构造函数
};

当创建 OuterClass 对象时,编译器生成的默认构造函数会调用 InnerClass 的默认构造函数,输出 "InnerClass default constructor"。

析构函数

析构函数与构造函数相对,用于在对象生命周期结束时执行清理工作。当对象被销毁时,无论是因为超出作用域、被显式删除(对于动态分配的对象)还是程序结束,析构函数都会被调用。

如果类没有显式定义析构函数,编译器会生成一个隐式的析构函数。这个隐式析构函数会按成员声明的逆序调用成员的析构函数。

以之前的 OuterClass 为例,编译器生成的析构函数会调用 InnerClass 的析构函数:

class InnerClass {
public:
    ~InnerClass() {
        std::cout << "InnerClass destructor" << std::endl;
    }
};

class OuterClass {
    InnerClass inner;
public:
    // 编译器生成的析构函数会调用 InnerClass 的析构函数
};

OuterClass 对象被销毁时,会输出 "InnerClass destructor"。

对于包含动态分配资源(如通过 new 分配的内存)的类,必须显式定义析构函数来释放这些资源,以避免内存泄漏。例如:

class ResourceHolder {
    int* data;
public:
    ResourceHolder() {
        data = new int(0);
    }
    ~ResourceHolder() {
        delete data;
    }
};

在上述代码中,ResourceHolder 类显式定义了析构函数来释放 new 分配的内存。如果没有这个析构函数,当 ResourceHolder 对象被销毁时,分配的内存将无法释放。

拷贝构造函数与拷贝赋值运算符重载

拷贝构造函数

拷贝构造函数用于通过已存在的对象创建一个新的对象,是一种特殊的构造函数,其参数是本类对象的引用。如果类没有显式定义拷贝构造函数,编译器会生成一个隐式的拷贝构造函数。这个隐式拷贝构造函数会执行成员逐一拷贝,即将源对象的每个非静态数据成员的值拷贝到目标对象的对应成员中。

考虑如下 Point 类:

class Point {
    int x;
    int y;
public:
    Point(int a, int b) : x(a), y(b) {}
    // 编译器生成的拷贝构造函数会进行成员逐一拷贝
};

在上述代码中,编译器生成的拷贝构造函数会将源 Point 对象的 xy 成员的值拷贝到新创建的 Point 对象中。

然而,对于包含动态分配资源的类,默认的拷贝构造函数可能会导致问题。例如,对于之前的 ResourceHolder 类:

class ResourceHolder {
    int* data;
public:
    ResourceHolder() {
        data = new int(0);
    }
    // 编译器生成的默认拷贝构造函数会导致浅拷贝问题
};

编译器生成的默认拷贝构造函数会简单地将源对象的 data 指针拷贝到目标对象,这意味着两个对象将指向同一块内存。当其中一个对象销毁时,这块内存会被释放,而另一个对象将持有一个悬空指针,导致未定义行为。

为了避免这种情况,需要显式定义拷贝构造函数来进行深拷贝:

class ResourceHolder {
    int* data;
public:
    ResourceHolder() {
        data = new int(0);
    }
    ResourceHolder(const ResourceHolder& other) {
        data = new int(*other.data);
    }
    ~ResourceHolder() {
        delete data;
    }
};

在上述代码中,显式定义的拷贝构造函数为新对象分配了独立的内存,并将源对象的数据拷贝到新内存中,避免了浅拷贝问题。

拷贝赋值运算符重载

拷贝赋值运算符(operator=)用于将一个已存在对象的值赋给另一个已存在的对象。与拷贝构造函数类似,如果类没有显式定义拷贝赋值运算符重载函数,编译器会生成一个隐式的版本。

隐式的拷贝赋值运算符重载函数同样执行成员逐一赋值。对于简单类,这通常是足够的:

class SimpleClass {
    int value;
public:
    SimpleClass(int v) : value(v) {}
    // 编译器生成的拷贝赋值运算符会进行成员逐一赋值
};

然而,对于包含动态分配资源的类,默认的拷贝赋值运算符重载函数会导致与默认拷贝构造函数相同的浅拷贝问题。例如:

class ResourceHolder {
    int* data;
public:
    ResourceHolder() {
        data = new int(0);
    }
    // 编译器生成的默认拷贝赋值运算符会导致浅拷贝问题
};

为了解决这个问题,需要显式定义拷贝赋值运算符重载函数:

class ResourceHolder {
    int* data;
public:
    ResourceHolder() {
        data = new int(0);
    }
    ResourceHolder(const ResourceHolder& other) {
        data = new int(*other.data);
    }
    ResourceHolder& operator=(const ResourceHolder& other) {
        if (this != &other) {
            delete data;
            data = new int(*other.data);
        }
        return *this;
    }
    ~ResourceHolder() {
        delete data;
    }
};

在上述代码中,显式定义的拷贝赋值运算符重载函数首先检查是否是自赋值,如果不是,则释放当前对象的资源,然后为其分配新的内存并拷贝数据。

C++11 引入的移动语义相关缺省函数

移动构造函数

C++11 引入了移动语义,旨在提高对象传递时的性能,尤其是对于包含动态分配资源的对象。移动构造函数用于从一个对象窃取资源,而不是进行深拷贝。如果类没有显式定义移动构造函数,编译器会在满足一定条件时生成一个隐式的移动构造函数。

考虑如下 StringHolder 类,它持有一个动态分配的字符串:

class StringHolder {
    char* str;
    size_t length;
public:
    StringHolder(const char* s) {
        length = std::strlen(s);
        str = new char[length + 1];
        std::strcpy(str, s);
    }
    // 编译器生成的移动构造函数(如果满足条件)
};

编译器生成移动构造函数的条件相对复杂。一般来说,如果类没有显式定义析构函数、拷贝构造函数或拷贝赋值运算符重载函数,且类的所有非静态数据成员都可以被移动,编译器会生成移动构造函数。

当编译器生成移动构造函数时,它会简单地将源对象的资源(如指针和长度)转移到目标对象,然后将源对象置于一个可析构的状态(通常是将指针设为 nullptr)。例如,编译器生成的移动构造函数可能类似如下形式:

class StringHolder {
    char* str;
    size_t length;
public:
    StringHolder(const char* s) {
        length = std::strlen(s);
        str = new char[length + 1];
        std::strcpy(str, s);
    }
    StringHolder(StringHolder&& other) noexcept {
        str = other.str;
        length = other.length;
        other.str = nullptr;
        other.length = 0;
    }
    ~StringHolder() {
        delete[] str;
    }
};

在上述代码中,移动构造函数将 other 对象的 strlength 成员转移到当前对象,并将 otherstr 设为 nullptrlength 设为 0。这样,other 对象在析构时不会释放已转移的资源。

移动赋值运算符重载

移动赋值运算符(operator=)与移动构造函数类似,用于将一个对象的资源移动到另一个对象。如果类没有显式定义移动赋值运算符重载函数,编译器会在满足一定条件时生成一个隐式的版本。

同样以 StringHolder 类为例,编译器生成的移动赋值运算符重载函数可能如下:

class StringHolder {
    char* str;
    size_t length;
public:
    StringHolder(const char* s) {
        length = std::strlen(s);
        str = new char[length + 1];
        std::strcpy(str, s);
    }
    StringHolder(StringHolder&& other) noexcept {
        str = other.str;
        length = other.length;
        other.str = nullptr;
        other.length = 0;
    }
    StringHolder& operator=(StringHolder&& other) noexcept {
        if (this != &other) {
            delete[] str;
            str = other.str;
            length = other.length;
            other.str = nullptr;
            other.length = 0;
        }
        return *this;
    }
    ~StringHolder() {
        delete[] str;
    }
};

在上述代码中,移动赋值运算符重载函数首先检查是否是自赋值,如果不是,则释放当前对象的资源,然后从 other 对象窃取资源,并将 other 对象置于可析构的状态。

缺省函数的隐式定义条件与禁用

隐式定义条件

如前文所述,编译器生成缺省函数是有条件的。对于默认构造函数,如果类没有显式定义任何构造函数,编译器会生成默认构造函数。但如果类包含常量成员、引用成员且没有提供初始化,或者类包含用户定义的构造函数(即使是带参数的构造函数),编译器通常不会生成默认构造函数。

对于析构函数,只要类没有显式定义析构函数,编译器就会生成一个隐式的析构函数。

拷贝构造函数和拷贝赋值运算符重载函数,在类没有显式定义它们时,编译器会生成隐式版本。然而,如果类包含不能被拷贝的成员(如 std::unique_ptr),编译器将不会生成隐式的拷贝相关函数。

移动构造函数和移动赋值运算符重载函数,在类没有显式定义析构函数、拷贝构造函数、拷贝赋值运算符重载函数,且所有非静态数据成员都可以被移动时,编译器会生成隐式版本。

禁用缺省函数

在某些情况下,我们可能不希望编译器生成某些缺省函数。例如,对于一个用于表示单例模式的类,我们不希望它有拷贝构造函数和拷贝赋值运算符,以防止对象被拷贝。C++11 引入了 = delete 语法来禁用函数。

例如,要禁用 MyClass 类的拷贝构造函数和拷贝赋值运算符:

class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass&) = delete;
    MyClass& operator=(const MyClass&) = delete;
};

在上述代码中,MyClass(const MyClass&) = delete; 声明了拷贝构造函数并将其禁用,MyClass& operator=(const MyClass&) = delete; 声明并禁用了拷贝赋值运算符。这样,任何试图拷贝 MyClass 对象的操作都会导致编译错误。

同样,我们也可以禁用移动构造函数和移动赋值运算符:

class MyClass {
public:
    MyClass() = default;
    MyClass(MyClass&&) = delete;
    MyClass& operator=(MyClass&&) = delete;
};

通过禁用这些缺省函数,可以更好地控制类对象的生命周期管理,确保程序的行为符合设计预期。

缺省函数对性能与资源管理的影响

性能影响

合理使用缺省函数可以显著提升程序性能。例如,移动语义相关的缺省函数(移动构造函数和移动赋值运算符重载函数)在对象传递时避免了不必要的深拷贝,从而提高了效率。

考虑如下函数,它接受一个 StringHolder 对象并返回:

StringHolder createStringHolder() {
    StringHolder holder("Hello, World!");
    return holder;
}

在 C++11 之前,holder 对象返回时会进行拷贝构造,即使返回的对象是临时的。而在 C++11 之后,如果 StringHolder 类有合适的移动构造函数(无论是编译器生成还是显式定义),holder 对象会被移动而不是拷贝,大大提高了性能。

另一方面,如果类包含复杂的自定义类型成员,默认构造函数、拷贝构造函数等缺省函数执行成员逐一初始化或拷贝,可能会带来性能开销。在这种情况下,显式优化这些函数(如使用初始化列表进行高效初始化)可以提升性能。

资源管理影响

缺省函数对于资源管理至关重要。默认构造函数和析构函数确保对象在创建和销毁时对成员进行正确的初始化和清理。拷贝构造函数和拷贝赋值运算符重载函数决定了对象拷贝时资源的处理方式,避免浅拷贝导致的资源管理问题。

移动构造函数和移动赋值运算符重载函数则优化了资源的转移,确保资源在对象之间高效传递而不造成额外的分配和释放开销。例如,std::vector 类在移动操作时,源 std::vector 的内部数组会被直接转移到目标 std::vector,避免了重新分配内存。

然而,如果对缺省函数使用不当,如未显式定义析构函数来释放动态分配的资源,或者使用默认的浅拷贝函数导致多个对象指向同一资源,会引发内存泄漏、悬空指针等资源管理问题,严重影响程序的稳定性和可靠性。

综上所述,深入理解 C++ 类缺省函数的生命周期管理,合理利用和定制这些函数,对于编写高效、健壮的 C++ 程序至关重要。通过正确使用默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符重载函数、移动构造函数和移动赋值运算符重载函数,并根据实际需求禁用某些缺省函数,可以确保对象在整个生命周期内的资源得到妥善管理,同时提升程序的性能。在实际编程中,应根据类的具体特点和需求,仔细考虑是否需要显式定义这些缺省函数,以实现最优的程序设计。