多态的原理

多态的原理从一道题开始
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
private:
    int _b = 1;
};
请问sizeof(Base)多大?
按照正常结构体大小逻辑应该是4,那么多出来的4是哪来的?
对象有虚函数后会存在一个虚函数表,表里会存储虚函数。
可以看到b中除了正常的成员变量,还有一个_vfptr(virtual function pointer)变量,即虚函数表。里面是一个指针数组,存储虚函数指针。用来实现多态。
以如下代码为例:
class Base
{
public:
        virtual void Func1()
        {
               cout << "Base::Func1()" << endl;
        }
        virtual void Func2()
        {
               cout << "Base::Func2()" << endl;
        }
        void Func3()
        {
               cout << "Base::Func3()" << endl;
        }
private:
        int _b = 1;
};
class Derive : public Base
{
public:
        virtual void Func1()
        {
               cout << "Derive::Func1()" << endl;
        }
private:
        int _d = 2;
};
int main()
{
        Base b;
        Derive d;
        return 0;
}
表中只存储了func1和func2,说明只有虚函数才会存储在虚函数表中。而存入虚表的意义在于为运行时决议做准备,至于什么是运行时决议,讲到了再说。
观察两组数据中的虚表,发现func1函数地址不一样,func2函数地址是一样的,因为func1被重写了,func2则被实现继承了。
虚函数的重写也叫覆盖,但重写和覆盖其实是两个不同层面的概念。重写是语法层的概念,指派生类对继承的基类虚函数实现进行重写;覆盖是原理层的概念,指派生类的虚表拷贝基类的虚表进行了修改,覆盖了重写的那个虚函数。
所以多态的实现本质上就是指针查虚函数表找到要调用的函数。基类指针一定指向一个基类,不管是原生基类,还是切片的基类,多态的实现就是依靠运行时去指向对象的虚表中查调用函数的地址,即多态属于运行时决议。
在Derive中添加一个func3函数,但不是虚函数,没有进入虚表。从结果来看,两个ptr指针最终都指向了基类中的func3,与多态的运行逻辑不同的是:多态是运行时决议,而这里是编译时决议。
编译器是如何知道构成多态的?
根据虚函数+三同原则,构成多态就在运行时去虚表中找;不构成多态就是普通的类指针调用成员函数。如上图中的func3调用,func3不构成多态,ptr指针根据指针类型去Base类中找。
多态调用:运行时决议——指在运行时确定调用函数的地址
普通调用:编译时决议——指在编译时确定调用函数的地址
多态调用和普通调用的汇编代码:
func1就是执行到虚函数中查表的步骤,多态都是指针或引用到指向的对象里面(即虚表)去找,即指向哪个对象,调用哪个对象的成员函数。不同的对象存了不同的虚表,就会导致调用了不同的成员函数。
只有重写才能实现多态,就是因为重写的虚函数覆盖了基类虚表中的被重写虚函数,所以才能达到多态(指向谁调用谁)的效果。只有时基类对象的指针或者引用,才能既可以指向基类,也可以指向派生类。
那么,为什么对象不能实现多态呢?对象不是也可以切片吗?
因为对象不符合多态的条件——看起来似乎是废话。那如果要将对象设计成能构成多态会怎么样?
先来看看三种切片的区别:赋值,是将派生类中基类的部分拷贝过去;指针,指向派生类当中基类的部分;引用,取派生类中基类的部分。那么赋值需要将虚表指针拷贝过去吗?需要,不拷贝怎么构成多态(指向哪个对象调用哪个,多态需要调用派生类的虚函数)?那么问题来了,现在有一个指针指向了完成虚表拷贝的,能实现多态的基类,请问这个指针实现多态会发生什么?
答案是会调用派生类的虚函数,这就违反多态的规定(指向什么类型,调用什么类型的虚函数,明明指向的是一个基类,却调用了派生类的虚函数)了。所以对象不能实现多态。
综上,对象切片的时候,只会将成员切片给基类对象,不会将虚表切片给基类对象(也可以认为虚表切片了,不过切片的虚表是基类虚表,不是重写后的虚表)。
有的地方会将多态分成两种:静态多态和动态多态
静态多态是指在程序编译阶段决定函数的行为,比如函数重载(函数重载用的都是同一个函数名,看起来就像是一个函数具有多种形态一样,所以在一些地方会被称为多态)。
动态多态是指这里讲的多态,在运行时决定函数行为。
下面来看一些新的机制:单继承中的虚表
class Base
{
public:
        virtual void Func1()
        {
               cout << "Base::Func1()" << endl;
        }
        virtual void Func2()
        {
               cout << "Base::Func2()" << endl;
        }
        void Func3()
        {
               cout << "Base::Func3()" << endl;
        }
private:
        int _b = 1;
};
class Derive : public Base
{
public:
        virtual void Func1()
        {
               cout << "Derive::Func1()" << endl;
        }
        void Func3()
        {
               cout << "Derive::Func3()" << endl;
        }
        virtual void Func4()
        {
               cout << "Derive::Func4()" << endl;
        }
private:
        int _d = 2;
};
int main()
{
        Base b;
        Derive d;
        return 0;
}
Derive对象中的虚表应该存在三个虚函数指针,为什么会只有两个呢?
记得在菱形虚拟继承中提到的监视窗口会不准确的情况吗?转到内存试试看:
在内存中,我们发现_vfptr指针指向的位置出现了第三个地址。可能就是指向第三个虚函数的地址。
要验证有一个很简单的方法:让一个对象继承这个类就行,但这里介绍一种方法,或者说思想:设计程序来验证猜想
现在需要取内存值,打印并调用,确认是否是Func4。
按照先子后父的空间释放规则,继承的基类在对象的最开始,所以虚表的数组的地址就是对象的地址。
下面添加上这段代码:
typedef void (*func)(); //函数指针的特殊typedef方式,typedef void (*)() func;是错误的
void print(func a[]) //a是一个函数指针数组的地址,有了typedef后定义指针就简单了,否则会复杂很多。
{
        for (size_t i = 0; a[i] != nullptr; ++i)
        {
               printf("[%d]:%p\n", i, a[i]);
               a[i](); //调用存储在指针数组中的地址指向的函数
        }
}
int main()
{
        Base b;
        Derive d;
        print((func*)*((int*)&d)); //类型转换也要符合一定的规则,不是相近类型之间不能强转,比如类不能强转为int
        //int类型不能传给func*类型。func类型不能传给func*类型。
        // 虚表数组中存储的是函数指针(func), 所以虚表的地址是一个func*类型的,所以要强转为func*类型
        return 0;
}
结果显示:
可以证明虚表中存储三个虚函数的地址。监视窗口隐藏了func4的函数指针。
在这里打印虚表可能会发生崩溃,原因在于判断虚表结束的条件是遍历到nullptr,认为虚表最后一个位置一定是空指针标志虚表的结束(仅限vs,g++就是虚表中有几个函数指针就写死i<几),但是存在不是空的情况。有时候修改一部分代码重新编译,但是原来存储虚表数据的地方没有清理干净,会导致程序崩溃。这算是vs的一个bug。解决方案:在"生成"中点击清理解决方案,然后重新生成解决方案就可以了。
解释一下原理:
对d的地址解引用拿到d的全部空间,对d的地址前4个字节解引用(如果是64位,要取前8字节),拿到_vfptr中存储的数据(_vfptr的地址和d是一样的,_vfptr是指针数组首元素的地址,不是首元素),此时为int类型的变量,强转为func类型的后传入print函数的参数a中,a[i]调用虚表中的第i个数据,加上函数调用符'()',调用指针指向的函数。
新的问题:虚表存在于哪个区域?栈?堆?静态区/数据段?常量区/代码段?
虚表是一个类型一个虚表,所有这个类型的对象都指向这个虚表。如果存在栈区,栈是用啦建立栈帧的,栈帧运行结束就销毁了,虚表一会建立,一会销毁,显然不合适。对象会在初始化的时候找到对应的虚表,并拷贝其中的内容。所以虚表是在一个始终存在的区域,所以堆区不合适,因为堆要动态申请,什么时候申请,什么时候释放都是问题。剩下的静态区和常量区都是运行阶段始终存在的,虚表就存储在这两个区域中。那么具体是哪一个呢?一般认为是在常量区,静态区里的是全局变量和静态变量,放入一个函数指针数组有点突兀。顺便说一下,派生类虚表拷贝基类虚表,再根据重写的虚函数覆盖是一种形象的说法,方便理解用的。因为常量区的内容不会修改。实际编译器是直接找到对应的虚函数。
现在再设计程序验证一下猜想:
在原有基础上添加代码:
int a = 1;
int main()
{
        Derive d;
        print((func*)*((int*)&d));
        int b = 2;
        static int c = 3;
        int* p1 = new int;
        const char* p2 = "hehe";
        cout << endl;
        printf("栈区:%p\n", &b);
        printf("堆区:%p\n", p1);
        printf("静态区:%p\n", &a);
        printf("静态区:%p\n", &c);
        printf("常量区:%p\n", p2);
        printf("虚表:%p\n", *((int*)&d));
        printf("函数地址:%p\n", &Derive::Func4);
        printf("函数地址:%p\n", &Base::Func2);
        printf("函数地址:%p\n", &Derive::Func2);
        return 0;
}
运行结果:
从结果来看,常量区与结果最接近。上面的是虚函数的地址,不是虚表的地址。
如果要取成员函数的地址需要特殊的格式:声明类域+取地址符。即:printf("函数地址:%p", &Derive::Func4);
从图示结果看出一个奇怪的现象:取到的Derive::Func4地址和虚表中存储的地址不一样。
这个问题可以看另一篇文章,找标题就行。
在开始新的内容前,先建一个思维导图:
最开始是探讨如果在派生类中新添一个虚函数的情况->结果是vs编译器不显示,但实际上已经存入虚表中了->证明已经存入虚表->虚表存储在哪个区域?->存储在常量区->发现存在虚表中的地址和成员函数的地址不一样。
多继承中的虚表:
class Base1
{
public:
        virtual void func1() { cout << "Base1::func1" << endl; }
        virtual void func2() { cout << "Base1::func2" << endl; }
private:
        int b1;
};
class Base2
{
public:
        virtual void func1() { cout << "Base2::func1" << endl; }
        virtual void func2() { cout << "Base2::func2" << endl; }
private:
               int b2;
};
class Derive : public Base1, public Base2
{
public:
        virtual void func1() { cout << "Derive::func1" << endl; }
        virtual void func3() { cout << "Derive::func3" << endl; }
private:
        int d1;
};
int main()
{
        Derive d;
        return 0;
}
Derive同时继承了Base1和Base2,那么Derive要如何处理Base1和Base2的虚表呢?
先看看结果(vs中虽然不一定准确,但是大框架是没问题的)
很显然,Derive对象中存在两张虚表,那么作为没有实现重写的虚函数func3会出现在哪里?
答案是在第一张虚表中。
利用之前打印虚表的代码,将两张虚表打印出来:
typedef void (*func)();
void print(func a[])
{
        for (size_t i = 0; a[i] != nullptr; ++i)
        {
               printf("[%d]:%p\n", i, a[i]);
               a[i]();
        }
}
问题是要打印虚表需要虚表的地址,第一张虚表可以拿到,第二站虚表如何拿呢?
先来看看Derive类的对象模型:
发现第二张虚表的地址就在第一张虚表地址的后8个字节,那就只需要将第一个虚表的地址,往后跳8个字节,但是也存在问题,要如何确定其他类的两个虚表之间的距离也是8个字节呢?所以这里实际是通过sizeof来确定跳多少个字节的。
int main()
{
        Derive d;
        func* v1 = (func*)(*(int*)&d);
        PrintVTable(v1);
        func* v2 = (func*)*(int*)((char*)&d + sizeof(Base1));
         //&d得到的是Derive*类型的,+1会跳过sizeof(Derive)个字节,所以&d要强转为char*类型,
        //然后取对应位置的前四个字节,就拿到第二个虚表的地址了
        PrintVTable(v2);
        return 0;
}
由图可以看出func3在第一个虚表中。
关于指针偏移:
Derive d;
Base1* ptr1 = &d;
Base2* ptr2 = &d;
Derive* ptr3 = &d;
三个指针都指向d,但是结果并不一样,因为d切片个Base2是将Base2数据的地址切片过去的,Base1地址和Base2地址不一样。
可以看出Base1和Base2之间差了一个Base1的大小。
在了解了虚表之后,就可以知道继承中菱形虚拟继承中虚基表第一个位置的数据是干什么的了。
用下列代码演示:
class A
{
public:
        virtual void func()
        {}
public:
        int _a;
};
class B : public A
{
public:
        virtual void func()
        {}
public:
        int _b;
};
class C : public A
{
public:
        virtual void func()
        {}
public:
        int _c;
};
class D : public B, public C
{
public:
        int _d;
};
int main()
{
        D d;
        d.B::_a = 1;
        d.C::_a = 2;
        d._b = 3;
        d._c = 4;
        d._d = 5;
        return 0;
}
如图所示代码,此时A中有虚函数,B,C继承A,并对A中的虚函数进行重写。编译正常,B,C中各有一个虚表,可以储蓄对A中虚函数的重写。
但是,如果B,C是虚拟继承就会报错,在虚拟继承下,B,C中存储A的未知统合为一块区域,(B,C中存储指向虚基表的指针,指针指向的虚基表中存储有对A区域的偏移量)这样就会导致编译器不知道在A的虚表中储存B中的重写,还是C中的重写。
如果非要虚拟继承,就必须在继承了B,C的D中重写A中的虚函数。(虚基表和虚表没有语法上的关系)如图:
B,C都虚继承A,且对A中的虚函数重写会报错
D中存储的数据模型
B,C虚基表中存储有对A的偏移量
当B中增加一个A中没有的虚函数,B就会存在虚表,虚基表就会发生变化:
func1是B中的虚函数,所以不能放在A的虚表中,B就需要创建虚表。计算机中存储的是补码,所以fffffffc代表-4(ffffffff是-1),代表当前位置(虚基表指针的位置)对虚表的偏移量。所以虚表位置就在虚基表位置-4距离上。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值