C++面向对象编程的三大核心特性之一:多态
一.多态的概念多态(polymorphism)的概念通俗来说就是多种形态。多态分为编译时多态(静态多态)和运⾏时多态(动态多态)这⾥我们重点讲运行时多态编译时多态(静态多态)和运⾏时多态(动态多态)。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板他们传不同类型的参数就可以调⽤不同的函数通过参数不同达到多种形态之所以叫编译时多态是因为他们实参传给形参的参数匹配是在编译时完成的我们把编译时⼀般归为静态运行时归为动态。运行时多态具体点就是去完成某个行为(函数)可以传不同的对象就会完成不同的⾏为就达到多种形态。比如买票这个行为当普通人买票时是全价买票学生买票时是优惠买票(5折或75折)军⼈买票时是优先买票。再⽐如同样是动物叫的⼀个⾏为(函数)传猫对象过去就是”(^ω^)喵“传狗对象过去就是汪汪。二.多态的定义及实现1.多态的构成条件多态是⼀个继承关系的下的类对象去调用同⼀函数产生了不同的行为。比如Student继承了Person。Person对象买票全价Student对象优惠买票。1.2实现多态还有两个必须重要条件• 必须是基类的指针或者引用调用虚函数• 被调用的函数必须是虚函数并且完成了虚函数重写/覆盖。说明要实现多态效果第⼀必须是基类的指针或引⽤因为只有基类的指针或引用才能既指向基类对象又指向派⽣类对象第⼆派⽣类必须对基类的虚函数完成重写/覆盖重写或者覆盖了基类和派生类之间才能有不同的函数多态的不同形态效果才能达到。我们在后面的原理部分更能体会到1.3虚函数类成员函数前面加virtual修饰那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。class Person { public: virtual void Buyticket()//变成虚函数 { cout 买票——全价 endl; } };虚函数的重写/覆盖派生类中有⼀个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表类型不是名字完全相同)称派生类的虚函数重写了基类的虚函数。 class Person { public: virtual void Buyticket()//变成虚函数 { cout 买票——全价 endl; } }; class Student:public Person { public: virtual void Buyticket()//与基类的成员函数构成重写 { cout 买票——半价 endl; } }; void Func(Person* ptr)//运用基类的指针 { ptr-Buyticket(); } int main() { Person p; Func(p);//传基类地址 Student s; Func(s);//传派生类地址 return 0; } 注意在重写基类虚函数时派⽣类的虚函数在不加virtual关键字时虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性)但是该种写法不是很规范不建议这样使⽤不过在考试选择题中经常会故意买这个坑让你判断是否构成多态。——多态场景的一个选择题以下程序输出结果是什么A: A-0 B: B-1 C: A-1 D: B-0 E: 编译出错 F: 以上都不正确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解释我们首先看到这个答案我们会感到十分疑惑这是什么答案好像把基类和派生类构成重写的函数的默认参数和函数运行部分各取一部分然后拼接而成。我们来慢慢分析一下我们可以看到AB类构成继承关系并且成员函数func也构成重写然后我们创建一个B类指针并指向B类对象用指针调用A类成员函数test然后用A类this指针调用func因为func已经构成重写而A类this也是由B类而来所以是调用了B类指针动态绑定然后我们自然就会选择D。为什么默认参数val是A类的呢那是因为默认参数是在编译时确定的静态绑定而函数实现部分由运行时确定动态绑定。所以选择答案是B我们再从一个例子来看 class Person { public: virtual void Buyticket(int val1)//默认值为1 { cout 买票——全价 valendl; } }; class Student:public Person { public: virtual void Buyticket(int val0)//默认值为0 { cout 买票——半价 valendl; } }; void Func(Person* ptr) { ptr-Buyticket(); } int main() { Person p; Func(p); Student s; Func(s); return 0; 结果全部为Person的默认值。所以我们在使用多态时只要专心完成运行部分细心默认值不会影响全部结果即可。1.4虚函数重写的一些其它问题• 协变(了解)派生类重写基类虚函数时与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用派生类虚函数返回派生类对象的指针或者引用时称为协变。协变的实际意义并不⼤所以我们了解⼀下即可。class Person { public: virtual Person Buyticket(int val1) { cout 买票——全价 valendl; return *this; } }; class Student:public Person { public: virtual Student Buyticket(int val0) { cout 买票——半价 valendl; return *this; } };结果依旧用其它的类也行只要基类用基类派生类用派生类就行如class A{}; class B :public A {}; class Person { public: virtual A Buyticket(int val1) { cout 买票——全价 valendl; A a; return a; } }; class Student:public Person { public: virtual B Buyticket(int val0) { cout 买票——半价 valendl; B b; return b; } };• 析构函数的重写基类的析构函数为虚函数此时派⽣类析构函数只要定义⽆论是否加virtual关键字都与基类的析构函数构成重写虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则实际上编译器对析构函数的名称做了特殊处理编译后析构函数的名称统⼀处理成destructor所以基类的析构函数加了vritual修饰派生类的析构函数就构成重写。这样设计的意义是防止派生类的内存泄漏我们假设没有将基类和派生类的析构函数设置为重写那它们只是继承的普通函数关系派生类都可以调用当我们只调用基类的析构函数那么只有基类的内存会释放派生类的内存会泄露程序没有结束。而派生类的析构函数是可以自动调用基类的析构那我们为什么不直接调用派生类析构呢所以我们直接将派生类的析构和基类的析构设置成重写这样只要有派生类创建调用一定是派生类析构那么内存一定不会泄露。class A { public: virtual ~A() // 虚函数 {} protected: int _a; }; class B :public A { public: ~B() { delete[]_b; _b nullptr; } protected: int* _b new int[10]; // 派生类中有额外的资源 }; // 用一个基类的指针指向派生类对象 int main() { A* a1 new B; delete a1; return 0; }切记一定要在基类析构上加上virtual注意这个问题⾯试中经常考察⼤家⼀定讲清楚为什么基类中的析构函数建议设计为虚函数。override和final关键字从上⾯可以看出C对虚函数重写的要求⽐较严格但是有些情况下由于疏忽⽐如函数名写错参数写错等导致⽆法构成重写⽽这种错误在编译期间是不会报出的只有在程序运⾏时没有得到预期结果才来debug会得不偿失因此C11提供了override可以帮助⽤⼾检测是否重写。如果我们不想让派生类重写这个虚函数那么可以⽤final去修饰。overrideclass Person { public: virtual void Buyticket(int val1) { cout 买票——全价 valendl; } }; class Student:public Person { public: virtual void buyticket(int val0)override { cout 买票——半价 valendl; } };finalclass Person { public: virtual void Buyticket(int val1)final { cout 买票——全价 valendl; } }; class Student:public Person { public: virtual void Buyticket(int val0) { cout 买票——半价 valendl; } };重载/重写/隐藏的对比三.纯虚函数和抽象类在虚函数的后⾯写上 0则这个函数为纯虚函数纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写但是语法上可以实现)只要声明即可。包含纯虚函数的类叫做抽象类抽象类不能实例化出对象如果派生类继承后不重写纯虚函数那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数因为不重写实例化不出对象。class Car { public: virtual void Drive() 0; // 纯虚函数 }; class Benz :public Car { public: virtual void Drive() { cout Benz-舒适 endl; } }; class BMW :public Car { public: virtual void Drive() { cout BMW-操控 endl; } }; int main() { //Car car;//编译报错实例化不出来 Car* pbenz new Benz; pbenz-Drive(); Car* pbmw new BMW; pbmw-Drive(); return 0; }四.多态的原理1.虚函数表指针下面编译为32位程序的运行结果是什么A. 编译报错 B. 运行报错 C. 8 D. 12class Base { public: virtual void Func1() { cout Func1() endl; } protected: int _b 1; char _ch x; }; int main() { Base b; cout sizeof(b) endl; // 计算b对象的大小 return 0; }答案是D32位程序对齐数是4int占4个字节char占1个字节对齐后为8。为什么结果是12呢原因是还有一个指针在这两个成员变量之前一个指针占4个字节所以重新计算结果是12。_vfptr是虚函数表指针⼀个含有虚函数的类中都至少都有⼀个虚函数表指针因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中虚函数表也简称虚表。2虚函数表• 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共⽤同⼀张虚表不同类型的对象各⾃有独⽴的虚表所以基类和派⽣类有各⾃独⽴的虚表。class Person { public: virtual void buyticket() { cout 全价票 endl; } protected: string _name; }; class Student :public Person { public: virtual void buyticket() { cout 半价票 endl; } protected: int _id; }; class Soldier :public Person { public: virtual void buyticket() { cout 优先买票 endl; } protected: int _codename; };• 派⽣类由两部分构成继承下来的基类和自己的成员⼀般情况下继承下来的基类中有虚函数表指针⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴的。• 派⽣类的虚函数表中包含(1)基类的虚函数地址(2)派⽣类重写的虚函数地址完成覆盖派⽣类⾃⼰的虚函数地址三个部分。class Base { public: virtual void func1() { cout Base::func1 endl; } virtual void func2() { cout Base::func2 endl; } void func5() { cout Base::func5 endl; } protected: int a 1; }; class Derive : public Base { public: // 重写基类的func1 virtual void func1() { cout Derive::func1 endl; } virtual void func3() { cout Derive::func1 endl; } void func4() { cout Derive::func4 endl; } protected: int b 2; }; int main() { Base b; Derive d; return 0; }• 虚函数表本质是⼀个存虚函数指针的指针数组⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C并没有进⾏规定各个编译器⾃⾏定义的vs系列编译器会再后⾯放0x00000000标记g系列编译不会放)• 虚函数存在哪的虚函数和普通函数⼀样的编译好后是⼀段指令都是存在代码段的只是虚函数的地址⼜存到了虚表中。 虚函数表存在哪的这个问题严格说并没有标准答案C标准并没有规定我们写下⾯的代码可以对比验证⼀下。vs下是存在代码段(常量区class Base { public: virtual void func1() { cout Base::func1 endl; } void func5(){} }; class Derive : public Base { public: // 重写基类的func1 virtual void func1() { cout Derive::func1 endl; } }; int main() { int i 0; static int j 1; int* p1 new int; const char* p2 xxxxxxxx; printf(栈:%p\n, i); printf(静态区:%p\n, j); printf(堆:%p\n, p1); printf(常量区:%p\n, p2); Base b; Derive d; Base* p3 b; Derive* p4 d; printf(Person虚表地址:%p\n, *(int*)p3); printf(Student虚表地址:%p\n, *(int*)p4); printf(虚函数地址:%p\n, Base::func1); printf(普通函数地址:%p\n, Base::func5); return 0; }3.多态的底层原理3.1多态是如何实现的满足多态条件后底层不再是编译时通过调用对象确定函数的地址而是运行时到指向的对象的虚表中确定对应的虚函数的地址这样就实现了指针或引用指向基类就调用基类的虚函数指向派⽣类就调⽤派⽣类对应的虚函数。class Person { public: virtual void buyticket() { cout 全价票 endl; } protected: string _name; }; class Student :public Person { public: virtual void buyticket() { cout 半价票 endl; } protected: int _id; }; class Soldier :public Person { public: virtual void buyticket() { cout 优先买票 endl; } protected: int _codename; };3.2动态绑定与静态绑定• 对不满足多态条件(指针或者引用调用虚函数)的函数调用是在编译时绑定也就是编译时确定调用函数的地址叫做静态绑定。• 满足多态条件的函数调用是在运行时绑定也就是在运行时到指向对象的虚函数表中找到调用函数 的地址也就做动态绑定。