用栗子打倒C++多态

本文深入解析C++中多态性的实现机制,包括虚函数、继承与虚表等内容。通过实例演示,详细解释类对象的存储结构、虚表的构建与作用,以及指针类型与内存分配的关系。进一步探讨多态性如何在运行时根据对象实际类型调用相应函数,以及构造函数与析构函数的特殊性。
摘要由CSDN通过智能技术生成

写在前面:

题目不是标题党哈,因为我比较喜欢把我自己理解的东西,然后写出栗子,并且画出图(我的画图水平还是可以的啦)来深入浅出得让别人理解。

C++的多态啊,虚函数,继承,虚表等等东西大家学过C++的都知道,但是如果要说出其中的所以然来,还真不一定说得明白和准确。

我自己也是看了很多博客,以及不少书籍,所以想总结一下,作为知识的梳理,也希望给疑惑的人给予解惑。

参考资料:

http://www.jellythink.com/archives/162这是Jelly介绍接口的原理的,想让大家了解COM,虚函数这部分图示做得还是很不错的。

http://www.jb51.net/article/41809.htm这一篇的例子挺好,不过有些地方没有说明白。

http://zhidao.baidu.com/link?url=naBFR7YFi_R7fu5dX3pJz5UsK2CCXI0cXobjjP_g_SKD9Qv--e1j8vYC7Fwyrf5cgpF82o2kelXXzB0lI-ILaa这介绍了多态的概念(广义、狭义)和应用场景。

《深入探索C++对象模型》(以下简称《Inside》)

大家可以提前去看一下,或者看完我的博客之后再去看,应该会有不少收获。

一、Class Object

首先,我们应该问自己,类对象在实例化后,存储结构究竟是怎么样的,类变量,类函数,静态变量,静态函数以及虚函数分别放在什么地方?

《Inside》中提到:“Nonstatic data members被配置于每一个class object之内,static data members则被存放在所有的class object之外,static和nonstatic function members也被放在所有的class object之外。”

举个书中的栗子:

对于:

class Point{
public:
	Point(float xval);//函数都在object外
	virtual ~Point();//虚函数在虚表vtbl中
	float x() const;//函数都在object外
	static int PointCount();//函数都在object外
protected:
	virtual ostream& print(ostream &os)const;//虚函数在虚表vtbl中
	float _x;//类变量在object内
	static int _point_count;//静态变量在object外
};


对应的图示如上所示,其中几个函数(无论是静态类函数还是普通类函数,不是virtual)的都在object外,静态类变量也在object外,而普通类变量和指向虚表vtbl的虚指针vptr在object内,虚表中除了有指向两个虚函数的指针外,还有一个指向type info的指针。

书中讲到:

virutal functions以两个步骤支撑:
1,每一个class产生出一堆指向virtual functions的指针,放在表格之中,这个表格被称为virtual table(vtbl)
2,每一个class object被添加了一个指针,指向相关的virtual table,通常这个指针被称为vptr,vptr的设定和重置都由每一个class的constructor、destructor和copy assignment运算符自动完成。每一个class所关联的type_info object(用以支持runtime type identification:RTTI)也经由virtual table被指出来,通常是放在表格的第一个slot处。

那么我们自己试验一下:

以下代码:

#include <iostream>
using namespace std;
class animal{
public:
	void sleep(){//普通类函数,不在object内
		cout << "animal sleep" << endl;
	}
	virtual void breathe(){//虚函数在虚表vtbl中
		cout << "animal breathe" << endl;
	}
	virtual void run(){//虚函数在虚表vtbl中
		cout << "animal run" << endl;
	}
	static string s_str;//静态变量在object外
	string name;//类变量在object内
};
int main(){
	animal An1;
	return 0;
}



在return 0;处设置断点查看变量An1的内部,可以看到object内只有name和__vfptr指向虚表的指针。

虚表中有两个指针,一个指向animal::breathe,一个指向animal::run,大致符合书中描述,不过并没有书中提到的本改在虚表中第一位置的type_info for Point,不知道是微软实现的不同还是后来C++标准变化了。

一般而言表现一个class object需要以下内存:
(1)其nonstatic data members的总和大小
(2)加上任何由于alignment的需求而填补(padding)上去的空间,(可能存在于members之间,也可能存在于集合体边界)
(3)加上为了支持virtual而由内部产生的任何额外负担(overhead)
上面的知识点其实和我们最熟知的sizeof有密切关系,因为只有在class object内的大小才会被计入sizeof中,如果对上述的Point类进行
cout << sizeof(Point) << endl;
我们会得到8,在class object之内只有一个float和一个__vfptr,刚好是4+4=8;
而如果cout << sizeof(animal) << endl;则会得到32,这是因为一个size(string)在我的VS2013中得到的是28(相当于string类的class object占28大小),再加上一个__vfptr,刚好是32。


二、Pointer type

接下来讨论指针,先考虑一个类和指向它的指针

class ZooAnimal{
public:
	ZooAnimal();
	virtual ~ZooAnimal();
	//...
	virtual void rotate();
protected:
	int loc;
	string name;
};
ZooAnimal za("Zoey");
ZooAnimal *pza = &za;


它的内存大致是这样的。

一个指向Animal类的指针是如何与一个指向整数的指针或一个指向template Array的指针有所不同的呢?

Animal *px;
int *pi;
Array<String>*ptra;
以内存需求的观点来看,没有什么不同!它们三个都需要有足够的内存来放置一个机器地址。“指向不同类型之各指针”间的差异,既不在其指针表示法不同,也不在其内容(代表一个地址)不同,而是在其锁寻址出来的object类型不同,也就是说,“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小。
那么,一个指向地址1000而类型为void*的指针,将涵盖怎样的地址空间呢?是的,我们不知道!这就是为什么一个类型为void*的指针只能够含有一个地址,而不能够通过它操作所指的object的缘故。
所以,转型(cast)其实是一种编译器指令,大部分情况下它并不改变一个指针所含的真正地址,它只影响“被指出之内存的大小和其内容”的解释方式。

可以看到pza中只是存放了地址,而是ZooAnimal这一个指针类型告诉编译器怎么来解释这个地址以及之后多大内存的内容。


再考虑以下类的继承关系:Bear继承自ZooAnimal

class Bear : public ZooAnimal{
public:
	Bear();
	~Bear();
	//...
	void ratate();
	virtual void dance();
	//...
protected:
	enum Dances{ ... };
	Dances dances_known;
	int cell_block;
};
Bear b("Yoqi");
Bear *pb = &b;
Bear &rb = *pb;

可以看到,对于:
Bear b;
ZooAnimal *pz = &b;
Bear *pb = &b;
Bear指针pb和ZooAnimal指针pz都指向Bear object的第一个byte,其间的差别是:pb所涵盖的地址包含整个Bear object,而pz所涵盖的地址只包含Bear object中的ZooAnimal subobject。
特别要注意的是:对于pz来说,它可以访问到__vptr_ZooAnimal,但是它不能访问到Bear自己部分的内容比如dances_known或cell_block(除非见标注1),而且,pz->__vptr_ZooAnimal中的虚表内容是Bear中的虚指针对应的虚表,里面的函数已经被覆盖了。(栗子中等下会提到,标注2)

标注1:

pz不能访问cell_block,因为后者属于Bear自己的东西而不是ZooAnimal中继承而来的。但是因为pz是指针,所以在转换类型后还是可以访问到的(相当于我们告诉编译器把pz当做Bear指针来看待,即把接下来的内容按照Bear的obejct的内容来分析)。

//不合法,cell_block不是ZooAnimal的一个member,
//虽然我们知道pz当前指向一个Bear object,但pz是一个ZooAnimal的指针
//它无法按照Bear那样去解读分析内容
pz->cell_block;//error

//ok,经过一个明确的downcast操作就没有问题!
(( Bear* ) pz)->cell_block;

//下面这样更好,不过它是一个run-time operation,运行时确定
if (Bear* pb2 = dynamic_cast<Bear*>(pz))
	pb2->cell_block;

其中,dynamic_cast运算符:用于将基类的指针或引用安全地转换成派生类的指针或引用

它配合typeid运算符:用于返回表达式的类型

dynamic_cast和typeid共同完成了RTTI(run-time type identification)运行时类型识别

当我们将这两个运算符用于某种类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型。

(参考《C++primer》P730)

也就是说,当你用ZooAnimal *pz的指针去指向Bear对象时,你用dynamic_cast将pz绑定为一个Bear对象,这样变化后的pz就可以访问Bear自己的特有变量或函数了。

举个栗子:

我们还是用animal,不过加上了fish的继承关系:

#include <iostream>
using namespace std;
class animal{
public:
	void sleep(){
		cout << "animal sleep" << endl;
	}
	virtual void breathe(){
		cout << "animal breathe" << endl;
	}
	virtual void run(){
		cout << "animal run" << endl;
	}
	static string s_str;
	string name;
};
class fish :public animal{
public:
	void breathe(){
		cout << "fish bubble" << endl;
	}
	void swim(){
		cout << "fish swim" << endl;
	};
	string fish_name;
};
int main(){
	animal An1;
	fish fh;
	animal *pAn = &fh; 
	pAn->breathe();
	//pAn->
	//dynamic_cast<fish*>(pAn)->
	animal An2 = fh;
	An2.breathe();
	return 0;
}
当写到pAn->它能够自动补全的只有animal中自己的变量、函数和虚函数(不过这个虚函数已经不是animal的虚函数了,标注2)


而当我们做了一个dynamic_cast的转化之后,它能够识别的还有fish种自己的特有方法和变量



三、多态

举个栗子:

接上一个栗子,看一下31行中pAn的内存,给大家解释一下虚函数是怎么实现的

这里有4个要关注的,Animal对象An,fish对象fh,指向对象fh的父类即animal类指针pAn,还有被子类进行对象赋值拷贝的父类An2

总的内存状况是如下:


让我们来庖丁解牛,首先,注意到animal类对象An和fish类对象fh,


可以看到,在子类对象fh中是有一个完整的父类结构的,里面的__vfptr和name也都有,但是注意到,因为fish中重新写了(相当于Java中@override)breathe函数,所以fh中的__vfptr中已经变成了fish::breathe,而不是An1中的animal::breathe,并且地址已经不一样了(标示2)。而fh中的animal::run的地址还是和An1的一样(公用),它们都是0x00e7150a。

用以下图示表示(图中的地址都是当前指针指向的地址,而不是当前指针所在的地址):


也许读者会疑惑,如果是两个Fish对象,它们的内存是怎么样的呢?要知道,类是公用虚表的,所以如果有多个fish对象,它们的name指向的地址肯定是不同的,但是它们的__vfptr指向的地址是相同的(指向同一个虚表),那么当然vbtl中各个项指向的地址也是相同的。(这个在接下来的An和An2中我们也可以看出来,标示3)

接着往下看,当ZooAnimal* pAn = &fh;后


可以看到pAn中和fh最大的区别就是少了黄框标注的fish独有的那部分变量,而注意到pAn虽然是一个animal的指针,但是它里面的虚表因为是在fh中的,所以里面的项还是和fh中一样的,__vfptr[0]还是fish::breathe()而不是animal::breathe(),这就是多态的实现了!所谓的“运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数”和“允许将子类类型的指针赋值给父类类型的指针。”

不过我个人还是疑惑为什么pAn中第一项有[fish]这个东西,里面的结构和上面的fh一模一样,除了fish和fh这两个名字上的差别,我觉得这个量应该不是在pAn指针中,而是只是指示说当前指向对象是fish对象用的。

咦,这会不会就是我们刚开始在《inside》当中看到在之前没有找到的type-info for Point呢,不过《inside》里面说应该是在vtbl中的。还不得而知。

因而,如果我们调用pAn->breathe(),将会有fish bubble,而不是animal breathe。

再在animal An2 = fh;之后,因为An2声明为一个对象,而不是指针,所以在fh给An赋值的时候发生默认的拷贝构造函数,(这是对象切割,详见标示4)这其中并不涉及虚表vtbl的拷贝,所以An2还是用的是animal类的虚表(如果fh有name,会把name复制给它,但也仅限于此)。


从内存中,我们也可以看出,An2和An1的虚表是一样的。这也是印证了之前的标志3。

所以对An2.breathe()的结果会是animal brethe而不是fish bubble。

程序的结果如下:


标示4:关于对象切割,可以参考:http://blog.csdn.net/xd1103121507/article/details/7266863

在我的栗子中,animal An2 = fh;发生了对象切割,造成An2用的是animal的虚表;

而animal pAn = &fh;不是对象切割,因为pAn用的fish的虚表,因而是多态。

另外一个还要注意对象切割的地方就是以对象作为参数时,用reference to const替换pass by value可避免对象切割。

总结:

C++的多态,是在每个类中维护一个虚表,只有在基类中用virtual声明的函数才会被加入虚表中,子类如果没有重写或覆盖(override)基类的虚函数,则子类的虚表中指针还是指向父类的对应虚函数地址,如果子类中重写了基类的虚函数,那么子类的虚表中对应项会指向新的函数地址,通过给子类类型指针赋值给父类类型指针,后者作为参数,在编译期间并不知道自己到底会调用哪个子类虚表中的函数,只有当参数确定下来之后,它才进行编译时绑定,根据参数的类型去寻找对应类中的虚表,才能确定对应的函数,这就是多态性和延迟绑定。

P.S:

为什么构造函数不能是虚函数,而析构函数一般都需要是虚函数呢?

因为虚表的建立是在构造函数的过程中的,所以我们不能设置构造函数为虚函数。因而构造函数的部分子类一般在自己的构造函数中再调用一下基类的构造函数;并且新建对象时你是明确对象的类型的,也不需要用多态的概念。

而析构函数设置为虚函数是可行的也是必要的,可行是因为析构函数也是类中的一个函数,并且在赋值过程后你可能有着一个基类却不能明确它的对象类型,这个时候就可以用虚函数的多态来应对子类中新的成员的析构部分。


——Apie陈小旭




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值