C++
的多态性实现机制剖析
――即
VC++
视频第三课
this
指针详细说明
作者:孙鑫
时间:
2006
年
1
月
12
日
星期四
1.
多态性和虚函数
我们先看一个例子:
#include <iostream.h>
class
animal
{
public
:
void sleep()
{
cout
<<"animal sleep"<<endl;
}
void
breathe()
{
cout
<<"animal breathe"<<endl;
}
};
class
fish:public animal
{
public
:
void
breathe()
{
cout
<<"fish bubble"<<endl;
}
};
void
main()
{
fish
fh;
animal
*pAn=&fh;
pAn
->breathe();
注意,在例
1-1
的程序中没有定义虚函数。考虑一下例
1-1
的程序执行的结果是什么?
答案是输出:
animal
breathe
我们在
main()
函数中首先定义了一个
fish
类的对象
fh
,接着定义了一个指向
animal
类的指针变量
pAn
,将
fh
的地址赋给了指针变量
pAn
,然后利用该变量调用
pAn
->breathe()
。许多学员往往将这种情况和
C++
的多态性搞混淆,认为
fh
实际上是
fish
类的对象,应该是调用
fish
类的
breathe()
,输出“
fish bubble
”,然后结果却不是这样。下面我们从两个方面来讲述原因。
1、
编译的角度
C++
编译器在编译的时候,要确定每个对象调用的函数的地址,这称为早期绑定(
early binding
),当我们将
fish
类的对象
fh
的地址赋给
pAn
时,
C++
编译器进行了类型转换,此时
C++
编译器认为变量
pAn
保存的就是
animal
对象的地址。当在
main()
函数中执行
pAn
->breathe()
时,调用的当然就是
animal
对象的
breathe
函数。
2、
内存模型的角度
我们给出了
fish
对象内存模型,如下图所示:
图
1-
1
fish
类对象
的内存模型
我们构造
fish
类的对象时,首先要调用
animal
类的构造函数去构造
animal
类的对象,然后才调用
fish
类的构造函数完成自身部分的构造,从而拼接出一个完整的
fish
对象。当我们将
fish
类的对象转换为
animal
类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是图
1-1
中的“
animal
的对象所占内存”。那么当我们利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法。因此,输出
animal breathe
,也就顺理成章了。
正如很多学员所想,在例
1-1
的程序中,我们知道
pAn
实际指向的是
fish
类的对象,我们希望输出的结果是鱼的呼吸方法,即调用
fish
类的
breathe
方法。这个时候,就该轮到虚函数登场了。
前面输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用迟绑定(
late binding
)技术。当编译器使用迟绑定时,就会在运行时再去确定对象的类型以及正确的调用函数。而要让编译器采用迟绑定,就要在基类中声明函数时使用
virtual
关键字(注意,这是必须的,很多学员就是因为没有使用虚函数而写出很多错误的例子),这样的函数我们称为虚函数。一旦某个函数在基类中声明为
virtual
,那么在所有的派生类中该函数都是
virtual
,而不需要再显式地声明为
virtual
。
下面修改例
1-1
的代码,将
animal
类中的
breathe()
函数声明为
virtual
,如下:
#include <iostream.h>
class
animal
{
public
:
void sleep()
{
cout
<<"animal sleep"<<endl;
}
virtual
void breathe()
{
cout
<<"animal breathe"<<endl;
}
};
class
fish:public animal
{
public
:
void breathe()
{
cout
<<"fish bubble"<<endl;
}
};
void
main()
{
fish
fh;
animal *pAn=&fh;
pAn
->breathe();
大家可以再次运行这个程序,你会发现结果是“
fish bubble
”,也就是根据对象的类型调用了正确的函数。
那么当我们将
breathe()
声明为
virtual
时,在背后发生了什么呢?
编译器在编译的时候,发现
animal
类中有虚函数,此时编译器会为每个包含虚函数的类创建一个虚表(即
vtable
),该表是一个一维数组,在这个数组中存放每个虚函数的地址。对于例
1-2
的程序,
animal
和
fish
类都包含
了一个虚函数
breathe()
,因此编译器会为这两个类都建立一个虚表,如下图所示:
图
1-
2
animal
类和
fish
类的虚表
那么如何定位虚表呢?编译器另外还为每个类的对象提供了一个虚表指针(即
vptr
),这个指针指向了对象所属类的虚表。在程序运行时,根据对象的类型去初始化
vptr
,从而让
vptr
正确的指向所属类的虚表,从而在调用虚函数时,就能够找到正确的函数。对于例
1-2
的程序,由于
pAn
实际指向的对象类型是
fish
,因此
vptr
指向的
fish
类的
vtable
,当调用
pAn
->breathe()
时,根据虚表中的函数地址找到的就是
fish
类的
breathe()
函数。
正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。那么虚表指针在什么时候,或者说在什么地方初始化呢?
答案是在构造函数中进行虚表的创建和虚表指针的初始化。还记得构造函数的调用顺序吗,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。对于例
2-2
的程序来说,当
fish
类的
fh
对象构造完毕后,其内部的虚表指针也就被初始化为指向
fish
类的虚表。在类型转换后,调用
pAn
->breathe()
,由于
pAn
实际指向的是
fish
类的对象,该对象内部的虚表指针指向的是
fish
类的虚表,因此最终调用的是
fish
类的
breathe()
函数。
要注意:对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针被初始化为本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以呢,才能实现动态的对象函数调用,这就是
C++
多态性实现的原理。
总结(基类有虚函数):
1、
每一个类都有虚表。
2、
虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
3、
派生类的虚表中虚函数地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。
2.
VC
视频第三课
this
指针说明
我在论坛的
VC
教学视频版面发了帖子,是模拟
MFC
类库的例子写的,主要是说明在基类的构造函数中保存的
this
指针是指向子类的,我们在看一下这个例子:
#include <iostream.h>
class
base;
base
* pbase;
class
base
{
public
:
base()
{
pbase
=this;
}
virtual void fn()
{
cout
<<"base"<<endl;
}
};
class
derived:public base
{
void fn()
{
cout
<<"derived"<<endl;
}
};
derived
aa;
void
main()
{
pbase
->fn();
我在
base
类的构造函数中将
this
指针保存到
pbase
全局变量中。在定义全局对象
aa
,即调用
derived aa;
时,要调用基类的构造函数,先构造基类的部分,然后是子类的部分,由这两部分拼接出完整的对象
aa
。这个
this
指针指向的当然也就是
aa
对象,那么我们在
main()
函数中利用
pbase
调用
fn()
,因为
pbase
实际指向的是
aa
对象,而
aa
对象内部的虚表指针指向的是自身的虚表,最终调用的当然是
derived
类中的
fn()
函数。
在这个例子中,由于我的疏忽,在
derived
类中声明
fn()
函数时,忘了加
public
关键字,导致声明为了
private
(默认为
private
),但通过前面我们所讲述的虚函数调用机制,我们也就明白了这个地方并不影响它输出正确的结果。不知道这算不算
C++
的一个
Bug
,因为虚函数的调用是在运行时确定调用哪一个函数,所以编译器在编译时,并不知道
pbase
指向的是
aa
对象,所以导致这个奇怪现象的发生。如果你直接用
aa
对象去调用,由于对象类型是确定的(注意
aa
是对象变量,不是指针变量),编译器往往会采用早期绑定,在编译时确定调用的函数,于是就会发现
fn()
是私有的,不能直接调用。:)
许多学员在写这个例子时,直接在基类的构造函数中调用虚函数,前面已经说了,在调用基类的构造函数时,编译器只“看到了”父类,并不知道后面是否后还有继承者,它只是初始化父类对象的虚表指针,让该虚表指针指向父类的虚表,所以你看到结果当然不正确。只有在子类的构造函数调用完毕后,整个虚表才构建完毕,此时才能真正应用
C++
的多态性。换句话说,我们不要在构造函数中去调用虚函数,当然如果你只是想调用本类的函数,也无所谓。
3.
参考资料:
1
、文章《在
VC6.0
中虚函数的实现方法》,作者:
backer
,网址:
http://www.mybole.com.cn/bbs/dispbbs.asp?boardid=4&id=1012&star=1
2
、书《
C++
编程思想》
机械工业出版社
4.
后记
本想再写详细些,发现时间不够,总是有很多事情,在加上水平也有限,想想还是以后再说吧。不过我相信,这些内容也能够帮助大家很好的理解了。也欢迎网友能够继续补充,大家可以鼓动鼓动
backer
,让他从汇编的角度再给一个说明,哈哈,别说我说的。