【C++】多态的语法与底层原理
1.多态的概念
1.1 概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。
举个例子:在现实当中,我们去火车站买票,一般都分三种情况:当普通人买票时,是全价买票;学生买票时,可以销售半折优惠;军人买票时,可以享受优先购票
2.多态的定义与实现
2.1多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。在继承中要构成多态的条件有两个:
1.必须通过基类的指针或者引用去调用虚函数
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
被virtual修饰的成员函数称为:虚函数(只要成员函数才能变成虚函数)
class person
{
public:virtual void buyticket(){cout << "全价" << endl;}
};
2.2虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数( 即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同 ),称子类的虚函数重写了基类的虚函数。
class person
{
public:virtual void buyticket(){cout << "全价" << endl;}
};class student : public person
{
public:virtual void buyticket(){cout << "半折" << endl;}
};void buyticket(person* p)
{p->buyticket();
}void buyticket(person& p)
{p->buyticket();
}int main()
{student s;person p;buyticket(s); //半折buyticket(P); //全价buyticket(&s); //半折buyticket(&p); //全价return 0;
}
以上派生类中就完成了重写(覆盖)了
注意::在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因 为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,建议在派生类对的虚函数前加上virtual
虽然说虚函数的重写需要符合三同(返回类型,函数名,参数列表(主要是类型))
但是存在两个例外
1.协变(基函数与派生类虚函数放会值类型不同)
C++的语法允许派生类和基类中虚函数返回值类型不同,但是要求返回值必须是基类和派生类关系的指针或者引用。满足前面要求就是协变
class person
{
public:virtual person* buyticket(){cout << "全价" << endl;return 0;}
};class student : public person
{
public:virtual student* buyticket(){cout << "半折" << endl;return 0;}
};
注意:只要是父子关系的指针或者引用都可以,父是父,子是子指针或者引用要匹配
class A{};
class B : public A{};
class person
{
public:virtual A* buyticket(){cout << "全价" << endl;return 0;}
};class student : public person
{
public:virtual B* buyticket(){cout << "半折" << endl;return 0;}
};
🚀析构函数可以是虚函数吗?为什么是虚函数?
答:
析构函数加virtual,是不是虚函数重写?
是,因为在类中析构函数被特殊处理成了destructor这个统一的名字。
为什么要这么处理?
因为要让它们构成重写。
为什么要让他们构成重写?
因为以下的场景:
class person
{
public:~person(){cout << "~person" << endl;}
};class student : public person
{
public:~student(){cout << "~student" << endl;}
};int main()
{person* p = new person;delete p;p = new student;delete p;
}
运行之后我们来看看它调用析构情况
可以看到程序运行结束之后,只调用了两次析构函数,这里可以发现在派生类中,少调用了一次析构函数,析构派生类部分(派生类的析构需要调用基类和它自己的析构函数,来进行空间的释放),原因就是在类中,析构函数名被编译器特殊处理成了destructor,导致基类中的析构函数将派生类中的析构函数隐藏/重定义了,编译器找不到派生类中的析构函数,所以就会造成以上的结果
在delete释放派生类空间的时候,需要进行两个操作( 1.调用析构函数destructor进行资源的释放 2. 调用operator delete() 释放整个空间 ),我们这里所期望的是一个多态调用,而不是一个普通调用
解决方法:使用虚函数,进行多态调用
class person
{
public:virtual ~person(){cout << "~person" << endl;}
};class student : public person
{
public:virtual ~student(){cout << "~student" << endl;}
};int main()
{person* p = new person;delete p;p = new student;delete p;
}
2.3 C++11 override 和 final
final
对函数使用:不允许该函数进行重写操作
class person
{
public:virtual void buyticket() final{cout << "全价" << endl;}
};class student : public person
{
public:virtual void buyticket(){cout << "半折" << endl;}
};
对类使用:不允许该类被继承
class person final
{
public:virtual void buyticket(){cout << "全价" << endl;}
};class student : public person
{
public:virtual void buyticket(){cout << "半折" << endl;}
};
override
帮助派生类检查是否完成重写,如果没有就会报错
class person
{
public:virtual void buyticket(){cout << "全价" << endl;}
};class student : public person
{
public:void buyticket(int) override{cout << "半折" << endl;}
};
设计题:设计一个类不想被继承应该怎么设计,不能使用final
方法一:将基类构造函数私有(C++98)
class A
{
private:A(){}
};class B : A
{
public:int _a;
};int main()
{B a;return 0;
}
基类中构造函数被封装之后,派生类就无法调用构造函数创建对象
如果要进行访问,可以使用静态成员函数
class A
{
public:static A createObj(){return A();}int _a = 1;
private:A(){}
};class B : A
{
public:int _a;
};int main()
{A p = A::createObj();p._a;return 0;
}
方法二:使用final,不允许基类被继承(C++11)
class A
{
private:A(){}
};class B : A
{
public:int _a;
};int main()
{B a;return 0;
}
2.4 重载、覆盖(重写)、隐藏(重定义)的对比
3. 抽象类
3.1 概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生 类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class car
{
public:virtual void func1() = 0; //纯虚函数
};int main()
{car a;return 0;
}
使用纯虚函数实例化对象,编译器会直接报错
继承car的派生类对象,也不能实例化对象
class car
{
public:virtual void func1() = 0; //纯虚函数
};class BMW : public car
{
public:};int main()
{car a;BMW b;return 0;
}
抽象类的使用:
class car
{
public:virtual void func1() = 0; //纯虚函数
};class BMW : public car
{
public:virtual void func1(){cout << "启动!" << endl;}
};class Benz : public car
{
public:virtual void func1(){cout << "刹车" << endl;}
};class BYD : public car
{
public:virtual void func1(){cout << "减速" << endl;}
};void func2(car* c)
{c->func1();
}int main()
{func2(new BMW);func2(new Benz);func2(new BYD);return 0;
}
抽象类也可以实现多态调用,抽象类就是用来间接强制要求你重写虚函数的
3.2 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
4.多态的原理
4.1单继承的虚函数表
大家看下面这一道题目:
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}virtual void Func2(){cout << "Func1()" << endl;}void Func3(){cout << "Func1()" << endl;}
private:char _b = 1;
};int main()
{cout << sizeof(Base) << endl;Base b1;return 0;
}
我们知道算类的大小不算成员函数,我们只需要考虑成员变量,只有一个成员变量,但是最终的运行结果是8,我们打开监视窗口看一下base中的成员变量
可以看到在类中多出了_vfptr(v代表virtual,f代表function),它是一个指针,这里就可以知道为什么sizeof(b1)算出来的值是8
_vfptr在vs下是放在第一个位置,存放位置与编译器有关,别的编译器可能会放到最后一位
_vfptr是一个虚表指针,指向虚函数表,虚函数表是用来存放虚函数指针的
我们来分析虚表里面存放的什么?
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(){}
private:int _d = 2;
};
int main()
{Base b;Derive d;return 0;
}
观察b和d两个对象
可以看到在derive中重写了func1的实现之后d对象中虚函数表也将Base::Func1覆盖成了Derive::Func1,所以重写也叫覆盖
通过虚指针指向的地址我们可以找到虚函数表在内存中的储存情况,从图中我们可以清楚的看到虚函数表中的最后一个位置被处理为了nullptr,这是编译器个性处理
此时我在Derive中新加了一个虚函数func4(),看这个函数是否会放到虚函数表中
class Derive : public Base
{
public:virtual void Func1(){}virtual void func4(){}
private:int _d = 2;
};
在监视窗口中看不到函数func4的身影,我们看看内存视图
内存视图中确实三个函数的地址,所以我猜测这最后一个地址就是func4函数的地址
那如何验证这个猜测呢?
我们这里做了一个小设计,通过使用函数指针指向虚函数表,来访问虚函数
//重定义函数指针为FUNC_TEST
typedef void(*FUNC_TEST) ();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(){}virtual void func4(){}
private:int _d = 2;
};void _vfptr_Print(FUNC_TEST* _vfptr)
{for (int i = 0; _vfptr[i] != nullptr; ++i){cout << _vfptr[i] << ' ';}cout << endl;
}int main()
{Base b;Derive d;//使用int*访问_vfptr,然后通过解引用放问虚函数表第一个元素FUNC_TEST* ptr = (FUNC_TEST*)(*(int*)&b);_vfptr_Print(ptr);ptr = (FUNC_TEST*)(*(int*)&d);_vfptr_Print(ptr);return 0;
}
通过将地址打印出来,来验证func4的存在,可以看到func4确实是存在于虚函数表中的
那么虚表存在哪呢?
栈, 堆, 动态区, 数据段, 代码段
同理,我们可以通过代码实践得出结果
int a = 1;
printf("栈区:%p\n", &a);int* b = new int;
printf("堆区:%p\n", b);static int c = 1;
printf("静态区:%p\n", &c);const char* str = "nxbw";
printf("代码段:%p\n", str);Base q;
printf("虚表1:%p\n", *(int*)&q);
可以看到虚表距离常量区几十个字节,离常量区非常近,其他区域相对常量区而言距离虚表很远的距离,依此可以判断虚函数表可能是在常量区
多态调用的条件:
1.基类的指针或者引用
为什么不能是派生类的指针或者引用?
答:因为使用派生类的指针或者引用只能接收派生类的指针或引用,它不能多种形态
为什么不能是基类对象?
person p = s; //切割赋值
person* ptr = &s; //引用
person& ref = s; //指针
有这样一个问题,如果我们使用对象,那就是将派生类切割拷贝赋值给基类,这时如果想访问派生类的虚函数的话,就需要将虚函数表也拷贝过去,通过虚函数表来访问这个函数,但是这样就会引发一个问题,如果下次使用指针或者引用调用的虚函数时候,就不知道调用的是父类还是子类的虚函数了,就乱套了,而且在vs下,切割赋值时,它不会拷贝虚函数表去基类中
2.在派生类中重写虚函数
答:在派生类中重写虚函数是必要的,在多态调用中我们需要传递不同的对象进行函数调用,我们需要重写虚函数,改变虚函数中指针的指向,以以此找到访问相对应的虚函数
通过观察和测试,我们发现了以下几点问题:
1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚 表指针也就是存在部分的另一部分是自己的成员。
2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表 中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函 数,所以不会放进虚表。
4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
5. 总结一下派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生 类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己 新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
6. 虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,
4.2动态绑定与静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体 行为,调用具体的函数,也称为动态多态。
5.多继承关系的虚函数表
需要注意的是在多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的
5.1 多继承中的虚函数表
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()
{cout << sizeof(Derive) << endl;Derive d;return 0;
}
可以看到在Derive的模型中它有两个虚表指针,在Derive中我添加了一个虚函数func4函数,大伙可以猜猜这个函数被放到了那个虚函数表中(1.Base1的函数表 2.Base2的虚函数表 3.放在Base1和Base2的函数表中)
开始测试:
//重定义函数指针为FUNC_TEST
typedef void(*FUNC_TEST) ();void _VFPTR_PRINT(FUNC_TEST* _vfptr)
{for (int i = 0; _vfptr[i] != nullptr; ++i){cout << i << " : "<<_vfptr[i] << ' ';}cout << endl;
}int main()
{Derive d;FUNC_TEST _vfptr1 = (FUNC_TEST)*(int*)&d;Base2* b2 = &d;FUNC_TEST _vfptr2 = (FUNC_TEST) * (int*)b2;_VFPTR_PRINT((FUNC_TEST*)_vfptr1);_VFPTR_PRINT((FUNC_TEST*)_vfptr2);return 0;
}
可以看到在Derive中加入的虚函数func4放在Base1的虚函数表中
typedef void(*FUNC_TEST) ();void _VFPTR_PRINT(FUNC_TEST* _vfptr)
{for (int i = 0; _vfptr[i] != nullptr; ++i){FUNC_TEST f = _vfptr[i];cout << _vfptr[i] << ' ';f();}cout << endl;
}int main()
{Derive d;FUNC_TEST _vfptr1 = (FUNC_TEST)*(int*)&d;Base2* b2 = &d;FUNC_TEST _vfptr2 = (FUNC_TEST) *(int*)b2;_VFPTR_PRINT((FUNC_TEST*)_vfptr1);_VFPTR_PRINT((FUNC_TEST*)_vfptr2);return 0;
}
在Derive中重写func1后,为什么Derive和Base1中调用的func1地址会不一样?
转到汇编,看看底层是怎么操作的
转到底层汇编之后发现ptr1调用func1时会直接跳到func1的地址处调用func1,而ptr经过不断的跳转之后也会去调用func1,在跳转期间我们可以看到最关键的一步操作就是:ecx减8(8就是Base1类的大小),ecx是this指针(ptr2)
因为ptr2指向的是Base2,这个指针并不能调用func1,所以编译器将this指针减8,修正ptr2所指向的位置,让它指向的Derive的开头,修正之后,它就可以调用func1函数了
为什么ptr1不需要修正指向?
在内存中,编译器不会去看数据是什么类型,类型只是代码层的叫法,它只会看你的地址指向,所以ptr1不会去做任何修改
int main()
{Derive d;Base1* ptr1 = &d;ptr1->func1();Base2* ptr2 = &d;ptr2->func1();Derive* ptr3 = &d;ptr3->func1();return 0;
}
ptr1和ptr2使用的是多态调用,它们满足重写和父子关系指针或引用
ptr3是普通调用,因为ptr3是一个基类的指针,它并不满足多态调用条件
5.2. 菱形继承、菱形虚拟继承
菱形继承:
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}int _a = 1;
};class B : public A
{
public:int _b = 1;
};class C : public A
{
public:int _c = 1;
};class D : public B, public C
{
public:int _d = 1;
};
在菱形继承中我们在A类中写下一个虚函数,C类,B类,D类中没有重写,并且都没有函数,观察D的对象模型是怎么样
由上图可以看到B和C类都继承了A类的虚表,并且带到了D类对象模型中,所以D类带有两个虚表
菱形虚拟继承:(在B和C类中重写A的虚函数)
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}int _a = 1;
};class B : virtual public A
{
public:virtual void func1(){cout << "B::func1()" << endl;}int _b = 1;
};class C : virtual public A
{
public:int _c = 1;
};class D : public B, public C
{
public:int _d = 1;
};
报错了,A类中func1继承不明确,因为B和C都继承了A,可以理解为A现在是B和C的共享,又因为B和C中都重写了func1,编译器不知道是谁重写了func1,这样就会导致继承不明确而报错
解决方法:在D类中重写func1
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}int _a = 1;
};class B : virtual public A
{
public:virtual void func1(){cout << "B::func1()" << endl;}int _b = 1;
};class C : virtual public A
{
public:virtual void func1(){cout << "C::func1()" << endl;}int _c = 1;
};class D : public B, public C
{
public:int _d = 1;
};
我们来看看它的对象模型是怎么样的
使用了菱形虚拟继承并且重写函数之后,可以看到编译器将A类的虚表和成员变量单独放到了D类对象模型的最后,并且给B和C类中加入了虚基表,在D类中重写的func1放到了A类的虚表中
在B和C中重写func1有意义吗?
有,在你单独使用B类或C类中的func1的时候,我们就需要在它们中进行重写。
将它们看作有符号ffffffff是-1,fcffffff是-4
由上图可知B和C类中的虚函数并没有放进A类虚表中,而是在B和C类中新增了一个虚表储存它们的虚函数
最后的最后,在实际中,尽量不要使用菱形虚拟继承,太麻烦了,可能会把自己给绕进去
下面有一道很经典的面试题:
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};
class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}
在main函数中,我们new了一个B的对象,然后使用该对象去调用基类的虚函数,使用派生类对象调用A中的成员函数时,派生类对象需要通过切割基类部分来调用test函数,这时候test当中它隐藏的this指针的类型为:A*(基类的指针),我们上面所说多态调用成立的条件:1.重写虚函数 2.子和父的指针或者引用,前面我们分析,this指针是基类指针,在这段代码中也可以看到func函数被重写,派生类中重写的虚函数符合三同,大家注意:函数表中只要参数类型一样就符合参数列表相同(上面提到过基类中的虚函数写过virtual后,派生类重写基类的虚函数可以不用带virtual),经过上一轮的分析,我们可以知道this->func()是一个多态调用,这个题目使用的是派生类的对象进行多态调用,所以这里调用的是派生类的虚函数,这道题的输出结果为:B->1
为什么会是这个结果呢?
因为我们在派生类中重写的是基类虚函数的实现而不是重写它的函数头(返回值,函数名,参数列表)所以这里虚函数的的调用其实是: 基类的函数头 + 派生类重写的实现
所以这题的输出结果为:B->1