多态的概念
了解多态之前,我们在现实生活一定经历过这俩个例子:
在购买火车票的时候,会根据你的类型来确定你的票价:
- 成人:全价
- 学生:半价
- 军人:免费
而某外卖系统的红包系统,也会根据一定类型来确定红包大小:
- 新人:大额红包
- 不经常使用该外卖系统的人:中等额度
- 经常使用外面系统的人:小额红包
从这些例子中,可以大致对多态有一定的理解:
多态的概念:通俗来讲,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
多态的定义以及实现
虚函数
class Person { public: virtual void fare() { cout << "全价" << endl; } };
虚函数:被virtual修饰的类成员函数被称为虚函数。
虚函数的重写
class Person { public: virtual void fare() { cout << "全价" << endl; } }; class Student : public Person { public: virtual void fare() { cout << "半价" << endl; } };
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值相同,函数名字,参数列表完全相同),称为子类的虚函数重写了基类的虚函数。
class Person { public: virtual void fare() { cout << "全价" << endl; } }; class Student : public Person { public: void fare() { cout << "半价" << endl; } };
【注意】观察上述代码,在重写基类虚函数的时候,派生类的虚函数在不加virtual关键字时,虽然也可也构成重写(因为继承后基类的虚函数被继承下来,在派生类依旧保持虚函数属性),但是这种写法不是很规范,不建议这样使用。
通过观察代码,前俩段代码是通过对象类型来调用函数,而后面俩条代码是通过看指向的对象,如果指向的对象是基类,则调用基类;如果指向的对象是派生类,则调用派生类。
虚函数重写的俩个例外:
1.协变(基类与与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用,称为协变。
class A{}; class B : public A{}; class Person { public: virtual A* fare() { cout << "全价" << endl; } }; class Student : public Person { public: virtual B* fare() { cout << "半价" << endl; } };
2.析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,但是这是不对的,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称被统一处理称desructor。
观察下面这段代码:
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; return 0; }
这段代码使用了为经过处理的析构函数,使用Person这个指针进行析构第一次p的时候,会调用Person这个析构函数,而使用p析构第二次p的时候,还是只会调用Person这个析构函数。
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; return 0; }
将析构函数前面加上virtual,进行虚函数的重写,可以将类析构函数都被处理为destructor这个统一的名字。
这里也是由于期望p->destructor()是一个多态调用,而不是普通调用。通过多态调用,可以将第二次的p在student析构一次,在Student析构一次,在Person析构一次。
这也是为什么将析构函数统一处理称destructor这个名字的原因,为了保证函数名的统一。
class Person { public: virtual ~Person() { cout << "~Person" << endl; } }; class Student : public Person { public: virtual ~Student() { cout << "~Student" << endl; } }; int main() { Person* p1 = new Person; delete p1; Person* p2 = new Student; delete p2; return 0; }
【注意】只有派生类student的析构函数重写了person的析构函数,当p1析构时会调用person的析构函数,而p2析构的时候会调用person与student的析构函数。
【注意】派生类的析构可以不写。
多态的构成条件
多态时在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。
在继承中构成多态的俩个条件:
1.必须通过基类的指针或者引用调用函数。
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
重写虚函数的条件以及一些例外:
条件1:三同“函数名相同、参数相同、返回值相同”
条件2:必须是虚函数(virtual)
例外1:派生类的重写虚函数可以不写virtual,但是建议都加上。
例外2:协变的返回值可以不同,但是要求返回值必须是父子类关系的引用与指针。
例外3:析构函数可以名字不同。
【注意】:对于多态而言,不同的对象传递过去,会调用不同的函数,多态调用看的是指向的对象,而普通类型调用看的是当前类型。
C++11特性:override与fianl
从前面的学习中了解到,C++对函数重写的要求比较严格,但是可以由于各种原因导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报错的,只有在程序运行时没有达到预期的结果需要进行调试会得不偿失。
C++11中提供了override和final俩个关键字,以便帮助用户检测是否重写。
fianl:检测虚函数,表示该虚函数不能再被重写。
class A { public: virtual void Fun1() final { cout << "A" << endl; } }; class B : public class A { public: virtual void Fun1() { cout << "B" << endl; } };
override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译错误。
class A { public: virtual void Fun1() { cout << "A" << endl; } }; class B : public A { public: virtual void Fun2() override { cout << "B" << endl; } };
如何设计一个不想被继承的类?
俩种方法:
1.C++98的方法:将基类构造函数私有化(private)
class A { public: private: A() {} }; class B : public A { public: B() {} };
此时会之间报错:无法访问 private 成员(在“A”类中声明),私有构造函数在子类不可见,而派生类的构造函数需要调用基类的构造函数。
伴随着报错还会出现一系列的问题:该类如何进行初始化操作?可以使用一个函数来调用构造函数。
class A { public: A Creatobj() { return A(); } private: A() {} };
此时需要考虑的是,如果没有初始化对象,那么该如何调用在函数来初始化对象呢?这是一个先有蛋还是先有鸡的关系。
可以使用静态函数来初始化创建对象:
class A { public: static A Creatobj() { return A(); } private: A() {} }; int main() { A::Creatobj(); return 0; }
可以通过这样的方式来进行对对象的初始化。
2.C++11的方法:基类后面加一个fianl可以变成最终类。
在C++11中规定,如果一个类后面加关键字fianl会导致这个类变成最终类,而使其无法被继承。
class A final { public: }; class B : public A { public: };
重载、覆盖(重写)、隐藏(重定义)的对比
多态的原理
虚函数表
观察下面这段代码:
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } protected: int _a; }; int main() { A a; cout << sizeof(a) << endl; return 0; }
在x86环境下,sizeof(a)的值为8.
这个答案可能有点不合常理,这是因为除了_a成员是int类型占了4个字节,还多了一个_vfptr的指针占4个字节,该指针被放在对象的前面(这是在vs环境下,有些平台可能会放在对象的后面,这个跟平台有关系),对象中的这个指针被称为虚函数表指针(v代表virtual,f代表function)。
一个含有虚函数的类中都至少都会有一个虚函数表指针,这是因为虚函数的地址要被放在虚函数表中,虚函数表也简称虚表。
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } virtual void Fun2() { cout << "A::Fun2()" << endl; } virtual void Fun3() { cout << "A::Fun3()" << endl; } protected: int _a; };
如果我们存放多个虚函数,在这个虚函数指针所指向的表中就会出现三个地址。
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } virtual void Fun2() { cout << "A::Fun2()" << endl; } void Fun3() { cout << "A::Fun3()" << endl; } protected: int _a; }; class B : public A { public: virtual void Fun1() { cout << "B::Fun2()" << endl; } void Fun4() { cout << "B::Fun4()" << endl; } protected: int _b; }; int main() { A a; B b; return 0; }
下面将通过测试来讲解这段代码:
1.派生类对象b中也有一个虚表函数,b对象由俩部分组成,一部风是基类继承下来的成员,虚表指针也就是存在这部分的,另一部分是自己的成员。
2.基类a对象和派生类b对象虚表是不一样的,这里可以发现Fun1函数完成了重写,所以b的虚表中存的是重写的B::Fun1,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3.另外Fun2继承下来后是虚函数,所以被放进了虚表,Fun3也被继承了下来,但是由于没有被关键字virtual修饰,不是虚函数,所以不会放进虚表。
4.虚函数表本质是一存虚函数指针的指针数组,一般情况下这个数组最后面都会放一个nullptr。
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; }
测试这段代码:其输出结果是B->1.
这是因为虚函数的重写的是虚函数的实现,声明式不会被改变的,所以在派生类可以不写virtual,只需要存在三同即好,编译器会在检查之前,将基类的声明域派生类的函数实现结合。
5.总结派生类的虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生类重写了基类中的某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- 派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的后面。
6.虚函数的重写是接口继承。
7.虚表是存在常量区的。
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } protected: int _a; }; class B : public A { public: virtual void Fun1() { cout << "B::Fun2()" << endl; } protected: int _b; }; int main() { int a = 0; printf("栈区:%p\n", &a); static int b = 0; printf("静态区:%p\n", &b); int* p = new int; printf("堆区:%p\n", p); const char* str = "hello"; printf("常量区:%p\n", str); A aa; printf("虚表aa:%p\n", *((int*)&aa)); B bb; printf("虚表bb:%p\n", *((int*)&bb)); return 0; }
通过这段代码的测试:
基本可以锁定虚表存储在常量区。
8.虚表里存的是虚函数指针,不是虚函数,虚函数和普通函数一样,都是存在代码段里的。对象中存的也不是虚表,而是虚表指针。
多态的原理
之前的学习中我们知道,多态的条件有俩个:
1.必须通过基类的指针或者引用调用函数。
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } protected: int _a; }; class B : public A { public: virtual void Fun1() { cout << "B::Fun2()" << endl; } protected: int _b; }; int main() { A a; A& pa = a; pa.Fun1(); B b; A& pb = b; pb.Fun1(); return 0; }
通过测试,我们可以知道不同对象去进行调用函数Fun1的时候会展现不同的形态。
这里有俩个问题:
1.为什么需要使用基类的指针才可以调用函数,而不能是派生类指针或者引用。
是因为派生类指针或引用只能调用派生类的指针和引用,基类的指针或者引用不仅可以调用基类的指针或引用,也可以调用派生类的指针或引用。
2.为什么不能是父类的对象?
对象的切片与指针或者引用的切片不同,当派生类赋值给基类对象进行切片,对象会进行拷贝,但是不会拷贝虚表,假设如果拷贝了虚表,然后调用了派生类的成员函数,但是当使用基类指针去指向该基类对象的时候,就也只能调用派生类的成员函数,而不是基类的成员函数。
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } protected: int _a; }; class B : public A { public: virtual void Fun1() { cout << "B::Fun1()" << endl; } protected: int _b; }; int main() { B b; A& pb = b; pb.Fun1(); b.Fun1(); return 0; }
我们通过反汇编的角度,分析使用基类引用与对象调用函数的不同:
这是基类引用时的汇编代码。
pb中存的时基类的指针,将pb移动到eax中。
[eax]就是取eax值指向的内容,这里相当于把b对象头4个字节(虚表指针)移动到了edx
[edx]就是取edx值指向的内容,这里相当于把虚表中的头4个字节存的虚函数指针移动到eax
call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后对象里面取到的。
这里不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call地址。
通过上面的了解,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象里面取栈的,不满足多态的函数调用时编译时确认好的。
动态绑定与静态绑定
在上述例子中总结:
1.静态绑定:静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如函数重载。
2.动态绑定:动态绑定又称为后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
单继承和多继承关系的虚函数表
单继承中的虚函数表
class A { public: virtual void Fun1() { cout << "A::Fun1()" << endl; } virtual void Fun2() { cout << "A::Fun2()" << endl; } protected: int _a; }; class B : public A { public: virtual void Fun1() { cout << "B::Fun1()" << endl; } virtual void Fun3() { cout << "B::Fun3()" << endl; } virtual void Fun4() { cout << "B::Fun4()" << endl; } protected: int _b; };
使用这段代码进行测试的时候,在编译的监视窗口看不见fun3和fun4。
这里是编译器的监视窗口故意隐藏了这俩个函数,也可以认为这是一个bug。
// 打印函数指针数组 // void PrintVFT(FUNC_PTR table[]) void PrintVFT(FUNC_PTR* table) { for (size_t i = 0; table[i] != nullptr; i++) { printf("[%d]:%p->", i, table[i]); FUNC_PTR f = table[i]; f(); } printf("\n"); } int main() { A a; B b; int vft1 = *((int*)&a); PrintVFT((FUNC_PTR*)vft1); int vft2 = *((int*)&b); PrintVFT((FUNC_PTR*)vft2); return 0; }
这里提供一种思路:
取出a,b对象的头4个字节,就是虚表的指针,前面讲解了虚函数的本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。
1.先取a的地址,强转成一个int*的指针。
2.再解引用取值,就取到了a对象的头4个字节的指针,这个值就是指向虚表的指针。
3.再强转成FUNC_PTR*,因为虚表就是一个存FUNC_PTR类型(虚函数指针类型)的数组。
4.虚表指针传递给PrintVFT进行打印虚表。
5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后没有放nullptr的话,会导致越界,这是编译器的问题。只需要清理解决方案即可。
多继承中的虚函数表
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() { Derive d; VFPTR* vTableb1 = (VFPTR*)(*(int*)&d); PrintVTable(vTableb1); VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1))); PrintVTable(vTableb2); return 0; }
多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
菱形继承与虚拟菱形继承
菱形继承
class A { public: virtual void Func1() { cout << "A::Func1" << endl; } public: int _a; }; class B : public A { public: virtual void Fun1() { cout << "B::Func1" << endl; } public: int _b; }; class C : public A { public: virtual void Fun1() { cout << "B::Func1" << endl; } public: int _c; }; class D : public B, public C { public: virtual void Fun1() { cout << "B::Func1" << endl; } public: int _d; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; }
这里需要注意在d的所在地址第一块区域是类B的所在区域,里面包含了虚表的地址,以及数据B::_a和_b。第二块区域是类C的所在区域,里面包含了虚表的地址,以及数据C::_a和_c。
菱形虚拟继承
class A { public: virtual void Func1() { cout << "A::Func1" << endl; } public: int _a; }; class B : virtual public A { public: virtual void Fun1() { cout << "B::Func1" << endl; } public: int _b; }; class C : virtual public A { public: virtual void Fun1() { cout << "B::Func1" << endl; } public: int _c; }; class D : public B, public C { public: virtual void Fun1() { cout << "B::Func1" << endl; } public: int _d; }; int main() { D d; d._a = 1; d._b = 3; d._c = 4; d._d = 5; return 0; }
实际中不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出现问题,另一方面这样的模型,访问基类成员有一定的性能损耗。
所以菱形继承、菱形虚拟继承一般实际中很少使用。
抽象类
概念
在虚函数的后面写上=0,则这个函数变成纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承之后也不能实例化出对象,只能重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现处理接口继承。
class Car { public: inline virtual void Drive() = 0; }; class Benz :public Car { public: inline virtual void Drive() { cout << "Benz-舒适" << endl; } }; class BMW :public Car { public: inline virtual void Drive() { cout << "BMW-操控" << endl; } }; class BYD :public Car { public: inline virtual void Drive() { cout << "BYD-build year dream" << endl; } }; void Func(Car* p) { p->Drive(); } int main() { Func(new Benz); Func(new BMW); Func(new BYD); return 0; }
这个类在现实世界没有对应的实体,其用途可以是作为指针或者引用使用。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,就不需要把函数定义为虚函数。
面试相关题
1. 什么是多态?
答:多态分为静态多态:函数重载;和动态多态:继承中的虚函数重写+基类指针引用。
2. 什么是重载、重写 ( 覆盖 ) 、重定义 ( 隐藏 ) ?
答:参考本节课件内容
3. 多态的实现原理?
答:静态多态:函数名修饰规则;动态多态:虚函数表
4. inline 函数可以是虚函数吗?
答:可以,不过编译器就忽略 inline 属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
5. 静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有 this 指针,使用类型 :: 成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表 阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析 构函数定义成虚函数。参考本节课件内容
8. 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针 对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函 数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况 下存在代码段( 常量区 ) 的。
10. C++ 菱形继承的问题?虚继承的原理?
答:参考继承课件。注意这里不要把虚函数表和虚基表搞混了。
11. 什么是抽象类?抽象类的作用?
答:参考(3. 抽象类)。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。