【C++游记】物种多样——谓之多态

  

枫の个人主页

你不能改变过去,但你可以改变未来

算法/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 多态的实现过程

当通过基类指针/引用调用虚函数时,多态的实现过程如下:

  1. 通过基类指针/引用访问对象的虚表指针(vptr)
  2. 通过vptr找到该对象所属类的虚函数表(vtable)
  3. 在虚函数表中找到对应虚函数的地址
  4. 调用该地址指向的函数(即派生类重写后的函数)
// 多态底层原理示例
#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++的学习很陡,时而巨难时而巨简单,希望宝子们和小枫一起坚持下去~你们的三连就是小枫的动力,感谢支持~

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

枫の大一

谢谢大佬,我会三连你的文章

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值