C++多态:从虚表指针到设计模式
Posted flying_music
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++多态:从虚表指针到设计模式相关的知识,希望对你有一定的参考价值。
多态是面向对象语言的一种高级特性。无论是底层的实现还是整体架构的设计,多态思想都有着很广泛的应用。学习多态不仅是要学习一种程序设计技术,更应该掌握的是其背后的设计思想。本文从底层讲起,一点一点剖析了多态的来龙去脉,希望能给大家呈现一个真实的多态。
从虚函数说起
虚函数是实现多态的语言基础,我们通过在继承体现中声明虚函数来实现多态技术。这里主要有三个关键点:
①继承体现,多态一定是存在于一个继承体现中的,没有继承就不会有多态发生。
②虚函数,只有声明为virtual的成员函数才能产生多态效果。
③基类指针或引用指向派生类对象,这是多态实现的最后一个条件,编译器根据派生类对象的类型来动态决定调用哪个虚函数,多态便产生了。
一个简单的多态示例代码如下:
#include<iostream>
using namespace std;
class B{ //基类
private:
int m_n;
double m_d;
public:
int m_f(); //普通函数
virtual int m_vf(); //虚函数
virtual ~B(){} //虚析构函数
};
int B::m_f()
{
cout<<"B::m_f()..."<<endl;
return 0;
}
int B::m_vf()
{
cout<<"B::m_vf()..."<<endl;
return 0;
}
class D : public B{ //派生类
public:
int m_f(); //普通函数
virtual int m_vf(); //虚函数
~D(){} //虚析构函数
};
int D::m_f()
{
cout<<"D::m_f()..."<<endl;
return 0;
}
int D::m_vf()
{
cout<<"D::m_vf()..."<<endl;
return 0;
}
void testFun(B *pB)
{
pB->m_f(); //调用一般的函数
pB->m_vf(); //调用虚函数
}
int main()
{
//虚函数测试
B b;
D d;
testFun(&b);
testFun(&d);
system("pause");
return 0;
}
经测试可发现,在用指针调用成员函数时,如果函数是普通函数,则根据指针的类型来确定调用函数的版本,即基类指针只能调用基类中的函数。如果函数是虚函数,则会根据指针所指类型来确定调用函数的版本,如果指向的对象为派生类对象,则调用派生类中的函数版本。
那么编译器是如何根据类型来决定函数调用的呢?这要从虚函数的底层实现说起。
虚函数的底层实现
这个话题比较大,涉及C++中的对象模型,要从一般的成员函数是如何实现的说起。当我们在程序中定义一个类时,C++编译器不会为我们分配任何内存。只有在类被实例化成对象时才会为对象分配内存,而且只为类的数据成员分配内存,成员函数在对象所占用的内存块中没有任何体现。在调用这些函数时,编译器自动为函数添加this指针,通过this指针来访问对应的类对象。也就是说,成员函数在整个类中只有一份实现代码。对应的示意图如下:
但是,如果我们将类中的函数改为虚函数,那编译器为了实现多态机制,会做如下处理:
①为含有虚函数的类设置一个指针表。这个表就是我们说的虚函数表(Virtual table),虚函数表是和含有虚函数的类是一一对应的。每当我们定义一个含有虚函数表的类,都会产生一个虚函数表。表里放的内容就是这个类中所有虚函数的入口地址,即函数指针。
②在每一个类对象的内存块儿里附加一个指针,这个指针指向其类的虚函数表,这就是我们的虚表指针(vptr)。
经过上面这两个改动,现在的对象模型变成了如下图所示的样子:
这里要注意以下两个事实:一是父类和派生类对应不同的虚函数表,统一继承层次的不同派生类也对应不同的虚函数表。还是那句话,虚函数表和类的类型是一一对应的。二是虽然虚函数表不同,但对象中的虚表指针的位置确实相对固定的,也就是说,(在同一继承体现中的)所有类的对象在内存块儿同一偏移量处(图中假设偏移量为0)存放着虚表指针。
有了上面这两个事实,我们便可以推测编译器在实现多态时到底是如何做的了。让我们回到本文开始时的那份代码。当调用testFun()函数时,形参pB指针指向实参对象。当利用pB调用m_vf()时,编译器发现这是一个虚函数,于是编译器根据pB找到虚表指针,然后根据这个指针找到虚函数表,在虚函数表里查找名称类似pointer_to_m_vf()的函数指针,再通过这个指针找到对应的m_vf()代码,从而实现了多态调用。
通过上面的分析我们发现,虚表指针是实现多态的关键所在。正是由于对象中有这样一个根据类型变化的参数,多态调用才得以顺利进行。而且,如果我们省略中间虚函数表一步,又或者我们只有一个虚函数,那么虚函数表就可以省略。这样一来,多态便变成了这样一件事情:在一个对象中添加一个指针,用这个指针指向我们要调用的函数,然后将带有指针的对象传给某个过程,再由这个过程调用以前的函数。这种反射式的调用这就是函数指针的使用价值,从某种角度来说,函数指针是比多态更基础的一种概念。
函数指针
多态是通过函数指针的方式来实现的,而函数指针的使用可以实现出比多态更灵活的功能。正如前面已经提到的,虚表指针是编译器是根据对象的类型为对象自动添加的,所以一个类的所有对象都有同样的虚表指针,也就有对应着同样的虚函数表。这样最后的效果是同一类的对象调用同一套虚函数。假设我们自己为对象添加一个函数指针,在类的构造函数中对这个指针进行初始化。这样一来我们就可以为每一个具体的对象指定不同的“虚函数”了。简单的测试用例如下:
#include<iostream>
using namespace std;
int foo1()
{
cout<<"foo1()..."<<endl;
return 0;
}
int foo2()
{
cout<<"foo2()..."<<endl;
return 0;
}
class A{
private:
int (*pFun)();
int m_n;
double m_d;
public:
A(int (*p_fun)()) : pFun(p_fun){}//初始函数指针
friend void show(A *pA);
};
void show(A *pA)
{
pA->pFun();
}
int main()
{
//函数指针测试
A a1(foo1); //a1的函数指针指向foo1
A a2(foo2); //a2的函数指针指向foo2
show(&a1);
show(&a2);
system("pause");
return 0;
}
测试结果显示,同一类型的对象在pA->pFun()过程中确实调用了不同的函数,而且整个过程没有用到虚函数!这其实就是人工实现了多态机制,而且比虚函数实现的多态更加灵活,在实际使用过程中,我们不仅可以传递函数,还可以传递函数对象,成员函数指针等等。当然,这种方法比用虚函数方法更加复杂了,而且新增加的友元函数破坏了类的封装性。到底该不该采用这种机制,还是要根据具体情况来具体分析的。
如果你对设计模式有所了解,那你应该可以看出上面这种实现策略其实是策略设计模式的简单应用。实际上,正是由于函数指针或者说多态的引入,为程序模式设计奠定了语言基础。
设计模式
关于设计模式的内容足可写一本书了,事实上确实有不少这样的书,对此感兴趣的同学可买一本书好好研究一下。这里只是说明多态在模式设计中的重要作用。
软件进行设计模式的重要概念之一是开闭原则,即要求设计的程序对扩展开放,对修改关闭。说白了,当我们要增加新功能时只要增加一些代码即可,不用修改原先的代码。这样做的好处是显而易见的,关键是如何实现的问题。在面向对象的语言中,用接口的概念来进行这种设计。接口就是程序对外预留的增加或者改变功能的地方。传统的程序是按照控制流一步一步顺序执行的,各个模块依次被调用来完成某一种特定功能。要想改变某一模块的功能,就必须进入模块内部进行代码修改。而接口的出现为设计提出了新思路,我们可以根据接口来实现新的模块,然后这些模块可以直接挂载到原先的程序中。这里有一张示意图说明此事:
以上是关于C++多态:从虚表指针到设计模式的主要内容,如果未能解决你的问题,请参考以下文章