这里谈论的关于类的关键字或保留字,针对结构体同样适合,在C++中,类和结构体其实没有什么区别了,同样支持继承、构造、析构等等,唯一差别是,两者的成员在默认的情况下,结构体中public访问权限,而类中private访问权限。
下面我们来理解下类和对象:
类是将数据成员和进行于其上的一系列操作(成员函数)封装在一起。
对象是类的实例化,怎样理解实例化?其实每一个实例对象都只是对其中的数据成员初始化,内存映像中每个对象仅仅保留属于自己的那份数据成员副本。而成员函数对于整个类而言却是被所有的实例化的类对象共享的,即一个类只保留一份成员函数。
那么每个对象怎样和这些可以认为是“分离”的成员函数发生联系,即成员函数如何操作对象的数据成员?记住this指针,无论对象通过(.)操作或者 (->)操作调用成员函数,编译时刻,编译器都会将这种调用转换成我们常见的全局函数的形式,并且多出一个参数(一般这个参数放在第一个),然后将 this指针传入这个参数。于是就完成了对象与成员函数的绑定(或联系)。
1. 类的定义使用到三种访问修饰符private/public/protected,它们要控制的是一个函数(施事)对一个类的成员(包括成员变量及成员方法)的访问权限。当我们说一个类可以访问XXX,其实暗指这个类的成员函数可以访问XXX。
private: 只能由该类中的函数、其友元函数访问,除此之外的用户程序都不能通过类对象对其进行访问;
protected: 可以被该类中的函数、子类的函数(public继承下)、以及其友元函数访问,除此之外的用户程序都不能通过类对象对其进行访问;
public: 可以被该类中的函数、子类的函数(public继承下)、其友元函数访问,在用户程序中也可以由该类的对象对其进行访问。
总结起来就是:
* 一个类友元(包含友元函数或者友元类的所有成员函数)可以访问该类的任何成员(包括成员变量及成员方法)。
* 除去友元外,private成员只有该类自身的成员函数可以访问,protected成员只有该类的成员函数及其派生类的成员函数可以访问,public成员则所有的函数都可以访问。
类的成员,不管使用哪种访问修饰符,都必须通过类的对象进行访问。即使是在类的成员函数内部,访问的数据也是通过类对象进行的,每个成员函数默认的第一个形参为this指针,其中访问的数据成员全部是由“this->”这种方式进行的,只是默认情况下都省略了而已。C++的访问修饰符的作用是以类为单位,而不是以对象为单位。通俗的讲,同类的对象间可以“互相访问”对方的数据成员,只不过访问途径不是直接访问。
我们知道,可以通过一个类对象调用public成员函数,然后通过这个成员函数就可以访问和操作该对象的所有数据成员以及成员函数,不管它们是用什么访问修饰符修饰。如果我们的类中有一个public成员函数,接受一个相同类对象的引用或者指针为形参,那么我们通过一个类对象调用一个public成员函数访问同类其他对象的所有数据成员。咋一想,好像有点问题,一个类对象的数据成员(即使是private成员)能被另外一个类对象的成员函数访问?但是仔细想想,其实是可以理解的。因为类的成员函数是共享的,访问数据成员都是通过类对象来进行的。
例如:
假如实例化了一个类对象A a(123,456),显然,这里不能在类之外直接这样访问:a.i(私有成员)、a.j(保护成员)。我们再实例化一个对象A aa(234,567),这个时候我们就可以使用a.disp(aa),来访问aa这个对象的私有和保护数据成员了。 cout<<i<<endl<<j<<endl;这条语句迷惑了我们,搞得好像这个函数属于a这个对象一样,所以能直接读取a对象的数据成员,但是其实这条语句的完整形式是:cout<<this->i<<endl<<this->j<<endl;
所以说这个成员函数并自己不知道要去访问哪个成员变量,必须通过传递对象的引用或者指针来访问相应对象的数据成员。
2. 类继承后成员访问属性变化:
使用private继承,父类的所有成员在子类中变为private;
使用protected继承,父类的protected和public成员在子类中变为protected,private成员不变;
使用public继承,父类中的方法属性不发生改变。
经过类的继承以后,基类的成员可以理解为:成为了继承类的成员,只是要做相应的访问属性改变,虽然基类成员好像是成为了继承类成员,但是还是和本身继承类数据成员有区别的,例如:继承类成员函数是不能访问继承过来的基类的私有成员,但可以访问继承过来的公有和保护成员。
3. virtual保留字
这里我们要讲的多态是指生效于运行时的动态多态,C++的动态多态技术是基于继承机制和虚函数的。多态可以理解成:不同的动作行为可以与同一个记号相关联。通俗的讲:基类类型的指针或引用可以调用基类的函数,也可以执行继承类的函数。这里的函数调用必须有个动态绑定,要实现这种动态绑定必须满足两个条件:
* 只有指定为虚函数的成员函数才能进行动态绑定;
* 必须通过基类类型的指针或者引用进行函数调用。
引用和指针的静态类型和动态类型可以不同,这是C++用以支持多态性的基石。因为每个派生类对象都包含基类部分,所以可以用基类类型的引用绑定到派生类对象的基类部分,也可以用基类类型的指针指向派生类对象(但是不能用继承类类型引用或指针绑定基类对象,除非强制类型转换)。基类类型的引用或者指针在编译就是可知的,这是静态类型,但是的它们所绑定的对象类型在运行时才可知,而且可能与的它们的静态类型不同,所以它们最终绑定的类型就是动态类型。
要理解多态中的动态绑定,首先要理解C++如何在继承层次中确定函数调用:
* 首先确定进行函数调用的对象、引用或指针的静态类型;
* 在该类中查找和调用的函数名字相同(不管参数)的函数,如果找不到,就在该类的直接基类中查找,如此循着它继承链往上找,直到找到名字相同的函数或者找完最后一个类,如果不能在类或者其他基类中找到该名字,则调用是错误的;
* 一旦在某个类中找到一个和待调用的函数名字相同的函数,则在这个类中查找所有这个名字的函数重载版本,看能否找到一个能与待调用函数实参类型相同的函数,不能找到则调用不合法;(前三个步骤是在编译的时候确定)
* 如果函数调用合法,如果函数是虚函数,且通用引用或者指针调用,则编译器生成代码以确定根据对象的动态类型运行哪个函数版本,否则编译器生成代码直接调用函数。
单纯从virtual关键字出发,考虑两层关系:一个基类一个继承类,使用基类类型引用或者指针进行函数调用,首先在基类中查找一个能与待调用函数实参类型相同的函数,如果找不到,则调用出错;如果找到,看该函数是否是虚函数,如果基类中该函数是虚函数,而且继承类中有相同原型的函数,即使没有用 virtual保留字,继承类中的函数自动变成虚函数,然后再运行过程中根据基类指针或引用绑定的对象来调用相应的函数,如果继承类中没有相同原型的函数,即使运行时绑定的是继承类对象,那么还是调用基类中的函数。
至于虚函数的实现原理,查看这里转载一篇文章http://blog.csdn.net/shenmea00000/archive/2007/10/31/1859762.aspx:
虚函数是在类中被声明为virtual的成员函数,当编译器看到通过指针或引用调用此类函数时,对其执行晚绑定,即通过指针(或引用)指向的类的类型信息来决定该函数是哪个类的。通常此类指针或引用都声明为基类的,它可以指向基类或派生类的对象。
多态指同一个方法根据其所属的不同对象可以有不同的行为(根据自己理解,不知这么说是否严谨)。
举个例子说明虚函数、多态、早绑定和晚绑定:
李氏两兄妹(哥哥和妹妹)参加姓氏运动会(不同姓氏组队参加),哥哥男子项目比赛,妹妹参加女子项目比赛,开幕式有一个参赛队伍代表发言仪式,兄妹俩都想去露露脸,可只能一人去,最终他们决定到时抓阄决定,而组委会也不反对,它才不关心是哥哥还是妹妹来发言,只要派一个姓李的来说两句话就行。运动会如期举行,妹妹抓阄获得代表李家发言的机会,哥哥参加了男子项目比赛,妹妹参加了女子项目比赛。比赛结果就不是我们关心的了。
现在让我们来做个类比(只讨论与运动会相关的话题):
(1)类的设计:
李氏兄妹属于李氏家族,李氏是基类(这里还是抽象的纯基类),李氏又派生出两个子类(李氏男和李氏女),李氏男会所有男子项目的比赛(李氏男的成员函数),李氏女会所有女子项目的比赛(李氏女的成员函数)。姓李的人都会发言(基类虚函数),李氏男和李氏女继承自李氏当然也会发言,只是男女说话声音不一样,内容也会又差异,给人感觉不同(李氏男和李氏女分别重新定义发言这个虚函数)。李氏两兄妹就是李氏男和李氏女两个类的实体。
(2)程序设计:
李氏兄妹填写参赛报名表。
(3)编译:
李氏兄妹的参赛报名表被上交给组委会(编译器),哥哥和妹妹分别参加男子和女子的比赛,组委会一看就明白了(早绑定),只是发言人选不明确,组委会看到报名表上写的是“李家代表”(基类指针),组委会不能确定到底是谁,就做了个备注:如果是男的,就是哥哥李某某;如果是女的,就是妹妹李某某(晚绑定)。组委会做好其它准备工作后,就等运动会开始了(编译完毕)。
(4)程序运行:
运动会开始了(程序开始运行),开幕式上我们听到了李家妹妹的发言,如果是哥哥运气好抓阄胜出,我们将听到哥哥的发言(多态)。然后就是看到兄妹俩参加比赛了。。。
但愿这个比喻说清楚了虚函数、多态、早绑定和晚绑定的概念和它们之间的关系。再说一下,早绑定指编译器在编译期间即知道对象的具体类型并确定此对象调用成员函数的确切地址;而晚绑定是根据指针所指对象的类型信息得到类的虚函数表指针进而确定调用成员函数的确切地址。
2、揭密晚绑定的秘密
编译器到底做了什么实现的虚函数的晚绑定呢?我们来探个究竟。
编译器对每个包含虚函数的类创建一个表(称为V TA B L E)。在V TA B L E中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地置一指针,称为v p o i n t e r(缩写为V P T R),指向这个对象的V TA B L E。通过基类指针做虚函数调 用时(也就是做多态调用时),编译器静态地插入取得这个V P T R,并在V TA B L E表中查找函数地址的代码,这样就能调用正确的函数使晚捆绑发生。为每个类设置V TA B L E、初始化V P T R、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数,这个对象的合适的函数就能被调用,哪怕在编译器还不知道这个对象的特定类型的情况下。(《C++编程思想》)
在任何类中不存在显示的类型信息,可对象中必须存放类信息,否则类型不可能在运行时建立。那这个类信息是什么呢?我们来看下面几个类:
以上三个类中:
no_virtual没有虚函数,sizeof(no_virtual)=4,类no_virtual的长度就是其成员变量整型a的长度;
one_virtual有一个虚函数,sizeof(one_virtual)=8;
two_virtual 有两个虚函数,sizeof(two_virtual)=8;有一个虚函数和两个虚函数的类的长度没有区别,其实它们的长度就是no_virtual的长度加一个void指针的长度,它反映出,如果有一个或多个虚函数,编译器在这个结构中插入一个指针( V P T R)。在one_virtual 和 two_virtual之间没有区别。这是因为V P T R指向一个存放地址的表,只需要一个指针,因为所有虚函数地址都包含在这个表中。
这个VPTR就可以看作类的类型信息。
那我们来看看编译器是怎么建立VPTR指向的这个虚函数表的。先看下面两个类:
两个类VPTR指向的虚函数表(VTABLE)分别如下:
base类
——————
VPTR——> |&base::vfun1 |
——————
|&base::vfun2 |
——————
derived类
———————
VPTR——> |&derived::vfun1 |
———————
|&base::vfun2 |
———————
|&derived::vfun3 |
———————
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个VTABLE,如上图所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在derived的VTABLE中,vfun2的入口就是这种情况。)然后编译器在这个类中放置VPTR。当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向相应的VTABLE,这在构造函数中发生。
一旦VPTR被初始化为指向相应的VTABLE,对象就"知道"它自己是什么类型。但只有当虚函数被调用时这种自我认知才有用。
个人总结如下:
1、从包含虚函数的类派生一个类时,编译器就为该类创建一个VTABLE。其每一个表项是该类的虚函数地址。
2、在定义该派生类对象时,先调用其基类的构造函数,然后再初始化VPTR,最后再调用派生类的构造函数(从二进制的视野来看,所谓基类子类是一个大结构体,其中this指针开头的四个字节存放虚函数表头指针。执行子类的构造函数的时候,首先调用基类构造函数,this指针作为参数,在基类构造函数中填入基类的vptr,然后回到子类的构造函数,填入子类的vptr,覆盖基类填入的vptr。如此以来完成 vptr的初始化。 )
3、在实现动态绑定时,不能直接采用类对象,而一定要采用指针或者引用。因为采用类对象传值方式,有临时基类对象的产生,而采用指针,则是通过指针来访问外部的派生类对象的VPTR来达到访问派生类虚函数的结果。
VPTR 常常位于对象的开头,编译器能很容易地取到VPTR的值,从而确定VTABLE的位置。VPTR总指向VTABLE的开始地址,所有基类和它的子类的虚函数地址(子类自己定义的虚函数除外)在VTABLE中存储的位置总是相同的,如上面base类和derived类的VTABLE中vfun1和vfun2 的地址总是按相同的顺序存储。编译器知道vfun1位于VPTR处,vfun2位于VPTR+1处,因此在用基类指针调用虚函数时,编译器首先获取指针指向对象的类型信息(VPTR),然后就去调用虚函数。如一个base类指针pBase指向了一个derived对象,那pBase->vfun2 ()被编译器翻译为 VPTR+1 的调用,因为虚函数vfun2的地址在VTABLE中位于索引为1的位置上。同理,pBase->vfun3 ()被编译器翻译为 VPTR+2的调用。这就是所谓的晚绑定。
我们来看一下虚函数调用的汇编代码,以加深理解。
void test(base* pBase)
{
pBase->vfun2();
}
int main(int argc, char* argv[])
{
derived td;
test(&td);
return 0;
}
derived td;编译生成的汇编代码如下:
mov DWORD PTR _td$[esp+24], OFFSET FLAT:??_7derived@@6B@ ; derived::`vftable’
由编译器的注释可知,此时PTR _td$[esp+24]中存储的就是derived类的VTABLE地址。
test(&td);编译生成的汇编代码如下:
lea eax, DWORD PTR _td$[esp+24]
mov DWORD PTR __$EHRec$[esp+32], 0
push eax
call ?test@@YAXPAVbase@@@Z ; test
调用test函数时完成了如下工作:取对象td的地址,将其压栈,然后调用test。
pBase->vfun2();编译生成的汇编代码如下:
mov ecx, DWORD PTR _pBase$[esp-4]
mov eax, DWORD PTR [ecx]
jmp DWORD PTR [eax+4]
首先从栈中取出pBase指针指向的对象地址赋给ecx,然后取对象开头的指针变量中的地址赋给eax,此时eax的值即为VPTR的值,也就是 VTABLE的地址。最后就是调用虚函数了,由于vfun2位于VTABLE的第二个位置,相当于 VPTR+1,每个函数指针是4个字节长,所以最后的 调用被编译器翻译为 jmp DWORD PTR [eax+4]。如果是调用pBase->vfun1(),这句就该被编译为 jmp DWORD PTR [eax]。
一篇关于虚函数很精辟的文章:
http://www.cppblog.com/mzty/archive/2008/04/16/42877.html#47244