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()过程中确实调用了不同的函数,而且整个过程没有用到虚函数!这其实就是人工实现了多态机制,而且比虚函数实现的多态更加灵活,在实际使用过程中,我们不仅可以传递函数,还可以传递函数对象,成员函数指针等等。当然,这种方法比用虚函数方法更加复杂了,而且新增加的友元函数破坏了类的封装性。到底该不该采用这种机制,还是要根据具体情况来具体分析的。

如果你对设计模式有所了解,那你应该可以看出上面这种实现策略其实是策略设计模式的简单应用。实际上,正是由于函数指针或者说多态的引入,为程序模式设计奠定了语言基础。

设计模式

关于设计模式的内容足可写一本书了,事实上确实有不少这样的书,对此感兴趣的同学可买一本书好好研究一下。这里只是说明多态在模式设计中的重要作用。

软件进行设计模式的重要概念之一是开闭原则,即要求设计的程序对扩展开放,对修改关闭。说白了,当我们要增加新功能时只要增加一些代码即可,不用修改原先的代码。这样做的好处是显而易见的,关键是如何实现的问题。在面向对象的语言中,用接口的概念来进行这种设计。接口就是程序对外预留的增加或者改变功能的地方。传统的程序是按照控制流一步一步顺序执行的,各个模块依次被调用来完成某一种特定功能。要想改变某一模块的功能,就必须进入模块内部进行代码修改。而接口的出现为设计提出了新思路,我们可以根据接口来实现新的模块,然后这些模块可以直接挂载到原先的程序中。这里有一张示意图说明此事:

带接口的图中,模块2根据不同的情况来调用不同的模块3版本。换句话说,模块2预留了接口,只要模块3实现了这个接口,那么就可被模块2所调用。在语言中,这个接口通常就是有虚函数来实现的,在此处,我们可以将模块2看做基类,里面定义了一个虚函数,然后模块3全是模块2的派生类,它们重写了基类中的虚函数,然后编译器就可以根据不同的模块3类型对象调用不同的函数了。说了这么多,还是举个例子帮助大家理解吧,下面是一个由模板模式实现的计算不同函数运行时间的程序。

#include <iostream>
#include <time.h>
#include <windows.h>
#include <stdlib.h>

using namespace std;

class TimeCounter{
private:
	time_t start;
	time_t end;
	virtual void runFun() = 0;	//接口
public:
	TimeCounter() : start(0), end(0) {}
	void calTime(){	//计算调用时间
		start = time(NULL);
		runFun();	//调用接口函数
		end = time(NULL);
	}
	time_t getTime(){
		return end - start;
	}
};
class FunCal1 : public TimeCounter{
private:
	virtual void runFun(){	//重写接口
		Sleep(2000);
	}
};
class FunCal2 : public TimeCounter{
private:
	virtual void runFun(){	//重写接口
		Sleep(5000);
	}
};

int main()
{
	//模板方法模式测试
	FunCal1 fc1;
	FunCal2 fc2;

	fc1.calTime();
	cout<<fc1.getTime()<<endl;

	fc2.calTime();
	cout<<fc2.getTime()<<endl;
	system("pause");
	return 0;
}
从代码可以看出,TimeCounter充当的就是接口类的作用,只要某个类继承了它,并且实现了runFun()接口,那就可以用统一的方法来计算不同函数的时间了。

这样的设计模式在程序设计系统中不胜枚举,我们熟悉的所有第三方库几乎都是这样接入系统的。举个最常见的例子,数据库是几乎是每个程序系统不可缺少的组成部分,而数据库厂家有很多,我们可以断定Oracle对数据表的操作方法和DB2是不一样的,那我们在程序设计时是否要学习每一种数据库的操作方法呢?当然不用了,所有软件开发工具(无论微软的C#还是sun的java)都为数据库操作提供了数据库操作规范。例如获得一个连接,执行一个查询语句等等,只要数据库厂家按照这个规范制定了自己的方法,那用户就可以统一使用而不用去管实现细节了。这个规范其实就是软件开发工具留给数据库厂家的接口。

关于多态的的讨论到此告一段落,在很多语言中,指针已经成为了保留内容。我们之所以要从指针说起,是因为指针带来的间接性在程序设计中是非常宝贵的特性,正是这种间接性隔离了不同模块之间的耦合关系,这是很多高级特性和模式设计的基础。

最后:多态思想博大精深,加上本人能力有限,如文中错误之处,欢迎大家指出!

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值