C++灵魂所在之---多态的前世与今生

来源:互联网 发布:mmd舞蹈动作数据 编辑:程序博客网 时间:2024/04/30 15:22

开头先送大家一句话吧:          

                                            

       众所周知,在20世纪80年代早期,C++在贝尔实验室诞生了,这是一门面向对象的语言,但它又不是全新的面向对象的语言,它是在传统的语言(C语言)进行面向对象扩展而来,但是它有些地方与C语言又有很多区别,又添加了很多C语言原来没有的内容与概念,所以有些地方是需要花时间去深入了解的。虽然这两者有密切关系,但是即使你很熟悉C语言,想要熟悉C++(先不说熟练掌握或者精通C++),还是得花很大一番功夫,光光是C++之中的三大特性就够研究好久的了。所以说学习C++的过程是一个漫长的过程,如果将来从事和C++(或者说其它任何一门优秀的语言)有关的工作,那么对语言的学习可能会贯穿一生,语言是一直发展而来的,所以说要想成为一个优秀的程序员,那么养成每天都学习一些新知识的习惯很重要,我从来都觉得一个人的习惯很重要,养成了每天学习的习惯,那么有一天突然没有学习,你会有一种今天有什么重要任务没有完成一样。但凡那些优秀的人,从来都有一个好的习惯,对于这一点我坚信不疑。好了,其它的也不多说了,千里之行,始于足下。        

       对于C++之中多态我准备分三个层次讲(当然这是我我理解上的三个层次,实际上这里面的内容远比我说的要多得多,也更深得多,我是由浅入深,根据所需要的挑着看吧,当然这里的深也只是相对的,如果说C++是一片海的话,那我也只能说我只是见识过浅滩上的风景,但是真正海底的神秘我还没有去研究,希望将来有机会可以研究到),如果只是单纯的想了解一下,就没有必要整篇都看。前几天写了在C++的学习过程中关于继承方面的一些知识,今天就写一写关于剩下的特性:多态(封装性需要说的内容比较少,所以就没有写)

       如果你只是想了解一下C++之中关于多态的知识,那么第一部分有你要的答案。如果你原本就对C++之中多态有了解,但是对是想了解内层的实现,那么最后一部分会满足你的好奇心!

      在这里我盗用一下王国维先生对于读书三境界的总结来作为我每一层次的小标题。

                                            

       按照我的习惯总是先从定义入手,当然这第一层次肯定了解定义是必须的。首先来看一下多态的定义:多态性的英文单词polymorphism来源于希腊词根poly(意为“很多”)和morph(意为“形态”),意思是具有多种形式或形态的情形,在C++语言中多态有着更广泛的含义。在C++之中多态的定义:多态性是指具有不同功能的函数可以用同一个函数名,这样就可以用一个函数名调用不同内容的函数。在面向对象方法中一般是这样表述多态性的:向不同的对象发送同一个消息, 不同的对象在接收时会产生不同的行为(即方法)。借用网上的一个牛人对于C++之中多态总结的较好的三句话:一、“相同函数名”,二:“依据上下文”,三:“实现却不同”。个人感觉这三句话总结地很到位,在这里我就盗用一下。
        既然这是第一层次,所以在这里有必要先说一下关于C++之中对象的类型,先再看一张图:


               

         在了解了对象的类型之后有助于你更好地理解之后的内容。

        好了,说完定义之后,我们肯定要来说一说多态的分类了,还是以图的形式给出,这样比较直观,也便于大家去理解:


       接下来我们就来逐个分析一下:

       先来看一下静态多态:编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推 断出要调用哪个函数,如果有对应的函数就调用该函数,否则出现编译错误。

       其实我们在之前学C++过程之中,使用函数重载的时候就已经用到了多态,只不过这是静态多态,如果当时不知道,可能你只是没有注意或者说没往多态这方面想而已。以下内容就是一个静态多态。

int My_Add(int left, int right){return (left + right);}float My_Add(float left, float right){return (left + right);}int main(){cout << My_Add(10, 20) << endl;cout << My_Add(12.34f, 24.68f) << endl;return 0;}
       看完静态多态之后,我们就来看一看动态多态,在没有特殊说明的情况之下,我们所说的多态一般都说的是动态多态。当然在说这个之前先知道什么时动态绑定:在程序执行期间(非编译期)判断所引用对象的实际类型,根据其实际类型调用相应的方法。
       我们用一个例子来说明吧!先看例子,下面再来解释:

class Airport                             //机场类{public:           void GoToT1()          { cout << "You want to T1 air terminal--->Please Left" << endl;       //T1候机楼}  void GotoT2()          { cout << "You want to T2 air terminal--->Please Right" << endl;      //T2候机楼}};class CPerson                            //人类{ public:           virtual void GoToTerminal(Airport & _terminal) = 0; };class Passage_A :public CPerson          //乘客A类(这类乘客需要去T1候机楼登机){ public:           virtual void GoToTerminal(Airport & _terminal){_terminal.GoToT1();}};class Passage_B :public CPerson          //乘客B类(这类乘客需要去T2候机楼登机){public:           virtual void GoToTerminal(Airport & _terminal){_terminal.GotoT2();}};void FunTest() {Airport terminal;                                //创建机场类对象for (int iIdx = 1; iIdx <= 10; ++iIdx)           {CPerson* pPerson;                    int iPerson = rand() % iIdx;            //设置随机值,这样可以使下面生成不同的子类对象       if (iPerson & 0x01)                  { pPerson = new Passage_A; }else                  { pPerson = new Passage_B;}pPerson->GoToTerminal(terminal);delete pPerson;                  pPerson = NULL;                  Sleep(1000);                            //每次休息1秒后继续输出}}int main(){FunTest();return 0;}

         在这里简单说明一下,上面代码首先有一个机场类,里面有两个方法,表示两个候机楼在不同地方,你需要左拐还是右拐。接下来就是一个人类,里面只有一个纯虚函数,因此这个人类就是一个抽象类。把这个抽象类作为基类,又派生出两个子类。在子类之中把父类之中的纯虚函数重新定义了(这也是必须的),(之前我在关于继承那一篇文章之中说过菱形继承之中就是利用虚拟继承解决数据二义性问题),也就是说这里不存在二义性问题,但是这里似乎更加具体,直接在子类中把父类之中的虚函数(带有virtual关键字修饰的函数)重写了(基类之中是一个纯虚函数,派生类必须要重写它才可以实例化对象)。

       简单说一下重写(或者说是覆盖,两者意思一样):如果两个成员函数处在不同的作用域之中(上面的例子是一个在父类,一个在子类),父类之中有virtual关键字修饰(这一点是必须的),而且它们的的参数相同,返回值类型也相同(这里有必要说明一中特殊情况就是协变,协变之中返回值类型可以不同),那么这样的两个成员函数就构成了重写。(派生类中的这个函数有没有virtual关键字修饰无所谓,可加可以不加,因为即使是派生类之中的这个函数,它本质上也还是一个虚函数)。

       由于派生类之中对父类的纯虚函数进行了重写,因此我们可以说上述代码实现了动态绑定。使用virtual关键字修饰函数时,指明该函数为虚函数(在上面例子中为纯虚函数),派生类需要重新实现,编译器将实现动态绑定

       在使用对象的指针的时候要千万注意一下,如下面的例子,没有实现动态绑定。

 int main()  {      Person  *p;      Man  *pm;      Woman *pw;      p = &man;     //如果你去调用其中的Man类中方法(基类之中也有同名方法),那么它会调用基类之中的方法    p = &woman;   //这是因为p的类型是一个基类的指针类型,那么在p看来,它指向的就是一个基类对象,                   //所以调用了基类函数。}
              最后看一下动态绑定的条件:

        1、必须是虚函数 2、通过基类类型的引用或者指针调用

       到这里简单地总结一下动态多态:动态多态性是在程序运行过程中才动态地确定操作所针对的对象。它又称运行时的多态性(动态多态性是通过虚函数(Virtual fiinction)实现的)。其实到了这里,对于多态这一块内容你已经有了初步的了解了,如果你只是想初步了解一下,已经足够了。但是可能还会有一些问题,不过也影响不大,毕竟只是初步认识一下,至少你明白了多态的概念以及使用上的一些注意点。如果你想搞清楚更深一层的一些问题,你可以继续阅读。


                          

      在这一层次上我们对虚函数,纯虚函数作进一步了解,动态绑定原理是什么?然后进入一个大内容---虚表的概念。

      在上一个内容上我们讨论过了关于动态绑定的概念,这时候可能会有疑问,动态绑定是如何实现的呢?那么接下来我们就来说一说里面原理性的内容。

       再解释之前我们还是先来理一理一些概念,这样可以更容易理解里面的内容,先做好准备工作。

首先是关于重载、重写(也可以叫做覆盖)、隐藏(或者说是重定义)这三者的有关内容,如图所示:


        接下来我们再来说一说上面提到过的几个概念,上面没有展开讲,这里仔细讲一讲:

                                                                   纯虚函数

       在成员函数的形参后面写上=0,则成员函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象(虽然抽象类不能实例化对象,但是可以定义抽像类的指针)。纯虚函数在派生类中重新定义以后,派生类才能实例化出对象。如下面代码中:

class Person {          virtual void Display () = 0;   // 纯虚函数 protected :          string _name ;                 // 姓名};class Student : public Person {    //在这类里面必须要对上面的Display ()方法重新定义以后Student才可以实力化对象};

                                                                                                    协  变

       在C++中,只要原来的返回类型是指向类的指针或引用,新的返回类型是指向派生类的指针或引用,覆盖的方法就可以改变返回类型。这样的类型称为协变返回类型(Covariant returns type)。如以下的两个函数就构成协变。当然协变也算一种覆盖。

class Base {         Base * FunTest()    {        //do something    }};class Derived : public Base{    <pre name="code" class="cpp">    Derived * FunTest()    {        //do something    }}; 

      

       有了上面这些铺垫之后我们就可以开始一个比较重要的内容:关于虚表的相关知识。先来以下代码:

class CTest{ public:       CTest(){ iTest = 10; }       /*virtual */~CTest(){}; private:       int iTest; };int main() { cout << sizeof(CTest) << endl;       return 0; }
           很容易的得到结果是4,但是如果将里面的注释内容,也就是virtual关键字放开,那么结果又会是多少呢?知道的人会觉得这是送分题,但是不知道的人却一脸茫然,答案是8,所以我们猜想,一定是编译器对有virtual成员函数的类做了特殊处理。

       先说个大概吧,简单地说就是:对于有虚函数的类,编译器都会维护一张虚表,对象的前四个字节就是指向虚表的指针。这也就可以解释为什么上面那个例子的原因了。

       当然我们的问题才刚刚开始,请仔细看下面一张图,可能会解决你的一些疑惑!


       我们来分析一下上面的内容。我们在在监视窗口之中对对象test取地址,发现虽然类中只有一个数据成员,但是发现另一个内容,其实它就是一个虚表指针,观察它的类型,发现它里面放着类似于地址的内容,但是我们可以在内存之中去查看一下它的内容。内存之中,虚表指针和数据成员是连着一起存放的,所以这个虚表指针一定是有什么作用的。我们再打开一个内存窗口,观察一下这个地址的所指向内容里面到底是什么。通过上面的图我们可以发现,其实这里面放的还是一个地址,突然间又有些疑惑了,我们可以转到反汇编去看一看,我们用virtual修饰的析构函数的入口地址就是刚刚我们看到的地址。在根据反汇编我们似乎就明白了什么。(细心的你会发现其实每个虚表的最下面总是放的是0x00000000,这也想相当于一个结束标志吧!)

       这时候,我们可以再来梳理一下,其实_vfptr存放的内容就是存放函数地址的地址,即_vfptr指向函数地址所在的内存空间,我们可以用图来表示:


        到现在为止,你对虚表以及虚表指针这些概念应该已经不陌生了,来总结一下就是:

       test对象中维护了一个虚表指针,虚表中存放着虚函数的地址。对test对象取地址可以看到的是虚表的指针以及它的其它成员变量,这个对象又是如何调用类中虚函数的呢?其实调用的虚函数是从虚表中查找的。如果基类中有多个虚函数的话,那么虚表中也会依次按基类中虚函数定义顺序存放虚函数的地址,并以0x 00 00 00 00 结尾。再如果子类中有自己定义的新的虚函数,那么会排在虚函数表的后边。在调用虚函数时由编译器自动计算偏移取得相应的虚函数地址。

           说到这里,第二层次的内容主要也讲完了,不过还有关于一些关于基类与派生类之间是如何利用这个虚表指针的,以及如果函数之中存在覆盖或者说没有覆盖,那么这虚函数表是否还是一样的呢?我把放到下一个内容,感兴趣的话可以接着往下看。   

                           

       如果你坚持看了下来,那么这一部分可能会有些复杂,在这一部分主要是对虚表作进一步剖析,也就是上面遗留下来的内容,以及复杂一点的带有虚函数多继承对象模型剖析。

       一个一个来说明,首先是上面遗留的内容:就是基类与派生类之间是如何利用这个虚表指针的呢?其实这个内容还是需要分为两部分来讲:一、基类与派生类不存在函数的覆盖;二、基类与派生类之间存在函数的覆盖。

       第一种情况比较简单,在这里我主要用文字说明一下,这样我们可以将重点放在第二种上面。

       第一种情况(不存在成员函数覆盖):先调用基类构造函数,虚表指针先指向基类虚表,然后调用子类构造函数,子类之中也有虚表指针,而且不是同一个,其实子类在构建起来之前的时候,这个虚表指针指向的是基类的虚表,但是当子类构建出来的时候,虚表指针马上发生变化,指向了一个新空间,这个新空间里面存放了基类的虚函数以及派生类自己的虚函数,在最后放入0x00000000,子类自己的虚表也就构建完成了。(这里说明一下:1、虚函数按照其声明的顺序存在于虚表之中。2、在派生类的虚表之中,前面是基类的虚函数,后面是派生类的虚函数)。

       接着转入第二种(存在成员函数覆盖):以下面代码分析(结合后面的图)

class CBase {public:           virtual void FunTest0(){ cout << "CBase::FunTest0()" << endl;}           virtual void FunTest1(){cout << "CBase::FunTest1()" << endl; }           virtual void FunTest2(){ cout << "CBase::FunTest2()" << endl;}          virtual void FunTest3(){ cout << "CBase::FunTest3()" << endl; } };class CDerived :public CBase { public:           virtual void FunTest0(){ cout << "CDerived::FunTest0()" << endl;}           virtual void FunTest1(){ cout << "CDerived::FunTest1()" << endl;}          virtual void FunTest4(){ cout << "CDerived::FunTest4()" << endl; }           virtual void FunTest5(){ cout << "CDerived::FunTest5()" << endl; } };typedef void(*_pFunTest)();void FunTest() {CBase base;           for (int iIdx = 0; iIdx < 4; ++iIdx)          {_pFunTest  pFunTest = (_pFunTest)(*((int*)*(int *)&base + iIdx));                  pFunTest();}cout << endl;          CDerived derived;           for (int iIdx = 0; iIdx < 6; ++iIdx)         { _pFunTest  pFunTest = (_pFunTest)(*((int*)*(int *)&derived + iIdx));                   pFunTest(); }}void TestVirtual() { CBase base0;         CDerived derived;          CBase& base1 = derived; }int main() {FunTest();         TestVirtual();          return 0; }


    说到这里大部分内容都已经结束了,好了,也是时候总结一下了:

  1. 派生类重写基类的虚函数实现多态,要求函数名、参数列表、返回值完全相同。(协变除外)
  2. 基类中定义了虚函数,在派生类中该函数始终保持虚函数的特性
  3. 只有类的成员函数才能定义为虚函数,静态成员函数不能定义为虚函数
  4. 如果在类外定义虚函数,只能在声明函数时加virtual关键字,定义时不用加
  5. 构造函数不能定义为虚函数,虽然可以将operator=定义为虚函数,但最好不要这么做,使用时容 易混淆
  6. 不要在构造函数和析构函数中调用虚函数,在构造函数和析构函数中,对象是不完整的,可能会 出现未定义的行为
  7. 最好将基类的析构函数声明为虚函数。(析构函数比较特殊,因为派生类的析构函数跟基类的析构 函数名称不一样,但是构成覆盖,这里编译器做了特殊处理)
  8. 虚表是所有类对象实例共用的

       还有一个关于多重继承之下的虚表指针的情况分析了,这种情况就比较复杂了,在这里同样也要分有无虚函数的覆盖这两种情况来分析。在这里我只简单地说一下有虚函数重载的情况,有兴趣的下来可以自己去试一试。

class CBase0 { public:          CBase0(){m_iTest = 0xA0; }          virtual void Print(){ cout << "m_iTest = " << hex << m_iTest << "  CBase2::Print()" << endl; }          int m_iTest; };class CBase1 { public:         CBase1(){ m_iTest = 0xB0;}          virtual void Print(){ cout << "m_iTest = " << hex << m_iTest << "  CBase2::Print()" << endl; }           int m_iTest; };class CBase2{public:         CBase2(){ m_iTest = 0xC0;}           virtual void Print(){ cout << "m_iTest = " << hex << m_iTest << "  CBase2::Print()" << endl; }          int m_iTest;};class CDerived :public CBase0, public CBase1, public CBase2 { public:          CDerived(){m_iTest = 0xD0; }           virtual void Print(){ cout << "m_iTest = " << hex << m_iTest << "  CDerived::Print()" << endl; }           int m_iTest; };void FunTest() { CDerived derived;          cout << sizeof(derived) << endl;          CBase0& base0 = derived;          base0.Print();         CBase1& base1 = derived;         base1.Print();          CBase2& base2 = derived;base2.Print();         derived.Print();}int main(){FunTest();return 0;}
            大家可以分析一下FunTest()函数会打印什么,以及Derived的内存布局是怎么样的呢?我给大家一张图,大家下来自行分析理解一下:


         其实如果你有兴趣,还可以自己剖析下菱形虚拟继承!!!不过肯定会有一些复杂。

         写到这里,说实话真的挺累的,终于可以歇一歇了,O(∩_∩)O,在最后推荐大家一本书:深度探索C++对象模型,这本书肯定是有一定难度的,光听“深入”两个字就应该觉得有些挑战,不过生活中还是应该多一些挑战。

        最后再送大家一句话:不忘初心,方得始终!

1 0
原创粉丝点击