多态的原理
虚函数表
我们先来看下面的一道题 :Base类实例化出对象的大小是多少?
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; };
有些同学可能看到这里会想到我们之前学习的类的大小计算
成员函数在公共区域 所以不算是类的大小 这里大小是四个字节
要是这样想 就被这个题目带进坑里面去了
我们先来看一下啊实际的大小是多少
实际大小是八 这就说明虚函数这里肯定是出问题了 那么问题出在哪里呢?
实际上 因为有虚函数的存在 它在内存中的分布应该是这样子的
指针的大小在32位的系统下是四个字节 因此我们最后算出来的结果才会是8
虚表指针指向一个虚函数表,简称虚表,每一个含有虚函数的类中都至少有一个虚表指针。
虚函数表中到底放的是什么?
我们写出下面的代码 设置三个对照组来对比一下
class person { public: virtual void func1() { cout << "func1" << endl; } virtual void func2() { cout << "func2" << endl; } void func3() { cout << "func3" << endl; } private: int _age = 1; }; class child : public person { public: virtual void func1() { cout << "func1-child" << endl; } private: int _name = 2; };
我们可以发现 最后在内存中的结果变成了这样子 变成了一个指针 加上本来的成员变量
我们可以将它们抽象成下面的图
实际上_ptr指向的就是虚表的地址 虚表里面存放着虚函数的地址
由于child对于func2重写了 所以说两个虚表指针指向的虚表中 func2的地址不一样
func1的地址则相同(因为没有被重写)
而由于func3根本不是虚函数 所以说地址不会在虚表中
因为最后我们将func2的地址覆盖掉了 这也就是为什么我们原理层叫做覆盖 语法层叫做重写的原因
注意点: 我们一般会在虚表指针数组的最后放置一个空指针(nullptr)
那么我们在这里总结下 派生类虚表的生成步骤如下
先将基类中的虚表内容拷贝一份到派生类中的虚表
如果派生类中重写了某个虚函数 则使用重写后的虚函数地址覆盖之前的
派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
那么接下来的问题又来了
虚表是什么阶段初始化的?虚函数存在哪里?虚表存在哪里?
虚表实际上是在构造函数初始化列表阶段进行初始化的
虚函数存放的位置和普通函数一样 都是存放在代码段
所以说我们虚表当中存放的地址并不是虚函数 而是指向虚函数的指针
虚表实际上也是存放在代码段的 我们可以使用下面的代码来证明
void test() { person p; person* ptr = &p; printf("虚表地址:%p\n", *((int*)ptr)); // 这里解释下上面这一步的原理 // 我们首先将指针转化成int*类型 之后再解引用 就变成了int类型 // 之后将这个int类型的数据再用地址的格式打印出来 // 至于为什么要打印前面四个字节呢? // 可以参考我们前面debug时候的内存图 前面四个字节就是虚表指针地址 int i = 0; printf("栈上地址: %p\n", &i); printf("数据段地址: %p\n", &j); // 全局变量再数据段 int* k = new int; printf("堆上地址: %p\n", k); const char* cp = "hello world"; printf("代码段地址: %p\n", cp); }
运行结果如下
我们可以发现 虚表地址和代码段的地址是最相似的 所以说虚表在代码段当中
多态的原理
那到底多态的原理是什么?
还是一样 我们先来写代码
class person { public: virtual void buy_ticket() { cout << "买票 - 原价" << endl; } private: int _a = 1; }; class child : public person { public: virtual void buy_ticket() { cout << "买票 - 半价" << endl; } private: int _b = 2; }; int main() { person p; child c; person* pptr = &p; person* cptr = &c; pptr->buy_ticket(); cptr->buy_ticket(); return 0; }
这个时候我们再看看上面的图
学完了上面的知识我们大概就能明白
由pptr和cptr指针找到的函数地址(再虚表中) 是不一样的函数
因此 在不同对象去完成同一行为的时候发生了多态的现象
为什么对象不能构成多态
还记不记得我们之前在继承章节学过一个行为叫做切片
当我们使用指针或者引用切片的时候 我们本质上得到的是子类从父类派生过去的那一部分
而有了虚函数之后本质上就是前面多了一个虚表指针 也就是说
我们切片后的指针还有引用都还是使用的子类的虚表指针
但是如果是对象的切片呢?
这里实际上经历了一个拷贝构造的过程
构造出来的person对象 本质上是一个父类对象 所以说它的虚表指针也就是父类的虚表指针
自然无法构成多态
这里总结下:
构成多态 和对象有关
不构成多态 和类型有关
动态绑定和静态绑定
静态绑定: 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态,比如:函数重载。
动态绑定: 动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
接下来我们通过汇编代码 来对其进行进一步的深入了解
首先 我们先验证下 是不是对象的确不能构成多态
class person { public: virtual void buy_ticket() { cout << "买票 - 原价" << endl; } private: int _a = 1; }; class child : public person { public: virtual void buy_ticket() { cout << "买票 - 半价" << endl; } private: int _b = 2; }; int main() { person p; child c; person p1 = c; p1.buy_ticket(); return 0; }
我们可以看到它的汇编代码是这样子的
这里我们发现 就是一个普通的调用函数的过程
但是如果我们使用引用或者指针来调用
像这样
int main() { person p; child c; person* p1 = &c; p1->buy_ticket(); return 0; }
我们再转到汇编代码
可以看到这样几行汇编
上面代码的意思实际上就是从虚基表中找到对应的函数地址然后调用的过程
这样就很好的体现了静态绑定是在编译时确定的,而动态绑定是在运行时确定的
继承多态面试题
概念题
1、下面哪种面向对象的方法可以让你变得富有()
A.继承 B.封装 C.多态 D.抽象
这个很显然 答案是A 继承 不用过多讲解
2、()是面向对象程序设计语言中的一种机制,这种机制实现了方法的定义与具体的对象无关,而方法的调用则可以关联于具体的对象。
A.继承 B.模板 C.对象的自身引用 D.动态绑定
本质上是多态 也就是动态绑定 这题选D
3、关于面向对象设计中的继承和组合,下面说法错误的是()
A.继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用。
B.组合的对象不需要关系各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用。
C.优先使用继承,而不是组合,是面向对象设计的第二原则。
D.继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现。
很明显 我们优先使用组合 而不是继承 所以C选项明显错误
4、以下关于纯虚函数的说法,正确的是()
A.声明纯虚函数的类不能实例化对象
B.声明纯虚函数的类是虚基类
C.子类必须实现基类的纯虚函数
D.纯虚函数必须是空函数
这里考察的是纯虚函数的概念 答案是A
5、关于虚函数的描述正确的是()
A.派生类的虚函数与基类的虚函数具有不同的参数个数和类型
B.内联函数不能是虚函数
C.派生类必须重新定义基类的虚函数
D.虚函数可以是一个static型的函数
这题很明显选B A C D都有明显的错误
6、关于虚表的说法正确的是()
A.一个类只能有一张虚表
B.基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C.虚表是在运行期间动态生成的
D.一个类的不同对象共享该类的虚表
A选项显然是错误的 父类既可以有父类的虚表也可以有子类的虚表
B显然也是错误的 子类基类不共用
C 虚表并不是动态产生的 它存在于代码段中
D D选项正确
7、假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则()
A.A类对象的前4个字节存储虚表地址,B类对象的前4个字节不是虚表地址
B.A类对象和B类对象前4个字节存储的都是虚基表的地址
C.A类对象和B类对象前4个字节存储的虚表地址相同
D.A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
这一题考察的是虚表的概念 以及储存位置 答案是D
8、下面程序输出结果是什么?
#include <iostream> using namespace std; class A { public: A(char* s) { cout << s << endl; } ~A() {}; }; class B : virtual public A { public: B(char* s1, char* s2) :A(s1) { cout << s2 << endl; } }; class C : virtual public A { public: C(char* s1, char* s2) :A(s1) { cout << s2 << endl; } }; class D : public B, public C { public: D(char* s1, char* s2, char* s3, char* s4) :B(s1, s2) , C(s1, s3) , A(s1) { cout << s4 << endl; } }; int main() { D* p = new D("class A", "class B", "class C", "class D"); delete p; return 0; }
A.class A class B class C class D
B.class D class B class C class A
C.class D class C class B class A
D.class A class C class C class D
这一题实际上是考察的继承的概念 先构造父类 再构造子类
和先析构子类再析构父类相反
所以说本题选A
9、下面说法正确的是?(多继承中指针的偏移问题)
class Base1 { public: int _b1; }; class Base2 { public: int _b2; }; class Derive : public Base1, public Base2 { public: int _d; }; int main() { Derive d; Base1* p1 = &d; Base2* p2 = &d; Derive* p3 = &d; return 0; }
A.p1 == p2 == p3
B.p1 < p2 < p3
C.p1 == p3 != p2
D.p1 != p2 != p3
答案是C 有关于指针偏移的问题 指针偏向于先继承的那个父类
10、以下程序输出结果是什么?
#include <iostream> using namespace std; class A { public: virtual void func(int val = 1) { cout << "A->" << val << endl; } virtual void test() { func(); } }; class B : public A { public: void func(int val = 0) { cout << "B->" << val << endl; } }; int main() { B* p = new B; p->test(); return 0; }
A.A->0 B.B->1 C.A->1 D.B->0
E.编译错误 F.以上都不正确
这一题考察的是缺省值的问题 记住结论就好 使用的是父类中的缺省值
问答题
1、什么是多态?
多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。多态又分为静态的多态和动态的多态。
2、什么是重载、重写(覆盖)、重定义(隐藏)?
重载是指两个函数在同一作用域,这两个函数的函数名相同,参数不同。
重写(覆盖)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名、参数、返回值都必须相同(协变例外),且这两个函数都是虚函数。
重定义(隐藏)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名相同。若两个基类和派生类的同名函数不构成重写就是重定义。
3、多态的实现原理?
构成多态的父类对象和子类对象的成员当中都包含一个虚表指针,这个虚表指针指向一个虚表,虚表当中存储的是该类对应的虚函数地址。因此,当父类指针指向父类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是父类当中对应的虚函数;当父类指针指向子类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是子类当中对应的虚函数。
4、inline函数可以是虚函数吗?
我们知道内联函数是会在调用的地方展开的,也就是说内联函数是没有地址的,但是内联函数是可以定义成虚函数的,当我们把内联函数定义虚函数后,编译器就忽略了该函数的内联属性,这个函数就不再是内联函数了,因为需要将虚函数的地址放到虚表中去。
6、构造函数可以是虚函数吗?
构造函数不能是虚函数,因为对象中的虚表指针是在构造函数初始化列表阶段才初始化的
7、析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
析构函数可以是虚函数,并且最后把基类的析构函数定义成虚函数。若是我们分别new一个父类对象和一个子类对象,并均用父类指针指向它们,当我们使用delete调用析构函数并释放对象空间时,只有当父类的析构函数是虚函数的情况下,才能正确调用父类和子类的析构函数分别对父类和子类对象进行析构,否则当我们使用父类指针delete对象时,只能调用到父类的析构函数
8、对象访问普通函数快还是虚函数更快?
对象访问普通函数比访问虚函数更快,若我们访问的是一个普通函数,那直接访问就行了,但当我们访问的是虚函数时,我们需要先找到虚表指针,然后在虚表当中找到对应的虚函数,最后才能调用到虚函数。
9、虚函数表是在什么阶段生成的?存在哪的?
虚表是在构造函数初始化列表阶段进行初始化的,虚表一般情况下是存在代码段(常量区)的。
10、C++菱形继承的问题?虚继承的原理?
菱形虚拟继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。
虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表获取到偏移量,进而找到对应的虚基类成员,从而解决了数据冗余和二义性的问题。
11、什么是抽象类?抽象类的作用?
抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去抽象纯虚函数,因为子类若是不抽象从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。
其次,抽象类可以很好的去表示现实世界中没有示例对象对应的抽象类型,比如:植物、人、动物等。
总结
本篇博客简单介绍了C++中多态的原理