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

C++析构函数的调用时机及其作用

2021-11-283.0k 阅读

C++析构函数的调用时机及其作用

析构函数的基本概念

在C++中,析构函数是类的一种特殊成员函数。它的主要职责是在对象生命周期结束时,负责清理对象所占用的资源。与构造函数用于对象的初始化相对应,析构函数则专注于对象的销毁工作。

析构函数的命名规则是在类名前加上波浪线 ~。例如,对于一个名为 MyClass 的类,其析构函数的声明形式为 ~MyClass()。析构函数没有返回值,也不能带有参数,每个类只能有一个析构函数。

析构函数的作用

  1. 资源释放
    • 当一个对象在程序中分配了动态资源,如动态内存(通过 new 运算符)、文件句柄、网络连接等,这些资源在对象不再需要时必须被释放,以避免资源泄漏。析构函数就是执行这种资源释放操作的合适场所。
    • 例如,假设我们有一个 String 类来管理字符串。在类内部,我们使用动态内存来存储字符串内容。代码如下:
class String {
private:
    char* str;
    int length;
public:
    String(const char* s) {
        length = strlen(s);
        str = new char[length + 1];
        strcpy(str, s);
    }
    ~String() {
        delete[] str;
    }
};
  • 在上述代码中,构造函数使用 new 分配了内存来存储字符串。当 String 对象被销毁时,析构函数会自动调用,通过 delete[] 释放之前分配的动态内存,从而防止内存泄漏。
  1. 对象清理
    • 除了释放动态资源,析构函数还可以用于执行其他类型的对象清理工作。比如关闭打开的文件,断开网络连接等。
    • 以下是一个简单的文件操作类示例:
#include <iostream>
#include <fstream>

class FileHandler {
private:
    std::ofstream file;
public:
    FileHandler(const char* filename) {
        file.open(filename);
        if (!file.is_open()) {
            std::cerr << "Failed to open file" << std::endl;
        }
    }
    ~FileHandler() {
        if (file.is_open()) {
            file.close();
        }
    }
    void writeToFile(const char* data) {
        if (file.is_open()) {
            file << data << std::endl;
        }
    }
};
  • 在这个 FileHandler 类中,构造函数打开一个文件。析构函数在对象销毁时负责关闭文件,确保文件资源得到正确清理。

析构函数的调用时机

  1. 自动对象的析构
    • 当一个对象是在函数内部定义的自动变量(非动态分配的对象),在函数执行结束时,该对象的析构函数会被自动调用。
    • 例如:
void function() {
    String s("Hello");
    // 函数执行到这里,s的生命周期结束,析构函数被调用
}
  • function 函数中,s 是一个 String 类型的自动对象。当函数执行完毕,s 超出其作用域,它的析构函数会自动调用,释放 s 所占用的动态内存。
  1. 动态分配对象的析构
    • 当使用 new 运算符动态分配一个对象时,必须使用 delete 运算符来释放该对象。当 delete 被调用时,对象的析构函数会被自动调用。
    • 示例代码如下:
int main() {
    String* ptr = new String("World");
    delete ptr;
    // delete 调用后,ptr所指向的String对象的析构函数被调用
    return 0;
}
  • 在上述代码中,ptr 指向一个动态分配的 String 对象。调用 delete ptr 不仅释放了分配给 String 对象的内存,还会自动调用该对象的析构函数。
  1. 对象数组的析构
    • 如果定义了一个对象数组,无论是静态分配还是动态分配,当数组对象被销毁时,数组中每个元素的析构函数都会被依次调用。
    • 静态分配对象数组示例:
int main() {
    String arr[2] = {String("Apple"), String("Banana")};
    // 当main函数结束,arr数组中每个String对象的析构函数依次被调用
    return 0;
}
  • 动态分配对象数组示例:
int main() {
    String* arr = new String[2] {String("Cat"), String("Dog")};
    delete[] arr;
    // delete[] 调用后,arr数组中每个String对象的析构函数依次被调用
    return 0;
}
  • 在动态分配对象数组的情况下,必须使用 delete[] 来释放数组内存,这样才能确保数组中每个对象的析构函数都被调用。如果使用 delete 而不是 delete[],只有数组第一个元素的析构函数会被调用,其他元素的析构函数不会被调用,从而导致资源泄漏。
  1. 父类和子类对象的析构
    • 当一个子类对象被销毁时,首先会调用子类的析构函数,然后再调用其父类的析构函数。这是因为子类对象通常包含其父类对象作为子对象,在销毁子类对象时,需要先清理子类特有的资源,然后再清理父类的资源。
    • 示例代码如下:
class Animal {
public:
    ~Animal() {
        std::cout << "Animal destructor" << std::endl;
    }
};

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

int main() {
    Dog d;
    // 当d被销毁时,先调用Dog的析构函数,再调用Animal的析构函数
    return 0;
}
  • 在上述代码中,Dog 类继承自 Animal 类。当 Dog 对象 d 被销毁时,首先输出 Dog destructor,然后输出 Animal destructor
  1. 异常处理中的析构
    • 在C++中,当异常被抛出时,会进行栈展开。在栈展开过程中,所有在异常抛出点之前创建的自动对象(局部对象)都会被销毁,它们的析构函数会被调用。
    • 例如:
void function2() {
    String s1("Before exception");
    try {
        String s2("Inside try block");
        throw 1;
    } catch(...) {
        // 异常被捕获,s2的析构函数已经在栈展开过程中被调用
    }
    // s1的析构函数在这里被调用
}
  • function2 函数中,当 throw 1 语句执行时,异常被抛出。在异常处理过程中,s2 的析构函数会被调用,因为 s2 是在 try 块内创建的自动对象。当 catch 块执行完毕,s1 的析构函数也会被调用。

析构函数与虚函数

  1. 虚析构函数的必要性
    • 当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,会导致未定义行为。具体来说,只有基类的析构函数会被调用,而派生类的析构函数不会被调用,这可能会导致派生类中分配的资源无法释放,从而引发资源泄漏。
    • 以下是一个错误示例:
class Base {
public:
    ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

class Derived : public Base {
private:
    int* data;
public:
    Derived() {
        data = new int[10];
    }
    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;
    // 这里只调用了Base的析构函数,Derived的析构函数未被调用,导致内存泄漏
    return 0;
}
  • 在上述代码中,通过 Base 指针 ptr 删除 Derived 对象时,由于 Base 的析构函数不是虚函数,只有 Base 的析构函数被调用,Derived 的析构函数没有被调用,Derived 类中分配的 data 数组内存无法释放。
  1. 虚析构函数的实现
    • 为了避免上述问题,基类的析构函数应该声明为虚函数。这样,当通过基类指针删除派生类对象时,会根据对象的实际类型调用正确的析构函数。
    • 修改上述代码如下:
class Base {
public:
    virtual ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

class Derived : public Base {
private:
    int* data;
public:
    Derived() {
        data = new int[10];
    }
    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;
    // 这里先调用Derived的析构函数,再调用Base的析构函数
    return 0;
}
  • 在修改后的代码中,Base 的析构函数被声明为虚函数。当通过 Base 指针 ptr 删除 Derived 对象时,会首先调用 Derived 的析构函数,释放 data 数组内存,然后再调用 Base 的析构函数。

析构函数中的异常处理

  1. 避免在析构函数中抛出异常
    • 在C++中,通常不建议在析构函数中抛出异常。这是因为析构函数在栈展开过程中会被自动调用,如果析构函数抛出异常,会导致程序进入一个未定义行为的状态。
    • 例如,考虑以下代码:
class Resource {
public:
    Resource() {
        // 初始化资源
    }
    ~Resource() {
        try {
            // 释放资源,可能会失败并抛出异常
            throw std::runtime_error("Resource release failed");
        } catch(...) {
            // 这里可以捕获异常并进行处理,避免异常传播出去
        }
    }
};
  • 在上述 Resource 类的析构函数中,如果抛出异常,可能会导致程序崩溃或出现未定义行为。更好的做法是在析构函数内部捕获并处理异常,避免异常传播。
  1. 特殊情况处理
    • 然而,在某些特殊情况下,可能无法避免在析构函数中处理异常。比如当一个对象需要释放多个资源,而释放其中一个资源失败可能会导致后续资源释放也失败。在这种情况下,可以使用局部变量来记录异常信息,并在析构函数结束时决定是否抛出异常。
    • 示例代码如下:
class MultipleResources {
private:
    int* resource1;
    int* resource2;
public:
    MultipleResources() {
        resource1 = new int;
        resource2 = new int;
    }
    ~MultipleResources() {
        std::exception_ptr exPtr1 = nullptr;
        try {
            delete resource1;
        } catch(...) {
            exPtr1 = std::current_exception();
        }
        std::exception_ptr exPtr2 = nullptr;
        try {
            delete resource2;
        } catch(...) {
            exPtr2 = std::current_exception();
        }
        if (exPtr1) {
            std::rethrow_exception(exPtr1);
        }
        if (exPtr2) {
            std::rethrow_exception(exPtr2);
        }
    }
};
  • MultipleResources 类的析构函数中,分别尝试释放 resource1resource2。如果释放 resource1 失败,将异常指针存储在 exPtr1 中。同样,如果释放 resource2 失败,将异常指针存储在 exPtr2 中。最后,如果有异常指针,通过 std::rethrow_exception 重新抛出异常。这种方式可以在一定程度上处理析构函数中的异常情况,但仍然需要谨慎使用,尽量避免在析构函数中抛出异常。

总结

析构函数在C++编程中扮演着至关重要的角色,它负责对象生命周期结束时的资源清理工作,确保程序不会出现资源泄漏等问题。了解析构函数的调用时机,如自动对象、动态分配对象、对象数组、继承体系以及异常处理中的析构函数调用,对于编写健壮的C++程序非常关键。同时,合理使用虚析构函数以及正确处理析构函数中的异常,也是C++开发者需要掌握的重要技能。通过深入理解析构函数的机制和应用场景,能够编写出更高效、更稳定的C++代码。在实际编程中,应该根据具体的需求和场景,精心设计析构函数,以确保对象资源得到正确的管理和释放。