前言
博客编写人:Willam
博客编写时间:2017/3/21
博主邮箱:2930526477@qq.com(有志同道合之人,可以加qq交流交流编程心得)
在基类的派生类中就可以通过重写虚函数来实现对基类虚函数的覆盖。当基类的指针指向派生类的对象时,基类指针对虚函数的调用实际上是调用了派生类的虚函数。这是面向对象中多态性的体现。
白话就是:
虚函数就是通过虚函数表来实现的。所谓的虚函数表(virtual table)其实就是一组指针数组,里面保存了该类的各个虚函数的地址。另外,为了保证虚函数表的最高的性能,所以虚函数表都是被放在一个类对象对应的内存空间的最前面的位置,因此我们可以通过类对象的地址对虚函数进行访问。
验证代码:
#include
using namespace std;
typedef void(*Function)(void);//函数指针,用于声明函数
class A {
public:
virtual void test() { cout << "A::test" << endl; }
virtual void test1() { cout << "A::test1" << endl; }
virtual void test2() { cout << "A::test2" << endl; }
void test3() {};
};
int main()
{
A a;
A b;
Function fun1 = NULL;
Function fun2 = NULL;
Function fun3 = NULL;
//首先&a是类对象的首地址,我们之前说了虚函数表就放在类对象
//的首地址那里,而为等下可以解引用,所以要转为(int*)
fun1 = (Function)*((int*)*(int *)&a); //代表函数test
fun2 = (Function)*((int*)(*(int *)&a) + 1); //代表函数test1
fun3 = (Function)*((int*)(*(int *)&a) + 2);//代表函数test2
fun1(); fun2(); fun3();
system("pause");
return 0;
}
编译环境:vs2015
输出:
通过设置断点的方式,我们可以得到如下的截图:
我么可以发现虚函数(1-2)都加入到虚函数表中来了,而最后一个test3并没有被加入到这里。除此之外,我还想强调一点就是,我们的虚函数表是静态,意思程序不会为每个类对象都申请一个虚函数表,如下图所示:
下面我们在通过一张图来形象的看出我们的虚函表到底在类对象中待在一个怎样的位置:
这里解释一下,其实我们这里的虚函数表的存放犹如链表,所以最后它会有一个结束符
首先,我们的先明确的就是在基类中声明了虚函数,就是希望在其子类中,对父亲类进行虚函数进行覆盖重写,这个也算是虚函数引入的初衷,但是如果你没有这么做,也不会出错,只是偏离了我们的初心。
下面,我们就分别举例说明单继承和多继承下的虚函数的使用例子;
/*单继承的例子*/
#include
using namespace std;
typedef void(*Function)(void);//函数指针,用于声明函数
class A {
public:
virtual void test() { cout << "A::test" << endl; }
virtual void test1() { cout << "A::test1" << endl; }
virtual void test2() { cout << "A::test2" << endl; }
};
class B :public A {
public:
//分别对test和test1函数进行覆盖重新
void test() { cout << "B::test" << endl; }
void test1() { cout << "B::test1" << endl; }
};
int main()
{
//我们通过一个基类的指针去指向一个子类的对象。
A *a = new B();
a->test();
a->test1();
a->test2();
system("pause");
return 0;
}
然后,我可以通过设置断点的方式来查看我们类的内部结构,如下图所示:
OK,从这里我们可以结合上述的两个结果,可以得出的结论是:当我们使用基类的指针指向子类对象时,基类的虚函数表中虚函数如果在子类中被重写了,那么该虚函数表的对应的函数指针的内容也会被改变(该为指向子类的对虚函数重写的函数),更加形象的描述如下:
下面我继续介绍多继承下的例子:
#include
using namespace std;
typedef void(*Function)(void);//函数指针,用于声明函数
class A {
public:
virtual void Atest() { cout << "A::test" << endl; }
virtual void Atest1() { cout << "A::test1" << endl; }
virtual void Atest2() { cout << "A::test2" << endl; }
};
class B {
public:
virtual void Btest() { cout << "B::test" << endl; }
virtual void Btest1() { cout << "B::test1" << endl; }
virtual void Btest2() { cout << "B::test2" << endl; }
};
class C {
public:
virtual void Ctest() { cout << "C::test" << endl; }
virtual void Ctest1() { cout << "C::test1" << endl; }
virtual void Ctest2() { cout << "C::test2" << endl; }
};
class D :public A,public B,public C {
public:
//分别对test和test1函数进行覆盖重新
void Atest() { cout << "D::test" << endl; }
void Btest() { cout << "D::test1" << endl; }
void Ctest() { cout << "D::test1" << endl; }
};
int main()
{
//我们通过一个基类的指针去指向一个子类的对象。
A *a = new D();
B *b = new D();
C *c = new D();
D d;
a->Atest();
a->Atest1();
a->Atest2();
cout << endl;
b->Btest();
b->Btest1();
b->Btest2();
cout << endl;
c->Ctest();
c->Ctest1();
c->Ctest2();
cout << endl;
system("pause");
return 0;
}
从输出,我可以看出这里其实和我们单继承的情况是一样的,如果我们使用基类指针去指向一个子类的对象时,当我调用了虚函数时,它是会调用子类的里面的函数,而对于这个多重继承的情况,我们更多考虑的子类中的是如何保存其父类的虚函数表,我们通过图也就是明白子类对象的最前面是按照继承顺序分别保存基类的信息,并且同样是把虚函数表放在该信息的最前面,如下图:
非类的成员函数不能定义为虚函数,类的成员函数中静态成员函数和构造函数也不能定义为虚函数,但可以将析构函数定义为虚函数。实际上,优秀的程序员常常把基类的析构函数定义为虚函数。因为,将基类的析构函数定义为虚函数后,当利用delete删除一个指向派生类定义的对象指针时,系统会调用相应的类的析构函数。而不将析构函数定义为虚函数时,只调用基类的析构函数。
首先我们要知道类中的static的含义,静态成员是和类绑定的,在内存中只有一份拷贝;通过类名或对象引用访问静态成员(数据或者函数)。如果定义为虚函数,那么它就是动态绑定的,也就是在派生类中可以被覆盖的
构造函数所完成的工作就是为了建立合适的对象,因此在没有构建好的对象上不可能执行多态(虚函数的目的就在于实现多态性)的工作。在继承体系中,构造的顺序就是从基类到派生类,其目的就在于确保对象能够成功地构建。构造函数同时承担着虚函数表的建立,如果它本身都是虚函数的话,如何确保virtual table的构建成功呢?
当基类的构造函数内部有虚函数时,会出现什么情况呢?结果是在构造函数中,虚函数机制不起作用了,调用虚函数如同调用一般的成员函数一样。
当基类的析构函数内部有虚函数时,又如何工作呢?与构造函数相同,只有“局部”的版本被调用。但是,行为相同,原因是不一样的。构造函数只能调用“局部”版本,是因为调用时还没有派生类版本的信息。析构函数则是因为派生类版本的信息已经不可靠了。**我们知道,析构函数的调用顺序与构造函数相反,是从派生类的析构函数到基类的析构函数。当某个类的析构函数被调用时,其派生类的析构函数已经被调用了,相应的数据也已被丢失,如果再调用虚函数的派生类的版本,就相当于对一些不可靠的数据进行操作,这是非常危险的。因此,在析构函数中,虚函数机制也是不起作用的。**
对于性能的分析,我们摘取了网络的一篇博客的内容(原文:虚函数浅析):
在单继承的情况下,调用虚函数所需的代价基本上和非虚函数效率一样,在大多数计算机上它多执行了很少的一些指令,所以有很多人一概而论说虚函数性能不行是不太科学的。
在多继承的情况下,由于会根据多个父类生成多个vptr,在对象里为寻找 vptr 而进行的偏移量计算会变得复杂一些,但这些并不是虚函数的性能瓶颈。
虚函数运行时所需的代价主要是虚函数不能是内联函数。这也是非常好理解的,是因为内联函数是指在编译期间用被调用的函数体本身来代替函数调用的指令,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。
”但虚函数的运行时多态特性就是要在运行时才知道具体调用哪个虚函数,所以没法在编译时进行内联函数展开。当然如果通过对象直接调用虚函数它是可以被内联,
但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。 因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。
所以通过上述的一段话,我们可以大体知道为什么虚函数的效率低的原因了,那就是它不是内联函数。
在上面的虚函数实现原理部分,可以看到为了实现运行时多态机制,编译器会给每一个包含虚函数或继承了虚函数的类自动建立一个虚函数表,所以虚函数的一个代价就是会增加类的体积。在虚函数接口较少的类中这个代价并不明显,虚函数表vtbl的体积相当于几个函数指针的体积,如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间。但这并不是最主要的代价,主要的代价是发生在类的继承过程中,在上面的分析中,可以看到,当子类继承父类的虚函数时,子类会有自己的vtbl,如果子类只覆盖父类的一两个虚函数接口,子类vtbl的其余部分内容会与父类重复。这在如果存在大量的子类继承,且重写父类的虚函数接口只占总数的一小部分的情况下,会造成大量地址空间浪费。在一些GUI库上这种大量子类继承自同一父类且只覆盖其中一两个虚函数的情况是经常有的,这样就导致UI库的占用内存明显变大。 由于虚函数指针vptr的存在,虚函数也会增加该类的每个对象的体积。在单继承或没有继承的情况下,类的每个对象会多一个vptr指针的体积,也就是4个字节;在多继承的情况下,类的每个对象会多N个(N=包含虚函数的父类个数)vptr的体积,也就是4N个字节。当一个类的对象体积较大时,这个代价不是很明显,但当一个类的对象很轻量的时候,如成员变量只有4个字节,那么再加上4(或4N)个字节的vptr,对象的体积相当于翻了1(或N)倍,这个代价是非常大的。
虚函数时给我们带来很多方便的地方,它帮助我们实现了类的多态性,但是这样也让我们的类的封装性遭到了破坏,如下代码所示:
#include
using namespace std;
class A {
private:
virtual void test() { cout << "test" << endl; };
};
class B :public A{
public:
};
typedef void(*Fun)(void);
int main()
{
B b;
Fun f = NULL;
//如果我们知道虚函数表的定义,就知道可以通过地址来访问这些
//私有函数了
f = (Fun)*((int*)*(int*)&b);
f();
system("pause");
return 0;
}
输出:
test
纯虚函数是在基类中声明了一个虚函数,但是没有实现的。而且只要是有纯虚函数的出现的类就是抽象类。
class A{
public:
virtual void fun()=0; //纯虚函数的声明方式
};
另外,我要记住抽象类是不能创建类的实例,只能创建它的派生类的实例。虽然,语法上没有要求改抽象类一定要被继承,但是从我们出发的初衷,我就是希望它是要被继承的。
另外,对于抽象类,我需要注意如下两点:
#include
using namespace std;
class A {
public:
virtual void test()= 0;
};
class B :public A{
public:
void test() { cout << "B::test" << endl; }
};
int main()
{
//A a; 这样是非法的,语法错误
A * a = new B(); //这个是正确的
B b;
A *c = &b;
a->test();
c->test();
system("pause");
return 0;
}
输出:
B::test
B::test
- 为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
- 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
在c++中,当我们在进行多继承的情况下,如果有如下的情况:
class A{};
class B:public virtual A {};
class C:public virtual A {};
class D:public B,public C {};
当出现一个类D同时继承了B、C,但是B,C又同时继承了A,那么这个时候为了节约空间,就会把类A进行虚拟继承。
所以下面,有两幅图,分别表示虚拟继承和不虚拟继承的情况下,B和C里的A是怎么样的一个情况
虚拟继承的情况下:
所以,我可以知道他们是通过公用一个A来节省空间的,
验证代码:
#include
#include
using namespace std;
class A {
public:
int a;
};
//虚拟继承
class B :public virtual A {
public:
int b;
};
class C :public virtual A {
public:
int c;
};
class D :public B,public C {
public:
int d;
};
//非虚拟继承
class B1 :public A {
public:
int b1;
};
class C1 :public A {
public:
int c1;
};
class D1 :public B1, public C1 {
public:
int d1;
};
int main()
{
//虚拟继承
D d;
B *b = &d;
C *c = &d;
cout << &(b->a) << endl;
cout << &(c->a) << endl;
//非虚拟继承
D1 d1;
B1 *b1 = &d1;
C1 *c1 = &d1;
cout << &(b1->a) << endl;
cout << &(c1->a) << endl;
cout << endl;
system("pause");
return 0;
}
输出结果:
从图中我们可以发现,在虚拟继承的情况下,B,C是共用一个A的,所以A的成员a的地址相同,而在非虚拟继承的情况下,则是另外一种情况,B和C分别有各自的a,所以会出现A里的成员a的地址不同。
然后,要我们分别给出各个情况的sizeof(a)和sizeof(b),其实在vs下运行的结果如下:
第一种:4,12
第二种:4,4
第三种:8,16
第四种:8,8
首先,考虑我们类对象都是存储其方法的函数指针,所以sizeof的指针就是占了4个字节,然后,考虑字节对齐(按4个字节对齐),所以情况1和2,sizeof(a)=4,情况3和4为:sizeof(a)=8;
下面我们考虑类b,只要是虚拟继承,则子类中还会要有一个指针是vptr_b_a,这个指针叫虚类指针,它是在程序运行时,指向对应那个共用的类A,因此我们可以知道第1种和第二种情况下,它要多加这个虚类指针。