C++ 虚函数相关,从头到尾捋一遍

众所周知,C++虚函数是一大难点,也是面试过程中必考部分。此次,从虚函数的相关概念、虚函数表、纯虚函数、再到虚继承等等跟虚函数相关部分,做一个比较细致的整理和复习。

  • 虚函数

    • OOP的核心思想是多态性(polymorphism)。把具有继承关系的多个类型称为多态类型。引用或指针的静态类型与动态类型不同这一事实正是C++实现多态性的根本。
    • C++ 的多态实现即是通过虚函数。在C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区别对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明为虚函数(virtual function)。
    • C++在使用基类的引用或指针调用一个虚函数成员函数时会执行动态绑定。因为只有直到运行时才能知道调用了那个版本的虚函数,所以所有的虚函数必须有定义。
    • 动态绑定只有当通过指针或引用调用虚函数时才会发生。
    • 一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。所以在派生类中可以再一次使用virtual指出,也可以不用。
    • 如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。
    • 在某些情况下,我们希望对虚函数的调用不进行动态绑定,而是强迫其执行虚函数的某个特定版本。
      //强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
      double price = basePtr->Base::net_price();
      
      通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
  • 抽象基类

    • 纯虚函数:一个纯虚函数无须定义。通过在函数体的位置(即在声明语句的分号之前)书写 =0 将一个虚函数说明为纯虚函数。其中 =0 只能出现在类内部的虚函数声明语句处。
    • 值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部,不能在类的内部为一个 =0 的函数提供函数体。
    • 含有纯虚函数的类是抽象基类。
      • 含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的类可以覆盖接口。我们不能(直接)创建一个抽象基类的对象。
        //Base 声明了纯虚函数,而 Derive将覆盖该函数
        Base b;         //错误,不能定义Base的对象
        Derive d;       //正确,Derive中没有纯虚函数
        
  • 虚函数表指针和虚函数表

    • 对于每一个定义了虚函数的类,编译器会为其创建一个虚函数表,该虚函数表被所有的类对象所共享,即它不是跟着对象走的,而是相当于静态成员变量,是跟着类走的。

    • 虚函数表指针vptr,每一个类的对象都有一个虚函数表指针,该指针指向类的虚函数表的位置。为了实现多态,当一个对象调用某个虚函数时,实际上是根据该虚函数指针vptr所指向的虚函数表vtable里找到相应的函数指针并调用之。

    • 关于vptr在对象内存布局中的存放位置,一般都是放在内存布局的最前面,当然,也可能有其他实现方式。

    • 基类定义如下所示:

      class Base{
      public:
          Base()
              :a(0), b(0), c('\0'){}
              
          virtual void fun1(){
              cout << "Base::fun1()" << endl;
          }
          
          virtual void fun2(){
              cout << "Base::fun2()" << endl;
          }
      private:
          int a;
          double b;
          char c;
      };
      

      类Base对象其内存布局方式为:

    • 考虑继承的情况,如下所示

      class Derive : public Base{
      public:
          Derive()
              :Base(),d(0), f(0){}
          
          virtual void fun1(){
              cout << "Derive::fun1()" << endl;
          }
          
          virtual void fun3(){
              cout << "Derive::fun3()" << endl;
          }
      private:
          int d;
          float f;
      };
      

      类Derive对象其内存布局如下所示:

      • 其实Derive对象的内存布局是可以这样理解,但是也不是很准确。
        如上所示,在Derive的定义中,我重新实现了Base的fun1(),直接继承了Base::fun2(),再新定义了 Derive::fun3()
        通过调试,即上面的右图发现,在Derive的对象中,能够看到的虚函数表是从Base继承而来的,其中里面覆写fun1(),继承了fun2(),但是并没有fun3()的函数指针。所以按照上边的左图,给出内存布局的话,可能会有一些误导。

      • 当派生类继承基类时,如果覆写了基类中的虚函数,在基类的虚函数表中,会使用覆写的函数覆盖基类对应的虚函数,如果没有覆写,则直接继承基类的虚函数。如上图所示的fun1 和 fun2 则是这种情况。

      • 当派生类再定义新的虚函数时,此时在基类的虚函数表中是无法体现出来的。所以,此时编译器会为派生类维护不止一个属于派生类的虚函数表,其中的有从基类继承而来的虚函数表,但是跟基类的不同,因为其中可能有函数覆写。另外则有一个用来记录当前派生类新定义的虚函数,函数 fun3即属于这种情况。当然,新维护的虚函数表的位置由编译器决定,也可以直接接到继承而来的虚函数表的后面,即也就只有一个表,但是这跟编译器的具体实现有关。所以,有那个意思就行了,不用太过深究具体实现细节。一般情况下,按照上面左图形式理解即可。

      • 由上可知,派生类如果没有定义新的虚函数,则直接继承虚类的虚函数表,并在其中做相应修改。如果定义了新的虚函数,不止要继承虚类的,还要维护自己的。
        所以上面的Derive的内存布局的另一种情况可能是:

    • 下面给出一个多重继承的讨论情况:

      class Base1{
      public: 
          Base1()
              {}
          
          virtual void fun1(){
              cout << "Base1::fun1()" << endl;
          }
          
          virtual void fun2(){
              cout << "Base1::fun2()" << endl;
          }
      };
      
      class Base2{
      public:
          Base2(){}
          
          virtual void fun3(){
              cout << "Base2::fun3()" << endl;
          }
          
          virtual void fun4(){
              cout << "Base2::fun4()" << endl;
          }
      };
      
      class Derive : public Base1, public Base2(){
      public:
          Derive()
              :Base1(), Base2() {}
              
          virtual void fun2(){
              cout << "Derive::fun2()" << endl;
          }
          
          virtual void fun3(){
              cout << "Derive::fun3()" << endl;
          }
          
          virtual void fun5(){
              cout << "Derive::fun5()" << endl;
          }
      }
      

      Derive的对象内存布局如下:

      注意:

      • 注意派生类和基类的覆盖关系和继承关系
      • 关于字节对齐问题,虚函数表指针,作为隐藏成员加入到类对象中,而隐藏成员的加入不能影响其后成员的字节对齐,所以,虚函数表指针总是占有最大字节对齐数的内存。
  • 虚继承

    • 这是篇好文章C++ 多继承和虚继承的内存布局,虽然不是很懂,但是确实有帮助。下面在给出一些相关概念。

    • 概念:为了解决从不同途径继承来的同名的数据成员在内存中有不同的拷贝造成数据不一致的问题,将共同基类设置为虚基类。此时,从不同途径继承过来的同名数据成员在内存中只有一个拷贝,同一个函数名也只有一个映射。解决了二义性问题,同时,也节省了内存,避免了数据不一致的问题。

    • C++ 对象的内存布局(下)关于虚拟继承的例子部从这篇文章学习,推荐。

    • 总结如下:
      • 无论是GCC还是VC++,除了一些细节上的不同,其大体上的对象布局是一样的。都是从Base1, 到Base2, 再到 Derive, 最后是虚基类 Base。
      • 关于虚函数表,尤其是第一个,GCC和VC++有很大的不一样。
  • 讨论

    • 带有虚函数的类的sizeof问题

      1.  class Base{
          public:
              virtual void fun(){}
          private:
              int a;
          };
      
      很明显:  sizeof(Base) = 8
      原因:带有虚函数的类具有虚函数指针,然后再加上int
      
      2.  class Base{
          public:
              virtual void fun(){}
          private:
              int a;
              double b;
          };
          
      乍一看  sizeof(Base) = 16, 其实应该是 sizeof(Base) = 24
      为什么呢, 因为前面关于字节对齐中,提到过 类的隐藏对象不能影响其后的数据成员的对齐,所以一般隐藏对象都是最大对齐字节的整数倍。此时 最大对齐为8,所以 虚函数表指针占4个字节,但需要填充4个。然后 int 占 4 个,再填充 4 个,最后double占8个。一共24个。
      
      3.  class A {
              int a;
              virtual ~A(){}
          };
      
          class B:virtual public A{
              virtual void funB(){}
          };
      
          class C:virtual public A{
              virtual void funC(){}
          };
      
          class D:public B,public C{
              virtual void funD(){}
          };
          
          sizeof(A) = 8
          sizeof(B) = 12
          sizeof(C) = 12
          sizeof(D) = 16
          
          A 中是虚函数指针 + int
          B、C 虚继承A,大小为 A + 指向虚基类的指针,B、C虽然新定义了虚函数,但是共享A中的虚函数指针。
          D 由于是普通继承 B、C,但是由于 B 、C是虚继承,所以D中保留A的一个副本。所以大小为 A + B指向虚基类的指针 + C指向虚基类的指针
      
    • 最后给出一个上面讨论 2 的具体实例。在VS2013下查看内存布局如下:


      上图中没有搞懂的部分,应该是随机数,系统随机的。不用管。

posted @ 2017-04-04 22:37  JeffLai  阅读(1137)  评论(2编辑  收藏  举报