• Index

虚函数和多态

Reads: 13

继承如果使用错误会导致内存泄漏,请看下面两个例子:

基础示例 1

#include <iostream>

class baseclass
{
public:
    baseclass(void)
    {
        std::cout << "基类构造函数执行中" << std::endl;
    }
    ~baseclass(void)
    {
        std::cout << "基类析构函数执行中" << std::endl;
    }
};

class derivedclass : public baseclass
{
public:
    derivedclass(void)
    {
        std::cout << "派生类构造函数执行中" << std::endl;
    }
    ~derivedclass(void)
    {
        std::cout << "派生类析构函数执行中" << std::endl;
    }
};

int main(void)
{
    baseclass *pointer = new derivedclass;
    delete pointer;
    return 0;
}

输出结果:

基类构造函数执行中
派生类构造函数执行中
基类析构函数执行中

基础讲解 1

我们没有看到派生类析构函数执行中。根据前面教程讲的,当用基类的指针或者引用保存派生类的对象的时候,如果此时用这个引用或者指针进行操作,那么只会执行基类的成员函数,而这个行为同样也适用于析构函数。所以上面代码中通过基类指针释放堆内存,只会调用基类的析构函数而不会调用派生类的析构函数。也因此,如果析构函数内有释放堆内存的代码,那么将无法释放堆内存而造成内存泄漏。

基础示例 2

那么如果派生类不需要在析构函数内进行释放操作,是不是就不会内存泄漏呢?看下面例子:

#include <iostream>

class testclass
{
public:
    testclass(void)
    {
        std::cout << "测试构造函数执行中" << std::endl;
    }
    ~testclass(void)
    {
        std::cout << "测试析构函数执行中" << std::endl;
    }
};

class baseclass
{
public:
    baseclass(void)
    {
        std::cout << "基类构造函数执行中" << std::endl;
    }
    ~baseclass(void)
    {
        std::cout << "基类析构函数执行中" << std::endl;
    }
};

class derivedclass : public baseclass
{
public:
    derivedclass(void)
    {
        std::cout << "派生类构造函数执行中" << std::endl;
    }
    ~derivedclass(void)
    {
        std::cout << "派生类析构函数执行中" << std::endl;
    }
private:
    testclass obj;
};

int main(void)
{
    baseclass *pointer = new derivedclass;
    delete pointer;
    return 0;
}

输出结果:

基类构造函数执行中
测试构造函数执行中
派生类构造函数执行中
基类析构函数执行中

基础讲解 2

同样也看不到测试析构函数执行中。这个例子也印证了我之前教程所说,成员变量的释放是必须要有析构函数的,而此时派生类的析构函数不执行,导致内存泄漏。

基础拓展

那么如果派生类没有成员变量,是不是就不会内存泄漏呢?事实上仍然会内存泄漏或者出现其他问题。

C++标准中对通过基类指针释放派生类内存的行为没有说明,即未定义行为除非派生类的析构函数是虚的。未定义行为的实际行为取决于编译器怎样做,但是无论编译器怎么做,使用未定义行为都是错误的做法,都会使程序出现未知错误

虚函数和多态

虚函数可以解决上面的问题。

只有除构造函数外的类成员函数才可以定义为虚函数。

在成员函数的声明前面加上关键字virtual,那么该成员函数就是虚函数,如:

virtual bool empty(void) const;

应该注意的是:virtual应该设定在自己设计的基类中的成员函数上,而不是设定在派生类或者别人设计的类上面。

派生类继承基类时,如果有成员函数和基类的虚函数同名时,那么派生类的这个成员函数也是虚函数;而将派生类的成员函数设计成和基类的虚函数同名的行为叫做覆盖或者重写(英文:override)。

与重载不同的是:派生类重写成员函数后,如果用基类指针或者引用操作派生类对象时,这时候如果调用重写的虚函数,那么调用的就是派生类的成员函数。换句话说就是,无论用对象还是基类指针还是基类引用,调用的都是派生类的成员函数,所以才会被叫作派生类重写虚函数。

基础示例

看例子更好理解:

#include <iostream>

class baseclass
{
public:
    virtual ~baseclass(void);
    void print_overload(void) const;
    virtual void print_override(void) const;
};

class derivedclass : public baseclass
{
public:
    virtual ~derivedclass(void);
    void print_overload(void) const;
    virtual void print_override(void) const override;
};

int main(void)
{
    baseclass *obj = new derivedclass;

    obj->print_overload();
    obj->print_override();

    delete obj;
    obj = nullptr;

    return 0;
}

baseclass::~baseclass(void)
{
    std::cout << "基类析构函数执行中" << std::endl;
}

void baseclass::print_overload(void) const
{
    std::cout << "基类重载函数执行中" << std::endl;
}

void baseclass::print_override(void) const
{
    std::cout << "基类重写函数执行中" << std::endl;
}

derivedclass::~derivedclass(void)
{
    std::cout << "派生类析构函数执行中" << std::endl;
}

void derivedclass::print_overload(void) const
{
    std::cout << "派生类重载函数执行中" << std::endl;
}

void derivedclass::print_override(void) const
{
    std::cout << "派生类重写函数执行中" << std::endl;
}

输出结果:

基类重载函数执行中
派生类重写函数执行中
派生类析构函数执行中
基类析构函数执行中

基础讲解

首先基类的析构函数声明为虚函数,那么所有继承它的派生类的析构函数都是虚函数。也就是说,无论派生类的析构函数有没有用virtual修饰,只要基类的析构函数是虚函数,那么所有派生类的析构函数都是虚函数。那么上面代码中virtual ~derivedclass(void);virtual可以省略。

虽然派生类中的virtual可以省略,但还是建议virtual明确写出来,因为如果有很多类,新类继承旧类,都不写virtual,那么后面想使用其中一个派生类作为基类的时候,就不知道是不是有虚函数,还要一个个找它的基类,看是不是其中一个定义成虚函数,不方便后续使用。

我们也看到代码中的派生类重写的虚函数后面有关键字override,关键字override只能用在派生类重写的虚函数上。关键字override也可以省略,但也是建议override明确写出来。如果使用了关键字override,由于关键字override只能用在派生类重写的虚函数上的这个规定,当你写错了函数名或者其他粗心大意的失误时,编译的时候编译器会报告错误告诉你:这个函数虽然写了override但它并不是可以重写的函数。

我们再看回代码。首先创建堆内存的对象,然后将内存地址赋值给基类指针。接着通过基类的指针调用成员函数print_overload,由于它没有指明虚函数,所以我们看到它输出的是基类重载函数执行中。然后通过基类的指针调用成员函数print_override,由于它指明了是虚函数,所以我们看到它输出的是派生类重写函数执行中。因为基类的析构函数是虚的,从而导致所有派生类的析构函数都是虚的,所以直接delete基类指针保存的地址时,它还是可以正确释放所有的内存空间。

基础拓展

最后说回什么是多态?

多态就是多种状态,就是基类指针或者基类引用可以保存不同的派生类对象,而且只需要使用基类统一的成员函数名称,就可以调用不同派生类的成员函数。

假设我们需要设计一个函数去统计字符串中某个字符数量,由于统计部分的代码都一样所以可以封装成函数,不同的就是由外部传进来需要对字符进行判断的条件。这时候就可以使用多态:设计一个基类和虚函数,使用基类的指针或引用作为统计函数的参数,然后由调用统计函数的人去继承这个基类并且重写这个虚函数。那么,统计函数就可以通过这个基类指针或引用调用它的成员函数,由于虚函数的作用,它将会调用派生类的虚函数,从而实现多态的功能。

说到这里,是不是感觉到有些熟悉的感觉?就是前面讲std::function的时候讲解的例子。事实上,大部分的多态都可以使用函数式编程(可以理解为使用std::function)来代替,只有少部分是代替不了的。C++标准库中,很多需要的外部接口都使用函数式编程而不是多态,因为使用函数式编程可以写更小代码,而且更容易理解,而使用函数式编程的额外损耗有可能比多态少。

注意事项和建议

  1. 当派生类的析构函数是非虚的时候,不能通过基类指针删除(delete)派生类内存,否则将产生未定义行为。
  2. C++标准库中除了IO库和异常以外,所有类的析构函数都是非虚的,所以继承之后的使用需要注意。
  3. 当你可以确定你设计的类不会用堆内存分配对象时,析构函数可以非虚。当你不确定你设计的类会被怎样使用,或者说你设计的类是作为第三方库供别人调用而不知道别人会怎样使用,这时候析构函数就应该使用虚析构函数,防止内存泄漏。
  4. 类的内部实现使用虚表保存虚函数,所以需要的内存也会多一点,而用基类指针访问虚函数时,也会有所下降。自己写程序的时候,其实不必太在意这些小损耗,毕竟损耗不大,反而可以让你少费时间去思考怎样设计。
  5. 尽量使用智能指针代替new/delete。

补充知识

关键字override从C++11开始加入。


Comments

Make a comment

  • Index

WARNING: You are using an old browser that does not support HTML5. Please choose a modern browser (Chrome / Microsoft Edge / Firefox / Sarafi) to get a good experience.