多态的概念
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
总的来说:多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。
多态的定义及实现
多态的构成条件
在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
class person
{
public:virtual void BT(){cout << "买票,全价" << endl;}
};class student : public person
{
public:virtual void BT(){cout << "买票,半价" << endl;}
};void Fan(person& p)
{p.BT();
}int main()
{person ps;student st;Fan(ps);Fan(st);return 0;
}
注意:接收对象的指针或引用,传递是父类就调用父类的函数,传递是子类就调用子类的函数。
在重写父类虚函数的时候,子类的虚函数在不加 virtual 关键字时虽然也能构成重写(因为继承后父类的虚函数被继承下来依旧保持虚函数的属性),这种方法不规范,不建议使用
虚函数的重写和其两个例外
虚函数的重写:
虚函数的重写(覆盖):子类中有一个跟父类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
刚才多态的构成条件已经实现了虚函数的重写,这里不做过多赘述
虚函数重写的两个例外:
1.协变(父类与子类虚函数返回值类型不同)
子类重写基类虚函数时,与父类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
这里不仅仅时返回当前父类和子类的类型,还可以返回其他继承关系的类和类类型
2.析构函数的重写(父类与子类析构函数的名字不同)
如果父类的析构函数为虚函数,此时子类析构函数只要定义,无论是否加virtual关键字,都与父类的析构函数构成重写,虽然父类与子类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
首先,看看不加 virtual 的情况
本义是想看让p1调用person的析构,p2先调用person的析构再调用student的析构,但是这里并没有调用student的析构,只有父类的析构,这样可能发生内存泄漏。
这是为什么呢?
因为这里构成了隐藏,~person()变为 this->destructor() ~student()为this->destructor()编译器将他们两个的函数名都统一处理destructor,因此调用的时候只看自身的类型,是person就调用person,student就调用student的函数,根本构不成多态,与我们期望的并不一样。
现在加上virtual关键字
student 能正常析构了
虽然说子类可以不加 virtual 关键字,建议不要这样,最好父类和子类都加上
同时析构函数加virtual是在new场景下才需要,其他环境可以不用
C++11 final和override
C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
final:修饰虚函数,表示该虚函数不能再被重写
final修饰类不能被继承
override: 检查子类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
重载、覆盖(重写)、隐藏(重定义)的对比
(只有重写要求原型相同,原型相同就是指 函数名/参数/返回值都相同)
函数重载:在同一个作用域中,两个函数的函数名相同,参数个数,参数类型,参数顺序至少有一个不同,函数返回值的类型可以相同,也可以不相同。
重定义(也叫做隐藏)是指在继承体系中,子类重新定义父类中有相同名称的非虚函数(参数列表可以不同),此时子类的函数会屏蔽掉父类的那个同名函数。
重写(也叫做覆盖)是指在继承体系中子类定义了和父类函数名,函数参数,函数返回值完全相同的虚函数。此时构成多态,根据对象去调用对应的函数。
抽象类
概念:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成纯虚函数。
多态的原理
虚函数表
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,那么派生类中这个表放了些什么呢?我们接着往下分析
我们多添加几个虚函数看看
可以发现虚函数会放到虚函数表里,普通函数不会,并且表的内容是一个数组,是函数指针数组
多态的原理
测试代码
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }virtual void fun(){}
private:int a;
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:int b;
};
void Func(Person* p)
{p->BuyTicket();
}
int main()
{Person p;Student s;Func(&p);Func(&s);return 0;
}
虚表指针里的内容
从图中我们可以看到,在内存2中输入 &p 可以找到p的地址,因为p的第一个内容是 _vfptr,因此p的地址也就是_vfptr的地址,通过_vfptr地址里就可以找到虚函数表里的内容,因此内存1中输入_vfptr的地址,便找到两个函数的地址。
同理拿到 s 的
为什么我要费劲心思去看内存呢?监视窗口不是可以看嘛,因为vs2022的监视窗口不一定准确,但内存一定准确。
第二个虚函数的地址一样,因为fun()没有被重写而被继承下来了,而BuyTicket()被子类重写覆盖掉了,这就是为什么重写也被称为覆盖。
引用和指针如何实现多态
可以分析为什么指针指向父类调用父类的函数,指向子类调用子类函数?
传递父类,通过vptr找到找到虚函数表的位置,再去找到虚函数的地址,有了虚函数的地址,便可以去call这个虚函数。
传递子类会进行切割
将子类的内容切割掉父类再去接受这个数据,一样会有vptr(子类的vptr),再去找到虚函数的地址,有了虚函数的地址,便可call这个虚函数,这样实现了多态。
为什么普通类实现不了多态
我们用普通类
给代码做点小改造,给Person加上构造
同时给p对象传10,他会调用构造函数将10赋给成员a。执行Func(p)时注意此时虚函数表的地址为0x0000007ff7dfd3ac18
当执行Func(s)此时a的直为0,此时虚函数表的地址为0x0000007ff7dfd3ac18,没错相同!
不难看出Func(s)传递时,切割出子类中父类的那一份成员会拷贝给父类,但是没有拷贝虚函数表指针。
为什么只拷贝成员,不拷贝虚函数表呢?C++祖师爷为何这么设计?
我们可以反向思考,假设 拷贝构造 和 赋值重载 会拷贝虚函数表指针那么如下,运行后输出的结果就为 买票,半价 了(因为不管指向的什么,只管你所存储的数据)这样就不能保证多态调用时,指向父类,父类调用的是父类的虚函数。因为还有可能经过一些操作,变成子类的虚函数
或许这问题不致命,那么析构呢,要清楚虚函数表还可能有析构函数,遇到如下情况该如何
Person* p = new Person;
Student s;
*p = s;
deletep;
这个时候Person父类的对象delete回去调用子类Student类的析构函数,这样会引发很多不可控的事情。
可能有些绕,只需要知道只有 引用和指针才能触发多态即可!
最后同类对象的虚表一样,如果子类没有重写父类的粗函数,那么他们的虚函数表指针不同,但里面的内容相同
虚函数表存放的位置
各个区地的地址可以通过代码获得,以此来判断虚函数表的存放位置
class Base
{
public:virtual void fun1(){cout << "base::fun1" << endl;}virtual void fun2(){cout << "base::fun2" << endl;}
private:int a;
};void fun()
{}int main()
{Base b1;Base b2;static int a = 0;int b = 0;int* p1 = new int;const char* p2 = "hello word";printf("静态区:%p\n", &a);printf("栈:%p\n", &b);printf("堆:%p\n", p1);printf("代码段:%p\n", p2);printf("虚表:%p\n", *((int*)&b1));printf("虚函数地址:%p\n", & Base::fun1);printf("普通函数:%p\n", fun);}
注意打印虚表这里,vs X86环境下的虚表的地址时存放在类对象的头四个字节上。因此通过强转来获取这是个头字节。
b1是类对象,取地址取出类对象的地址,强转为(int*)代表我们只取四个字节,再解引用,即可获取第一个元素的地址,也就是虚函数表指针的地址
从结果来看代码段和虚表的地址非常接近,存放在代码段的常量区
虚函数和普通函数地址最为接近,存放在代码段
动态绑定与静态绑定(了解)
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,
比如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数,也称为动态多态。
单继承和多继承关系的虚函数表
单继承中的虚函数表
测试代码
class Base
{
public:virtual void func1(){cout << "Base::func1" << endl;}virtual void func2(){cout << "Base::func2" << endl;}private:int a;
};class Derive : public Base
{
public:virtual void func1(){cout << "Derive::func1" << endl;}virtual void func3(){cout << "Derive::func3" << endl;}virtual void func4(){cout << "Derive::func4" << endl;}private:int b;
};class X : Derive
{
public:virtual void func3(){cout << "X::func3" << endl;}
};int main()
{Base b;Derive d;X x;return 0;
}
监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。
打开内存窗口输入_vfptr的地址,找到d中的四个虚函数地址
在vs环境下,虚函数表里的虚函数都是以0结尾符合我们之前观察到的
我们可以通过这一点来打印虚表。
下面我们typedef了虚函数表指针 typedef void(*VFTPTR)(); 可以通过这个函数指针数组来打印里面的虚函数,这个打印函数终止条件就是 !=0 ,传递的参数内容跟前面我们分析的差不多,只是多了一个强转,PrintVFPtr((VFTPTR*)*(int*)&b) ; 因为后面的 *(int*)&b 虽然内容是地址,但是表现形式是一个整形,需要强为 (VFTPTR*) 。
再*((int*)&d) 就会取到vTableAddress指向的地址,即可得到虚函数的地址
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};
class Derive :public Base {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }
private:int b;
};
class X : Derive
{
public:virtual void func3() { cout << "X::func3" << endl; }
};typedef void(*VFTPTR)();void PrintVFPtr(VFTPTR* a)
{for (size_t i = 0; a[i] != nullptr; ++i){printf("a[%d]:%p\n", i, a[i]);VFTPTR f = a[i];f();}cout << endl;
}int main()
{Base b;Derive d;X x;PrintVFPtr((VFTPTR*)(*((int*)&b)));PrintVFPtr((VFTPTR*)(*((int*)&d)));PrintVFPtr((VFTPTR*)(*((int*)&x)));return 0;
}
我们运行以下代码便可打印出虚表里面的内容
多继承中的虚函数表
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;
};typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数cout << " 虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
int main()
{printf("%p\n", &Derive::func1);Derive d;//PrintVTable((VFPTR*)(*(int*)&d));PrintVTable((VFPTR*)(*(int*)&d)); PrintVTable((VFPTR*)(*(int*)((char*)&d+sizeof(Base1))));
}
结论就是:func1是重写的函数,再子类的两个父类的虚表中储存的func1地址不同,但是通过一系列的call这个地址,这个地址的内容又是jump到另一个指令,最终都会跳到子类重写的func1上
PrintVTable((VFPTR*)(*(int*)&d));
因为对象中存虚表指针,虚表指针中存的是虚表(一个指针数组),则需要先解引用访问到这个对象的前四个字节内容(存的就是虚表指针),此时的虚表指针 *((int*)&d)是一个int类型,再把虚表指针类型强转成指针数组类型才能传参
PrintVTable((VFPTR*)(*(int*)((char*)&d+sizeof(Base1)))); 是找到Base2的虚表地址后再解引用找到虚表(直接加2个int字节也能找到base2,考虑Base1可能不单单是2个int大小,这里建议用sizeof(Base1) )
结论: Derive对象Base2虚表中func1是Base2指针ptr2去调用。但是这时ptr2发生切片指针偏移,需要修正。中途就需要修正存储this指针ecx的值
虚函数使用规则
虚函数使用规则:
(1)虚函数在类中声明和类外定义的时候,virtual关键字只在声明时加上,而不能加在在类外实现上
(2)静态成员不可以是虚函数。因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
(3)友元函数不属于成员函数,不能成为虚函数
(4)静态成员函数就不能设置为虚函数(原因:静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表,就无法实现多态,因此不能设置为虚函数)
(5)析构函数建议设置成虚函数,因为有时可能利用多态方式通过基类指针调用子类析构函数(尤其是父类的析构函数强力建议设置为虚函数,这样动态释放父类指针所指的子类对象时,能够达到析构的多态)
inline函数可以是虚函数吗?
答:可以,不过多态调用的时候编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。
静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
构造函数可以是虚函数吗?
答:不能,因为对象中的 虚函数表指针 是在 构造函数初始化列表阶段才初始化的 。虚函数的意义是多态,多态调用时到虚函数表中去找,构造函数之前还没初始化,如何去找?
析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。析构函数名统一会被处理成destructor()
对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
虚函数表是在什么阶段生成的,存在哪的?
答: 虚函数表是在编译阶段就生成的 ,一般情况下存在代码段(常量区)的。( 虚函数表指针初始化是指把虚函数表的指针放到对象中去,但生成仍是在编译阶段 )