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

C++子类析构时父类析构函数的调用机制

2021-06-217.9k 阅读

C++子类析构时父类析构函数的调用机制基础概念

在C++的面向对象编程中,类继承是一项强大的特性。当一个类(子类)从另一个类(父类)继承时,子类会继承父类的成员变量和成员函数。析构函数在对象生命周期结束时被调用,用于清理对象所占用的资源。了解子类析构时父类析构函数的调用机制,对于编写健壮、无内存泄漏的C++代码至关重要。

析构函数的定义与作用

析构函数是类的一种特殊成员函数,其名称与类名相同,但前面加一个波浪号(~)。析构函数没有返回类型,也不接受参数。当对象被销毁时,析构函数会自动被调用,例如当对象离开其作用域、对象被delete操作符删除(如果对象是通过new动态分配的)或者程序结束时。析构函数的主要作用是释放对象在生命周期内分配的资源,如动态分配的内存、打开的文件句柄、网络连接等。

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

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

int main() {
    Derived d;
    return 0;
}

在上述代码中,Base类有一个构造函数和一个析构函数,Derived类继承自Base类,也有自己的构造函数和析构函数。在main函数中,创建了一个Derived类的对象d。当d离开其作用域时,会自动调用析构函数。运行这段代码,输出如下:

Base constructor called.
Derived constructor called.
Derived destructor called.
Base destructor called.

从输出可以看出,首先调用父类Base的构造函数,然后调用子类Derived的构造函数。而在析构时,先调用子类Derived的析构函数,然后调用父类Base的析构函数。

调用机制的原理

C++中这种先调用子类析构函数再调用父类析构函数的机制,是基于对象的构造和析构顺序的一致性原则。在构造对象时,先构造父类部分,再构造子类部分。这是因为子类对象包含了父类对象的所有成员,只有先构造好父类部分,子类才能在此基础上进行构造。同样,在析构时,先析构子类部分,再析构父类部分。因为子类可能在构造过程中分配了额外的资源,需要先清理子类自己的资源,然后再清理父类的资源。

多层继承下的调用顺序

当存在多层继承时,这种调用机制依然遵循同样的原则。假设有三个类ABCB继承自AC继承自B

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

class B : public A {
public:
    B() {
        std::cout << "B constructor called." << std::cout;
    }
    ~B() {
        std::cout << "B destructor called." << std::cout;
    }
};

class C : public B {
public:
    C() {
        std::cout << "C constructor called." << std::cout;
    }
    ~C() {
        std::cout << "C destructor called." << std::cout;
    }
};

int main() {
    C c;
    return 0;
}

运行上述代码,输出如下:

A constructor called.
B constructor called.
C constructor called.
C destructor called.
B destructor called.
A destructor called.

可以看到,构造时按照从最顶层父类A到最底层子类C的顺序调用构造函数,析构时则按照从最底层子类C到最顶层父类A的顺序调用析构函数。

虚析构函数的影响

虚析构函数的必要性

当使用基类指针指向派生类对象,并通过基类指针删除对象时,如果基类的析构函数不是虚函数,会发生未定义行为。这是因为编译器在编译时,只知道指针的静态类型是基类,所以只会调用基类的析构函数,而不会调用派生类的析构函数,导致派生类中分配的资源无法释放,从而产生内存泄漏。

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

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

int main() {
    Base* basePtr = new Derived();
    delete basePtr;
    return 0;
}

在上述代码中,Base类的析构函数不是虚函数。main函数中通过Base类指针basePtr指向Derived类对象,并调用delete。此时,只会调用Base类的析构函数,而Derived类的析构函数不会被调用,Derived类中动态分配的数组data无法释放,导致内存泄漏。

虚析构函数的作用

为了避免上述问题,需要将基类的析构函数声明为虚函数。当基类的析构函数是虚函数时,通过基类指针删除派生类对象时,会首先调用派生类的析构函数,然后调用基类的析构函数,确保所有资源都能正确释放。

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

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

int main() {
    Base* basePtr = new Derived();
    delete basePtr;
    return 0;
}

在这个修改后的代码中,Base类的析构函数被声明为虚函数。运行代码,输出如下:

Base constructor called.
Derived constructor called.
Derived destructor called.
Base destructor called.

可以看到,通过基类指针删除派生类对象时,正确地调用了派生类和基类的析构函数,避免了内存泄漏。

纯虚析构函数

纯虚析构函数的定义

在一些情况下,基类可能是一个抽象类,它本身没有具体的实现,但需要定义一个虚析构函数。为了确保所有派生类都能正确地实现析构函数,可以将基类的析构函数定义为纯虚析构函数。纯虚析构函数的定义语法是在函数声明后加上= 0,并且即使是纯虚析构函数,也必须在类外提供实现。

class AbstractBase {
public:
    virtual ~AbstractBase() = 0;
};

AbstractBase::~AbstractBase() {
    std::cout << "AbstractBase destructor called." << std::endl;
}

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

在上述代码中,AbstractBase类定义了一个纯虚析构函数,并且在类外提供了实现。ConcreteDerived类继承自AbstractBase类,并实现了自己的析构函数。

纯虚析构函数的调用

当通过AbstractBase类指针删除ConcreteDerived类对象时,会先调用ConcreteDerived类的析构函数,然后调用AbstractBase类的析构函数。

int main() {
    AbstractBase* ptr = new ConcreteDerived();
    delete ptr;
    return 0;
}

运行上述代码,输出如下:

ConcreteDerived constructor called.
ConcreteDerived destructor called.
AbstractBase destructor called.

纯虚析构函数确保了抽象基类的析构行为是可预测的,同时也强制派生类实现自己的析构函数,以正确清理资源。

多重继承下的析构函数调用

多重继承的概念

多重继承是指一个类可以从多个基类继承成员。在C++中,一个类可以有多个直接父类。例如:

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

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

class C : public A, public B {
public:
    C() {
        std::cout << "C constructor called." << std::endl;
    }
    ~C() {
        std::cout << "C destructor called." << std::endl;
    }
};

在上述代码中,C类从A类和B类多重继承。

多重继承下析构函数的调用顺序

在多重继承下,析构函数的调用顺序与构造函数的调用顺序相反。构造时,按照基类在继承列表中的顺序依次调用基类的构造函数,然后调用子类的构造函数。析构时,先调用子类的析构函数,然后按照与构造相反的顺序调用基类的析构函数。

int main() {
    C c;
    return 0;
}

运行上述代码,输出如下:

A constructor called.
B constructor called.
C constructor called.
C destructor called.
B destructor called.
A destructor called.

可以看到,先调用AB的构造函数(按照继承列表顺序),然后调用C的构造函数。析构时,先调用C的析构函数,然后按照与构造相反的顺序调用BA的析构函数。

菱形继承与虚基类的析构函数调用

菱形继承的问题

菱形继承是多重继承中一种特殊的情况,即一个类从两个或多个类继承,而这些类又从同一个基类继承。例如:

class A {
public:
    int value;
    A() {
        std::cout << "A constructor called." << std::endl;
    }
    ~A() {
        std::cout << "A destructor called." << std::endl;
    }
};

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

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

class D : public B, public C {
public:
    D() {
        std::cout << "D constructor called." << std::endl;
    }
    ~D() {
        std::cout << "D destructor called." << std::endl;
    }
};

在上述代码中,D类从BC继承,而BC又都从A继承,形成了菱形继承结构。这种结构会导致D类中存在两份A类的成员,这可能会引起命名冲突和数据冗余等问题。

虚基类的引入

为了解决菱形继承的问题,C++引入了虚基类。通过在继承时使用virtual关键字声明虚基类,可以确保在最终的派生类中只存在一份虚基类的成员。

class A {
public:
    int value;
    A() {
        std::cout << "A constructor called." << std::endl;
    }
    ~A() {
        std::cout << "A destructor called." << std::endl;
    }
};

class B : virtual public A {
public:
    B() {
        std::cout << "B constructor called." << std::endl;
    }
    ~B() {
        std::cout << "B destructor called." << std::endl;
    }
};

class C : virtual public A {
public:
    C() {
        std::cout << "C constructor called." << std::endl;
    }
    ~C() {
        std::cout << "C destructor called." << std::endl;
    }
};

class D : public B, public C {
public:
    D() {
        std::cout << "D constructor called." << std::endl;
    }
    ~D() {
        std::cout << "D destructor called." << std::endl;
    }
};

在这个修改后的代码中,BC都以虚基类的方式继承A

虚基类析构函数的调用顺序

在虚基类的情况下,析构函数的调用顺序仍然遵循先子类后父类的原则。但是,虚基类的析构函数会在其所有非虚基类的析构函数之前被调用。

int main() {
    D d;
    return 0;
}

运行上述代码,输出如下:

A constructor called.
B constructor called.
C constructor called.
D constructor called.
D destructor called.
C destructor called.
B destructor called.
A destructor called.

可以看到,A(虚基类)的构造函数首先被调用,然后是BC的构造函数,最后是D的构造函数。析构时,先调用D的析构函数,然后是CB的析构函数,最后调用A的析构函数。

异常处理与析构函数调用

构造函数中抛出异常

当在构造函数中抛出异常时,已经构造的部分对象会被正确地析构。例如:

class Resource {
public:
    Resource() {
        std::cout << "Resource constructor called." << std::cout;
        throw std::runtime_error("Constructor exception");
    }
    ~Resource() {
        std::cout << "Resource destructor called." << std::cout;
    }
};

class Container {
public:
    Container() {
        std::cout << "Container constructor called." << std::cout;
        res = new Resource();
    }
    ~Container() {
        delete res;
        std::cout << "Container destructor called." << std::cout;
    }
private:
    Resource* res;
};

int main() {
    try {
        Container c;
    } catch (const std::exception& e) {
        std::cerr << "Exception caught: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,Resource类的构造函数抛出异常。Container类的构造函数在创建Resource对象时会捕获这个异常。由于Resource对象部分构造失败,Resource类的析构函数会被调用,而Container类的析构函数不会被完整执行(因为res指针可能没有正确初始化)。运行代码,输出如下:

Container constructor called.
Resource constructor called.
Resource destructor called.
Exception caught: Constructor exception

析构函数中抛出异常

在析构函数中抛出异常是非常危险的,因为析构函数通常在栈展开时被调用。如果在析构函数中抛出异常,可能会导致程序崩溃。C++标准规定,当析构函数抛出异常且没有被捕获时,std::terminate函数会被调用,从而终止程序。

class BadDestructor {
public:
    ~BadDestructor() {
        throw std::runtime_error("Destructor exception");
    }
};

int main() {
    try {
        BadDestructor bd;
    } catch (const std::exception& e) {
        std::cerr << "Exception caught: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,BadDestructor类的析构函数抛出异常。由于这个异常在析构函数中没有被捕获,程序会调用std::terminate并终止。

为了避免在析构函数中抛出异常,可以在析构函数中捕获异常并进行适当处理,例如记录日志或者忽略异常。

class SafeDestructor {
public:
    ~SafeDestructor() {
        try {
            // 可能抛出异常的代码
        } catch (const std::exception& e) {
            std::cerr << "Exception in destructor: " << e.what() << std::endl;
            // 进行异常处理,如记录日志
        }
    }
};

这样可以确保析构函数能够安全地执行,不会导致程序异常终止。

总结

C++子类析构时父类析构函数的调用机制是C++面向对象编程的重要组成部分。理解构造和析构的顺序、虚析构函数的作用、纯虚析构函数的定义与使用、多重继承和菱形继承下的析构函数调用顺序,以及异常处理与析构函数的关系,对于编写高质量、无错误的C++代码至关重要。通过合理运用这些知识,可以有效地避免内存泄漏、资源未释放等问题,提高程序的稳定性和可靠性。在实际编程中,需要根据具体的需求和场景,正确设计类的继承结构和析构函数,以确保对象的生命周期得到正确管理。