C++进阶:(二)多态的深度解析

目录

前言

一、多态的概念:什么是多态?

[1.1 多态的通俗理解](#1.1 多态的通俗理解)

[1.2 多态的分类](#1.2 多态的分类)

[1.2.1 编译时多态(静态多态)](#1.2.1 编译时多态(静态多态))

[1.2.2 运行时多态(动态多态)](#1.2.2 运行时多态(动态多态))

二、多态的定义及实现:三大核心条件

[2.1 核心条件一:继承关系](#2.1 核心条件一:继承关系)

[2.2 核心条件二:虚函数与重写](#2.2 核心条件二:虚函数与重写)

[2.2.1 虚函数的定义](#2.2.1 虚函数的定义)

[2.2.2 虚函数的重写(覆盖)](#2.2.2 虚函数的重写(覆盖))

[2.3 核心条件三:基类的指针或引用调用虚函数](#2.3 核心条件三:基类的指针或引用调用虚函数)

[2.4 多态实现的完整示例](#2.4 多态实现的完整示例)

三、虚函数重写的进阶细节

[3.1 协变:返回值类型不同的特殊重写](#3.1 协变:返回值类型不同的特殊重写)

[3.1.1 协变的定义](#3.1.1 协变的定义)

协变的注意事项

[3.2 析构函数的重写:避免内存泄漏](#3.2 析构函数的重写:避免内存泄漏)

[3.3 override 和 final 关键字:增强重写的安全性](#3.3 override 和 final 关键字:增强重写的安全性)

[3.3.1 override:验证重写是否正确](#3.3.1 override:验证重写是否正确)

[3.3.2 final:禁止虚函数被重写](#3.3.2 final:禁止虚函数被重写)

[3.4 重载、重写、隐藏的区别(面试高频)](#3.4 重载、重写、隐藏的区别(面试高频))

示例:三者的直观对比

四、纯虚函数和抽象类

[4.1 纯虚函数的定义](#4.1 纯虚函数的定义)

[4.2 抽象类的特性](#4.2 抽象类的特性)

[4.3 抽象类的应用场景](#4.3 抽象类的应用场景)

五、多态的底层原理:虚函数表与动态绑定

[5.1 虚函数表指针(__vfptr)](#5.1 虚函数表指针(__vfptr))

[5.1.1 虚函数表指针的本质](#5.1.1 虚函数表指针的本质)

[5.1.2 包含虚函数的类的对象大小](#5.1.2 包含虚函数的类的对象大小)

[5.2 虚函数表(vtable)的结构](#5.2 虚函数表(vtable)的结构)

[5.2.1 基类的虚表](#5.2.1 基类的虚表)

[5.2.2 派生类的虚表](#5.2.2 派生类的虚表)

[5.2.3 虚表的存储位置](#5.2.3 虚表的存储位置)

[5.3 动态绑定与静态绑定](#5.3 动态绑定与静态绑定)

[5.3.1 静态绑定(Static Binding)](#5.3.1 静态绑定(Static Binding))

[5.3.2 动态绑定(Dynamic Binding)](#5.3.2 动态绑定(Dynamic Binding))

[5.4 多态的性能开销](#5.4 多态的性能开销)

六、多态的面试高频题解析

[6.1 选择题:虚函数重写与多态判断](#6.1 选择题:虚函数重写与多态判断)

[6.2 简答题:多态的实现条件](#6.2 简答题:多态的实现条件)

[6.3 简答题:虚函数表是什么?它存储在哪里?](#6.3 简答题:虚函数表是什么?它存储在哪里?)

[6.4 简答题:为什么析构函数要声明为虚函数?](#6.4 简答题:为什么析构函数要声明为虚函数?)

[6.5 编程题:利用多态实现计算器](#6.5 编程题:利用多态实现计算器)

总结


前言

在 C++ 面向对象编程的三大核心特性(封装、继承、多态)中,多态无疑是最具灵活性和扩展性的特性。它允许不同类的对象对同一消息做出不同响应,让代码更具通用性和可维护性,是实现设计模式、框架开发的基础。很多开发者在初学多态时,往往只能掌握表面用法,对其底层原理和进阶细节理解不深。本文将从概念定义出发,逐步深入多态的实现条件、核心机制、底层原理,结合大量实战代码和面试高频考点,全面解析 C++ 多态的方方面面,帮助大家真正吃透这一核心特性。下面就让我们正式开始吧!


一、多态的概念:什么是多态?

1.1 多态的通俗理解

多态(polymorphism),字面意思是 "多种形态"。在编程语境中,指的是同一个行为(函数调用),作用于不同的对象,会产生不同的执行结果。生活中处处可见多态的影子:

  • 买票行为:普通人买票全价、学生买票打折、军人买票优先,同样是 "买票" 操作,不同身份的人(不同对象)执行结果不同;
  • 动物叫声:猫叫是 "喵",狗叫是 "汪汪",同样是 "发声" 行为,不同动物(不同对象)表现形式不同;
  • 交通工具行驶:汽车在路上跑,飞机在天上飞,轮船在水里游,同样是 "移动" 行为,不同交通工具(不同对象)实现方式不同。

这种 "一个接口,多种实现" 的思想,正是多态的核心价值 ------ 它屏蔽了不同对象之间的差异,让开发者可以通过统一的方式调用不同对象的方法,极大简化了代码逻辑。

1.2 多态的分类

C++ 中的多态分为两大类:编译时多态(静态多态)运行时多态(动态多态),二者的核心区别在于 "行为确定的时机" 不同。

1.2.1 编译时多态(静态多态)

编译时多态是指在编译阶段就确定了函数的调用关系,行为结果在编译时已经明确。它的实现方式主要有两种:

  • 函数重载:同一作用域内,函数名相同但参数列表(参数类型、个数、顺序)不同的函数,编译器会根据实参类型匹配对应的函数;
  • **函数模板:**通过模板参数自动适配不同类型,编译时会为每种使用的类型生成对应的函数实例。

示例:函数重载实现静态多态

cpp 复制代码
#include <iostream>
using namespace std;

// 函数重载:参数类型不同
int Add(int a, int b) {
    cout << "int Add: ";
    return a + b;
}

double Add(double a, double b) {
    cout << "double Add: ";
    return a + b;
}

// 函数重载:参数个数不同
int Add(int a, int b, int c) {
    cout << "int Add(3 params): ";
    return a + b + c;
}

int main() {
    cout << Add(1, 2) << endl;         // 调用int Add(int, int)
    cout << Add(1.5, 2.5) << endl;     // 调用double Add(double, double)
    cout << Add(1, 2, 3) << endl;      // 调用int Add(int, int, int)
    return 0;
}

运行结果

cpp 复制代码
int Add: 3
double Add: 4
int Add(3 params): 6

静态多态的特点是效率高 (编译时确定调用地址,无运行时开销),但灵活性差(必须在编译时明确所有可能的行为,无法适应运行时动态变化的场景)。

1.2.2 运行时多态(动态多态)

运行时多态是指在程序运行阶段才确定函数的调用关系,行为结果取决于运行时的对象类型。它是 C++ 多态的核心,也是本文重点讲解的内容。

示例:运行时多态的直观体现

cpp 复制代码
#include <iostream>
using namespace std;

// 基类:人
class Person {
public:
    // 虚函数:买票
    virtual void BuyTicket() {
        cout << "普通人买票:全价" << endl;
    }
};

// 派生类:学生(继承自Person)
class Student : public Person {
public:
    // 重写基类虚函数
    virtual void BuyTicket() {
        cout << "学生买票:半价(硬座)/75折(高铁二等座)" << endl;
    }
};

// 派生类:军人(继承自Person)
class Soldier : public Person {
public:
    // 重写基类虚函数
    virtual void BuyTicket() {
        cout << "军人买票:优先购票" << endl;
    }
};

// 统一接口:调用买票行为
void DoBuyTicket(Person& people) {
    people.BuyTicket(); // 同一调用语句,不同对象表现不同
}

int main() {
    Person p;
    Student s;
    Soldier sol;
    
    DoBuyTicket(p);   // 输出:普通人买票:全价
    DoBuyTicket(s);   // 输出:学生买票:半价(硬座)/75折(高铁二等座)
    DoBuyTicket(sol); // 输出:军人买票:优先购票
    
    return 0;
}

运行结果

cpp 复制代码
普通人买票:全价
学生买票:半价(硬座)/75折(高铁二等座)
军人买票:优先购票

在这个示例中,DoBuyTicket函数接收Person类型的引用,但传入不同的派生类对象时,会执行对应的BuyTicket方法。这种 "同一接口,多种实现" 的效果,正是运行时多态的核心体现。它的特点是灵活性高 (支持动态扩展,新增派生类无需修改原有接口代码),但有轻微运行时开销(需要在运行时查找函数地址)。

二、多态的定义及实现:三大核心条件

想要实现 C++ 运行时多态,必须满足三个核心条件,缺一不可。很多开发者在使用多态时出现问题,本质上都是没有完全满足这三个条件。

2.1 核心条件一:继承关系

多态必须建立在类的继承体系之上,即存在基类(父类)和派生类(子类)的继承关系。派生类通过继承基类,获得基类的接口(虚函数),并可以根据自身需求重写该接口。

需要注意

  • 支持单一继承(一个派生类继承一个基类)和多重继承(一个派生类继承多个基类),但多重继承可能导致虚函数表复杂,需要谨慎使用;
  • 派生类必须是公有继承(public inheritance),才能保证基类的指针 / 引用可以访问派生类的虚函数(私有继承或保护继承会限制访问权限)。

2.2 核心条件二:虚函数与重写

2.2.1 虚函数的定义

虚函数是多态的**"开关"** ,在基类的成员函数前加上virtual关键字,该函数就成为虚函数。

语法格式

cpp 复制代码
class 基类名 {
public:
    virtual 返回值类型 函数名(参数列表) {
        // 函数实现
    }
};

注意事项

  • virtual关键字仅需在基类声明时添加,派生类重写时可加可不加,但建议加上,能够提高代码可读性;
  • 非成员函数(全局函数)、静态成员函数(static修饰)、构造函数不能声明为虚函数;
  • 析构函数可以(且建议)声明为虚函数,这是面试高频考点,后续会详细讲解。

2.2.2 虚函数的重写(覆盖)

虚函数的重写(也叫覆盖)是指:派生类中有一个与基类虚函数完全相同的函数,即满足 "三同" 原则:

  1. 函数名相同;
  2. 参数列表(参数类型、个数、顺序)相同;
  3. 返回值类型相同(协变情况除外,后续讲解)。

示例:虚函数重写的正确实现

cpp 复制代码
#include <iostream>
using namespace std;

class Animal {
public:
    // 基类虚函数
    virtual void Talk() const {
        cout << "动物发出声音" << endl;
    }
};

class Cat : public Animal {
public:
    // 重写基类虚函数:三同原则
    virtual void Talk() const {
        cout << "(>^ω^<)喵~" << endl;
    }
};

class Dog : public Animal {
public:
    // 重写基类虚函数:三同原则(派生类可省略virtual,但不推荐)
    void Talk() const { // 仍构成重写,因为继承了基类虚函数属性
        cout << "汪汪汪!" << endl;
    }
};

// 统一接口
void LetHear(const Animal& animal) {
    animal.Talk();
}

int main() {
    Cat cat;
    Dog dog;
    
    LetHear(cat); // 输出:(>^ω^<)喵~
    LetHear(dog); // 输出:汪汪汪!
    
    return 0;
}

常见错误:不满足三同原则,导致重写失败

cpp 复制代码
// 错误示例1:参数列表不同
class Animal {
public:
    virtual void Eat(string food) { // 参数为string类型
        cout << "动物吃" << food << endl;
    }
};

class Rabbit : public Animal {
public:
    virtual void Eat(const char* food) { // 参数为const char*类型,不满足三同
        cout << "兔子吃" << food << endl;
    }
};

// 错误示例2:返回值类型不同(非协变)
class Animal {
public:
    virtual int GetAge() { // 返回int类型
        return 0;
    }
};

class Bird : public Animal {
public:
    virtual double GetAge() { // 返回double类型,不满足三同(非协变)
        return 1.5;
    }
};

在上述错误的示例中,派生类的函数与基类虚函数不满足 "三同" 原则,无法构成重写,因此也就无法实现多态。

2.3 核心条件三:基类的指针或引用调用虚函数

多态的触发必须通过基类的指针基类的引用来调用虚函数,直接通过对象本身调用虚函数无法触发多态。

示例:不同调用方式的对比

cpp 复制代码
#include <iostream>
using namespace std;

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

class Student : public Person {
public:
    virtual void BuyTicket() {
        cout << "学生买票:半价" << endl;
    }
};

int main() {
    Person p;
    Student s;
    
    // 1. 直接通过对象调用:无多态
    p.BuyTicket(); // 输出:普通人买票:全价
    s.BuyTicket(); // 输出:学生买票:半价(这是直接调用派生类函数,非多态)
    
    // 2. 基类指针调用:触发多态
    Person* p1 = &p;
    Person* p2 = &s;
    p1->BuyTicket(); // 输出:普通人买票:全价
    p2->BuyTicket(); // 输出:学生买票:半价(多态生效)
    
    // 3. 基类引用调用:触发多态
    Person& r1 = p;
    Person& r2 = s;
    r1.BuyTicket(); // 输出:普通人买票:全价
    r2.BuyTicket(); // 输出:学生买票:半价(多态生效)
    
    return 0;
}

为什么必须用基类指针 / 引用?

  • 基类指针 / 引用具有 "兼容性":可以指向基类对象,也可以指向派生类对象(派生类对象包含基类部分);
  • 直接通过对象调用时,编译器会根据对象的静态类型(编译时确定的类型)调用函数,是无法体现动态多态的;
  • 通过基类指针 / 引用调用时,编译器会延迟到运行时,根据指针 / 引用实际指向的对象类型来调用对应的虚函数。

2.4 多态实现的完整示例

结合以上三个条件,下面给出一个完整的多态实现示例,涵盖继承、虚函数重写、基类指针 / 引用调用三个核心要素:

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 基类:交通工具
class Vehicle {
public:
    Vehicle(string name) : _name(name) {}
    
    // 虚函数:行驶
    virtual void Run() {
        cout << _name << ":正在行驶" << endl;
    }
    
    // 虚函数:鸣笛
    virtual void Honk() {
        cout << _name << ":鸣笛警告" << endl;
    }

protected:
    string _name; // 交通工具名称
};

// 派生类:汽车
class Car : public Vehicle {
public:
    Car(string name) : Vehicle(name) {}
    
    // 重写Run函数
    virtual void Run() {
        cout << _name << ":在公路上匀速行驶,速度60km/h" << endl;
    }
    
    // 重写Honk函数
    virtual void Honk() {
        cout << _name << ":嘀嘀嘀~" << endl;
    }
};

// 派生类:飞机
class Plane : public Vehicle {
public:
    Plane(string name) : Vehicle(name) {}
    
    // 重写Run函数
    virtual void Run() {
        cout << _name << ":在蓝天上飞行,高度10000米" << endl;
    }
    
    // 重写Honk函数
    virtual void Honk() {
        cout << _name << ":呜呜呜~(航空警报)" << endl;
    }
};

// 派生类:轮船
class Ship : public Vehicle {
public:
    Ship(string name) : Vehicle(name) {}
    
    // 重写Run函数
    virtual void Run() {
        cout << _name << ":在海面上航行,航向正东" << endl;
    }
    
    // 重写Honk函数
    virtual void Honk() {
        cout << _name << ":嘟嘟嘟~(雾笛)" << endl;
    }
};

// 统一接口:控制交通工具运行和鸣笛
void ControlVehicle(Vehicle* vehicle) {
    vehicle->Run();   // 多态调用Run函数
    vehicle->Honk();  // 多态调用Honk函数
    cout << "------------------------" << endl;
}

int main() {
    // 创建不同交通工具对象
    Car car("家用轿车");
    Plane plane("民航客机");
    Ship ship("远洋货轮");
    
    // 通过基类指针调用,触发多态
    ControlVehicle(&car);
    ControlVehicle(&plane);
    ControlVehicle(&ship);
    
    return 0;
}

运行结果如下

cpp 复制代码
家用轿车:在公路上匀速行驶,速度60km/h
家用轿车:嘀嘀嘀~
------------------------
民航客机:在蓝天上飞行,高度10000米
民航客机:呜呜呜~(航空警报)
------------------------
远洋货轮:在海面上航行,航向正东
远洋货轮:嘟嘟嘟~(雾笛)
------------------------

从运行结果可以看出,ControlVehicle函数通过基类指针Vehicle接收不同的派生类对象,却能正确调用各自的RunHonk方法,实现了 "一个接口,多种实现" 的多态效果。如果后续需要新增 "高铁""自行车" 等交通工具,只需新增派生类并重写虚函数,无需修改ControlVehicle*函数,这大大提高了代码的可扩展性。

三、虚函数重写的进阶细节

在实际开发和面试中,虚函数重写还有很多容易混淆的进阶细节,比如协变、析构函数重写、override 和 final 关键字等。这些细节既是重点,也是高频考点,大家也需要深入理解。

3.1 协变:返回值类型不同的特殊重写

我在前面提到,虚函数重写需要满足**"三同"** 原则,其中返回值类型必须相同。但存在一种特殊情况 ------协变,允许派生类虚函数的返回值类型与基类虚函数不同。

3.1.1 协变的定义

协变是指:基类虚函数返回基类对象的指针或引用派生类虚函数返回派生类对象的指针或引用(派生类是基类的子类)。这种情况下,即使返回值类型不同,也构成虚函数重写。

示例:协变的实现

cpp 复制代码
#include <iostream>
using namespace std;

// 基类:A
class A {};

// 派生类:B(继承自A)
class B : public A {};

// 基类:Person
class Person {
public:
    // 基类虚函数:返回A*
    virtual A* BuyTicket() {
        cout << "普通人买票:全价" << endl;
        return new A();
    }
};

// 派生类:Student
class Student : public Person {
public:
    // 派生类虚函数:返回B*(B是A的子类),构成协变
    virtual B* BuyTicket() {
        cout << "学生买票:半价" << endl;
        return new B();
    }
};

int main() {
    Person p;
    Student s;
    
    Person* p1 = &p;
    Person* p2 = &s;
    
    delete p1->BuyTicket(); // 输出:普通人买票:全价
    delete p2->BuyTicket(); // 输出:学生买票:半价(多态生效)
    
    return 0;
}
协变的注意事项
  • 协变仅支持返回值为**"指针" 或 "引用"**的情况,返回值为普通对象时不支持;
  • 派生类虚函数的返回值类型必须是基类虚函数返回值类型的子类(即存在继承关系);
  • 协变的实际应用场景较少,主要用于一些特殊的设计模式(如工厂模式),了解即可。

3.2 析构函数的重写:避免内存泄漏

析构函数的重写是面试中最高频的考点之一。很多开发者在使用多态时,容易忽略析构函数的虚函数声明,导致派生类对象的资源无法释放,引发内存泄漏。

下面通过一个问题场景来引入对析构函数重写的介绍------未声明虚析构函数

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    Base() {
        cout << "Base::构造函数" << endl;
        _p = new int[10]; // 动态分配内存
    }
    
    // 非虚析构函数
    ~Base() {
        cout << "Base::析构函数" << endl;
        delete[] _p; // 释放内存
    }

private:
    int* _p;
};

class Derive : public Base {
public:
    Derive() {
        cout << "Derive::构造函数" << endl;
        _q = new char[100]; // 动态分配内存
    }
    
    ~Derive() {
        cout << "Derive::析构函数" << endl;
        delete[] _q; // 释放内存
    }

private:
    char* _q;
};

int main() {
    Base* p = new Derive(); // 基类指针指向派生类对象
    delete p; // 释放对象
    
    return 0;
}

运行结果

cpp 复制代码
Base::构造函数
Derive::构造函数
Base::析构函数

问题分析

  • delete p时,由于基类Base的析构函数不是虚函数,编译器根据基类指针的静态类型(Base*)调用基类的析构函数;
  • 派生类Derive的析构函数没有被调用,导致**_q**指向的内存无法释放,引发内存泄漏。

解决方案:声明虚析构函数

将基类的析构函数声明为虚函数,派生类的析构函数会自动构成重写,从而触发多态析构

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    Base() {
        cout << "Base::构造函数" << endl;
        _p = new int[10];
    }
    
    // 虚析构函数
    virtual ~Base() {
        cout << "Base::析构函数" << endl;
        delete[] _p;
    }

private:
    int* _p;
};

class Derive : public Base {
public:
    Derive() {
        cout << "Derive::构造函数" << endl;
        _q = new char[100];
    }
    
    // 派生类析构函数:自动重写基类虚析构
    ~Derive() {
        cout << "Derive::析构函数" << endl;
        delete[] _q;
    }

private:
    char* _q;
};

int main() {
    Base* p = new Derive();
    delete p;
    
    return 0;
}

运行结果

cpp 复制代码
Base::构造函数
Derive::构造函数
Derive::析构函数
Base::析构函数

原理说明

  • 编译器会对析构函数的名称做特殊处理,编译后所有析构函数的名称统一为destructor
  • 基类析构函数声明为virtual后,派生类析构函数会自动重写该虚函数;
  • delete p时,通过基类指针调用虚析构函数,触发多态,先调用派生类析构函数(释放派生类资源),再调用基类析构函数(释放基类资源),避免内存泄漏。

因此,只要类可能被继承,并且可能通过基类指针删除派生类对象,就必须将基类的析构函数声明为虚函数;若类不会被继承,或不会通过基类指针删除派生类对象,可不用声明虚析构函数(因为虚函数会增加对象内存开销)。

3.3 override 和 final 关键字:增强重写的安全性

C++11 引入了overridefinal两个关键字,用于解决虚函数重写中的 "隐式错误",增强代码的可读性和安全性。

3.3.1 override:验证重写是否正确

override关键字用于派生类的虚函数,表示该函数意图重写基类的虚函数。编译器会检查是否满足重写条件,若不满足则编译报错。

示例:override 的使用

cpp 复制代码
#include <iostream>
using namespace std;

class Car {
public:
    // 基类虚函数:注意函数名是Drive(拼写正确)
    virtual void Drive() {
        cout << "汽车:基本行驶功能" << endl;
    }
};

class Benz : public Car {
public:
    // 意图重写Drive函数,但拼写错误(Dirve)
    virtual void Dirve() override { // 编译报错:没有重写任何基类方法
        cout << "奔驰:舒适行驶" << endl;
    }
};

int main() {
    Benz benz;
    return 0;
}

VS 编译器下的编译错误信息

cpp 复制代码
error C3668: "Benz::Dirve": 包含重写说明符"override"的方法没有重写任何基类方法

作用

  • 强制编译器验证重写条件(三同原则),避免因拼写错误、参数不匹配等导致的重写失败;
  • 明确告知其他开发者该函数是重写基类的虚函数,提高代码可读性。

3.3.2 final:禁止虚函数被重写

final关键字用于基类的虚函数,表示该函数禁止被任何派生类重写;也可用于类,表示该类禁止被继承。

示例 1:禁止虚函数被重写

cpp 复制代码
#include <iostream>
using namespace std;

class Car {
public:
    // final修饰虚函数:禁止派生类重写
    virtual void Drive() final {
        cout << "汽车:基本行驶功能" << endl;
    }
};

class BMW : public Car {
public:
    // 试图重写被final修饰的函数,编译报错
    virtual void Drive() {
        cout << "宝马:操控性行驶" << endl;
    }
};

int main() {
    BMW bmw;
    return 0;
}

VS 编译器下的编译错误信息

cpp 复制代码
error C3248: "Car::Drive": 声明为"final"的函数无法被"BMW::Drive"重写

示例 2:禁止类被继承

cpp 复制代码
#include <iostream>
using namespace std;

// final修饰类:禁止被继承
class Car final {
public:
    virtual void Drive() {
        cout << "汽车:基本行驶功能" << endl;
    }
};

// 试图继承被final修饰的类,编译报错
class Audi : public Car {
public:
    virtual void Drive() {
        cout << "奥迪:科技感行驶" << endl;
    }
};

int main() {
    Audi audi;
    return 0;
}

VS 编译器下的编译错误信息

cpp 复制代码
error C3246: "Audi": 无法从"Car"继承,因为它已被声明为"final"

作用

  • 限制虚函数的重写或类的继承,避免不必要的扩展,保证代码的稳定性;
  • 明确告知其他开发者该函数 / 类不允许被修改,提高代码可维护性。

3.4 重载、重写、隐藏的区别(面试高频)

C++ 中函数的重载、重写、隐藏是三个容易混淆的概念,面试中经常会以选择题或简答题的形式考察。通过下面这张图来详细对比三者的区别:

示例:三者的直观对比
cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    // 1. 重载:同一作用域,参数列表不同
    void Func(int a) {
        cout << "Base::Func(int): " << a << endl;
    }
    
    void Func(double a) {
        cout << "Base::Func(double): " << a << endl;
    }
    
    // 虚函数:用于重写
    virtual void Show() {
        cout << "Base::Show()" << endl;
    }
    
    // 普通函数:用于隐藏
    void Display() {
        cout << "Base::Display()" << endl;
    }
};

class Derive : public Base {
public:
    // 2. 重写:基类虚函数,三同原则
    virtual void Show() {
        cout << "Derive::Show()" << endl;
    }
    
    // 3. 隐藏:与基类同名,不满足重写条件
    void Display() {
        cout << "Derive::Display()" << endl;
    }
    
    // 隐藏:与基类Func同名,但参数列表不同(非重载,因为作用域不同)
    void Func(const char* str) {
        cout << "Derive::Func(const char*): " << str << endl;
    }
};

int main() {
    Derive d;
    
    // 测试重载:调用Base类的重载函数
    d.Func(10);        // 输出:Base::Func(int): 10
    d.Func(3.14);      // 输出:Base::Func(double): 3.14
    
    // 测试隐藏:调用Derive类的Func(隐藏基类Func)
    d.Func("hello");   // 输出:Derive::Func(const char*): hello
    
    // 测试重写:基类指针指向派生类,多态调用
    Base* p = &d;
    p->Show();         // 输出:Derive::Show()(重写,多态)
    
    // 测试隐藏:基类指针调用基类Display,派生类对象调用派生类Display
    p->Display();      // 输出:Base::Display()(隐藏,静态绑定)
    d.Display();       // 输出:Derive::Display()(隐藏,静态绑定)
    
    return 0;
}

运行结果

cpp 复制代码
Base::Func(int): 10
Base::Func(double): 3.14
Derive::Func(const char*): hello
Derive::Show()
Base::Display()
Derive::Display()

结论

  • 重载看**"同一作用域 + 参数不同"**;
  • 重写看 "不同作用域 + 三同 + 虚函数"
  • 隐藏看**"不同作用域 + 同名 + 非重写"**;
  • 只有重写能触发多态,重载和隐藏都是静态绑定。

四、纯虚函数和抽象类

在实际开发中,有些基类只需要定义接口,不需要实现具体功能,具体功能由派生类实现。这时就需要用到纯虚函数和抽象类。

4.1 纯虚函数的定义

纯虚函数是指在基类中声明的、没有具体实现 的虚函数,语法格式为在虚函数声明后加上**=0**。

语法格式

cpp 复制代码
class 基类名 {
public:
    virtual 返回值类型 函数名(参数列表) = 0; // 纯虚函数
};

需要注意以下两点

  • 纯虚函数不需要实现(语法上允许提供实现,但无实际意义);
  • 包含纯虚函数的类称为抽象类

4.2 抽象类的特性

抽象类是一种特殊的类,具有以下核心特性:

  1. 抽象类不能实例化对象(编译报错);
  2. 派生类 必须重写 抽象类中的所有纯虚函数,否则派生类也会成为抽象类,无法实例化;
  3. 抽象类可以定义普通成员函数和成员变量;
  4. 抽象类的指针 / 引用可以指向其派生类对象(用于实现多态)。

示例:纯虚函数和抽象类的使用

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 抽象类:形状(包含纯虚函数)
class Shape {
public:
    Shape(string name) : _name(name) {}
    
    // 纯虚函数:计算面积(仅声明,无实现)
    virtual double CalculateArea() = 0;
    
    // 纯虚函数:计算周长(仅声明,无实现)
    virtual double CalculatePerimeter() = 0;
    
    // 普通成员函数:显示形状名称
    void ShowName() {
        cout << "形状:" << _name << endl;
    }

protected:
    string _name;
};

// 派生类:圆形(重写所有纯虚函数)
class Circle : public Shape {
public:
    Circle(string name, double radius) : Shape(name), _radius(radius) {}
    
    // 重写纯虚函数:计算面积(圆面积=πr²)
    virtual double CalculateArea() {
        return 3.14159 * _radius * _radius;
    }
    
    // 重写纯虚函数:计算周长(圆周长=2πr)
    virtual double CalculatePerimeter() {
        return 2 * 3.14159 * _radius;
    }

private:
    double _radius; // 半径
};

// 派生类:矩形(重写所有纯虚函数)
class Rectangle : public Shape {
public:
    Rectangle(string name, double length, double width) 
        : Shape(name), _length(length), _width(width) {}
    
    // 重写纯虚函数:计算面积(矩形面积=长×宽)
    virtual double CalculateArea() {
        return _length * _width;
    }
    
    // 重写纯虚函数:计算周长(矩形周长=2×(长+宽))
    virtual double CalculatePerimeter() {
        return 2 * (_length + _width);
    }

private:
    double _length; // 长
    double _width;  // 宽
};

// 派生类:三角形(未重写所有纯虚函数,仍为抽象类)
class Triangle : public Shape {
public:
    Triangle(string name, double a, double b, double c) 
        : Shape(name), _a(a), _b(b), _c(c) {}
    
    // 仅重写一个纯虚函数,另一个未重写
    virtual double CalculatePerimeter() {
        return _a + _b + _c;
    }

private:
    double _a, _b, _c; // 三边长
};

// 统一接口:计算并显示形状的面积和周长
void ShowShapeInfo(Shape* shape) {
    shape->ShowName();
    cout << "面积:" << shape->CalculateArea() << endl;
    cout << "周长:" << shape->CalculatePerimeter() << endl;
    cout << "------------------------" << endl;
}

int main() {
    // 1. 抽象类不能实例化对象(编译报错)
    // Shape shape("未知形状"); 
    
    // 2. 派生类(重写所有纯虚函数)可以实例化
    Circle circle("圆形", 5.0);
    Rectangle rect("矩形", 4.0, 6.0);
    
    // 3. 未重写所有纯虚函数的派生类不能实例化(编译报错)
    // Triangle tri("三角形", 3.0, 4.0, 5.0);
    
    // 4. 抽象类指针指向派生类对象,实现多态
    ShowShapeInfo(&circle);
    ShowShapeInfo(&rect);
    
    return 0;
}

运行结果

cpp 复制代码
形状:圆形
面积:78.5397
周长:31.4159
------------------------
形状:矩形
面积:24
周长:20
------------------------

4.3 抽象类的应用场景

抽象类的核心价值是定义统一接口,强制派生类实现特定功能,常见应用场景包括:

  1. **框架开发:**定义框架的核心接口,具体实现由用户自定义的派生类完成;
  2. **设计模式:**如工厂模式、策略模式等,通过抽象类定义产品或策略的接口;
  3. **团队协作:**统一代码规范,确保不同开发者实现的派生类都包含必要的功能接口。

例如,在图形处理软件中,抽象类Shape定义了所有图形必须具备的 "计算面积" 和 "计算周长" 接口,后续新增 "正方形""椭圆形" 等图形时,只需继承Shape并重写纯虚函数,即可无缝集成到现有代码中,无需修改原有接口逻辑。

五、多态的底层原理:虚函数表与动态绑定

很多人在使用多态的时候,只知道 "怎么用",却不知道 "为什么能这样用"。理解多态的底层原理,不仅能帮助我们更灵活地使用多态,也是面试中的核心考点(比如 "虚函数表是什么?""动态绑定的过程是怎样的?"等)。

5.1 虚函数表指针(__vfptr)

在 C++ 中,当一个类包含虚函数时,编译器会为该类的每个对象添加一个隐藏的成员变量 ------虚函数表指针(virtual function table pointer) ,简写为**__vfptr**。

5.1.1 虚函数表指针的本质

  • __vfptr是一个指针,指向一个存储虚函数地址的数组,这个数组称为虚函数表(virtual function table) ,简称虚表(vtable)
  • 一个类的所有对象共用同一张虚表(虚表存储在代码段 / 常量区,而非对象中);
  • 虚函数表指针的大小为 4 字节(32 位系统)或 8 字节(64 位系统),独立于类的其他成员变量。

5.1.2 包含虚函数的类的对象大小

下面通过示例验证包含虚函数的类的对象大小(以 32 位系统为例):

cpp 复制代码
#include <iostream>
using namespace std;

// 普通类(无虚函数)
class Base1 {
public:
    void Func() {}
private:
    int _a = 1;
    char _ch = 'x';
};

// 包含虚函数的类
class Base2 {
public:
    virtual void Func() {}
private:
    int _a = 1;
    char _ch = 'x';
};

int main() {
    cout << "Base1对象大小:" << sizeof(Base1) << endl; // 输出:8(int占4字节,char占1字节,内存对齐为8字节)
    cout << "Base2对象大小:" << sizeof(Base2) << endl; // 输出:12(4字节__vfptr + 4字节int + 1字节char,内存对齐为12字节)
    
    return 0;
}

运行结果(32 位系统):

cpp 复制代码
Base1对象大小:8
Base2对象大小:12

分析

  • Base1无虚函数,对象大小由成员变量决定(int 4 字节 + char 1 字节,内存对齐为 8 字节);
  • Base2包含虚函数,对象大小 = 虚函数表指针(4 字节) + 成员变量大小(8 字节),内存对齐后为 12 字节。

这证明了包含虚函数的类的对象中,确实存在一个隐藏的虚函数表指针。

5.2 虚函数表(vtable)的结构

虚函数表是一个存储虚函数地址的数组,其结构取决于类的继承关系和虚函数重写情况。

5.2.1 基类的虚表

基类包含虚函数时,编译器会为其生成一张虚表,虚表中存储基类所有虚函数的地址。

示例:基类虚表结构

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    virtual void Func1() { cout << "Base::Func1" << endl; }
    virtual void Func2() { cout << "Base::Func2" << endl; }
private:
    int _a = 1;
};

int main() {
    Base b;
    // 通过内存查看虚表结构(32位系统)
    // b的内存布局:__vfptr(4字节) + _a(4字节)
    // __vfptr指向虚表,虚表中存储Func1和Func2的地址,末尾可能有nullptr标记(编译器相关)
    return 0;
}

基类虚表的结构示意如下

cpp 复制代码
Base的虚表(vtable for Base):
[0] → &Base::Func1
[1] → &Base::Func2
[2] → nullptr(VS编译器标记,g++无此标记)

5.2.2 派生类的虚表

派生类继承基类后,会继承基类的虚表指针和虚表。当派生类重写基类的虚函数时,会将虚表中对应基类虚函数的地址替换为派生类重写函数的地址;同时,派生类新增的虚函数会被添加到虚表的末尾。

示例:派生类虚表结构

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    virtual void Func1() { cout << "Base::Func1" << endl; }
    virtual void Func2() { cout << "Base::Func2" << endl; }
private:
    int _a = 1;
};

class Derive : public Base {
public:
    // 重写基类Func1
    virtual void Func1() { cout << "Derive::Func1" << endl; }
    // 新增虚函数Func3
    virtual void Func3() { cout << "Derive::Func3" << endl; }
private:
    int _b = 2;
};

int main() {
    Derive d;
    // d的内存布局:__vfptr(4字节) + 继承的_a(4字节) + 自身的_b(4字节)
    // __vfptr指向派生类的虚表,虚表中Func1被替换为Derive::Func1,新增Func3
    return 0;
}

派生类虚表的结构示意如下

cpp 复制代码
Derive的虚表(vtable for Derive):
[0] → &Derive::Func1(重写,替换基类Func1地址)
[1] → &Base::Func2(未重写,继承基类Func2地址)
[2] → &Derive::Func3(新增,添加到虚表末尾)
[3] → nullptr(VS编译器标记)

5.2.3 虚表的存储位置

虚函数表存储在代码段(常量区),而非堆或栈中。可以通过以下代码验证:

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    virtual void Func1() {}
};

class Derive : public Base {
public:
    virtual void Func1() {}
};

int main() {
    // 栈:局部变量
    int i = 0;
    // 静态区:静态变量
    static int j = 1;
    // 堆:动态分配内存
    int* pHeap = new int;
    // 常量区:字符串常量
    const char* pConst = "hello";
    
    Base b;
    Derive d;
    Base* pBase = &b;
    Derive* pDerive = &d;
    
    // 输出各区域地址
    cout << "栈地址:" << &i << endl;
    cout << "静态区地址:" << &j << endl;
    cout << "堆地址:" << pHeap << endl;
    cout << "常量区地址:" << (void*)pConst << endl;
    cout << "Base虚表地址:" << *(int*)pBase << endl; // 虚表指针指向的地址即虚表地址
    cout << "Derive虚表地址:" << *(int*)pDerive << endl;
    
    delete pHeap;
    return 0;
}

运行结果(32 位系统):

cpp 复制代码
栈地址:0x0019FF3C
静态区地址:0x0041D000
堆地址:0x0042D740
常量区地址:0x0041ABA4
Base虚表地址:0x0041AB44
Derive虚表地址:0x0041AB84

我们也可以通过VS中的监视窗口和内存窗口进行观察:

分析

  • 虚表地址(0x0041AB44、0x0041AB84)与常量区地址(0x0041ABA4)非常接近,说明虚表存储在代码段 / 常量区;
  • 栈、堆、静态区的地址与虚表地址差异较大,进一步验证了虚表的存储位置。

5.3 动态绑定与静态绑定

多态的实现本质是动态绑定 ,而普通函数调用是静态绑定。二者的核心区别在于函数地址的确定时机。

5.3.1 静态绑定(Static Binding)

静态绑定是指在编译阶段就确定函数的调用地址,直接生成函数调用指令。

适用场景

  • 普通函数调用;
  • 静态成员函数调用;
  • 通过对象直接调用虚函数(非基类指针 / 引用);
  • 不满足多态条件的函数调用。

示例:静态绑定的汇编代码分析

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    void Func() { cout << "Base::Func" << endl; } // 普通函数
};

int main() {
    Base b;
    b.Func(); // 静态绑定
    return 0;
}

对应的汇编代码(VS 编译器,32 位)如下:

cpp 复制代码
0041141E  lea         ecx,[b]
00411421  call        Base::Func (04110ACh) // 直接调用Func的地址04110AC

分析 :编译时直接确定Func的地址(04110AC),生成call指令调用该地址,无运行时开销。

5.3.2 动态绑定(Dynamic Binding)

动态绑定是指在运行阶段通过虚函数表指针查找虚函数地址,再调用函数。

适用场景:通过基类指针 / 引用调用虚函数(满足多态条件)。

示例:动态绑定的汇编代码分析

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    virtual void Func() { cout << "Base::Func" << endl; } // 虚函数
};

class Derive : public Base {
public:
    virtual void Func() { cout << "Derive::Func" << endl; } // 重写虚函数
};

void CallFunc(Base* p) {
    p->Func(); // 动态绑定
}

int main() {
    Derive d;
    CallFunc(&d);
    return 0;
}

对应的汇编代码(VS 编译器,32 位)如下:

cpp 复制代码
void CallFunc(Base* p) {
00411450  push        ebp
00411451  mov         ebp,esp
00411453  push        esi
00411454  mov         esi,dword ptr [p] // esi = p(基类指针)
    p->Func();
00411457  mov         eax,dword ptr [esi] // eax = p->__vfptr(虚表指针)
00411459  mov         edx,dword ptr [eax] // edx = 虚表[0](Func的地址)
0041145B  mov         ecx,dword ptr [p] // ecx = p(this指针)
0041145E  call        edx // 调用edx中的地址(Derive::Func)
00411460  pop         esi
00411461  pop         ebp
00411462  ret
}

动态绑定的过程如下

  1. 取出基类指针p指向的对象中的虚表指针**__vfptr**(*eax = p);
  2. 从虚表中取出对应的虚函数地址(*edx = eax,即虚表第 0 个元素);
  3. 调用该地址对应的函数(call edx);
  4. 由于p指向Derive对象,虚表中存储的是Derive::Func的地址,因此最终调用Derive::Func

这就是多态的底层实现原理:通过虚函数表指针和虚表,在运行时动态查找函数地址,实现 "同一接口,多种实现"。

5.4 多态的性能开销

多态的灵活性是以轻微的性能开销为代价的,主要体现在两个方面:

  1. 内存开销:包含虚函数的类的每个对象都会增加一个虚函数表指针(4/8 字节);
  2. 时间开销 :动态绑定需要在运行时查找虚表,比静态绑定多两次指针间接访问(__vfptr → 虚表 → 虚函数地址)。

但在大多数场景下,这种开销是可以忽略不计的。只有在对性能要求极高的核心模块(如高频调用的函数),才需要考虑是否使用多态。

六、多态的面试高频题解析

多态是 C++ 面试的重中之重,以下整理了常见的面试题及详细解析,帮助读者应对面试。

6.1 选择题:虚函数重写与多态判断

题目:以下程序的输出结果是什么?( )

cpp 复制代码
#include <iostream>
using namespace std;

class A {
public:
    virtual void func(int val = 1) { cout << "A->" << val << endl; }
    virtual void test() { func(); }
};

class B : public A {
public:
    void func(int val = 0) { cout << "B->" << val << endl; }
};

int main() {
    B* p = new B;
    p->test();
    delete p;
    return 0;
}

选项:A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确

解析

  1. A::func是虚函数,B::funcA::func满足三同原则(函数名、参数列表、返回值相同),构成重写;
  2. pB类型,调用test()函数(继承自A*),test()中调用func()
  3. func()是虚函数,通过this指针(B类型)调用,触发多态,调用B::func*;
  4. 虚函数的默认参数是由基类函数声明 决定的(静态绑定),而非派生类。A::func的默认参数是 1,因此val的值为 1;
  5. 最终输出B->1

因此最终答案为:B

6.2 简答题:多态的实现条件

题目:C++ 中运行时多态的实现需要满足哪些条件?

解析

  1. 存在继承关系(基类和派生类,公有继承);
  2. 基类声明虚函数virtual修饰);
  3. 派生类重写基类的虚函数(满足三同原则,协变除外);
  4. 通过基类的指针或引用调用虚函数。

6.3 简答题:虚函数表是什么?它存储在哪里?

题目:什么是虚函数表?虚函数表存储在内存的哪个区域?

解析

  1. 虚函数表(vtable)是一个存储虚函数地址的指针数组,由编译器自动生成;
  2. 包含虚函数的类的每个对象都会有一个虚函数表指针(__vfptr),指向该类的虚表;
  3. 虚函数表存储在代码段(常量区),而非堆、栈或静态区;
  4. 同一类的所有对象共用同一张虚表,不同类(基类和派生类)有各自独立的虚表。

6.4 简答题:为什么析构函数要声明为虚函数?

题目:为什么基类的析构函数建议声明为虚函数?如果不声明会有什么问题?

解析

  1. 目的:避免内存泄漏。当通过基类指针删除派生类对象时,确保派生类的析构函数被调用;
  2. 问题:若基类析构函数不是虚函数,删除基类指针指向的派生类对象时,编译器会根据基类指针的静态类型调用基类析构函数,派生类析构函数不会被调用,导致派生类中动态分配的资源无法释放,引发内存泄漏;
  3. 原理:基类析构函数声明为虚函数后,派生类析构函数会自动重写该虚函数(编译器统一处理析构函数名称),删除对象时触发多态,先调用派生类析构函数,再调用基类析构函数。

6.5 编程题:利用多态实现计算器

题目:利用 C++ 多态实现一个计算器,支持加法、减法、乘法、除法运算,要求可以灵活扩展新的运算(如取模、平方)。

解析

  1. 定义抽象基类Calculator,声明纯虚函数Calculate(统一接口);
  2. 定义派生类Add、Subtract、Multiply、Divide,分别重写Calculate函数,实现对应运算;
  3. 新增运算时,只需新增派生类并重写Calculate,无需修改原有代码。

实现代码

cpp 复制代码
#include <iostream>
#include <stdexcept>
using namespace std;

// 抽象基类:计算器
class Calculator {
public:
    Calculator(double a, double b) : _a(a), _b(b) {}
    virtual ~Calculator() {}
    
    // 纯虚函数:计算接口
    virtual double Calculate() const = 0;
    
    // 获取操作数
    double GetA() const { return _a; }
    double GetB() const { return _b; }

protected:
    double _a; // 操作数1
    double _b; // 操作数2
};

// 派生类:加法
class Add : public Calculator {
public:
    Add(double a, double b) : Calculator(a, b) {}
    virtual double Calculate() const {
        return GetA() + GetB();
    }
};

// 派生类:减法
class Subtract : public Calculator {
public:
    Subtract(double a, double b) : Calculator(a, b) {}
    virtual double Calculate() const {
        return GetA() - GetB();
    }
};

// 派生类:乘法
class Multiply : public Calculator {
public:
    Multiply(double a, double b) : Calculator(a, b) {}
    virtual double Calculate() const {
        return GetA() * GetB();
    }
};

// 派生类:除法
class Divide : public Calculator {
public:
    Divide(double a, double b) : Calculator(a, b) {
        if (b == 0) {
            throw invalid_argument("除数不能为0");
        }
    }
    virtual double Calculate() const {
        return GetA() / GetB();
    }
};

// 派生类:取模(新增运算,无需修改原有代码)
class Mod : public Calculator {
public:
    Mod(int a, int b) : Calculator(a, b) {
        if (b == 0) {
            throw invalid_argument("模数不能为0");
        }
    }
    virtual double Calculate() const {
        return static_cast<int>(GetA()) % static_cast<int>(GetB());
    }
};

// 统一接口:执行计算并输出结果
void DoCalculate(const Calculator& calc, const string& opName) {
    cout << calc.GetA() << " " << opName << " " << calc.GetB() << " = " << calc.Calculate() << endl;
}

int main() {
    try {
        Add add(10, 5);
        Subtract sub(10, 5);
        Multiply mul(10, 5);
        Divide div(10, 5);
        Mod mod(10, 3);
        
        DoCalculate(add, "+");    // 输出:10 + 5 = 15
        DoCalculate(sub, "-");    // 输出:10 - 5 = 5
        DoCalculate(mul, "*");    // 输出:10 * 5 = 50
        DoCalculate(div, "/");    // 输出:10 / 5 = 2
        DoCalculate(mod, "%");    // 输出:10 % 3 = 1
        
        // 测试除数为0的异常
        // Divide div2(10, 0);
    } catch (const exception& e) {
        cout << "错误:" << e.what() << endl;
    }
    
    return 0;
}

运行结果

cpp 复制代码
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
10 / 5 = 2
10 % 3 = 1

总结

多态是 C++ 面向对象编程的灵魂,掌握多态的概念、实现和原理,不仅能写出更灵活、可维护的代码,也是成为高级 C++ 开发者的必备技能。建议大家结合本文的示例代码反复练习,深入理解每个细节,尤其是虚函数表和动态绑定的底层逻辑,应对面试时才能游刃有余。我们下期再见!

相关推荐
CsharpDev-奶豆哥7 小时前
JavaScript性能优化实战大纲
开发语言·javascript·性能优化
小妖同学学AI8 小时前
Rust 深度解析:变量、可变性与所有权的“安全边界”
开发语言·安全·rust
2301_764441338 小时前
基于python构建的低温胁迫实验
开发语言·python
ICT系统集成阿祥8 小时前
华为CloudEngine系列交换机堆叠如何配置,附视频
开发语言·华为·php
wjs20248 小时前
C++ 基本语法
开发语言
金色熊族9 小时前
装饰器模式(c++版)
开发语言·c++·设计模式·装饰器模式
七夜zippoe9 小时前
仓颉语言核心特性深度解析——现代编程范式的集大成者
开发语言·后端·鸿蒙·鸿蒙系统·仓颉
四谎真好看9 小时前
Java 黑马程序员学习笔记(进阶篇21)
java·开发语言·笔记·学习·学习笔记
Dream it possible!9 小时前
LeetCode 面试经典 150_链表_旋转链表(64_61_C++_中等)
c++·leetcode·链表·面试