超级详细讲解C++中的多态
作者:programing菜鸟
多态概念引入
多态字面意思就是多种形态。
我们先来想一想在日常生活中的多态例子:买票时,成人买票全价,如果是学生那么半价,如果是军人,就可以优先买票。不同的人买票会有不同的实现方法,这就是多态。
1、C++中多态的实现
1.1 多态的构成条件
C++的多态必须满足两个条件:
1 必须通过基类的指针或者引用调用虚函数
2 被调用的函数是虚函数,且必须完成对基类虚函数的重写
我们来看看具体实现。
class Person //成人 { public: virtual void fun() { cout << "全价票" << endl; //成人票全价 } }; class Student : public Person //学生 { public: virtual void fun() //子类完成对父类虚函数的重写 { cout << "半价票" << endl;//学生票半价 } }; void BuyTicket(Person* p) { p->fun(); } int main() { Student st; Person p; BuyTicket(&st);//子类对象切片过去 BuyTicket(&p);//父类对象传地址 }
调用的两个BuyTicket() 答案是什么呢?
如果不满足多态呢?
这说明了很重要的一点,如果满足多态,编译器会调用指针指向对象的虚函数,而与指针的类型无关。如果不满足多态,编译器会直接根据指针的类型去调用虚函数。
1.2 虚函数
用virtual修饰的关键字就是虚函数。
虚函数只能是类中非静态的成员函数。
virtual void fun() //error! 在类外面的函数不能是虚函数 {}
1.3虚函数的重写
子类和父类中的虚函数拥有相同的名字,返回值,参数列表,那么称子类中的虚函数重写了父类的虚函数,或者叫做覆盖。
class Person { public: virtual void fun() { cout << "Person->fun()" << endl; } }; class Student { public: //子类重写的虚函数可以不加virtual,因为子类继承了父类的虚函数, //编译器会认为你是想要重写虚函数。 //void fun() 可以直接这样,也对,但不推荐。 virtual void fun()//子类重写父类虚函数 { cout << "Student->fun()" << endl; } };
虚函数重写的两个例外:
协变:
子类的虚函数和父类的虚函数的返回值可以不同,也能构成重载。但需要子类的返回值是一个子类的指针或者引用,父类的返回值是一个父类的指针或者引用,且返回值代表的两个类也成继承关系。这个叫做协变。
class Person { public: virtual Person* fun()//返回父类指针 { cout << "Person->fun()" << endl; return nullptr; } }; class Student { public: //返回子类指针,虽然返回值不同,也构成重写 virtual Student* fun()//子类重写父类虚函数 { cout << "Student->fun()" << endl; return nullptr; } };
也可以这样,也是协变,
class A {}; class B : public A {}; //B继承A class Person { public: virtual A* fun()//返回A类指针 { return nullptr; } }; class Student { public: //返回B类指针,虽然返回值不同,也构成重写 virtual B* fun()//子类重写父类虚函数 { return nullptr; } };
2.析构函数的重写
析构函数是否需要重写呢?
让我们来考虑这样一种情况,
//B继承了A,他们的析构函数没有重写。 class A { public: ~A() { cout << "~A()" << endl; } }; class B : public A { public: ~B() { cout << "~B()" << endl; } }; A* a = new B; //把B的对象切片给A类型的指针。 delete a; //调用的是谁的析构函数呢?你希望调用谁的呢?
显然我们希望调用B的析构函数,因为我们希望析构函数的调用跟指针指向的对象有关,而跟指针的类型无关。这不就是多态吗?但是结果却调用了A的析构函数。
所以析构函数要实现多态。But,析构函数名字天生不一样,怎么实现多态?
实际上,析构函数被编译器全部换成了Destructor,所以我们加上virtual就可以。
只要父类的析构函数用virtual修饰,无论子类是否有virtual,都构成析构。
这也解释了为什么子类不写virtual可以构成重写,因为编译器怕你忘记析构。
class A { public: virtual ~A() { cout << "~A()" << endl; } }; class B : public A { public: virtual ~B() { cout << "~B()" << endl; } };
1.4 C++11 override && final
C++11新增了两个关键字。用final修饰的虚函数无法重写。用final修饰的类无法被继承。final像这个单词的意思一样,这就是最终的版本,不用再更新了。
class A final //A类无法被继承 { public: virtual void fun() final //fun函数无法被重写 {} }; class B : public A //error { public: virtual void fun() //error { cout << endl; } };
被override修饰的虚函数,编译器会检查这个虚函数是否重写。如果没有重写,编译器会报错。
class A { public: virtual void fun() {} }; class B : public A { public: //这里我想重写fun,但写成了fun1,因为有override,编译器会报错。 virtual void fun1() override { cout << endl; } };
1.5 重载,覆盖(重写),重定义(隐藏)
这里我们来理一理这三个概念。
1.重载:重载函数处在同一作用域。
函数名相同,函数列表必须不同。
2.覆盖:必须是虚函数,且处在父类和子类中。
返回值,参数列表,函数名必须完全相同(协变除外)。
3.重定义:子类和父类的成员变量相同或者函数名相同,
子类隐藏父类的对应成员。
子类和父类的同名函数不是重定义就是重写。
2、抽象类
2.1 抽象类的概念
再虚函数的后面加上=0就是纯虚函数,有纯虚函数的类就是抽象类,也叫做接口类。抽象类无法实例化出对象。抽象类的子类也无法实例化出对象,除非重写父类的虚函数。
class Car { public: virtual void fun() = 0; //不用实现,只写接口就行。 }
这并不意味着纯虚函数不能写实现,只是我们大部分情况下不写。
那么虚函数有什么用呢?
1,强制子类重写虚函数,完成多态。
2,表示某些抽象类。
2.2 接口继承和实现继承
普通函数的继承就是实现继承,虚函数的继承就是接口继承。子类继承了函数的实现,可以直接使用。虚函数重写后只会继承接口,重写实现。所以如果不用多态,不要把函数写成虚函数。
纯虚函数就体现了接口继承。下面我们来一道题,展现一下接口继承。
class A { public: virtual void fun(int val = 0)//父类虚函数 { cout <<"A->val = "<< val << endl; } void Fun() { fun();//传过来一个子类指针调用fun() } }; class B: public A { public: virtual void fun(int val = 1)//子类虚函数 { cout << "B->val = " << val << endl; } }; B b; A* a = &b; a->Fun();
结果是什么呢?
B->val = 0
子类对象切片给父类指针,传给Fun函数,满足多态,会去调用子类的fun函数,但是子类的虚函数继承了父类的接口,所以val是父类的0.
3、 多态的原理
3.1 虚函数表
多态是怎样实现的呢?
先来一道题目,
class A { public: virtual void fun() {} protected: int _a; };
sizeof(A)是多少?是4吗?NO,NO,NO!
答案是8个字节。
我们定义一个A类型的对象a,打开调试窗口,发现a的内容如下
我们发现除了成员变量_a以外,还多了一个指针。这个指针是不准确的,实际上应该是_vftptr(virtual function table pointer),即虚函数表指针,简称虚表指针。在计算类大小的时候要加上这个指针的大小。那么虚表是什么呢?虚表就是存放虚函数的地址地方。每当我们去调用虚函数,编译器就会通过虚表指针去虚表里面查找。
下面我们用一个小栗子来说明虚函数的使用会用指针。
class A { public: void fun1() {} virtual void fun2() {} }; A* ap = nullptr; ap->fun1(); //调用成功,因为这是普通函数的调用 ap->fun2(); //调用失败,虚函数需要对指针操作,无法操作空指针。
我们先来看看继承的虚函数表。
class A { public: virtual void fun1() {} virtual void fun2() {} }; class B : public A { public: virtual void fun1()//重写父类虚函数 {} virtual void fun3() {} }; A a; B b; //我们通过调试看看对象a和b的内存模型。
子类跟父类一样有一个虚表指针。
子类的虚函数表一部分继承自父类。如果重写了虚函数,那么子类的虚函数会在虚表上覆盖父类的虚函数。
本质上虚函数表是一个虚函数指针数组,最后一个元素是nullptr,代表虚表的结束。
所以,如果继承了虚函数,那么
1 子类先拷贝一份父类虚表,然后用一个虚表指针指向这个虚表。
2 如果有虚函数重写,那么在子类的虚表上用子类的虚函数覆盖。
3 子类新增的虚函数按其在子类中的声明次序增加到子类虚表的最后。
下面来一道面试题:
虚函数存在哪里?
虚函数表存在哪里?
虚函数是带有virtual的函数,虚函数表是存放虚函数地址的指针数组,虚函数表指针指向这个数组。对象中存的是虚函数指针,不是虚函数表。
虚函数和普通函数一样存在代码段。
那么虚函数表存在哪里呢?
我们创建两个A对象,发现他们的虚函数指针相同,这说明他们的虚函数表属于类,不属于对象。所以虚函数表应该存在共有区。
堆?堆需要动态开辟,动态销毁,不合适。
静态区?静态区存放全局变量和静态变量不合适。
所以综合考虑,把虚函数表也存放在了代码段。
3.2多态的原理
我们现在来看看多态的原理。
class Person //成人 { public: virtual void fun() { cout << "全价票" << endl; //成人票全价 } }; class Student : public Person //学生 { public: virtual void fun() //子类完成对父类虚函数的重写 { cout << "半价票" << endl;//学生票半价 } }; void BuyTicket(Person* p) { p->fun(); }
这样就实现了不同对象去调用同一函数,展现出不同的形态。
满足多态的函数调用是程序运行是去对象的虚表查找的,而虚表是在编译时确定的。
普通函数的调用是编译时就确定的。
3.3动态绑定与静态绑定
1.静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
2.动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
我们说的多态一般是指动态多态。
这里我附上一个有意思的问题:
就是在子类已经覆盖了父类的虚函数的情况下,为什么子类还是可以调用“被覆盖”的父类的虚函数呢?
#include <iostream> using namespace std; class Base { public: virtual void func() { cout << "Base func\n"; } }; class Son : public Base { public: void func() { Base::func(); cout << "Son func\n"; } }; int main() { Son b; b.func(); return 0; }
输出:Base func
Son func
这是C++提供的一个回避虚函数的机制
通过加作用域(正如你所尝试的),使得函数在编译时就绑定。
(这题来自:虚函数)
4 、继承中的虚函数表
4.1 单继承中的虚函数表
这里DV继承BV。
class BV { public: virtual void Fun1() { cout << "BV->Fun1()" << endl; } virtual void Fun2() { cout << "BV->Fun2()" << endl; } }; class DV : public BV { public: virtual void Fun1() { cout << "DV->Fun1()" << endl; } virtual void Fun3() { cout << "DV->Fun3()" << endl; } virtual void Fun4() { cout << "DV->Fun4()" << endl; } };
我们想个办法打印虚表,
typedef void(*V_PTR)(); //typedef一下函数指针,相当于把返回值为void型的 //函数指针定义成 V_PTR. void PrintPFTable(V_PTR* table)//打印虚函数表 { //因为虚表最后一个为nllptr,我们可以利用这个打印虚表。 for (size_t i = 0; table[i] != nullptr; ++i) { printf("table[%d] : %p->", i, table[i]); V_PTR f = table[i]; f(); cout << endl; } } BV b; DV d; // 取出b、d对象的前四个字节,就是虚表的指针, //前面我们说了虚函数表本质是一个存虚函数指针的指针数组, //这个数组最后面放了一个nullptr // 1.先取b的地址,强转成一个int*的指针 // 2.再解引用取值,就取到了b对象前4个字节的值,这个值就是指向虚表的指针 // 3.再强转成V_PTR*,这是我们打印虚表函数的类型。 // 4.虚表指针传给PrintPFTable函数,打印虚表 // 5,有时候编译器资源释放不完全,我们需要清理一下,不然会打印多余结果。 PrintPFTable((V_PTR*)(*(int*)&b)); PrintPFTable((V_PTR*)(*(int*)&d));
结果如下:
4.2 多继承中的虚函数表
我们先来看一看一道题目,
class A { public: virtual void fun1() { cout << "A->fun1()" << endl; } protected: int _a; }; class B { public: virtual void fun1() { cout << "B->fun1()" << endl; } protected: int _b; }; class C : public A, public B { public: virtual void fun1() { cout << "C->fun1()" << endl; } protected: int _c; }; C c; //sizeof(c) 是多少呢?
sizeof( c )的大小是多少呢?是16吗?一个虚表指针,三个lnt,考虑内存对齐后确实是16.但是结果是20.
我们来看看内存模型。在VS下,c竟然有两个虚指针
每个虚表里都有一个fun1函数。
所以C的内存模型应该是这样的,
而且如果C自己有多余的虚函数,会按照继承顺序补在第一张虚表后面。
下面还有一个问题,可以看到C::fun1在两张虚表上都覆盖了,但是它们的地址不一样,是不是说在代码段有两段相同的C::fun1呢?
不是的。实际上两个fun1是同一个fun1,里面放的是跳转指令而已。C++也会不犯这个小问题。
最后,我们来打印一下多继承的虚表。
//Derive继承Base1和Base2 class Base1 { public: virtual void fun1() { cout << "Base1->fun1()" << endl; } virtual void fun2() { cout << "Base1->fun2()" << endl; } }; class Base2 { public: virtual void fun1() { cout << "Base2->fun1()" << endl; } virtual void fun2() { cout << "Base2->fun2()" << endl; } }; class Derive : public Base1, public Base2 { public: virtual void fun1() { cout << "Derive->fun1()" << endl; } virtual void fun3() { cout << "Derive->fun3()" << endl; } };
打印的细节,从Base2继承过来的虚表指针放在第一个虚表指针后面,我们想要拿到这个指针需要往后挪一个指针加上一个int的字节,但是指针的大小跟操作系统的位数有关,所以我们可以用加上Base2的大小个字节来偏移。
这里注意要先强转成char*,不然指针的加减会根据指针的类型来确定。
Derive d; PrintPFTable((V_PTR*)(*(int*)&d)); PrintPFTable((V_PTR*)(*(int*)((char*)&d+sizeof(Base2))));
Ret:
总结
到此这篇关于C++多态的文章就介绍到这了,更多相关C++多态详解内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!