你不能改变过去,但你可以改变未来
算法/C++/数据结构/C
Hello,这里是小枫。C语言与数据结构和算法初阶两个板块都更新完毕,我们继续来学习C++的内容呀。C++是接近底层有比较经典的语言,因此学习起来注定枯燥无味,西游记大家都看过吧~,我希望能带着大家一起跨过九九八十一难,降伏各类难题,学会C++,我会尽我所能,以通俗易懂、幽默风趣的方式带给大家形象生动的知识,也希望大家遇到困难不退缩,遇到难题不放弃,学习师徒四人的精神!!!故此得名【C++游记】
话不多说,让我们一起进入今天的学习吧~~~
一、多态的概念
多态(polymorphism)字面意思是“多种形态”,在C++中分为两类:编译时多态(静态多态)和运行时多态(动态多态)。
1. 编译时多态(静态多态)
核心是“编译期确定调用哪个函数”,主要通过函数重载和函数模板实现。
原理:通过不同的参数类型/个数,编译器在编译阶段就确定要调用的函数版本,无需运行时判断。
// 函数重载示例(编译时多态)
#include <iostream>
using namespace std;
// 加法函数:int类型
int add(int a, int b) {
return a + b;
}
// 加法函数:double类型(重载)
double add(double a, double b) {
return a + b;
}
int main() {
cout << add(1, 2) << endl; // 编译时确定调用int版本
cout << add(1.5, 2.5) << endl;// 编译时确定调用double版本
return 0;
}
2. 运行时多态(动态多态)
核心是“运行期确定调用哪个函数”,即“同一个行为,传入不同对象,产生不同结果”。
生活案例:
- 买票行为:普通人全价、学生打折、军人优先
- 动物叫行为:猫“喵”、狗“汪汪”
// 动物叫示例(运行时多态)
#include <iostream>
using namespace std;
class Animal {
public:
// 虚函数:关键标志
virtual void talk() const {
cout << "动物叫" << endl;
}
};
class Cat : public Animal {
public:
// 重写虚函数
virtual void talk() const override {
cout << "(>^ω^<)喵" << endl;
}
};
class Dog : public Animal {
public:
virtual void talk() const override {
cout << "汪汪" << endl;
}
};
// 统一接口:接收基类引用
void letsHear(const Animal& animal) {
animal.talk(); // 运行时确定调用哪个版本
}
int main() {
Cat cat;
Dog dog;
letsHear(cat); // 输出:(>^ω^<)喵
letsHear(dog); // 输出:汪汪
return 0;
}
注意:
本文重点讲解运行时多态,因为它是C++面向对象的核心,也是面试高频考点;编译时多态相对简单,日常开发中使用频率也较低。
二、多态的定义及实现
2.1 多态的构成条件
要实现运行时多态,必须同时满足以下两个核心条件:
- 条件1:必须通过基类的指针或引用调用虚函数
- 条件2:被调用的函数必须是虚函数,且派生类必须对基类的虚函数完成重写(覆盖)
为什么必须用基类指针/引用?
因为只有基类的指针或引用才能“兼容”指向基类对象和派生类对象,普通基类对象无法做到这一点(会发生切片,丢失派生类特性)。
2.1.1 虚函数
虚函数是多态的“开关”,定义方式:在类成员函数前加virtual关键字。
// 虚函数定义示例
class Person {
public:
// 虚函数:加virtual关键字
virtual void BuyTicket() {
cout << "买票-全价" << endl;
}
// 注意:非成员函数不能加virtual(编译报错)
// virtual void func() {} // 错误:全局函数不能是虚函数
};
class Student : public Person {
public:
// 派生类虚函数:建议显式加virtual(规范)
virtual void BuyTicket() {
cout << "买票-打折" << endl;
}
};
注意:
1.virtual只能修饰类成员函数,不能修饰全局函数、静态成员函数、构造函数;
2. 派生类的虚函数可以省略virtual(因为继承后基类虚函数的“虚属性”会保留),但不建议这样写,会降低代码可读性。
2.1.2 虚函数的重写(覆盖)
重写(覆盖)是指:派生类中有一个与基类完全相同的虚函数(返回值类型、函数名、参数列表必须完全一致),此时派生类的虚函数会“覆盖”基类的虚函数。
// 虚函数重写示例
#include <iostream>
using namespace std;
class Person {
public:
// 基类虚函数
virtual void BuyTicket() {
cout << "Person: 买票-全价" << endl;
}
};
class Student : public Person {
public:
// 派生类重写虚函数(返回值、函数名、参数列表完全一致)
virtual void BuyTicket() override { // override关键字:检测重写是否正确
cout << "Student: 买票-半价" << endl;
}
};
class Soldier : public Person {
public:
// 派生类重写虚函数
virtual void BuyTicket() override {
cout << "Soldier: 买票-优先" << endl;
}
};
// 统一接口:基类指针
void Func(Person* ptr) {
ptr->BuyTicket(); // 运行时确定调用哪个版本
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps); // 输出:Person: 买票-全价
Func(&st); // 输出:Student: 买票-半价
Func(&sr); // 输出:Soldier: 买票-优先
return 0;
}
2.1.3 析构函数的重写(面试重点)
析构函数的重写是一个特殊场景:基类析构函数为虚函数时,派生类析构函数无论是否加virtual,都与基类析构函数构成重写。
原因:编译器会将所有析构函数的名称统一处理为destructor,因此即使派生类析构函数名与基类不同,也能构成重写。
// 析构函数重写的重要性(避免内存泄漏)
#include <iostream>
using namespace std;
class A {
public:
// 基类析构函数:加virtual
virtual ~A() {
cout << "~A()" << endl;
}
};
class B : public A {
public:
B() {
_p = new int[10]; // 动态申请内存
}
// 派生类析构函数:无需显式加virtual(但建议加)
~B() override {
delete[] _p; // 释放内存
cout << "~B(): 释放了int数组" << endl;
}
private:
int* _p;
};
int main() {
A* p1 = new A;
A* p2 = new B;
delete p1; // 输出:~A()(正确)
delete p2; // 输出:~B(): 释放了int数组 → ~A()(正确,无内存泄漏)
return 0;
}
面试必问:为什么基类析构函数建议设计为虚函数?
如果基类析构函数不是虚函数,当用基类指针指向派生类对象并删除时,只会调用基类析构函数,不会调用派生类析构函数,导致派生类中动态申请的内存无法释放,造成内存泄漏。
2.2 易混淆概念对比
C++中重载、重写(覆盖)、重定义(隐藏)是三个容易混淆的概念,这里用表格清晰对比:
概念 | 定义 | 作用范围 | 函数名 | 参数列表 | 返回值 | virtual关键字 |
---|---|---|---|---|---|---|
重载 | 同一作用域内的同名函数 | 同一类 | 相同 | 不同 | 可以不同 | 无关 |
重写(覆盖) | 派生类重写基类的虚函数 | 基类与派生类 | 相同 | 相同 | 相同(协变除外) | 基类必须有,派生类可省略 |
重定义(隐藏) | 派生类与基类同名函数(非重写) | 基类与派生类 | 相同 | 可以相同/不同 | 可以不同 | 基类无virtual或参数不同 |
// 重载、重写、重定义对比示例
#include <iostream>
using namespace std;
class Base {
public:
// 重载:同一类中,函数名相同,参数不同
void func() {
cout << "Base::func()" << endl;
}
void func(int x) {
cout << "Base::func(int x)" << endl;
}
// 虚函数:可被重写
virtual void virtualFunc() {
cout << "Base::virtualFunc()" << endl;
}
// 非虚函数:会被派生类重定义(隐藏)
void nonVirtualFunc() {
cout << "Base::nonVirtualFunc()" << endl;
}
};
class Derived : public Base {
public:
// 重写(覆盖):重写基类虚函数
virtual void virtualFunc() override {
cout << "Derived::virtualFunc()" << endl;
}
// 重定义(隐藏):与基类nonVirtualFunc同名,参数相同但基类无virtual
void nonVirtualFunc() {
cout << "Derived::nonVirtualFunc()" << endl;
}
// 重定义(隐藏):与基类func同名但参数不同
void func(double x) {
cout << "Derived::func(double x)" << endl;
}
};
int main() {
Derived d;
Base* b = &d;
b->func(); // 调用Base::func()
b->func(10); // 调用Base::func(int x)
b->virtualFunc(); // 调用Derived::virtualFunc()(重写,多态)
b->nonVirtualFunc(); // 调用Base::nonVirtualFunc()(非虚函数,不构成多态)
d.func(3.14); // 调用Derived::func(double x)
d.Base::func(); // 显式调用基类被隐藏的函数
return 0;
}
三、纯虚函数与抽象类
3.1 纯虚函数
纯虚函数是一种特殊的虚函数:在声明时初始化为0,且没有函数体。它的作用是强制派生类必须重写该函数。
// 纯虚函数定义
class Shape {
public:
// 纯虚函数:=0表示没有函数体
virtual double area() const = 0;
// 普通虚函数:可以有函数体
virtual void printInfo() const {
cout << "这是一个图形" << endl;
}
};
3.2 抽象类
含有纯虚函数的类称为抽象类(也叫接口类)。抽象类有以下特性:
- 抽象类不能实例化对象(无法创建具体实例)
- 抽象类的派生类必须重写所有纯虚函数,否则该派生类仍为抽象类
- 抽象类可以定义普通成员函数和成员变量
- 可以声明抽象或引用(这是多态的基础)
// 抽象类示例
#include <iostream>
using namespace std;
// 抽象类:含有纯虚函数
class Shape {
public:
// 纯虚函数:计算面积
virtual double area() const = 0;
// 纯虚函数:计算周长
virtual double perimeter() const = 0;
// 普通成员函数
void print() const {
cout << "面积: " << area() << ", 周长: " << perimeter() << endl;
}
};
// 派生类:圆
class Circle : public Shape {
public:
Circle(double r) : _radius(r) {}
// 必须重写所有纯虚函数
double area() const override {
return 3.14 * _radius * _radius;
}
double perimeter() const override {
return 2 * 3.14 * _radius;
}
private:
double _radius; // 半径
};
// 派生类:矩形
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : _width(w), _height(h) {}
// 必须重写所有纯虚函数
double area() const override {
return _width * _height;
}
double perimeter() const override {
return 2 * (_width + _height);
}
private:
double _width; // 宽
double _height; // 高
};
// 多态应用:统一接口操作不同图形
void showShapeInfo(const Shape& shape) {
shape.print();
}
int main() {
// 错误:抽象类不能实例化对象
// Shape shape;
// 正确:可以声明抽象类的指针/引用
Circle circle(5.0);
Rectangle rect(3.0, 4.0);
showShapeInfo(circle); // 输出:面积: 78.5, 周长: 31.4
showShapeInfo(rect); // 输出:面积: 12, 周长: 14
return 0;
}
抽象类的应用场景:
当我们需要定义一个基类,但不希望它被实例化,只作为派生类的接口规范时,就可以使用抽象类。例如:
- 图形类(Shape):派生类可以是圆形、矩形、三角形等
- 动物类(Animal):派生类可以是猫、狗、鸟等
- 设备类(Device):派生类可以是打印机、扫描仪、投影仪等
四、多态的底层原理
C++多态的底层是通过虚函数表(Virtual Table,简称vtable)和虚表指针(vpointer,简称vptr)实现的。理解这一机制,能帮你更深入地掌握多态的本质。
4.1 虚函数表(vtable)
当一个类中含有虚函数时,编译器会为该类创建一个虚函数表:
- 虚函数表是一个函数指针数组,存储该类所有虚函数的地址
- 每个含有虚函数的类只有一个虚函数表(所有对象共享)
- 派生类会继承基类的虚函数表,如果重写了基类的虚函数,会用派生类自己的函数地址覆盖虚表中对应的位置
- 如果派生类有新的虚函数,会被添加到虚函数表的末尾
4.2 虚表指针(vptr)
每个含有虚函数的类的对象,都会有一个虚表指针(vptr):
- 虚表指针是对象的第一个成员(存储在对象内存的最前面)
- 虚表指针指向该类的虚函数表
- 对象创建时,编译器会自动初始化vptr,使其指向相应的虚函数表
4.3 多态的实现过程
当通过基类指针/引用调用虚函数时,多态的实现过程如下:
- 通过基类指针/引用访问对象的虚表指针(vptr)
- 通过vptr找到该对象所属类的虚函数表(vtable)
- 在虚函数表中找到对应虚函数的地址
- 调用该地址指向的函数(即派生类重写后的函数)
// 多态底层原理示例
#include <iostream>
using namespace std;
class Base {
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
void func3() { cout << "Base::func3()" << endl; } // 非虚函数
private:
int _b = 1;
};
class Derived : public Base {
public:
virtual void func1() override { cout << "Derived::func1()" << endl; }
virtual void func3() { cout << "Derived::func3()" << endl; } // 新的虚函数
private:
int _d = 2;
};
int main() {
Base b;
Derived d;
// 注意:以下输出结果可能因编译器不同而略有差异
cout << "Base对象大小: " << sizeof(b) << endl; // 输出:8(4字节_b + 4字节vptr)
cout << "Derived对象大小: " << sizeof(d) << endl;// 输出:12(4字节_b + 4字节_d + 4字节vptr)
return 0;
}
上述代码的虚函数表结构如下:
Base类的虚函数表
- vtable[0] → &Base::func1
- vtable[1] → &Base::func2
Derived类的虚函数表
- vtable[0] → &Derived::func1(覆盖基类的func1)
- vtable[1] → &Base::func2(继承基类的func2)
- vtable[2] → &Derived::func3(新增的虚函数)
注意:
1. 虚函数表是编译器在编译期生成的,存储在只读数据段(.rodata);
2. 虚表指针是在对象构造时初始化的,指向所属类的虚函数表;
3. 多态会带来轻微的性能开销(多一次指针间接访问),但通常可以忽略不计。
五、常见问题与面试题
Q1:静态成员函数可以是虚函数吗?
A:不可以。因为静态成员函数属于类,不属于某个具体对象,没有this指针,而虚函数的调用需要通过对象的vptr找到vtable,因此静态成员函数不能是虚函数。
Q2:构造函数可以是虚函数吗?
A:不可以。因为对象的vptr是在构造函数执行时初始化的,在构造函数还未执行时,vptr尚未指向正确的虚函数表,因此构造函数不能是虚函数。
Q3:析构函数为什么要设为虚函数?
A:如前文所述,当用基类指针指向派生类对象并删除时,如果基类析构函数是虚函数,会先调用派生类析构函数,再调用基类析构函数,确保资源正确释放;否则只会调用基类析构函数,导致派生类资源泄漏。
Q4:多态有什么优缺点?
A:优点:
1. 提高代码的复用性和可维护性;
2. 提高代码的扩展性,新增派生类不影响原有代码;
3. 接口统一,使用者无需关心具体实现。
缺点:
1. 增加了系统复杂度;
2. 带来轻微的性能开销(虚函数调用需要查表);
3. 可能隐藏错误,调试难度增加。
Q5:什么情况下会发生隐藏(重定义)?
A:派生类中的函数与基类中的函数同名,且不构成重写时,会发生隐藏:
1. 基类函数不是虚函数,派生类函数与基类函数同名(无论参数是否相同);
2. 基类函数是虚函数,但派生类函数与基类函数参数不同。
Q6:如何判断一段代码是否构成多态?
A:同时满足以下条件:
1. 存在继承关系;
2. 基类中存在虚函数,派生类重写了该虚函数;
3. 通过基类的指针或引用调用该虚函数。
六、总结
- 多态分为编译时多态(函数重载、模板)和运行时多态(虚函数);
- 运行时多态的实现条件:基类指针/引用 + 虚函数重写;
- 虚函数重写要求:函数名、参数列表、返回值完全相同(协变除外);
override
关键字用于检查重写是否正确,final
关键字用于禁止重写或继承;- 含有纯虚函数的类是抽象类,不能实例化,派生类必须重写所有纯虚函数;
- 多态的底层是通过虚函数表(vtable)和虚表指针(vptr)实现的;
- 基类析构函数建议设为虚函数,避免派生类对象销毁时的内存泄漏;
七、结语
今日C++到这里就结束啦,如果觉得文章还不错的话,可以三连支持一下。感兴趣的宝子们欢迎持续订阅小枫,小枫在这里谢谢宝子们啦~小枫の主页还有更多生动有趣的文章,欢迎宝子们去点评鸭~C++的学习很陡,时而巨难时而巨简单,希望宝子们和小枫一起坚持下去~你们的三连就是小枫的动力,感谢支持~