虚表、虚函数

作者: Baqun | 来源:发表于2016-10-21 18:03 被阅读422次
    什么是虚函数?

    使用 virtual 关键字修饰的函数即为虚函数,virtual 关键字只能对类中的非静态函数使用。一种特殊的虚函数为纯虚函数,纯虚函数没有具体的实现,在函数形参列表后加上 =0,定义为纯虚函数。含有纯虚函数的类称为抽象类,抽象类不能进行实例化。

    virtual void fun1() {      //虚函数的定义方式
    ...
    };
    virtual void fun2() = 0;   //纯虚函数的定义方式
    
    虚函数的作用是什么?

    虚函数用于 C++ 三大特性之一多态的实现,C++ 没有 interface 关键字,为了实现接口的重用,需要利用虚函数。

      多态的表现为,同样是基类的指针,但由于指向的派生类不同,所带来的实现也不同。
    
    class Base{
    public:
        virtual void fun(){
            cout << "Base fun" << endl;
        }
    };
    
    class Derived1 : public Base{
    public:
        virtual void fun(){
            cout << "Derived1 fun" << endl;
        }
    };
    
    class Derived2 : public Base{
    public:
        virtual void fun(){
            cout << "Derived2 fun" << endl;
        }
    };
    int main()
    {
        Base *pb  = new Base();       //基类指针指向基类对象
        Base *pd1 = new Derived1();   //基类指针指向派生类对象
        Base *pd2 = new Derived2();   //基类指针指向派生类对象
    
        pb->fun();       //Base fun
        pd1->fun();      //Derived1 fun
        pd2->fun();      //Derived2 fun
    }
    

    上述代码中,pb、pd1、pd2 都是 Base 类型的指针,但由于指向的对象类型不同,同样的 fun() 实现也不同。具体的原理稍后详述。这里我们先注意到,我们使用了一个基类类型的指针去指向派生类的对象,也就是指针的类型和指向对象的类型是不一致的。这里涉及到一个静态类型动态类型的关系。
    静态类型:指针或者引用申明的类型
    动态类型:指针实际指向的类型

    对于基本类型来说,是不会出现指针类型和指向的实际类型不一致的情况,例如一个指向 int 的指针去指向一个 char ,编译器会有如下错误

    int *p = new char();   //"char *" 类型的值不能用于初始化 "int *" 类型的实体
    

    而在基类指针指向派生类中,派生类继承了基类,可以粗浅的理解为基类是派生类的一个子集(不准确),派生类包含了一个完整的基类,而基类指针实际上指向的是派生类中的基类部分,所以这种情况下,静态类型和动态类型不一致是合法的。
      那么问题来了 —— 既然基类指针指向的是派生类中的基类部分,那么逻辑上来说3个 fun()的结果应该都是 “Base fun”,为什么会出现三种不一样的结果呢?这就涉及到 C++ 多态实现的一个关键点 —— 虚函数表。

    虚函数表

    虚函数表,字面意思理解就是存放虚函数的表。虚函数表是一个数组,数组元素储存的是虚函数的指针。一个类如果存在虚函数,那么就会有一个虚指针(vptr),这个虚指针指向虚函数表。
      而当一个派生类继承了一个有虚函数的基类时,派生类不会产生新的虚表,而是在原来的虚表上更改,自身与基类虚表中相同的函数会覆盖掉虚表中的对应函数,与虚表中不同的虚函数会添加到虚表的尾部

    派生类仅有一个基类:
    //类的定义如下
    class Base {
    public:
        virtual void fun_1();
        virtual void fun_2();
    };
    
    class Derived :public Base{
    public:
        virtual void fun_1();
        virtual void fun_3();
    };
    
    结构:
    vtanle.png
      可以看到,Base 的定义中存在 fun_1 和 fun_2 两个虚函数,所以 Base 中虚表存放了两个虚函数。而 Derived 的定义中存在 fun_1 和 fun_3 两个虚函数,那么首先 Derived 会将 Base 的虚表结构拷贝下来,当发现 fun_1 的虚函数时,就会将自身的 fun_1 替换父类的 fun_1,而存在不“冲突”的虚函数时,会将自身的虚函数依次放置在虚表的末尾。当出现基类指针指向派生类时,这样的构造方式方便虚函数的的调用。
    vptr的位置

    既然存在虚指针和虚表,虚指针是存放在类的什么部位?我们可不可以通过虚指针直接来访问虚函数呢?
      答案是可以的。为了能够正确取得虚函数,编译器会将虚指针放在对象实例的最开始的位置。可以用以下代码验证:

    typedef void(*FUN)(void); //返回类型为 void,参数为 void
    
    class Base{
    private:
        virtual void fun1(){
            cout << "private: Base fun1" << endl;
        }
    public:
        virtual void fun2(){
            cout << "public: Base fun2" << endl;
        }
    };
    int main()
    {
        Base tmp;
        FUN pf; //函数指针 pf
        pf = (FUN)*((int*)*(int*)(&tmp)); 
        pf();
        pf = (FUN)*((int*)*(int*)(&tmp) + 1);
        pf();
    }
    

    输出结果

    private: Base fun1
    public: Base fun2
    

    这个输出结果就比较有趣了,我们利用虚指针,得到了虚函数表中的函数并且进行了调用,验证了我们的猜想 —— 可以直接通过对象的首地址来获得虚指针,并且可以利用虚指针调用虚函数。但同时,我们调用了一个 private 成员函数,也就是说,我们利用这种方式绕过了访问权限。
      C++会尽量规范使用者的行为,但由于它的灵活性,其实并不能防止使用者做一些危险的事。

    派生类有多个基类:

    C++中有一种特殊的继承方式,就是多继承。那么在多继承的情况下派生类的虚表结构是如何呢?
      其实,在多继承中,虚表的结构是和单继承类似的。派生类对于每一个基类,都会按照单继承的方式将其虚函数保留、替换、增加。所不同的是派生类对于每一个基类都会进行这样的操作,那么相应的,派生类中就会存在多个虚表。同时,虚指针的排列顺序是按照继承顺序排列。

    //类的声明如下
    class Base1 {
    public:
        virtual void fun_1();
        virtual void fun_2();
    };
    
    class Base2 {
    public:
        virtual void fun_1();
        virtual void fun_3();
    };
    
    class Derived :public Base1 , public Base2{
    public:
        virtual void fun_1();
    };
    

    结构:


    vtable2.png

    一些补充:

    override 关键字:

    派生类如果想要覆盖基类的虚函数,那么派生类的虚函数必须和基类声明完全一致。如果有不一致的情况,那么应该属于隐藏而不是覆盖。

    class Base{
    public:
        virtual void fun(){ cout << " Base fun" << endl; }
    };
    
    class Derived :public Base{
    public:
        virtual void fun(int a) { cout << " Drtived fun" << endl;}
    };
    
    int main()
    {
        Base *p = new Drtived();
        p->fun();  // 输出结果为 Base fun
        // p->fun(1); Error:函数调用中的参数过多
    }
    

    为了避免写代码时失误将派生类函数写错,C++提供了 override 关键字。使用 override 声明的成员函数如果在基类中找不到对应的虚函数,那么编译器会提醒。 override 使用如下:

       virtual void fun(int a) override { cout << " Drtived fun" << endl;}
       //Error : 使用 “override” 声明的成员函数不能重写基类成员
    

    不过override 关键字需要编译器支持 C++11。

    虚表的生命周期

    我们来看以下代码:
    在构造函数和析构函数中都调用了虚函数 fun

    class Base{
    public:
        virtual void fun(){ cout << " Base fun" << endl; }
        Base(){ fun(); }
        ~Base(){ fun(); }
    };
    int main()
    {
        Base tmp;
    }
    

    输出:

    Base fun
    Base fun
    

    结果显示,在构造函数调用时虚表已经创建好了。而释放是在析构函数之后的回收过程中。

    虚析构函数

    虚函数一般的意义是为了接口重用,如果派生类不会对其进行修改,就没有将其定义为虚函数的必要。但有一个例外就是虚析构函数。我们来看以下程序:

    class Base{
    public:
        Base(){ cout << "Base" << endl; }
        ~Base(){ cout << "~Base" << endl; }
    
    };
    
    class Derived :public Base{
    public:
        Derived(){ cout << "Derived " << endl; }
        ~Derived(){ cout << "~Derived " << endl; }
    };
    
    int main()
    {
        Base *p = new Derived();
        delete p;
    }
    

    执行结果:

    Base
    Derived 
    ~Base
    

    也就是说,派生类的析构函数并没有被调用。这样的结果就会造成对象析构不完全,造成内存泄漏。如果在基类的析构函数前加上 virtual 修饰:

    virtual ~Base(){ cout << "~Base" << endl; }
    

    再次执行以上程序,输出结果为:

    Base
    Derived 
    ~Derived 
    ~Base
    

    派生类析构函数被正确执行。

    总结:

    1.派生类是对基类的虚表进行修改,但并不是直接修改基类本身,所以基类与派生类的虚指针是不同的。这个很好理解,如果派生类直接对基类虚表进行修改,那直接实例化基类对象的时候不就会执行派生类的函数。而且在多个派生类的情况下,基类虚表结构就不明了。
      2.虚函数的意义其实是为了接口重用,一个类如果不需要根据实例化不同而有不同的展示,那么就没有必要将某些函数定义为虚函数。
      3.如果一个函数的实现本身没有合理的缺省值,那么应该将其定义为纯虚函数,由派生类去实现它。含有纯虚函数的类称为抽象类,抽象类的实例化没有意义,所以它不能直接实例化。
      4.如果派生类没有实现基类的纯虚函数,那么编译器会报错,因为外部调用时不能找到该函数的具体实现。
      5.尽量使用 override 来避免编码失误。
      6.如果一个类有作为基类的可能,那么尽量将析构函数加上 virtual 修饰。

    相关文章

      网友评论

        本文标题:虚表、虚函数

        本文链接:https://www.haomeiwen.com/subject/bhimyttx.html