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

C++析构函数调用场景的全面分析

2023-07-314.3k 阅读

C++析构函数基础概念

在C++ 中,析构函数是类的一种特殊成员函数。它与构造函数相对应,主要负责在对象生命周期结束时执行清理工作。析构函数的名称与类名相同,但前面会加上波浪号 ~。例如,对于类 MyClass,其析构函数的定义如下:

class MyClass {
public:
    ~MyClass() {
        // 清理代码
    }
};

析构函数没有参数,也没有返回值(包括 void)。当对象被销毁时,系统会自动调用析构函数。这意味着它不需要像普通成员函数那样被显式调用。

局部对象的析构函数调用

当一个对象是在函数内部定义的局部对象时,它的生命周期在函数结束时结束。此时,析构函数会被自动调用。

void function() {
    MyClass obj; // 局部对象
    // 函数体
} // 函数结束,obj的析构函数被调用

在上述代码中,当 function 函数执行到末尾时,obj 的析构函数会被自动调用,以清理 obj 可能占用的资源。

动态分配对象的析构函数调用

  1. 使用 newdelete 通过 new 运算符动态分配的对象,需要使用 delete 运算符来释放内存。在使用 delete 时,对象的析构函数会被调用。
MyClass* ptr = new MyClass();
// 操作ptr指向的对象
delete ptr; // 调用MyClass的析构函数并释放内存

这里,new MyClass() 创建了一个 MyClass 类型的对象,并返回指向该对象的指针。当执行 delete ptr 时,首先调用 MyClass 的析构函数,然后释放分配给该对象的内存。

  1. 使用智能指针 C++ 提供了智能指针来管理动态分配的对象,以避免内存泄漏。智能指针在其生命周期结束时会自动调用 delete,从而调用对象的析构函数。
  • std::unique_ptr
#include <memory>

void useUniquePtr() {
    std::unique_ptr<MyClass> uPtr(new MyClass());
    // 操作uPtr指向的对象
} // uPtr生命周期结束,MyClass的析构函数被调用

std::unique_ptr 独占所指向的对象。当 std::unique_ptr 对象被销毁时(在 useUniquePtr 函数结束时),它会自动调用 delete 来释放对象,进而调用对象的析构函数。

  • std::shared_ptr
#include <memory>

void useSharedPtr() {
    std::shared_ptr<MyClass> sPtr1(new MyClass());
    std::shared_ptr<MyClass> sPtr2 = sPtr1; // sPtr1和sPtr2共享同一个对象
    // 操作sPtr1和sPtr2指向的对象
} // sPtr1和sPtr2生命周期结束,当引用计数降为0时,MyClass的析构函数被调用

std::shared_ptr 使用引用计数来管理对象。多个 std::shared_ptr 可以指向同一个对象,当最后一个指向该对象的 std::shared_ptr 被销毁(引用计数降为 0)时,对象的析构函数会被调用,内存也会被释放。

数组对象的析构函数调用

  1. 栈上的数组对象 对于在栈上定义的对象数组,当数组超出其作用域时,数组中每个元素的析构函数都会被调用。
class MyClass {
public:
    ~MyClass() {
        std::cout << "MyClass destructor called" << std::endl;
    }
};

void stackArray() {
    MyClass arr[3];
    // 对arr进行操作
} // arr超出作用域,数组中每个MyClass对象的析构函数被调用

stackArray 函数结束时,arr 数组中的三个 MyClass 对象的析构函数会依次被调用。

  1. 堆上的数组对象 通过 new[] 分配的对象数组,需要使用 delete[] 来释放。delete[] 会为数组中的每个元素调用析构函数,然后释放内存。
MyClass* heapArr = new MyClass[3];
// 对heapArr进行操作
delete[] heapArr; // 数组中每个MyClass对象的析构函数被调用并释放内存

这里,delete[] heapArr 确保了 heapArr 数组中每个 MyClass 对象的析构函数都被调用,随后释放分配给数组的内存。如果使用 delete heapArr(而不是 delete[] heapArr),只会调用 heapArr[0] 的析构函数,这会导致内存泄漏和未定义行为。

成员对象的析构函数调用

当一个类包含其他类类型的成员对象时,这些成员对象的析构函数调用顺序与它们在类中声明的顺序相反。

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

class OuterClass {
private:
    InnerClass inner1;
    InnerClass inner2;
public:
    ~OuterClass() {
        std::cout << "OuterClass destructor called" << std::endl;
    }
};

OuterClass 的析构函数被调用时,inner2 的析构函数会首先被调用,然后是 inner1 的析构函数,最后是 OuterClass 自身析构函数中的代码被执行。

基类和派生类的析构函数调用

  1. 正常情况 当派生类对象被销毁时,派生类的析构函数首先被调用,然后基类的析构函数被调用。
class BaseClass {
public:
    ~BaseClass() {
        std::cout << "BaseClass destructor called" << std::endl;
    }
};

class DerivedClass : public BaseClass {
public:
    ~DerivedClass() {
        std::cout << "DerivedClass destructor called" << std::endl;
    }
};
DerivedClass dObj;
// dObj生命周期结束
// 首先调用DerivedClass的析构函数,然后调用BaseClass的析构函数
  1. 通过基类指针删除派生类对象 如果通过基类指针删除派生类对象,并且基类的析构函数不是虚函数,会导致未定义行为。只有当基类的析构函数是虚函数时,才能确保派生类的析构函数被正确调用。
class BaseClass {
public:
    virtual ~BaseClass() {
        std::cout << "BaseClass destructor called" << std::endl;
    }
};

class DerivedClass : public BaseClass {
public:
    ~DerivedClass() {
        std::cout << "DerivedClass destructor called" << std::endl;
    }
};
BaseClass* bPtr = new DerivedClass();
delete bPtr; // 调用DerivedClass的析构函数,再调用BaseClass的析构函数

在上述代码中,由于 BaseClass 的析构函数是虚函数,当 delete bPtr 执行时,首先会调用 DerivedClass 的析构函数,然后调用 BaseClass 的析构函数。如果 BaseClass 的析构函数不是虚函数,delete bPtr 只会调用 BaseClass 的析构函数,而不会调用 DerivedClass 的析构函数,这可能导致内存泄漏和其他错误。

异常处理中的析构函数调用

  1. 栈展开 当在函数中抛出异常时,C++ 会进行栈展开。这意味着从抛出异常的点开始,函数调用栈中的局部对象会被依次销毁,它们的析构函数会被调用。
class Resource {
public:
    Resource() {
        std::cout << "Resource constructor called" << std::endl;
    }
    ~Resource() {
        std::cout << "Resource destructor called" << std::endl;
    }
};

void functionWithException() {
    Resource res1;
    throw std::exception();
    Resource res2; // 这行代码不会被执行
}

int main() {
    try {
        functionWithException();
    } catch (const std::exception& e) {
        // 捕获异常
    }
    // 程序继续执行
    return 0;
}

functionWithException 函数中,当 throw std::exception() 执行时,res1 的析构函数会被调用,因为栈展开会销毁 res1res2 由于还未被创建(因为异常抛出导致后续代码未执行),所以不会调用其构造函数和析构函数。

  1. try - catch - finally(C++ 中无直接对应,但可模拟) 虽然 C++ 没有像 Java 那样直接的 try - catch - finally 结构,但可以通过一些技巧来实现类似的功能。例如,可以使用自定义的析构函数来确保在异常发生时执行清理工作。
class Guard {
public:
    ~Guard() {
        // 清理代码
        std::cout << "Guard destructor: cleaning up" << std::endl;
    }
};

void functionWithGuard() {
    Guard guard;
    try {
        // 可能抛出异常的代码
        throw std::exception();
    } catch (const std::exception& e) {
        // 处理异常
    }
    // 无论是否抛出异常,guard的析构函数都会在函数结束时被调用
}

functionWithGuard 函数中,Guard 对象 guard 的析构函数会在函数结束时被调用,无论是正常结束还是因为异常导致的结束。这类似于 finally 块的功能,用于执行必要的清理操作。

全局对象的析构函数调用

全局对象在程序启动时构造,在程序结束时析构。它们的析构函数调用顺序与构造函数调用顺序相反。

class GlobalClass {
public:
    GlobalClass() {
        std::cout << "GlobalClass constructor called" << std::endl;
    }
    ~GlobalClass() {
        std::cout << "GlobalClass destructor called" << std::endl;
    }
};

GlobalClass globalObj;

int main() {
    // 程序主体
    return 0;
}

在程序结束时,globalObj 的析构函数会被调用,以清理 globalObj 可能占用的资源。由于全局对象的构造和析构函数调用依赖于程序的启动和结束机制,需要注意它们可能会与其他库或系统资源的初始化和清理相互影响。

临时对象的析构函数调用

临时对象是在表达式求值过程中创建的对象,它们通常在表达式结束时被销毁,其析构函数会被调用。

class TempClass {
public:
    TempClass() {
        std::cout << "TempClass constructor called" << std::endl;
    }
    ~TempClass() {
        std::cout << "TempClass destructor called" << std::endl;
    }
};

TempClass createTemp() {
    return TempClass();
}

void useTemp() {
    TempClass obj = createTemp();
    // obj的构造函数和析构函数调用
}

createTemp 函数中返回的 TempClass 临时对象,在赋值给 obj 后,临时对象的析构函数会被调用。而 objuseTemp 函数结束时,其析构函数也会被调用。

总结各类场景下析构函数调用的特点

  1. 局部对象:在函数结束时自动调用析构函数,遵循栈的后进先出原则,按照定义顺序的相反顺序销毁。
  2. 动态分配对象:使用 delete(或智能指针自动调用 delete)来触发析构函数调用并释放内存。智能指针通过引用计数(std::shared_ptr)或独占所有权(std::unique_ptr)来管理对象的生命周期。
  3. 数组对象:栈上数组在作用域结束时,每个元素的析构函数依次调用;堆上数组需要使用 delete[] 来确保每个元素的析构函数被调用。
  4. 成员对象:析构函数调用顺序与声明顺序相反,先销毁成员对象,再执行自身析构函数代码。
  5. 基类和派生类:派生类对象销毁时,先调用派生类析构函数,再调用基类析构函数。通过基类指针删除派生类对象时,基类析构函数必须为虚函数,以确保正确调用派生类析构函数。
  6. 异常处理:栈展开时会销毁局部对象,调用其析构函数。可通过自定义析构函数模拟 try - catch - finally 中的 finally 功能。
  7. 全局对象:在程序结束时析构,调用顺序与构造顺序相反。
  8. 临时对象:在表达式结束时销毁,调用析构函数。

理解这些析构函数调用场景对于编写正确、高效且无内存泄漏的 C++ 代码至关重要。在实际编程中,需要根据具体的需求和对象的生命周期来合理设计和使用析构函数。