虚表、虚函数

作者: 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 修饰。

相关文章

  • 第二周(Geek Band)

    对象模型 1、vptr和vtbl(虚函数与虚表) 调用虚函数vfun,通过虚指针vptr找到虚表vtbl,通过虚表...

  • 虚表、虚函数

    什么是虚函数? 使用 virtual 关键字修饰的函数即为虚函数,virtual 关键字只能对类中的非静态函数使用...

  • C++——虚函数表,常见问题,RTTI,typecast

    一、虚表 函数指针数组虚表的位置 override就是子类写的虚函数将父类的虚函数覆盖 虚表是在对象生成的时候才有...

  • Part2_Week2(boolan)

    vptr和vtbl:如果类中包含虚函数,则其对象中包含一个虚指针,虚指针指向一个虚表,虚表指向虚函数的定义。虚函数...

  • c++虚函数与虚表初步

    虚指针与虚表 虚表和虚函数是为了实现动态多态的机制,由编译器实现 当一个类本身定义了虚函数,或其父类有虚函数时,编...

  • 虚函数

    虚函数被虚表引用,所以,就算你没有用到,也是要实现的。如果你实在不需要,使用空函数。 纯虚函数只是告诉虚表,这个函...

  • C++——虚函数,纯虚函数,函数重载

    异质链表 虚函数虚函数主要用于多态 若一个类中含有虚函数则系统会自动的创建一个表,该表用于存放虚函数的入口地址称该...

  • C++多态

    多态原理 当类存在虚函数时,编译器会为该类维护一个表,这个表就是虚函数表(vtbl),里面存放了该类虚函数的函数指...

  • GeekBand-C++面向对象高级编程(下)-Week2

    对象模型:虚函数表(vtbl)与虚表指针(vptr) 我们知道,C++中,可以通过虚函数来实现多态性,而虚函数是通...

  • C++虚函数表

    虚函数表 C++中虚函数是通过一张虚函数表(Virtual Table)来实现的,在这个表中,主要是一个类的虚函数...

网友评论

    本文标题:虚表、虚函数

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