多态(上)
文章目录
- 多态(上)
- 1. 多态的概念
- 2. 多态的定义及实现
- 2.1 多态的构成条件
- 2.1.1 实现多态还有两个必须重要条件:
- 结合动物例子串起来看
- 2.1.2 虚函数
- 2.1.3 虚函数的重写/覆盖
- 坑点总结
- 2.1.4 多态场景的⼀个选择题
- 2.1.5 虚函数重写的⼀些其他问题
- 1.协变(Covariance)
- 1.1 结合协变代码讲解:
- 1.2 协变完整带注释代码
- 2. 析构函数的虚函数重写(面试重点)
- 最重要实际意义(面试核心)
- 1. 析构代码逐行讲解
- 反面场景(坑)
- 2. 虚析构完整带注释代码
- 3. 考试 / 面试小结
- 2.1.6 override 和final关键字
- 1、override(检查是否成功重写虚函数)
- 问题背景
- override 的作用
- 2. final(两个用途)
- 用途 1:修饰虚函数 —— 禁止派生类重写这个虚函数
- 用途 2:修饰类 —— 禁止这个类被继承
- 总结
- 补充小对比
- 2.1.7 重载/重写/隐藏的对比
- 1. 重载(同一作用域)
- 2. 重写 / 覆盖(实现多态,继承体系 + virtual 虚函数)
- 3. 隐藏(又叫遮蔽,继承体系,不满足重写条件)
- 总结对照表(方便记忆)
1. 多态的概念
同一个动作,交给不同对象去做,表现出不一样的效果,这就是多态(多种形态)
多态分成两大类:静态多态(编译时)、动态多态(运行时)
- 静态多态【编译阶段就确定好调用哪个函数】
代表:函数重载、模板
同一个函数名字,根据你传入不同参数,直接确定执行哪段代码
程序还没跑起来(编译的时候),编译器一看参数,就知道要用哪个方法。
举例子:
add(1,2)两个数字相加
add("a","b")两个字符串拼接
函数名都是 add,参数不一样,功能不一样。
代码编译的时候就分清调用哪个,所以叫静态 / 编译时多态。
- 动态多态【程序跑起来之后,才确定执行什么代码】(面向对象重点)
规则:同一个行为,传入不同对象,产生不同结果,程序运行的时候才决定用哪段逻辑
原文两个例子通俗翻译:
例子 1:买票
动作:【买票】
- 普通人对象执行买票 → 付全款
- 学生对象执行买票 → 半价优惠
- 军人对象执行买票 → 优先购票
动作名称不变都是 “买票”,对象不一样,行为结果不一样。
例子 2:动物叫唤
统一动作:【叫】
- 猫对象调用【叫】 → 喵喵喵
- 狗对象调用【叫】 → 汪汪汪
静态多态 vs 动态多态最简单区分
✅ 静态多态:看参数区分逻辑(编译时定好)
✅ 动态多态:看对象区分逻辑(程序运行时才确定)
2. 多态的定义及实现
2.1 多态的构成条件
多态是⼀个继承关系的下的类对象,去调⽤同⼀函数,产⽣了不同的⾏为。⽐如Student继承了 Person。Person对象买票全价,Student对象优惠买票。
2.1.1 实现多态还有两个必须重要条件:
- 条件 1:要用基类指针 / 基类引用去调用函数
基类相当于通用模板(比如:人、动物),派生类是具体种类(学生、猫、军人、狗)。
普通指针只能固定指向一种对象;
基类指针 / 引用很灵活:既能指向父类对象,也能指向子类对象。
举例子:
基类 = 动物
派生类 = 猫、狗
动物* p; // 基类指针 p = new 猫(); // 可以指向猫 p = new 狗(); // 也可以指向狗如果不用基类指针,直接用猫指针只能指向猫,狗指针只能指向狗,没法统一用一套代码接收多种子类,自然谈不上多态。
简单理解:基类指针就是一个 “通用容器”,能装下所有子类。
条件 2:调用的函数必须是虚函数,并且子类完成重写(覆盖)
基类里的函数前面加上
virtual,它就变成虚函数。派生类写一个一模一样的函数(函数名、参数、返回值完全匹配),这就叫重写 / 覆盖。
没有虚函数会发生什么?
就算基类指针指向猫、指向狗,调用方法时只会死板执行基类里面的代码,不会自动调用猫、狗自己实现的版本,出不来多态效果!
虚函数作用:相当于给程序开了一个开关。程序运行的时候,不去死板看指针是什么类型,而是看指针真正指向的是什么对象,调用这个对象自己重写的函数。
结合动物例子串起来看
基类【动物】,里面写虚函数virtual 叫()
子类【猫】重写叫():喵喵喵
子类【狗】重写叫():汪汪汪
动物* a = new 猫(); a->叫(); // 输出喵喵喵 a = new 狗(); a->叫(); // 输出汪汪汪满足两条规则,多态生效!
以下是反面教材:
如果函数不加 virtual(不是虚函数)
基类指针哪怕指向猫,调用叫(),只会执行动物默认的叫声,不会调用猫的版本。
如果不用基类指针,直接用猫指针、狗指针各自调用
虽然也能正常叫唤,但是只是普通函数调用,不是多态,没法用统一的指针接收所有子类。
2.1.2 虚函数
类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意⾮成员函数不能加virtual修饰。
class Person { public: virtual void BuyTicket() { cout << "买票全价" << endl;} };2.1.3 虚函数的重写/覆盖
(1)定义
基类写了一个虚函数;
子类里面写一个函数:函数名一模一样、参数一模一样、返回值符合要求。
这就叫:子类重写(覆盖)了基类虚函数。
(2) 重点注意点
子类可以不写
virtual,依然能构成重写基类函数已经是虚函数,继承到子类后,这个函数天生自带虚函数身份。
哪怕子类函数前面省略
virtual,依旧算是重写,多态照样生效。❗但是不推荐省略 virtual,代码可读性很差,规范开发都建议写上。
💡考试高频坑!
选择题经常故意把子类的
virtual删掉,迷惑你,很多人误以为没有 virtual 就不能重写、无法多态,这是易错点。
简单记住:虚属性是从基类继承下来的,子类可省略 virtual;但规范上一定要加上。
(3) 补充一个硬性前提
想要实现动态多态,必须满足重写;如果函数名字 / 参数不一样,那不叫重写,自然触发不了多态。
(4)结合两段代码讲解
案例 1:买票 Person & Student
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-打折" << endl; } };基类Person:BuyTicket()带有virtual,是虚函数
子类Student:函数名BuyTicket、参数列表、返回值和基类完全一致 →构成重写
void Func(Person* ptr) // 条件1:基类指针 { ptr->BuyTicket(); } int main() { Person ps; Student st; Func(&ps); // 普通人类对象 → 全价买票 Func(&st); // 学生对象 → 打折买票 return 0; }✅ 两条动态多态条件全部满足:
① 使用基类指针调用函数
② 虚函数 + 子类完成重写
运行结果:
买票-全价 买票-打折👉 关键点:指针永远都是Person*类型,但是程序运行时,看指针指向什么对象,就调用哪个类的函数,这就是多态。
拓展测试:把 Student 里面的 virtual 删掉
void BuyTicket() { cout << "买票-打折" << endl; }依然构成重写,多态依旧生效!只是代码不规范,考试要认清这个情况。
案例 2:动物叫声 Animal Dog Cat
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; } };基类Animal拥有虚函数talk();
Dog、Cat 中talk()函数名、参数、const 修饰、返回值完全匹配基类,构成重写。
void letsHear(const Animal& animal) // 条件1:基类引用(指针/引用二选一都可以) { animal.talk(); } int main() { Cat cat; Dog dog; letsHear(cat); letsHear(dog); return 0; }✅ 使用基类引用(也是实现多态合法方式)+ 虚函数重写,满足条件。
运行结果:
(>^ω^<)喵 汪汪再次区分:指针、引用都能实现多态;直接用父类对象接收子类(值传递切割)无法实现多态!
坑点总结
子类重写虚函数时,省略 virtual,依旧是重写,能够触发多态;
很多考生误以为子类必须写 virtual 才算重写,这是出题人最爱设置的陷阱;
但是工程开发规范:建议子类重写虚函数时主动写上 virtual(C++11 推荐再加 override 关键字);
如果函数名 / 参数不一样,不叫重写,叫函数隐藏,无法实现多态!
2.1.4 多态场景的⼀个选择题
各位大佬,请亮出您的答案!
答案:B: B->1
解释:
- 先看能不能触发多态
基类 A:
virtual void func(int val = 1)派生类 B:
void func(int val = 0)函数名、返回值、参数列表完全相同,
构成虚函数重写
(子类不写 virtual 依然有效)。
- 调用流程梳理
B* p = new B; p->test();test()这个函数定义在 A 类里面:virtual void test(){ func(); }- 虽然 test 写在 A 中,但是 test 内部调用
func()。由于 func 是虚函数,会触发多态,执行 B 类重写的 func。
重点:最终跑的是
B::func(),不是 A::func ()。
- 最容易踩坑:默认参数编译期确定,不参与多态!
✅ 记住铁律:
虚函数是运行时确定调用哪个函数;函数默认参数值,是编译阶段就确定好的,不会跟着子类改变!
test()属于 A 类的成员函数,编译的时候,编译器看到func();(无传参),直接拿A 类 func 的默认参数 val=1。
运行时:执行的是 B 的 func 函数,但传入的参数值是编译期敲定的1。
- 最终执行
运行B::func(1)
打印输出:B->1
总结:
调用哪个函数(A::func 还是 B::func)运行时看虚函数;
参数默认值在编译时就定死,由调用处所在的类版本决定,不会动态跟随子类!
易错辨析
很多人会误以为:调用 B 的 func,就应该使用 B 的默认参数 0。❌错误!
默认参数不参与动态多态,编译时在 A 的 test 函数内部,func 不传参,直接绑定 A 的默认值 1。
拓展验证
如果代码写成:
A* p = new B(); p->func(); // 依然输出 B->1同样原理!
对应选项: 选项B: B->1✅
简易流程图
p->test()→ 执行 A::test () → test 里面调用 func ()
→ 虚函数多态,运行找到 B::func
→ 参数编译阶段确定为 A 的默认值1
→ 执行B::func(1)→ 输出B->1
2.1.5 虚函数重写的⼀些其他问题
1.协变(Covariance)
正常虚函数重写要求:返回值必须一模一样。
协变是唯一的特例:
基类虚函数返回【基类指针 / 基类引用】
派生类重写的虚函数返回【派生类指针 / 派生类引用】
这种返回值不一样,但依然合法构成重写,就叫做协变。
⚠️ 硬性限制:
- 只允许指针、引用;普通对象不能协变
- 必须是「基类 ← 派生类」的继承关系
- 日常开发极少使用,了解概念即可。
1.1 结合协变代码讲解:
class A {}; class B : public A {}; class Person { public: virtual A* BuyTicket() { cout << "买票-全价" << endl; return nullptr; } }; class Student : public Person { public: virtual B* BuyTicket() { cout << "买票-打折" << endl; return nullptr; } };基类虚函数返回A*
子类重写函数返回B*
B 公有继承 A,满足协变条件 →合法重写,可以触发多态
void Func(Person* ptr) { ptr->BuyTicket(); } int main() { Person ps; Student st; Func(&ps); // 输出:买票-全价 Func(&st); // 输出:买票-打折 return 0; }运行逻辑:
Func 接收基类指针,调用 BuyTicket。因为构成合法重写,多态生效。
ptr 指向 Student 对象,自动执行 Student::BuyTicket。
1.2 协变完整带注释代码
#include <iostream> using namespace std; class A {}; // B公有继承A,满足协变的继承关系 class B : public A {}; class Person { public: // 基类虚函数:返回基类A的指针 virtual A* BuyTicket() { cout << "买票-全价" << endl; return nullptr; } }; class Student : public Person { public: // 协变:返回派生类B*,允许构成重写 virtual B* BuyTicket() { cout << "买票-打折" << endl; return nullptr; } }; // 参数:基类指针,满足动态多态条件 void Func(Person* ptr) { ptr->BuyTicket(); } int main() { Person ps; Student st; Func(&ps); Func(&st); return 0; }2. 析构函数的虚函数重写(面试重点)
正常成员函数重写:函数名、参数必须完全相同。
析构函数是编译器特殊处理的例外!
- 源代码里基类析构
~A(),派生类析构~B()名字看起来不同 - 编译阶段,编译器把所有析构函数统一改名
destructor - 只要基类析构加 virtual,派生类析构自动构成重写,派生类析构写不写 virtual 都有效。
最重要实际意义(面试核心)
当用基类指针指向派生类对象,执行delete 指针;
- 如果基类析构不是虚函数:只会调用基类析构,派生类析构不执行。派生类里面申请的内存无法释放 →内存泄漏。
- 如果基类析构是虚函数:触发多态,先调用派生类析构,再调用基类析构,资源完整释放。
1. 析构代码逐行讲解
class A { public: // 基类析构设置为virtual virtual ~A() { cout << "~A()" << endl; } }; class B : public A { public: // 自动重写基类虚析构,可省略virtual ~B() { cout << "~B()->delete:" << _p << endl; delete[] _p; // 释放子类申请的堆内存 } protected: int* _p = new int[10]; // 子类持有堆资源 };int main() { A* p1 = new A; A* p2 = new B; delete p1; // 释放A对象,调用 ~A() delete p2; // 基类析构是虚函数,触发多态 // 先调用 ~B() 释放_p数组,再调用 ~A() return 0; }✅ 运行输出:
~A() ~B()->delete:0x... ~A()反面场景(坑)
如果删掉virtual,变成~A(){}
delete p2;只会执行~A(),不会执行~B ()
_p指向的堆数组没有释放,永久内存泄漏。
💡面试标准答案:
如果一个类可能被继承,最好把基类析构定义为虚析构函数,防止基类指针管理派生类对象时发生内存泄漏。
2. 虚析构完整带注释代码
#include <iostream> using namespace std; class A { public: // 重点:基类析构加上virtual,成为虚析构函数 virtual ~A() { cout << "~A()" << endl; } }; class B : public A { public: // 自动重写基类虚析构,virtual可写可不写,推荐写上增强可读性 ~B() { cout << "~B()->delete:" << _p << endl; delete[] _p; // 释放子类堆资源 } protected: // 子类在堆上开辟内存 int* _p = new int[10]; }; int main() { A* p1 = new A; A* p2 = new B; delete p1; delete p2; return 0; }3. 考试 / 面试小结
- 协变仅支持指针 / 引用,日常开发很少用,认识即可
- 析构函数名称源代码不同,编译器统一改名,满足虚函数重写条件
- 被继承的基类,析构函数建议加 virtual,避免基类指针 delete 派生类对象造成内存泄漏。
2.1.6 override 和final关键字
1、override(检查是否成功重写虚函数)
问题背景
虚函数重写要求很严:函数名、参数、返回值(协变除外)都要匹配。
一不小心写错函数名、参数,编译器不会报错,它只会认为:你在派生类新增了一个独立函数,没有完成重写。
等到运行程序,多态失效,结果不对,你还要花大量时间查 bug。
override 的作用
在派生类的虚函数后面写上override
编译器会主动检查:这个函数能不能成功重写基类的虚函数。
- 满足重写规则:正常编译
- 写错名字 / 参数,无法构成重写:直接编译报错,提前发现错误。
示例:
class A { public: virtual void func(){} }; class B : public A { public: // 正确:成功重写 void func() override{} // void func(int x) override{} // 报错!参数不一样,无法重写 };小建议:写重写的虚函数时,习惯性加上 override。
2. final(两个用途)
用途 1:修饰虚函数 —— 禁止派生类重写这个虚函数
基类虚函数后面加final,后续所有子类都不能重写它。
子类强行重写 → 编译报错。
class A { public: virtual void func() final {} }; class B : public A { public: // void func(){} // 编译报错,func被final禁止重写 };用途 2:修饰类 —— 禁止这个类被继承
类名后面加 final,别的类不能继承它
class A final {}; // class B:public A{}; // 报错,A不能被继承总结
- override:告诉编译器「我打算重写虚函数,帮我检查有没有写错」,防止隐性 bug。
- final:上锁。修饰函数 = 不让子类重写;修饰类 = 不让别人继承这个类。
补充小对比
- override:只用在派生类的虚函数
- final:可以写在基类虚函数后,也可以写在类名后
2.1.7 重载/重写/隐藏的对比
如果追女生是每个少年(男生)心中第一件事喵,那么搞清楚这三个对比就是现在的当务之急,重中之重喵。(其实我想说这是第0件事,哈哈哈哈哈,因为计算机的世界是二进制的花园喵)
先一句话分清场景:
重载:同一个班级里;重写:父子班级 + 虚函数;隐藏:父子班级、但是达不到重写条件。
1. 重载(同一作用域)
条件:同一个范围,函数名字一样,参数不一样。
🌰例子:
班里同一个教室(同一个类 / 同一个作用域)有两位老师,都叫 “讲课”
讲课 (讲数学)
讲课 (讲语文)
名字都叫讲课,但是授课内容(参数)不同。
调用的时候看你传什么参数,自动匹配对应函数。
关键点:
全都在同一个教室(同一作用域),和继承没关系。
返回值随便,可以相同也可以不同,只看参数。
// 同一个类内部,属于重载 class Person { public: void Talk(string subject); void Talk(int age); };2. 重写 / 覆盖(实现多态,继承体系 + virtual 虚函数)
条件:父类、子类两个不同作用域;父类函数是 virtual 虚函数;函数名、参数一致(协变除外)。
🌰例子:
父亲开小店:virtual 买票 () → 全价票
儿子继承店铺,重新改造规则:买票 () → 学生打折票
规则名称一模一样,入参一样。
有人通过店铺招牌(父类指针)买票时,自动区分是谁经营店铺,执行对应的买票逻辑,这就是多态。
重点区别:
必须加virtual!不加就不叫重写。
只有重写才能触发动态多态。
class Person { public: virtual void BuyTicket() { cout << "全价票"; } }; class Student : public Person { public: void BuyTicket() override { cout << "打折票"; } // 重写 };3. 隐藏(又叫遮蔽,继承体系,不满足重写条件)
规则:父子类,函数名相同,只要构不成重写,全部是隐藏。
两种常见情况:
1)父类没有写 virtual;
2)有 virtual,但是参数列表不一样;
🌰例子:
父亲店铺功能:买票 ()(无参数)
儿子店铺:买票 (int num)(带参数)
名字都叫买票,但是参数不一样。
如果你拿着儿子店铺的招牌调用买票 (),只会看到儿子自己的版本,父亲同名函数直接被藏起来了。想要调用父类版本,必须手动指定Person::BuyTicket()。
⚠️ 重要坑:
隐藏≠重写,不会触发多态!
额外补充:父子类同名成员变量,也属于隐藏!
父亲有个变量int id;,子类也写int id;,子类 id 会隐藏父类 id。
class Person { public: virtual void BuyTicket() { cout << "全价票"; } }; class Student : public Person { public: void BuyTicket(int x) { cout << "打折票"; } // 参数不一样!无法构成重写,属于【隐藏】 };总结对照表(方便记忆)
- 重载:同一个房间,同名不同参
- 重写:父子房间、带 virtual、签名一致,实现多态
- 隐藏:父子房间、名字相同,但达不到重写标准;父类同名成员被遮蔽
又是美好的一天,各位观众姥爷们,若有什么不懂的地方,欢迎在评论区留言哦,我会给出相应解释的。如有什么疏忽的细节,请点出,我会及时纠正。感谢观看。喵喵