前言: 前几天学了多态。 然后过去几天一直在测试多态的底层与机制。今天将多态的机制以及它的本质分享给受多态性质困扰的友友们。
本节内容只涉及多态的原理, 也就是那张虚表的规则,有点偏向底层。 本节不谈语法!不谈语法!不谈语法!想要学习语法的话本节并不合适。
虚函数表
一个类中, 如果包含虚函数成员, 那么这个类进行实例化的对象中,会出现一张虚函数表 ,
这个概念很重要,只要包含虚函数成员, 就有虚函数表!
只要包含虚函数成员, 就有虚函数表!
只要包含虚函数成员, 就有虚函数表!
通过调试下图代码进行观察:
class A
{
public:A(){func();cout << "A()" << endl;}virtual void func(){cout << "Afunc()" << endl;}void test(){func();}private:int a = 0x11;//这里初始化使用十六进制是为了好观察
};int main()
{A a;return 0;
}
绿框框就是A类型的那张虚函数表。 注意:A类的任何实例化对象的虚函数表是同一个或着多个。
虚函数表如何生成
虚函数表的本质是一个函数指针数组。 他被存放在常量区。
编译器通过编译, 就能分析整个类中的函数成员是否存在虚函数。 从而向常量区申请一块内存用来存放虚函数地址的地址, 这也就是我们所说的虚函数表。所以, 虚函数表是在编译期间生成的。
虚函数表是存放函数指针的函数指针数组,所以这张虚函数表的类型是函数指针的指针也就是二级指针, 然后我们类实例化对象要保存这么一张虚函数表就需要用到一个三级指针指向这张虚函数表。 是不是感觉很恶心?不过没关系, 这一切都是编译器的工作, 我们只需要了解原理即可。像上图中的A类的实例化对象a, 我们现在观察下它的内存:
ps: 因为不小心将对象a和成员变量名重复了, 下面将会令成员变量名变成_a;
好了, 现在我们知道了虚函数表是在什么时候生成的。 那么虚函数表的指针_vfptr是什么时候加入到对象的成员变量中的呢?下面我们进行进行观察。
现在对象a刚刚定义。虽然成员变量还没有初始化,但是对象a中已经有了虚函数表的指针_vfptr, 只是没有初始化。 所以, 我们就可以下结论:如果一个类含有虚函数表, 那么这个类的实例化对象,会在生成的一瞬间增加虚函数表指针变量。但是这一个或者多个虚函数指针变量并没有进行初始化。也就是说, 如果类中含有虚函数, 那么这个类中默认存在一个_vfptr指针变量。(这里可能增加多个虚函数表指针, 这个涉及继承, 也是多态的本质。后续讨论)。
然后我们接着进行调试, 观察这个虚函数表指针什么时候进行初始化
进入构造函数, 没有变化。 继续调试。
变了。 当我们调试到初始化列表的时候, 对象a的虚函数表进行了初始化。 这里第二个结论就出来了:对象的虚函数表会在构造函数的初始化列表进行初始化。也就是说, _vfptr指针和其他的成员变量一样, 都是在初始化列表进行初始化。
通过上面的分析,我们基本可以总结一下:虚函数列表是在编译期间进行生成的。 它存放在常量区;而虚函数列表的指针_vfptr会和类的其他成员变量一样。 实例化瞬间声明变量, 初始化列表初始化变量。
继承与重写
多态在语法规则上, 说了多态的形成条件:1、虚函数的重写;
2、父类对子类的引用或者父类的指针指向子类。
重写
现在, 我们来讨论虚函数的重写:
虚函数在什么情况下会有重写的概念?当一个父类存在虚函数。 并且子类同样有函数名称相同的函数(这个时候不管这个函数是不是虚函数, 都会被编译器默认识别成虚函数)的时候就会有重写的概念。这里的虚函数名称相同是指函数名称相同,函数参数相同,返回值相同。
重写必须有相同函数名称的虚函数的继承。继承后子类的虚函数列表之中不会再有父类的相应的虚函数。只存在重写的虚函数。但是不影响父类。
如图:
class A
{
public:virtual void func(int a = 1){cout << "Afunc():" << a << endl;}private:int _a = 0x11;//这里初始化使用十六进制是为了好观察
};class C : public A
{
public:virtual void func(int a = 3)//该func重写了父类的func. 所以C类的虚函数列表之中不会包含父类的func,只有自己这个重写了的func。{cout << "Cfunc()" << a << endl;}int c = 0x11;
};int main()
{C cc;A* ptr = &cc;ptr->func();cc.func();return 0;
}
这里子类的func重写了父类的func. 所以C类的虚函数列表之中不会包含父类的func,只有自己这个重写了的func。(这条重写性质非常重要! 后续多态的形成会用到!)
我们观察一下vs:
通过观察右边的内存窗口,我们可以看到C的实例化对象cc之中的虚函数表中只有一个 函数的地址。 (这里的00 00 00 00就是虚函数列表结尾的标志。 相当于字符串的末尾斜杠零)。
虚函数表的继承
但是图中, 现在我们还可以看到另外一个现象。 那就是为什么cc的虚函数指针跑到了继承的A类的虚函数列表里?
这里是虚函数列表继承的规则。
如果是单继承。那么子类的虚函数的地址和继承来的父类的虚函数的地址都会存放在一张虚函数列表之中。并且这张虚函数列表之中父类的虚函数在低地址。 子类的虚函数在高地址。
如下是一个单继承。
class A
{
public:virtual void func(int a = 1){cout << "Afunc():" << a << endl;}private:int _a = 0x11;//这里初始化使用十六进制是为了好观察
};class C : public A
{
public:virtual void func(int a = 3){cout << "Cfunc()" << a << endl;}virtual void func2(){cout << "func2()" << endl;}int c = 0x11;
};int main()
{C cc;A* ptr = &cc;ptr->func();cc.func();return 0;
}
在C类中, C只继承了A类, 所以是单继承。 C类之中只有一张虚函数列表。 这张虚函数列表之中包含了C重写的虚函数func()和自己本身的虚函数func2().我们看一下内存图:
这里需要注意的是不要只观察监视窗口绿色箭头所指向的地方。 因为监视窗口有些时候是不准的, 就像这个时候就不准。 我们需要看一下内存窗口红色箭头指向的地方。 这里是真正的底层内存。 我们可以发现C类的实例化对象中只有一张虚函数列表, 并且这张虚函数列表之中有两个函数指针。 一个是重写的父类的func函数指针。 一个是自己的func2函数指针。 (其实这里我总结了一个结论:任意的虚函数表之中只有三类虚函数指针,第一种是继承的父类的并且重写了的虚函数指针, 第二种是继承的父类的没有被重写的虚函数指针, 第三种是自己的虚函数指针。C类虚函数表中包含的指针式第一种和第三种)
现在看多继承。
如果是多继承,假如继承了n个父类。那么子类会有n张虚函数表。 子类的虚函数地址和第一个继承的类的虚函数地址存放在一张虚函数表之中,地址存放规则同单继承。其他虚函数表各自存放继承来的父类的虚函数或者重写的虚函数。 这里要注意的是, 子类对象的这些虚函数表与父类的虚函数列表不是同一张, 但是如果虚函数没有被子类重写, 那么里面存放的虚函数地址是相同的。
这里测试如下代码
class A
{
public:virtual void func(int a = 1){cout << "Afunc():" << a << endl;}virtual void funcA() {cout << "func()A" << endl;}private:int _a = 0x33;//这里初始化使用十六进制是为了好观察
};class B
{
public:virtual void funcB(){cout << "func()B" << endl;}private:int b = 0x22;
};class C : public A, public B
{
public:virtual void func(int a = 3){cout << "Cfunc()" << a << endl;}virtual void funcC(){cout << "func()C" << endl;}int c = 0x11;
};int main()
{C cc;A* ptr = &cc;ptr->func();cc.func();return 0;
}
如图是C类继承A类和B类之后的实例化对象cc中的虚函数表情况。 可以看到,cc中有两张虚函数表。 一张存放在A类的板块, 一张存放在B类的板块。(这里为什么要分板块, 是因为要切片。 当我们使用父类的引用引用子类对象或者父类指针解引用子类对象的时候,我们能拿到的就是相应的为继承来的变量开辟的板块空间)
现在我们就来观察底层内存空间。
这时C, B, A类的成员变量。 我们通过对他们进行缺省值初始化。 为了将他们每个板块区分出来。 现在我们开始观察空间。
对cc取地址, 观察cc的空间
这里红色箭头就是子类C本身的变量_c, 绿色箭头就是继承的B类的变量_b, 蓝色箭头就是继承的A类的变量_a。
所以这里我们就能判断, A类的内存板块就是前八个字节。B类的内存板块是中间八个字节。然后最后四个字节是C类本身的成员变量c的内存空间。
多态的形成
现在我们来重新回顾一下语法上多态的形成条件:
1、虚函数的重写
2、父类对子类的引用或者父类的指针对子类的解引用
现在虚函数的重写上面我们已经进行了分析。 现在我们来分析第二条内容。
当我们进行父类对子类的引用或者父类的指针对子类进行解引用的时候, 这时候其实会发生切片原理。
如图利用A*的ptr指针解引用一个C类的实例化对象相当于只拿到了图中横线上面的部分。
我们已经知道, ptr解引用后拿到的其实是C类实例化对象之中属于A类的那一块内存。这块内存中的虚函数表之中的虚函数就是继承而来的A类的虚函数。 (如果有函数被重写, 还要将相应的虚函数进行替换。 如果忘记了这条性质, 请回看重写模块)
这个时候如果, C类中继承A类而来的虚函数被重写时,并且我们恰好通过ptr调用了C这个被重写的虚函数, 这就是多态。
也就是说我们使用A类的指针找到了C的实例化对象中的重写的继承A类而来的虚函数。这个结果其实和我们使用C类的实例化对象调用相应的虚函数的结果是一样的。
这里重点就是切片原理和重写的性质。
最后还有另外一个要点:就是对于缺省值。
对于多态的缺省值, 我们要特殊关照。
如果构成了多态, 那么这个时候调用的虚函数的缺省值应该是父类的缺省值。
对于如下代码进行测试
class A
{
public:virtual void func(int a = 1){cout << "Afunc():" << a << endl;}virtual void funcA() {cout << "func()A" << endl;}private:int _a = 0x33;//这里初始化使用十六进制是为了好观察
};class C : public A
{
public:virtual void func(int a = 3){cout << "Cfunc()" << a << endl;}virtual void funcC(){cout << "func()C" << endl;}int c = 0x11;
};int main()
{C cc;A* ptr = &cc;ptr->func();return 0;
}
这是A类中的func
这是C类中的func
请问。 测试中的代码, 打印结果是什么呢?
我们看一下vs的结果:
这里之所以不是Cfunc()3的原因是因为这里的缺省值使用的时父类的缺省值。
这里我们需要特殊记忆, 当构成多态的时候。 假如虚函数有缺省值, 那么这个缺省值时是父类的缺省值。 如果父没有缺省值, 子类有。 那么这个重写的虚函数没有缺省值。 注意, 这里是当构成多态的时候 !当构成多态的时候!当构成多态的时候!假如没有构成多态, 我直接使用C类对象调用重写的func函数, 那么结果就还剩Cfunc()3.
以上, 就是多态的底层原理。