小谈多态
多态是c++面向对象的三大特性之一,即允许单个成员函数拥有多个不同的实现;当调用成员函数时,会根据类实例化的具体类型去执行不同的函数实现。
一、多态的种类
多态分为静态多态和动态多态。其中静态多态是指编译时绑定函数地址,对应的实现形式有函数重载和模板编程。而动态多态则是指运行时绑定函数地址,与之相关的就是RTTI、虚函数、虚表指针、虚表等一系列概念。
二、多态的相关概念
1、RTTI机制
RTTI(运行时类型识别),主要是应用于多态中,使得程序可以在运行时根据基类的指针或引用来获取该指针实际指向的类型。
因为在多态场景下,我们是根据基类指针指向的类型去寻找对应的虚函数,而不是根据其基类指针类型去寻找对应的虚函数。所以RTTI是实现多态的关键。
那么RTTI的原理是什么呢?
RTTI是通过关键字typeid关键字实现的,该运算符可以返回表达式或者类型名的实际类型。首先根据typeid确定指针类型,然后查询虚函数表的-1位置进行类型匹配(多态类的类型信息保存在虚函数表的索引为-1的项中。该项对应的是一个 type_info 对象的地址,该type_info 对象保存着该对象对应的类型信息,每个类都对应着一个 type_info 对象。那么其实就可以理解为,虚函数表属于哪个类,那么虚函数表的-1位置的type_info类型就是哪个类的类型),进而确定虚函数表的类型。
2、虚函数
1)虚函数的定义
虚函数使用virtual关键字声明,一般是在基类中声明,派生类中重写。
2)虚函数与普通函数的区别
① 是否需要用virtual关键字声明
② 重写(虚函数)/重载(普通函数)的区别
③ 函数调用时函数地址静态绑定/动态绑定的区别
3)虚函数和纯虚函数的区别
① 声明方式不一样(是否有函数体)
② 纯虚函数对应的是抽象类,抽象类不能实例化对象
③ 纯虚函数对应接口继承,派生类必须进行重写,否则派生类也不能实例化对象
4)抽象类为什么不能实例化对象?
因为抽象类的虚函数表中纯虚函数对应的表项是空的,为了防止非法访问所以禁止实例化。
3、虚函数表和虚表指针
虚函数表是编译时确定,而虚函数表指针是在运行时确定,这是实现多态的关键
1)虚函数表
① 虚表中存放的是函数指针
② 虚函数表存放在代码段
2)虚函数表在单继承和多继承中的区别?
单继承。
子类会将父类的虚函数表拷贝,并将重写的虚函数进行覆盖。
多继承。
会继承多个虚函数表,子类对于虚函数的重写会将所有的虚函数中的相应函数进行覆盖, 但是子类独有的虚函数只会添加到第一个虚函数表。
3)是否可以利用虚函数表绕开访问权限?
可以。只有虚函数可以,因为只要是虚函数都会存放在虚函数表中,在继承成就会被全部拷贝。通过偏移找到该函数指针就可以。
三、虚函数表遍历
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
};
//定义函数指针类型
typedef void(*Fun)(void);
Base b0,b;
cout << sizeof(b0) << endl;
cout << (&b0) << endl;; //853418506536
cout << (&b) << endl;; //853418506568
Fun pFun = NULL;
//虚函数表指针 存储在对象内存的前8个字节,所以直接用 int64_t 取出前8个字节并强制转换成指针
//此时 v_table 就是该对象的虚函数表指针
int64_t *v_table = (int64_t*)(&b);
cout << "虚函数表地址:" << (int64_t*)(&b) << endl;
//存储指针需要一个地址, 指针指向一个地址
cout << "指针地址:" << &v_table << endl;
//虚函数表就像是一个 指针数组,虚函数按照声明顺序排列在虚函数表上
//虚函数表指针指向 虚函数指针数组的首地址,对虚函数表指针解引用就得到虚函数指针数组的首地址
//指针指向的是什么? 解引用解出来的就是什么
int64_t tmp = *((int64_t*)(&b));
//上一步我们得到 虚函数表的首地址,64位系统下的指针是 8字节存储的,那么我们取该地址下的前 8个字节 就是虚函数表中对应的
//第一个虚函数指针
int64_t *v_ptr = (int64_t*)tmp;
//cout << "虚函数表 — 第一个函数地址:" << v_ptr << endl;
//v_ptr++;
//cout << "虚函数表 — 第一个函数地址:" << v_ptr << endl;
//对虚函数指针进行解引用,得到对应的函数指针 并进行强制转换
pFun = (Fun)(*v_ptr);
while (pFun != nullptr) {
pFun();
v_ptr++; //v_ptr 的size为 8,那么++的含义就是 每次在之前的基础上偏移8个字节
//那么就是指向下一个虚函数对应的指针
pFun = (Fun)(*v_ptr);
}
那么在多继承场景下,如何打印第二个或者第三个虚表的函数?
1)在取得第一个虚函数表指针后,先不对其进行解引用
2)先计算第一个类的大小,即偏移量
3)累加操作 v_table += 偏移量/8
//虚函数表地址 --- 其实就相当于解引用 将对象的内存地址中取出前8位
int64_t *v = (int64_t*)(p);
cout << "v: " << v << endl;
v += sizeof(Person)/8;
注意理解为什么/8,因为指针的累加操作是根据指针类型的大小,即v ++并不是v += 1,而是v+=8; 因为v 的数据类型的大小占据8个字节。
四、重载、重写和重定义
1、重载(静态多态)
1)重载的发生条件
① 同一作用域
② 函数名必须相同
③ 参数列表必须不同
④ 返回值类型相同/不相同是无所谓的,但是只有返回值类型不同是构不成重载的(c++编译后的函数名带有 返回类型、函数名、形参列表,但是编译器不会根据返回类型区分函数)
2)为什么C语言不能发生重载?
c编译后的函数名不带有形参列表
3)当子类和父类发生拥有同名函数时,函数名相同,但是参数列表不同,发生重载吗?
发生重定义,会隐藏父类的同名成员函数。
2、重写(动态多态)
1)重写的发生条件
① 必须是虚函数,并且父类进行声名,子类进行重写
② 函数名必须相同,参数列表必须相同
③ 返回值一般相同,协变除外
3、重定义
继承但不发生多态,成为重定义。
当子类和父类拥有同名成员时。子类会对父类的同名成员进行隐藏。如果想用子类对象去访问父类的隐藏变量,需要添加类作用域符号。
五、面试相关问题
1、子类赋值给父类,会不会发生动态绑定?
不会。只有用子类对象去初始化父类指针或引用的时候,才能触发动态绑定。因为编译器是根据指针的指向而不是类型去调用对应的虚函数。
当用子类对象去给父类赋值时,就相当强制类型转换,强制类型转换其实就是内存的拷贝。因为子类对象的内存是大于等于父类的,当发生类型转换的时候,就只剩下的父类的部分,即此时对象内存首地址存放的虚函数表指针是基类的
2、哪些函数不能声明为虚函数?
构造函数
静态函数
友元函数
内联函数
模板函数
3、构造函数/析构函数中能不能调用虚函数?
可以的。原因也很简单,仔细过一下构造函数的执行流程即可。调用B的构造函数,先调用A的构造函数,调用A的构造函数,先按照A对象的内存布局进行初始化,因为虚表指针是放在顶部的,先初始化虚表指针,指向虚表(虚表在编译期就生成),之后按照声明顺序初始化成员变量。最后调用构造函数{ }中的代码。由此可见调用this->fun( )时已经设定好虚表指针,所以调用不会有任何问题。之后B将虚表指针指向自己的虚表,初始化自己的成员变量,最后调用B(){ }中的代码this->fun( ),这个时候对象顶部的虚表指针指向B的虚表,调到的自然是B::fun( )。