虚函数、纯虚函数、多态与虚表机制详解

发布时间 2023-05-28 20:18:52作者: C++杀我

虚函数

在类的定义中,前面有virtual 关键字的成员函数就是虚函数

注:派生类中的成员函数 与 基类中虚函数同名且参数相同的函数,不加virtual也会自动变成虚函数

纯虚函数与抽象类

没有函数体的虚函数叫做纯虚函数,包含纯虚函数的类叫抽象类。

 例如上面Base中的Examp就是一个纯虚函数,因为它有virtual声明但是没有函数体。然后此时,Base类成为了抽象类。

注:

  • 抽象类只能作为基类来派生新类使用,不能创建抽象类的对象
  • 抽象类的指针和引用可以指向由抽象类派生出来的类的对象。
  • 在抽象类的成员函数内可以调用纯虚函数,但是在构造函数或析构函数内部 不能调用纯虚函数(包括普通的虚函数)。
  • 如果一个类从抽象类派生而来,那么当且仅当它实现了基类中的所有纯虚函数,它才能成为非抽象类。

 

为什么在构造函数或析构函数内部 不能调用纯虚函数(包括普通的虚函数)???

首先,我们要知道虚函数是动态绑定的,也就是说虚函数的调用是在运行的时候决定的。在编译的时候这个对象并不能知道它是属于基类,还是属于派生出来的某个类,只有在运行时根据对象的实际类型才能判定,例如代码:

class Animal {
public:
    virtual void makeSound() {
        std::cout << "动物发出了一种声音。\n";
    }
};
class Dog : public Animal {
public:
    void makeSound() override {
        std::cout << "狗发出了汪汪的声音。\n";
    }
};
class Cat : public Animal {
public:
    void makeSound() override {
        std::cout << "猫发出了喵喵的声音。\n";
    }
};
int main() {
    Animal* animalPtr;
    Dog dog;
    Cat cat;

    animalPtr = &dog;
    animalPtr->makeSound();  // 运行时决定调用Dog类的makeSound()

    animalPtr = &cat;
    animalPtr->makeSound();  // 运行时决定调用Cat类的makeSound()

    return 0;
}

当我们调用 animalPtr->makeSound() 时,实际上是根据指针所指向的对象来决定调用具体哪个类的makeSound() 函数。这只有在运行才能做到。

然后我们也许会认为在构造函数中也会发生动态绑定这样的事情,那就大错特错了。

因为如果当前结构,是一个多态的结构,基类的构造函数会比派生类的构造函数先执行,此时派生类还没有被构造,所以此时的对象还是父类的,不会触发多态。并且

析构函数也是一样,析构函数是用来销毁一个类或者说对象的,子类会先进行析构,然后再调用父类的析构,所以如果你想在父类中通过虚函数实现多态效果,最起码要有这个子类这个对象吧?连子类都整个先被析构掉了,你再定义个virtual有什么意义呢?

我们再继续从两个层面深入理解一下:

  1.概念上

  在构造函数中,我们只能知道当前类已经被初始化,但是并不能知道有哪些类继承自它,然后虚函数在层次上是可以“向前”和“向外”进行调用的,如果我们在构造函数中也这么做,那么我们所调用的函数可能操作还没有被初始化的成员,这将导致灾难发生。

  2.机械上:

  当一个类的构造函数被调用的时候,首先做的就是初始化虚函数表指针(VPTR)。然后,构造函数只知道它所属的类,它不知道这个这个类是否是基于其他类的。当编译器生成构造函数代码的时候,它只为该类的构造函数生成代码,而不是为基类或者派生类生成代码(因为类不知道哪些类继承了它)。因此,它使用的虚函数表指针必须始终指向该类的虚函数表(VTABLE)。而且只有它是最后一个被调用的构造函数,那么在该对象的声明周期内,VPTR将保持被初始化为指向这个VTABLE。但是如果接着还有一个更晚派生类的构造函数被调用,那么这个构造函数又将设置VPTR指向它的VTABLE,以此类推,直到最后的构造函数结束。VPTR的状态是由被最后调用的构造函数来确定的。这就是为什么构造函数调用是按照从基类到最晚派生类的顺序的另一个理由。

  但是,当这一系列构造函数调用正发生时,每个构造函数都已经设置VPTR指向它自己的VTABLE。如果函数调用使用虚机制,它将只产生通过它自己的VTABLE的调用,而不是最后派生的VTABLE(所有构造函数被调用后才会有最后派生的VTABLE)。另外,许多编译器认识到,如果在构造函数中进行虚函数调用,应该使用早绑定,因为它们知道晚绑定将只对本地函数产生调用。无论哪种情况,在构造函数中调用虚函数都不能得到预期的结果。

以下是一个简单的代码示例来说明上述概念:

#include <iostream>
class Base {
public:
    Base() {
        std::cout << "Base constructor" << std::endl;
        initVPTR();
    }

    virtual void initVPTR() {
        std::cout << "Base::initVPTR() called" << std::endl;
    }

    virtual void someFunction() {
        std::cout << "Base::someFunction() called" << std::endl;
    }

    void doSomething() {
        std::cout << "Base::doSomething() called" << std::endl;
        someFunction();  // 调用虚函数
    }
};

class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived constructor" << std::endl;
        initVPTR();
    }

    virtual void initVPTR() {
        std::cout << "Derived::initVPTR() called" << std::endl;
    }

    virtual void someFunction() {
        std::cout << "Derived::someFunction() called" << std::endl;
    }
};

int main() {
    Derived obj;
    obj.doSomething();
    return 0;
}

在基类中有一个someFunction()函数,并且在派生类中对它进行了重写。然后在基类中添加了一个doSomething(),它会调用someFunction()。这样做是为了演示在构造函数中调用虚函数无法得到预期结果的情况。

输出情况:

Base constructor
Base::initVPTR() called
Derived constructor
Derived::initVPTR() called
Base::doSomething() called
Base::someFunction() called

可以发现,尽管 doSomething() 函数在基类中调用了虚函数 someFunction(),但实际执行的是基类的函数实现,而不是派生类的函数实现。这是因为在派生类的构造函数期间,虚函数机制尚未完全建立起来,因此虚函数调用会被解析为基类的函数。

 

多态

多态的实现是通过虚函数。

多态的关键在于通过基类指针或引用调用一个虚函数时,编译阶段不能确定到底调用的是基类还是派生类的函数,运行时才能够确定——这叫动态联编。虚函数因为用了虚函数表机制,调用的时候会增加内存开销,具体见下文。

注:在非构造函数,非析构函数的成员函数中调用虚函数,是多态;在构造函数和析构函数中调用虚函数,不是多态。

补:一般多态指的是上述的动态多态,还有一种静态多态是通过函数的重载实现的。
非虚函数静态联编,效率要比虚函数高,但是不具备动态联编能力。

 

虚表机制

每一个有虚函数的类(或有虚函数的类的派生类)都有一个虚函数表(数组),该表列出了各个虚函数地址,该类的任何对象中都放着一个虚表指针(32位占4字节,64位占8字节),虚表指针会在运行时动态绑定决定该对象的虚函数地址。

那么虚函数表是在运行时创建的还是编译时创建的呢?虚函数表在编译的时候就确定了,而类对象的虚函数指针vptr是在运行阶段确定的。(类的函数的调用并不是在编译时就确定的,而是在运行时才确定的,由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以声明为虚函数。虚函数和虚函数表是两个不同的东西,虚函数的调用是在运行时才确定的,虚函数表是在编译时就已经确定的了 。)

/*——参考自C++ primer一书中的案例——*/
#include "iostream"
using namespace std;
class Base {
public:
    int a;
    virtual void print1() {};
    virtual void print2() { cout << "Base" << endl; };
};
class Derived : public Base {
public:
    int b;
    void print2() { cout << "Derived" << endl; }; //redefined
    virtual void print3() {}; //new
};
int main() {
    Derived A;
    Base* B = &A;//调用派生类Derived的成员函数 输出Derived
    B->print2();
    //验证虚表指针占空间32位时输出8,12
    cout << sizeof(Base) << "," << sizeof(Derived);
    return 0;
}

类虚函数表与各虚函数地址

1、可观察到每个类都创建一个虚函数表,其中:

  • Derived类未对继承的Base类中的虚函数print1()进行重写,则地址不变。
  • 重写了print2()虚函数,地址则改变。
  • 新定义了一个虚函数,则虚函数表新增一个虚函数地址。

2、那Base类与Derived类的字节大小为什么是8,12呢?因为虚表指针也会占用一个存储地址空间。

 

 

与此同时,调用虚函数时也要通过虚表指针来寻址,因而需要消耗一定量的时间,但这相较于没有多态人工重写这些函数的时间量小得多。

3、为什么调用Base类的指针下的虚函数,执行的是Dervied中的虚函数呢?

若使用指向对象的引用或指针调用虚方法,程序将根据对象类型来调用方法,而不是指针的类型。

如果派生类没有重定义虚函数,则会使用基类中的虚函数。

原文链接:https://blog.csdn.net/qq_31788759/article/details/106225916

        https://blog.csdn.net/chen134225/article/details/81564972