一、什么是多态多态通俗来说就是 多种形态。同一个行为作用在不同的对象上产生不同的结果。C 的多态分两大类第一类叫编译时多态也叫静态多态。典型代表是函数重载和函数模板。传不同类型的参数编译器在编译阶段就确定好调用哪个函数所以叫编译时多态。比如函数重载参数个数或者类型不一样编译器匹配到不同的函数版本这就是静态的多种形态。第二类叫运行时多态也叫动态多态。这是本篇博客的重点。运行时多态的意思是完成某个行为的函数传不同的对象进去会执行不同的函数体产生不同的行为。这个调用哪个函数不是编译时定死的而是程序跑起来之后根据实际对象来决定。举个最经典的例子买票这个行为。普通人去买票全价学生去买票打折军人去买票优先。同样是调用 BuyTicket 这个函数传不同的人进去结果不一样。再比如动物叫传猫对象进去输出 喵传狗对象进去输出 汪汪。这就是运行时多态。二、运行时多态的构成条件要实现运行时多态必须同时满足两个条件缺一不可。条件一必须用基类的指针或者引用去调用虚函数。 为什么必须是指针或引用因为只有基类指针或引用才能既指向基类对象又指向派生类对象。如果是基类对象本身接收派生类对象会发生切片把子类那部分切掉就失去多态的基础了。条件二被调用的函数必须是虚函数并且派生类完成了对这个虚函数的重写也叫覆盖。 重写的意思是派生类写了一个和基类完全一样的虚函数函数名、参数列表、返回值都相同。只有重写了基类和派生类才有不同的函数体才能产生不同的行为。下面用买票代码来演示运行结果 买票 —— 全价 买票 —— 打折这段代码完美体现了两个条件。Func 的参数是 Person * 基类指针满足条件一。Person 的 BuyTicket 加了 virtualStudent 重写了它满足条件二。于是 Func (ps) 时 ptr 指向 Person 对象调用 Person 的 BuyTicketFunc (st) 时 ptr 指向 Student 对象调用 Student 的 BuyTicket。同一个 Func 函数传不同对象跑出不同结果这就是多态。这里有个细节要注意Func 里面虽然写的是 ptr-BuyTicket ()但调用哪个函数跟 ptr 本身的类型没有关系完全由 ptr 指向的实际对象决定。ptr 的类型永远是 Person*但它指向谁就调用谁的虚函数。三、虚函数和虚函数的重写什么是虚函数类的成员函数前面加 virtual 关键字修饰这个函数就叫虚函数。注意非成员函数全局函数不能加 virtual。什么是虚函数重写派生类中有一个跟基类完全相同的虚函数即返回值类型、函数名、参数列表完全一致就说派生类的虚函数重写了基类的虚函数。这里有一个考试特别爱挖坑的点派生类重写虚函数时就算不加 virtual 关键字也能构成重写。因为基类的虚函数被继承下来后在派生类中依旧保持虚函数的属性。但这种写法不规范实际开发中建议加上 virtual可读性更好。不过做选择题的时候要小心题目故意在派生类去掉 virtual问你构不构成多态答案是构成。再看一个动物叫的例子用引用实现多态class Animal { public: virtual void talk() const {} }; class Dog : public Animal { public: virtual void talk() const { std::cout 汪汪 std::endl; } }; class Cat : public Animal { public: virtual void talk() const { std::cout (^ω^)喵 std::endl; } }; void letsHear(const Animal animal) { animal.talk(); } int main() { Cat cat; Dog dog; letsHear(cat); letsHear(dog); return 0; }这里 letsHear 的参数是 const Animal 基类引用传 cat 进去调用 Cat::talk传 dog 进去调用 Dog::talk。引用和指针一样都能实现多态。四、经典选择题虚函数加默认参数的坑这是一道面试高频选择题选项A. A-0 B. B-1 C. A-1 D. B-0 E. 编译出错 F. 以上都不对正确答案是 B输出 B-1。这道题的坑在于把两个绑定规则混在了一起很多人搞混。我来一步一步拆解。第一步B* p new B在堆上创建了一个 B 对象p 指向它。第二步p-test ()。B 类没有重写 test 函数所以调用从 A 继承来的 A::test ()。A::test 里面写的是 func ()等价于 this-func ()。第三步这里的 this 指针它的类型是 A*因为 test 函数定义在 A 类里面但 this 实际指向的是 B 对象。func 是虚函数虚函数是运行时动态绑定看实际对象是谁。实际对象是 B所以调用 B::func ()。到这里可以确定输出一定是 B-xxx排除 A 和 C。第四步最关键的一步。func () 没有传参用的是默认参数。默认参数是编译期静态绑定的编译器在编译 A::test 里面的 func () 这行代码时看的是 this 的静态类型 A*去 A 类的函数声明里找默认值A 的 func 声明是 int val 1所以编译期就把实参填成了 1。我们需要记住的是重写是重写虚函数的实现。虽然运行时调用的是 B::func但传入的参数是编译时就定死的 1不是 B 类声明的 0。所以最终执行 B::func (1)输出 B-1。我在代码里还加了一行 p-func () 做对比测试。p-func () 的时候p 的静态类型是 B*编译器看 B 类的 func 声明默认值是 0所以输出 B-0。同样是调用 B::func在哪调用、看哪个类的声明决定了默认参数的值。总结一句话虚函数运行时动态绑定看实际对象默认参数编译时静态绑定看调用处的指针类型。两者是分开的各管各的。工程上重写虚函数时不要修改默认参数否则很容易出这种反直觉的 bug。五、虚函数重写的特殊情况5.1协变了解即可正常重写要求返回值类型完全相同但有一个例外叫协变。基类虚函数返回基类对象的指针或引用派生类虚函数返回派生类对象的指针或引用这种情况也构成重写叫协变。Person 的 BuyTicket 返回 AStudent 的 BuyTicket 返回 BB 继承 A返回值是父子关系的指针这就叫协变依然构成重写。协变实际开发用得不多了解概念就行面试偶尔会问。5.2析构函数的重写重点析构函数的重写比较特殊。基类析构函数加了 virtual 之后派生类的析构函数只要定义了不管加不加 virtual都和基类析构函数构成重写。为什么看起来名字不一样还能重写因为编译器对析构函数的名字做了特殊处理编译后所有析构函数的名字统一变成 destructor所以基类和派生类的析构函数名字其实是一样的满足重写条件。为什么基类析构函数建议写成虚函数看下面这段代码我在代码旁边写了注释基类的指针去指向 new 出来的基类和派生的对象不加 virtual 就会出问题内存泄漏。具体来说A* p2 new Bp2 是基类指针指向派生类对象。如果 A 的析构不加 virtualdelete p2 的时候不会触发多态编译器只看 p2 的类型是 A*直接调用 A 的析构函数B 的析构函数完全不会执行。B 类里面申请的资源比如_p 指向的内存就得不到释放造成内存泄漏。加上 virtual 之后delete p2 触发多态先调用 B 的析构释放 B 自己的资源然后 B 析构结束后编译器自动调用父类 A 的析构。完整释放没有泄漏。这里还要区分一个场景栈上的局部对象 B b1出作用域析构的时候不管有没有 virtual都会先调用B () 再调用A ()。virtual 虚析构只影响 父类指针 delete 堆上子类对象 这一个场景。栈对象的析构顺序是语言语法强制的和 virtual 无关。我在代码注释里还写了底层原理因为多态的原因给析构函数底层起了个名字p2-destructor ()。delete 一个对象实际上做两件事先调用析构函数再调用 operator delete 释放内存。析构函数是不是虚函数决定了调用的是哪个版本的析构。六、override 和 final 关键字C 对虚函数重写的要求很严格函数名写错一个字母、参数差一个就不构成重写了。而且这种错误编译期不会报错只有运行时发现结果不对才去 debug很麻烦。C11 引入了 override 关键字放在派生类虚函数后面编译器会帮你检查这个函数到底有没有重写基类的虚函数。如果没重写成功直接编译报错把问题提前到编译期。这段代码会编译报错因为基类写的是 Dirve少了一个 r派生类写的是 Drive名字不一样不构成重写。加了 override 之后编译器帮你查出来了。如果不加 override这段代码能编译通过但运行时不会有多态效果bug 很难找。final 关键字的作用正好相反。如果不想让派生类重写某个虚函数就在基类的虚函数后面加 final派生类再尝试重写就会编译报错。class Car { public: virtual void Drive() final {} }; class Benz : public Car { public: virtual void Drive() { cout Benz-舒适 endl; } // 编译报错 };final 还可以修饰类表示这个类不能被继承。七、重载、重写、隐藏的对比这三个概念经常放在一起考选择题必须分清楚。重载两个函数在同一个作用域函数名相同参数不同参数的类型或者个数不同返回值可以相同也可以不同。重载和多态没关系是编译时确定的。重写也叫覆盖两个函数分别在继承体系的父类和子类不同作用域函数名、参数、返回值都必须相同协变是例外并且两个函数都必须是虚函数。重写是运行时多态的前提。隐藏也叫重定义两个函数分别在父类和子类不同作用域函数名相同只要不构成重写就是隐藏。注意父类和子类的成员变量同名也叫隐藏。隐藏之后子类对象直接访问这个名字会优先用子类的把父类的藏起来了需要加父类才能访问父类的。简单区分同一作用域同名不同参是重载父子类虚函数三同是重写父子类同名但不构成重写就是隐藏。八、纯虚函数和抽象类在虚函数声明的后面写上 0这个函数就叫纯虚函数。纯虚函数一般只声明不实现语法上可以给实现但没什么意义因为它就是要被派生类重写的。包含纯虚函数的类叫做抽象类。抽象类不能实例化出对象。如果派生类继承了抽象类但没有重写纯虚函数那么派生类也是抽象类同样不能实例化。纯虚函数的意义在于强制派生类重写虚函数。你不重写就连对象都创建不了逼着你去实现。抽象类相当于定义了一个接口规范所有继承它的类都必须实现这个接口这在设计模式里用得非常多。九、多态的底层原理前面讲了怎么用多态现在深入到底层看看多态到底是怎么实现的。9.1虚函数表指针先看一道题class Base { public: virtual void Func1() { cout Func1() endl; } protected: int _b 1; char _ch x; }; int main() { Base b; cout sizeof(b) endl; return 0; }32 位程序下sizeof (b) 的结果是 12 字节。_b 占 4 字节_ch 占 1 字节加起来才 5 字节怎么会是 12因为含有虚函数的类对象里面会多一个虚函数表指针叫__vfptrv 代表 virtualf 代表 function。32 位下指针占 4 字节加上内存对齐总共 12 字节。这个__vfptr 放在对象的最前面有些平台可能放最后面跟平台有关。它指向一张虚函数表简称虚表。虚表里面存的是这个类所有虚函数的地址。9.2 多态是怎么实现的还是用买票的例子这次加一个 Soldier 类三个类都有虚函数class Person { public: virtual void BuyTicket() { cout 买票-全价 endl; } private: string _name; }; class Student : public Person { public: virtual void BuyTicket() { cout 买票-打折 endl; } private: string _id; }; class Soldier : public Person { public: virtual void BuyTicket() { cout 买票-优先 endl; } private: string _codename; }; void Func(Person* ptr) { ptr-BuyTicket(); } int main() { Person ps; Student st; Soldier sr; Func(ps); Func(st); Func(sr); return 0; }每个对象的内存布局最前面都有一个__vfptr。Person 对象的__vfptr 指向 Person 的虚表虚表里存的是 Person::BuyTicket 的地址。Student 对象的__vfptr 指向 Student 的虚表因为 Student 重写了 BuyTicket所以 Student 虚表里对应位置存的是 Student::BuyTicket 的地址。Soldier 同理。当 Func (ps) 时ptr 指向 Person 对象程序运行时通过 ptr 找到对象取出对象前面的__vfptr找到 Person 的虚表从虚表里取出 BuyTicket 的地址调用 Person::BuyTicket。当 Func (st) 时ptr 指向 Student 对象同样的步骤取出来的是 Student 虚表里的 Student::BuyTicket 地址调用 Student::BuyTicket。这就是多态的底层原理不是编译时把函数地址定死而是运行时到指向的对象的虚表里去找函数地址。指向谁的对象就用谁的虚表就调用谁的虚函数。9.3静态绑定与动态绑定理解了虚表就能理解动态绑定和静态绑定了。不满足多态条件的函数调用编译器在编译时就确定了调用哪个函数直接把函数地址写死在指令里这叫静态绑定也叫早绑定。满足多态条件的调用基类指针或引用 虚函数编译器不会在编译时确定地址而是生成一段指令运行时去对象的虚表里找函数地址再调用这叫动态绑定也叫晚绑定。看汇编指令对比会更直观。// ptr是指针BuyTicket是虚函数满⾜多态条件。 // 这⾥就是动态绑定编译在运⾏时到ptr指向对象的虚函数表中确定调⽤函数地址 ptr-BuyTicket(); 00EF2001 mov eax,dword ptr [ptr] 00EF2004 mov edx,dword ptr [eax] 00EF2006 mov esi,esp 00EF2008 mov ecx,dword ptr [ptr] 00EF200B mov eax,dword ptr [edx] 00EF200D call eax // BuyTicket不是虚函数不满⾜多态条件。 // 这⾥就是静态绑定编译器直接确定调⽤函数地址 ptr-BuyTicket(); 00EA2C91 mov ecx,dword ptr [ptr] 00EA2C94 call Student::Student (0EA153Ch)动态绑定多了好几步寻址所以有轻微的性能开销但换来的是灵活性这个开销在绝大多数场景下可以忽略。9.4 虚函数表的细节关于虚表有几个要点要记住第一同类型的对象共用同一张虚表不同类型的对象各自有独立的虚表。Person 的所有对象共用 Person 虚表Student 的所有对象共用 Student 虚表。虚表是属于类的不是属于某个对象的对象里只存一个指向虚表的指针。第二派生类对象的虚表是怎么来的派生类先把基类虚表的内容拷贝一份过来然后如果派生类重写了某个虚函数就把虚表里对应位置的地址覆盖成派生类重写后的函数地址。派生类自己新增的虚函数追加在虚表后面。举个例子Base 的虚表里有func1 (Base 版)、func2 (Base 版)。Derive 的虚表里有func1 (Derive 版覆盖了原来的)、func2 (Base 版继承来的没改)、func3 (Derive 自己新增的)。注意 func5 和 func4 不是虚函数不会进虚表。虚表只存虚函数的地址。第三虚函数存在哪虚函数和普通函数一样编译好之后是一段指令存在代码段。只是虚函数的地址额外被存了一份到虚表里。虚表本身存在哪C 标准没有规定VS 下是存在代码段常量区可以通过打印地址对比验证。第四虚表本质上是一个存虚函数指针的数组。VS 编译器会在数组最后放一个 0x00000000 作为结束标记g 不会放这个是编译器自行定义的。十、总结多态分编译时多态和运行时多态。运行时多态的两个必要条件基类指针或引用调用虚函数派生类重写了该虚函数。虚函数重写要求函数名、参数、返回值三相同协变是返回值的例外。派生类不加 virtual 也能构成重写但不规范。析构函数建议加 virtual否则父类指针 delete 子类对象会内存泄漏。override 帮你检查重写是否成功final 禁止重写。重载、重写、隐藏要分清楚同作用域同名不同参是重载父子类虚函数三同是重写父子类同名不构成重写就是隐藏。纯虚函数等于 0包含纯虚函数的类是抽象类不能实例化强制派生类重写。底层原理含虚函数的对象前面有__vfptr 指向虚表虚表存虚函数地址。多态调用时运行时到对象的虚表里找函数地址这叫动态绑定普通调用编译时确定地址叫静态绑定。派生类虚表是拷贝基类虚表后覆盖重写的函数、追加自己的虚函数。