什么是虚函数?
使用 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 修饰。
网友评论