C++对象模型之继承与多态的探索

多态

在C++中继承和多态为它的俩大特性:

那么对于我们常说的动态多态,它是如何形成的?

通常我们都会说应用赋值兼容规则,让一个Base类指针或引用指向一个派生类对象,那么当我们在基类中定义出一个虚函数,在派生类中我们对它进行重写后。当我们用指向派生类对象的基类指针调用这个函数时,就会发生多态,当指针指向对象不同时,它就会调用不同类中的该函数。

那么何为赋值兼容规则呢,为什么Base类指针就可以指向派生类对象呢?为什么派生类对象就可以给Base类对象赋值呢?

Tips: 下面讨论中相应类的代码 :

class Base1
{
public:
    Base1()
        :_b(0)
    {}
    virtual Base1* A()
    {
        cout << "i am Base1" << endl;
        return this;
    }
    int _b;
};
class Base2
{
public:
    Base2()
        :_b2(1)
    {}
    virtual Base2* A()
    {
        cout << "i am Base2" << endl;
        return this;
    }
    virtual Base2* B()
    {
        cout << "B" << endl;
        return this;
    }
    int _b2;
};
class Dervied:public Base1,public Base2
{
public:
    Dervied()
        :Base1(), Base2(), _d(2)
    {}
    Dervied* A()
    {
        cout << "i am Dervied" << endl;
        return this;
    }
    int _d;
};

赋值兼容规则

  1. Base类指针指向派生类对象
    这里写图片描述
    用Base2基类接受一个派生类对象的汇编

    常常我们都说在赋值兼容规则中,基类指针可以指向派生类对象,那么它到底是什么原理呢?

    从上面的汇编代码中,我们可以看出关键的几行。
    1 lea eax,[d] 它代表我们取派生类对象D的地址放入寄存器eax中
    2 lea ecx,[d] 它代表我们取派生类对象D的地址放入寄存器ecx中
    3 add ecx,8 它代表我们这里把该派生类对象地址加8,这个加8之后代表的就是派生类对象中Base2处的地址。
    4 mov ptr[ebp-10ch],ecx 它指把派生类中基于Base2的地址放入一个临时变量中。
    5 mov edx,ptr[ebo-10ch] 它指从临时变量中把它放入到寄存器edx中。
    6 mov ptr[q],edx 它代表把edx中的基于Base2的地址传给变量q中。


    所以综上来看,其实为何我们能把一个派生类对象地址赋值给基类指针呢?
    只是编译器把该派生类对象地址调整至相应的基类对象处,再把这个调整之后的地址赋值给基类指针。(所以这里可以看出实际也是基类地址给基类指针赋值)

  2. 派生类对象赋值给基类对象
    这里写图片描述
    这里写图片描述

                              图2(该图为operator = 函数中的汇编)
    

    这里写图片描述

那么对于派生类对象给基类对象赋值,我们可以从上面汇编中就可以看出它到底指的是什么了
  首先在main中的几条重要的汇编:
  1lea     eax,[d]      它代表把派生类对象地址放入eax中。
  2lea     ecx,[d]      它代表把派生类对象地址放入ecx中。
  3add    ecx,8        它代表把派生类对象下调8个字节,至Base2处,。
  4mov    edx,ptr [ebp-10Ch]  它代表把在派生类对象中基于Base2处的地址存入寄存器edx中。
  5lea      ecx,[b]     它代表把基类对象Base2地址存入ecx中。
  6call     Base2::operator=   这里指调用了赋值运算符重载函数。(可见在底层中类的每个运算符的应用都是调用相应的运算符重载函数)

  我们都很熟悉栈帧结构了,在函数调用时它首先会把参数从右至左以此压栈,再把返回的地址和之前上一个栈帧中的ebp中的值压栈,再把之前寄存器中保存的值压入栈中。接下来会清空寄存器中的值。
  那么来看看在赋值运算符重载函数中的几条重要汇编:
  1pop  ecx       它代表从栈帧中把栈顶内容存入ecx中,即基类对象的地址。(因为最后一次push 的内容是ecx中内容)
  2mov ptr[this],ecx                       把基类对象地址放入this指针中。
  3mov ecx,ptr[__that]                  把that中派生类中基于Base2处的地址存入到ecx中。
  4mov edx,ptr[ecx+4]                  把基类对象地址向下调整四个字节,即成员变量_b2的值1存入edx中。
  5mov ptr[eax+4],edx                  把派生类对象中的整形变量内容赋值给基类对象中的成员变量。
  综上我们可以看出实际派生类对象给基类对象赋值,首先调用了赋值重载函数。然后再把相应的派生类对象的地址向下偏移至对应的基类对象的地址处,然后再完成相应的赋值。

总结
  那么从上我们可以看出实际上对于无论是基类指针引用派生类对象还是派生类对象给基类对象赋值,底层都是通过调整派生类对象的地址至相应的基类地址处然后进行操作(用派生类对象中相应的Base类对象部分进行赋值或用派生类对象中基于Base类对象地址的赋值)。
  上面这点十分重要这可不是单单的强转导致就可以做到的,强转在多继承模型中并不起作用,实际上它是把派生类对象的地址调整到相应基类的地址处从而实现的派生类对象给基类对象赋值/赋址。

多态的实现之虚函数

那么从上面我们了解到了赋值兼容的实质,对于完成这些指针调整后是如何形成多态的呢?
  在C++中,我们当我们在类中写了一个虚函数,底层编译器就会为这个对象生成一个虚表,在虚表中记录了相应的虚函数的地址,那么编译器在为我们生成对象时,就把这个虚表指针加入到了我们生成的对象中,在VS2013下(即微软编译器的处理下)虚表指针是在对象的顶部。当对象我们调用这个虚函数时,编译器在编译期并不知道是那个对象调用的该虚函数,所以它就会先查找虚表,找到相应的虚函数在表中的索引,因为索引是不会改变的,等到执行期程序跑起来时,该索引上的函数地址就为相应的虚函数地址,这样我们就完成了动态多态。
  那么虚表中的虚函数都有那几种呢:
  1 基类自己的虚函数
  2 派生类自己的虚函数
  3 一个纯虚函数的地址(因为纯虚函数是没有定义的,当我们在外面意外调用这个纯虚函数,它就会结束程序)

单继承下的虚函数

   单继承下,无非就是在派生类对象顶部加入了个虚表指针,需要改写时用相应改写后的函数地址替换之前的该函数地址,然后如果派生类中自己也有虚函数,就在虚表的末位处按类声明的顺序一一加入虚表中。

多继承下的虚函数

                     这里写图片描述
                         派生类对象
         这里写图片描述
                        图A(在gcc平台下)
               这里写图片描述
                        图B(在gcc平台下)
               这里写图片描述
                        图C(在gcc平台下)
Tips:
图B为Base2的虚表,图C为Base1的虚表,图A为Dervied虚表由上可看出虚表都存了些什么东西

    因为每个派生类对象都与其后的基类对象都是继承关系,故每个基类子对象都有一个相应的虚表指针,第一个基类对象与派生类对象共享一个虚表指针。
    那么在多继承下,假设继承了多个基类。我们用第一个基类指针调用虚函数或派生类调用第一个基类中重写的虚函数时,根据前面的赋值兼容规则,它与派生类对象地址相同不需要调整。那么后续的基类对象调用虚函数或派生类调用虚函数时,从赋值兼容规则看出有些情况当调用这些函数时,this指针是需要调整的。
那么那些情况呢,又是如何调整的呢?

具体是俩种情景:

    第一种情景是用派生类指针调用未重写的基类虚函数,首先该指针会向下偏移到该基类地址处,然后查看该基类的虚表,通过在该基类的虚表中查找相应的虚函数把偏移后的地址传入完成调用。

    第二种情景派生类对基类(指第二或其后基类)的虚函数进行了重写(我们常用的动态多态就是这种重写情景),当用一个基类指针调用这个重写函数时,编译器会先查找该基类对象的虚表,然后调用该重写虚函数,但是该重写的虚函数现在已然是派生类的虚函数,它的隐含参数this指针为Dervied*p类型,故我们不能直接调用该函数。这里就引用了thunk技术。

   thunk技术就是指先对它的指针做出相应的调整(调整为派生类的地址处),然后再调用该虚函数。(一般的虚析构函数就是这样调用的)。
thunk
                            如图这个[thunk]就是thunk技术
              这里写图片描述
                          在thunk中,先偏移至相应出再调重写函数

另类疑问

既然基类虚表被派生类改写,那么基类指针是否可以那些调用只在派生类中存在的虚函数,在基类中是不存在的这些虚函数呢?
  虽然它的虚表被改写,但实际上类就是一个作用域,在该作用域下并没有该虚函数,即使虚表中有这些虚函数的地址,它们也是不能被调用的。

多态

综上我认为动态多态的前提是赋值兼容规则,那么赋值兼容规则的前提是public下的继承,所以一切的一切,多态是因为public下的基类指针调用了重写函数而发生的一种现象

虚表

这里还需要注意一下,就是有个vptr指针不一定都是在派生类对象顶部的,虽然我给出的实列图是vptr指针在第一个字段,但是那只不过是恰好罢了,只是因为基类和派生类公用了一份vptr而已,巧好Base类里有虚函数,Der类里也有虚函数, 所以公用了一张虚表而已。

class Base
{
    Fun2()
    { }
    int _a;
};
class Der : public Base
{
     virtual Fun()
     {}
     int _d;
}
        这个对象模型:
          Base
          vptr
          Der

不可声明为virtual的成员函数

  1. 当类中的内联函数以多态的方式(指针/引用)方式调用的时候不能声明为虚函数,因为内联函数在编译期展开,而多态需要在运行期绑定,所以没法内联。但是,当以非多态的形式调用可以内联,即对象调用或者显示的使用指针/对象 加作用域的方式调用。(具体是否内联由编译期决定)
struct Base
{
    virtual ~Base()
    {}
    virtual void Foo()
    {
        cout<<"Base"<<endl;
    }
};
struct Dervied : public Base
{
     virtual void Foo()
     {
        cout<<"Dervied"<<endl;
     }
};

int main()
{
    Base * b = new Dervied;
    b -> Foo(); //不可能内联,因为无法在编译期决定调那个函数
    b -> Base::Foo();//可以内联,是否内联看编译器
    delete b;
}

2 静态成员函数不能是虚函数,因为虚函数是面对对象的,而静态成员函数是面对类的,其次调虚函数需要虚表指针,而静态成员函数可以不依赖对象调用,所以也就是不依赖虚表指针调用,所以没法声明为virtual。
3. 构造函数不能声明虚函数,因为调虚函数前需要虚表指针,而虚表指针又在构造函数中被创建,使其指向相应类的虚表,所以不能。

虚函数表/虚基类表

它们俩个都是virtual的声明而生成的表,一个是在函数的声明,一个是在继承中的声明,有很多相似却又有不同。
虽然都是虚表,但是虚函数表,每一个带有虚函数的类公用一张虚函数表,这个虚函数表的公用就像类的静态对象的存在,一个类公用一个虚函数表,并且虚表都是const 对象,在Linux 下 它被保存在 rodata 段,也就是只读数据段,跟代码段一个级别哦,如果我们想修改它,程序就会被终止掉。
虚基表就跟虚函数表不同了,每一个对象都有一个唯一的虚基表,这是因为根据不同的继承体系,公共基类处于在对象模型中的不同偏移量位置,所以每一个对象都有一张唯一的虚基表。

猜你喜欢

转载自blog.csdn.net/sdoyuxuan/article/details/73927494