当前位置: 首页 > news >正文

深入浅出 C++ 多态:从概念到原理

在 C++ 面向对象编程中,多态是三大核心特性(封装、继承、多态)之一,它让代码更具灵活性和扩展性,是设计模式的重要基础。本文将结合实例,从概念、实现条件、核心原理到实际应用,全面拆解 C++ 多态的底层逻辑与使用技巧。

1. 多态的概念

多态(polymorphism)通俗来讲,就是同一行为作用于不同对象,产生不同的执行结果。比如 "买票" 这个行为:

  • 普通人买票是全价;
  • 学生买票可享受打折优惠;
  • 军人买票能优先购票。

在C++中,多态分为两类:
编译时多态(静态多态):通过函数重载、函数模板实现,编译器在编译阶段就确定调用的函数版本,核心是 "参数匹配";
运行时多态(动态多态):本文重点讲解,通过虚函数和继承实现,函数调用的版本在程序运行时才确定,核心是 "对象类型匹配"。

2. 多态的实现条件

要实现运行时多态,必须同时满足以下三个条件,缺一不可:
1. 存在继承关系
派生类必须继承自基类(public 继承,保证基类指针 / 引用能访问派生类的基类部分)。例如 Student、Soldier 类继承自 Person 类。

2. 基类指针 / 引用调用函数
必须通过基类的指针或引用调用函数,不能直接用基类对象(否则会触发切片,无法实现多态)。

3. 虚函数重写
基类中声明虚函数(加 virtual 关键字),派生类对该虚函数完成重写(函数名、参数列表、返回值类型完全相同,协变除外)

代码示例:

// 基类
class Person 
{
public:// 虚函数:声明多态接口virtual void BuyTicket() {cout << "普通人买票:全价" << endl;}
private:string _name;
};// 派生类1:学生
class Student : public Person 
{
public:// 重写基类虚函数virtual void BuyTicket() {cout << "学生买票:打折(硬座5折/高铁二等座75折)" << endl;}
private:string _studentId;
};// 派生类2:军人
class Soldier : public Person 
{
public:// 重写基类虚函数(virtual可省略,但不推荐)void BuyTicket() override {cout << "军人买票:优先购票" << endl;}
private:string _militaryId;
};// 统一接口:接收基类指针
void TicketCounter(Person* ptr) 
{ptr->BuyTicket(); // 多态调用:由指针指向的对象类型决定
}int main() 
{Person person;Student student;Soldier soldier;TicketCounter(&person);   // 输出:普通人买票:全价TicketCounter(&student);  // 输出:学生买票:打折(硬座5折/高铁二等座75折)TicketCounter(&soldier);  // 输出:军人买票:优先购票return 0;
}

输出结果:

3. 虚函数

3.1 虚函数定义

类成员函数前加virtual修饰,即为虚函数。注意:

非成员函数(全局函数)不能加virtual;

静态成员函数(static 修饰)不能是虚函数;

析构函数可以是虚函数(且非常重要)。

class Person 
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
};

3.2 虚函数的重写/覆盖

虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。​

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),虽然从语法角度来说仍构成了多态,但是该种写法不是很规范,不建议这样使用。

class Person 
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};class Student : public Person 
{
public:virtual void BuyTicket() { cout << "买票-打折" << endl; }
};void Func(Person* ptr)
{// 这里可以看到虽然都是Person指针ptr在调用BuyTicket // 但是跟ptr没关系,而是由ptr指向的对象决定的。 ptr->BuyTicket();
}int main()
{Person ps;Student st;Func(&ps);Func(&st);return 0;
}

输出结果:

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;
}

输出结果:

这里我们要注意一点,参数列表中只要参数的类型、个数完全相同即可,形参的名称以及缺省参数值不影响判断是否为重写

class Base 
{
public:// 基类虚函数:带缺省参数,形参名为val1virtual void func(int val1 = 10) {cout << "Base::func(val1=" << val1 << ")" << endl;}
};class Derive : public Base 
{
public:// 派生类重写:形参名为val2(名称不同),缺省参数为20(值不同)// 但参数类型、个数、顺序与基类完全相同 → 构成重写virtual void func(int val2 = 20) {cout << "Derive::func(val2=" << val2 << ")" << endl;}
};int main() 
{Base* p = new Derive;p->func(); // 调用派生类重写的func,但缺省参数由基类类型决定Derive* d = new Derive;d->func(); // 直接调用派生类func,缺省参数由派生类类型决定return 0;
}

输出结果:

3.3 多态场景经典题型

以下程序输出结果是什么()

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(B->1)

解析:我们先来认识一下接口继承实现继承的区分:

  • 普通函数继承:属于 “实现继承”,派生类直接继承基类函数的实现逻辑
  • 虚函数继承:属于 “接口继承”,派生类继承的是基类虚函数的接口规则(函数名、参数类型、缺省参数等),目的是为了重写实现以达成多态。

重写的虚函数是 “基类声明接口,派生类实现具体逻辑”。在题目中,func 是虚函数,因此 B 继承的是 A::func 的接口(包括缺省参数 val=1 的规则),而重写的是实现逻辑(输出 B->)。

test 函数属于基类 A,其内部的 this 指针类型是 A*。当通过 this->func() 调用时:
多态机制让 func 执行派生类 B 的实现(因为 this 实际指向 B 对象);
但接口规则由 this 的类型(A*)决定,因此缺省参数取 A::func 的 val=1。最终输出B->1。

3.4 虚函数重写特殊情况

(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;}
};

(2)析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,但实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。​

下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中需要释放资源。​

class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};class B : public A 
{
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,
// 才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。 
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}

输出结果:

这里我们p1调用了一次A的析构,然后A的析构函数时虚函数,delete p2时触发多态先调用了B的析构函数,然后调用A的析构(调用派生类析构后自动调用基类析构),保证内存完全释放。

这里我们要了解的是,因为在这种场景下我们需要做到内存的完全释放,所以我们需要用到重写,因此编译器才会将析构函数的名称统一成destructor以符合重写规则,继而让我们能做到内存的完全释放。

3.5 override和final关键字

override:用于派生类虚函数后,强制检查是否重写基类虚函数,写错函数名 / 参数时编译报错;

final:用于基类虚函数后,禁止派生类重写该函数;也可用于类,禁止该类被继承。

// error C3668: “Benz::Drive”: 包含重写说明符“override”的⽅法没有重写任何基类⽅法 
class Car 
{
public:virtual void Dirve(){}
};class Benz :public Car 
{
public:virtual void Drive() override { cout << "Benz-舒适" << endl; }
};int main()
{return 0;
}
// error C3248: “Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写 
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; }
};int main()
{return 0;
}

3.6 重载/重写/隐藏的对比

这个我们注重理解记忆,而非死记硬背。

4. 纯虚函数和抽象类

在虚函数的后面写上 = 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()
{// 编译报错:error C2259: “Car”: 无法实例化抽象类 Car car;Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}

5. 多态的原理

5.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;
}

输出结果:

为什么会是这个结果呢?我们来看看b的内存:

从图中可知,在b中我们多出了一个_vfptr的指针,简称vptr,我们称为虚函数表指针,该指针指向虚函数表,简称虚表。一个类中只要含有虚函数,那么虚函数的地址都会放在虚表中,也就会存在虚函数表指针。

要点:

1. 虚表本质是一个存储虚函数地址的指针数组,末尾通常有 nullptr 标记;

2. 同类型对象共用同一张虚表,不同类型对象有独立虚表;

3. 虚表和虚函数都存储在代码段(常量区),而非堆或栈。

我们看以下代码:

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 = 0;
};class Derive1 : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}virtual void Func4(){cout << "Base::Func4()" << endl;}
private:int _d1 = 1;
};class Derive2 : public Base
{
public:virtual void Func2(){cout << "Derive::Func2()" << endl;}virtual void Func3(){cout << "Base::Func3()" << endl;}
private:int _d2 = 2;
};int main()
{Base b;Derive1 d1;Derive1 d11;Derive2 d2;return 0;
}

在图中我们d1和d11中的_vfptr指向同一张表,和d2指向的表又不相同,这正印证了我们所说的第二点: 同类型对象共用同一张虚表,不同类型对象有独立虚表

5.2 虚函数表

派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。

派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。

派生类的虚函数表中包含基类的虚函数地址、派生类重写后覆盖的虚函数地址以及派生类自己的虚函数地址三个部分。

**如果派生类对基类的虚函数进行重写,派生类的虚函数表会发生改变。**所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

5.3 多态的原理

当通过基类指针调用虚函数时:
1. 取指针指向对象的 vptr(虚表指针);

2. 从 vptr 指向的虚表中,找到对应虚函数的地址;

3. 调用该地址对应的函数(基类或派生类版本)。

多态的原理简单来说就是通过对象里的虚函数表指针,去找到其对应的虚函数表,子类对象的虚指针就指向子类的虚函数表,父类对象的虚指针就指向父类的虚函数表,进而调到对应的虚函数,实现多态。

这就是 "运行时多态" 的核心:函数地址不是编译时确定,而是运行时从虚表中查找。

6. 总结

C++ 多态的核心是 "通过基类指针 / 引用调用虚函数,运行时确定函数版本",其底层依赖虚函数表和虚表指针实现动态绑定。掌握多态需牢记:

三大实现条件:继承、基类指针 / 引用、虚函数重写;

关键细节:虚析构函数、override / final 关键字、纯虚函数与抽象类;

底层逻辑:虚表存储虚函数地址,vptr 指向虚表,动态绑定实现多态行为。

多态是 C++ 灵活性的核心体现,熟练运用多态能让代码更简洁、易扩展,也是通往高级编程和设计模式的必经之路。

结语

好好学习,天天向上!有任何问题请指正,谢谢观看!

http://www.dtcms.com/a/560926.html

相关文章:

  • 多实现类(如IService有ServiceA/ServiceB)的注入配置与获取
  • web自动化测试-Selenium04_iframe切换、窗口切换
  • 分类与回归算法(一)- 模型评价指标
  • 浙江十大建筑公司排名用v9做网站优化
  • 江门网站建设自助建站站内seo和站外seo区别
  • 嵌入式Linux:线程同步(自旋锁)
  • RHCE复习第一次作业
  • 2025年山西省职业院校技能大赛应用软件系统开发赛项竞赛样题
  • 铁路机车乘务员心理健康状况的研究进展
  • 人才市场官方网站装修公司网站平台
  • Flink 2.1 SQL:解锁实时数据与AI集成,实现可扩展流处理
  • 【软件安全】什么是AFL(American Fuzzy Lop)基于覆盖率引导的模糊测试工具?
  • 山西省最新干部调整佛山网站建设优化
  • 背包DP合集
  • Docker 拉取镜像:SSL 拦截与国内镜像源失效问题解决
  • full join优化改写经验
  • 软件测试:黑盒测试用例篇
  • 【Linux】Linux第一个小程序 - 进度条
  • ubuntu新增用户
  • 青州市网站建设长沙招聘网58同城招聘发布
  • 江苏中南建设集团网站是多少长沙互联网网站建设
  • 从零开始的云原生之旅(十一):压测实战:验证弹性伸缩效果
  • 民宿网站的建设wordpress gallery
  • 【开题答辩全过程】以 广州网红点打卡介绍网站为例,包含答辩的问题和答案
  • Taro 源码浅析
  • Chart.js 混合图:深度解析与应用技巧
  • redis 大key、热key优化技巧|空间存储优化|调优技巧(一)
  • 监视你的脚本:自动 Linux 活动审计
  • 15.1.2.linux常见操作用例
  • 【Java Web学习 | 第五篇】CSS(4) -盒子模型