C++ 多态详解:从概念到虚表底层原理(代码轰炸)

本文是 C++ 面向对象系列的第三篇,前两篇分别讲了继承的构造析构顺序和虚继承的菱形问题。多态是面向对象三大特性里最有"魔法感"的一个,但魔法背后都有代价------本篇从语法讲到底层虚表,把这个代价和机制讲清楚。

目录

​编辑

一、多态是什么

二、多态的构成条件

[2.1 虚函数](#2.1 虚函数)

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

[2.3 为什么必须是指针或引用](#2.3 为什么必须是指针或引用)

三、虚函数重写的几个细节

[3.1 协变(了解即可)](#3.1 协变(了解即可))

[3.2 析构函数的重写(重要,面试高频)](#3.2 析构函数的重写(重要,面试高频))

[3.3 override 和 final(C++11,实际工程常用)](#3.3 override 和 final(C++11,实际工程常用))

四、重载、重写、隐藏的区别(经常考)

五、纯虚函数和抽象类

六、多态的底层原理:虚函数表

[6.1 虚函数表指针(vfptr)](#6.1 虚函数表指针(vfptr))

[6.2 派生类的虚表是怎么建立的](#6.2 派生类的虚表是怎么建立的)

[6.3 多态调用的底层执行过程](#6.3 多态调用的底层执行过程)

[6.4 一道经典面试题解析](#6.4 一道经典面试题解析)

七、总结



一、多态是什么

多态(polymorphism)字面意思是"多种形态"。C++ 里分两类:

编译时多态(静态多态):函数重载、函数模板。参数不同,调用不同的函数,绑定发生在编译期。

运行时多态(动态多态):同一个函数调用,根据对象的实际类型,在运行时决定调用哪个版本。这是本篇重点。

用买票举例最直观:同样是 BuyTicket() 这个动作,传进来的是普通人就全价,是学生就打折,是军人就优先。行为随对象类型变化,这就是运行时多态。

cpp 复制代码
namespace Jianyi
{
    class Person
    {
    public:
        virtual void BuyTicket()
        {
            std::cout << "Person::买票 - 全价" << std::endl;
        }
    };

    class Student : public Person
    {
    public:
        virtual void BuyTicket() override
        {
            std::cout << "Student::买票 - 打折" << std::endl;
        }
    };

    class Soldier : public Person
    {
    public:
        virtual void BuyTicket() override
        {
            std::cout << "Soldier::买票 - 优先" << std::endl;
        }
    };

    // 注意:参数类型是基类指针,不是具体类型
    void Func(Person* ptr)
    {
        ptr->BuyTicket(); // 调用谁的版本,取决于 ptr 指向谁
    }
}

int main()
{
    Jianyi::Person  ps;
    Jianyi::Student st;
    Jianyi::Soldier sr;

    Jianyi::Func(&ps); // Person::买票 - 全价
    Jianyi::Func(&st); // Student::买票 - 打折
    Jianyi::Func(&sr); // Soldier::买票 - 优先

    return 0;
}

输出:

复制代码
Person::买票 - 全价
Student::买票 - 打折
Soldier::买票 - 优先

同一个 Func,同一行 ptr->BuyTicket(),三次调用结果不同。这就是多态。


二、多态的构成条件

运行时多态要成立,必须同时满足两个条件,缺一不可:

  1. 通过基类的指针或引用调用函数
  2. 被调用的函数是虚函数,且派生类完成了重写(覆盖)

这两个条件不是随意规定的,后面讲虚表原理时会看到,这两条正好对应底层的两个机制。

2.1 虚函数

在类的成员函数前加 virtual,它就成了虚函数:

cpp 复制代码
class Person
{
public:
    virtual void BuyTicket() { std::cout << "全价" << std::endl; }
};

几个注意点:

  • virtual 只能修饰成员函数,普通函数、静态函数不能加
  • 构造函数不能是虚函数(构造时对象还没建好,虚表机制还没生效)
  • 析构函数建议virtual,后面专门讲原因

2.2 虚函数的重写(覆盖)

派生类中存在一个与基类虚函数返回值类型、函数名、参数列表完全相同的函数,就构成重写:

cpp 复制代码
namespace Jianyi
{
    class Base
    {
    public:
        virtual void func(int x) { std::cout << "Base::func " << x << std::endl; }
    };

    class Derive : public Base
    {
    public:
        // 构成重写:三要素完全相同
        virtual void func(int x) override { std::cout << "Derive::func " << x << std::endl; }
    };
}

有一个容易踩的坑:派生类重写时可以不写 virtual ,因为基类的虚函数被继承下来后,在派生类里依然保持虚函数属性。但这样写不规范,而且考试选择题里经常用这个设坑,让你判断是否构成多态。建议养成习惯,重写时显式加上 virtual

2.3 为什么必须是指针或引用

这是很多人没想清楚的点。直接用对象调用会怎样?

cpp 复制代码
namespace Jianyi
{
    void FuncByValue(Person p)   // 传值
    {
        p.BuyTicket(); // 永远调用 Person 的版本,多态失效
    }

    void FuncByRef(Person& p)    // 引用,多态生效
    {
        p.BuyTicket();
    }

    void FuncByPtr(Person* p)    // 指针,多态生效
    {
        p->BuyTicket();
    }
}

传值的问题是对象切片(object slicing) :把 Student 传给 Person 形参时,多出来的 Student 部分被切掉了,剩下的只是一个 Person 对象,虚表指针也变成了 Person 的,自然调不到 Student 的版本。

指针和引用不会拷贝对象本身,它们只是"指向"那个对象,对象的实际类型不变,虚表也不变,多态才能发挥作用。


三、虚函数重写的几个细节

3.1 协变(了解即可)

重写有一个例外:协变 。派生类重写基类虚函数时,如果返回值是基类指针/引用 → 派生类指针/引用,也算合法重写:

cpp 复制代码
namespace Jianyi
{
    class A {};
    class B : public A {};

    class Base
    {
    public:
        virtual A* clone() { return new A; }
    };

    class Derive : public Base
    {
    public:
        virtual B* clone() override { return new B; } // 返回值不同,但构成协变,合法
    };
}

协变实际使用场景很少,了解概念即可,面试偶尔会问"重写的例外情况是什么"。

3.2 析构函数的重写(重要,面试高频)

析构函数是个特殊情况。基类和派生类的析构函数名字不同(~Base vs ~Derive),按理不满足重写的三要素,但编译器对析构函数做了特殊处理:编译后所有析构函数统一处理为 destructor 这个名字

所以只要基类析构函数加了 virtual,派生类析构函数不管写不写 virtual,都自动构成重写。

为什么基类析构函数一定要加 virtual 看这个例子:

cpp 复制代码
namespace Jianyi
{
    class Base
    {
    public:
        // 如果不加 virtual,用基类指针 delete 派生类对象时,只会调用 Base 的析构
        virtual ~Base()
        {
            std::cout << "~Base()" << std::endl;
        }
    };

    class Derive : public Base
    {
    public:
        ~Derive()
        {
            std::cout << "~Derive() 释放资源" << std::endl;
            delete[] _buf;
        }
    private:
        char* _buf = new char[64];
    };
}

int main()
{
    Jianyi::Base* p = new Jianyi::Derive; // 基类指针指向派生类对象

    delete p; // 如果 ~Base 不是虚函数,这里只调 ~Base(),_buf 没被释放 → 内存泄漏
              // 加了 virtual,多态生效,先调 ~Derive(),再调 ~Base(),正确释放
    return 0;
}

如果 ~Base 不是虚函数,delete p 是静态绑定,直接根据指针类型(Base*)调 ~Base(),派生类的 _buf 就永远泄漏了。

结论:只要类有可能被继承,析构函数就加 virtual,这是工程习惯。

面试角度:这道题几乎是必考题。答题时一定要说清楚:不加 virtual → 静态绑定 → 只调基类析构 → 派生类资源泄漏。结合代码例子才能说完整。

3.3 override 和 final(C++11,实际工程常用)

C++ 对重写的要求很严格,函数名、参数、返回值三要素有一个写错,就不是重写了,变成了隐藏。更糟糕的是编译器不会报错,运行时才会发现行为不对。

C++11 为此提供了两个关键字:

override:告诉编译器,这个函数我打算重写基类的虚函数,帮我检查一下。

cpp 复制代码
namespace Jianyi
{
    class Car
    {
    public:
        virtual void Drive() {}
    };

    class Benz : public Car
    {
    public:
        // 注意:故意把 Drive 写成了 drive(大小写错误)
        // 加了 override,编译器立刻报错:没有找到可重写的基类虚函数
        virtual void drive() override {} // error!
    };
}

没有 override 的话,drive() 会悄悄变成一个新函数,把 Car::Drive() 隐藏掉,多态失效,而且不报任何错误。override 就是把这个问题从运行时提前到编译时暴露。

final:禁止派生类重写这个虚函数,或者禁止类被继承。

cpp 复制代码
namespace Jianyi
{
    // 用在函数上:禁止重写
    class Car
    {
    public:
        virtual void Drive() final {}
    };

    class Benz : public Car
    {
    public:
        virtual void Drive() {} // error:Drive 被声明为 final,不能重写
    };

    // 用在类上:禁止继承
    class SingletonBase final {};
    class Derived : public SingletonBase {}; // error:SingletonBase 不能被继承
}

final 的实际用途:单例模式、不希望被扩展的工具类,或者明确某个虚函数的语义在这一层就已经固定了。


四、重载、重写、隐藏的区别(经常考)

这三个概念放在一起很容易混,整理对比如下:

重载(Overload) 重写/覆盖(Override) 隐藏(Hide)
作用域 同一作用域 父子类不同作用域 父子类不同作用域
函数名 相同 相同 相同
参数 必须不同 必须相同 可以不同
返回值 可以不同 必须相同(协变例外) 可以不同
virtual 无要求 两个都必须是虚函数 只要不构成重写就是隐藏
多态 静态多态 动态多态 不产生多态

隐藏是最容易踩的坑:派生类里写了和基类同名的函数,但不满足重写条件(比如基类没加 virtual,或者参数不同),就会把基类的版本隐藏掉。用基类指针调用时,不会触发多态,也不会调到派生类的版本,行为和预期完全不同。

cpp 复制代码
namespace Jianyi
{
    class Base
    {
    public:
        void func(int x) { std::cout << "Base::func(int)" << std::endl; } // 普通函数,非虚
    };

    class Derive : public Base
    {
    public:
        void func(double x) { std::cout << "Derive::func(double)" << std::endl; } // 参数不同
        // Base::func(int) 被隐藏了!
    };
}

int main()
{
    Jianyi::Derive d;
    d.func(1);     // 调的是 Derive::func(double),Base 版本被隐藏
    d.Base::func(1); // 要显式指定才能调到 Base 版本
    return 0;
}

五、纯虚函数和抽象类

在虚函数后面加 = 0,就是纯虚函数:

cpp 复制代码
class Shape
{
public:
    virtual double area() = 0; // 纯虚函数,不需要实现(语法上可以,但没意义)
    virtual void print() = 0;
};

包含纯虚函数的类叫抽象类,抽象类有两个特点:

  1. 不能实例化(不能创建对象)
  2. 派生类继承后,如果没有重写所有纯虚函数,派生类也是抽象类,同样不能实例化

纯虚函数的作用是强制派生类重写,它定义了一套接口规范,具体实现交给派生类:

cpp 复制代码
namespace Jianyi
{
    // 抽象基类,定义"形状"的接口
    class Shape
    {
    public:
        virtual double area() const = 0;
        virtual void   print() const = 0;
        virtual ~Shape() {} // 析构也要是虚函数
    };

    class Circle : public Shape
    {
    public:
        explicit Circle(double r) : _r(r) {}

        double area() const override
        {
            return 3.14159 * _r * _r;
        }

        void print() const override
        {
            std::cout << "Circle, r=" << _r << ", area=" << area() << std::endl;
        }
    private:
        double _r;
    };

    class Rectangle : public Shape
    {
    public:
        Rectangle(double w, double h) : _w(w), _h(h) {}

        double area() const override
        {
            return _w * _h;
        }

        void print() const override
        {
            std::cout << "Rectangle, " << _w << "x" << _h << ", area=" << area() << std::endl;
        }
    private:
        double _w, _h;
    };
}

int main()
{
    // Jianyi::Shape s; // error:抽象类不能实例化

    // 用基类指针管理不同的派生类对象
    Jianyi::Shape* shapes[] = {
        new Jianyi::Circle(5.0),
        new Jianyi::Rectangle(3.0, 4.0),
    };

    for (auto* s : shapes)
    {
        s->print(); // 多态:根据实际类型调用对应的 print
        delete s;
    }

    return 0;
}

输出:

cpp 复制代码
Circle, r=5, area=78.5397
Rectangle, 3x4, area=12

这种"用基类指针/引用统一操作不同派生类"的模式,是多态最核心的使用场景,工程里非常常见。

面试角度:抽象类的意义不只是"不能实例化",更重要的是它定义了一套接口约定,强制派生类去实现。这是面向对象里"开闭原则"的体现:对扩展开放(新增派生类),对修改关闭(不动基类接口)。


六、多态的底层原理:虚函数表

现在来看多态是怎么实现的。这部分是理解多态的关键,也是面试经常深问的地方。

6.1 虚函数表指针(vfptr)

先看一个问题:含虚函数的类,对象大小是多少?

cpp 复制代码
namespace Jianyi
{
    class Base
    {
    public:
        virtual void func1() { std::cout << "Base::func1" << std::endl; }
        virtual void func2() { std::cout << "Base::func2" << std::endl; }
        void         func3() { std::cout << "Base::func3" << std::endl; } // 普通函数
    protected:
        int  _a = 1;
        char _ch = 'x';
    };
}

int main()
{
    // 32位程序下:int(4) + char(1) + 对齐(3) + vfptr(4) = 12 字节
    // 64位程序下:int(4) + char(1) + 对齐(3) + vfptr(8) = 16 字节
    std::cout << sizeof(Jianyi::Base) << std::endl;
    return 0;
}

比只有数据成员时多了一个指针的大小。这个多出来的就是虚函数表指针(__vfptr,vfptr = virtual function pointer)

每个含虚函数的类的对象里,都有一个 vfptr,它指向这个类的虚函数表(vtable / 虚表)。虚表是一个函数指针数组,存着这个类所有虚函数的地址:

几个关键结论:

  • 同类型的对象共用同一张虚表(虚表是类级别的,不是对象级别的)
  • func3 是普通函数,不在虚表里,地址直接编译时确定
  • 虚表存在代码段(常量区),不在堆也不在栈

6.2 派生类的虚表是怎么建立的

派生类继承基类时,会把基类的虚表复制一份作为自己虚表的起点,然后:

  • 如果派生类重写了某个虚函数,就把虚表里对应位置的地址覆盖成派生类自己的函数地址
  • 派生类新增的虚函数追加到虚表末尾
cpp 复制代码
namespace Jianyi
{
    class Base
    {
    public:
        virtual void func1() { std::cout << "Base::func1" << std::endl; }
        virtual void func2() { std::cout << "Base::func2" << std::endl; }
    protected:
        int _a = 1;
    };

    class Derive : public Base
    {
    public:
        virtual void func1() override { std::cout << "Derive::func1" << std::endl; } // 重写
        virtual void func3()          { std::cout << "Derive::func3" << std::endl; } // 新增
    protected:
        int _b = 2;
    };
}

虚表对比:

Derive 对象的内存布局:

注意:Derive 对象里只有一个 vfptr,它指向的是 Derive 自己的虚表(不是 Base 的虚表),这张虚表里已经把重写的函数地址换掉了。

6.3 多态调用的底层执行过程

现在可以解释多态是怎么发生的了:

cpp 复制代码
void Func(Jianyi::Base* ptr)
{
    ptr->func1(); // 这里发生了什么?
}

不满足多态条件时(静态绑定) :编译器在编译时就确定调用地址,直接 call Base::func1,地址写死在指令里。

满足多态条件时(动态绑定):编译器生成的汇编大致如下:

cpp 复制代码
mov eax, dword ptr [ptr]       ; 取出 ptr 指向的对象的首地址(即 vfptr 的地址)
mov edx, dword ptr [eax]       ; 取出 vfptr,即虚表地址
mov eax, dword ptr [edx]       ; 取出虚表第[0]项,即 func1 的地址
call eax                       ; 调用

三步:找对象 → 找虚表 → 找函数地址 → 调用。这就是为什么多态有一点点额外开销(相比普通函数调用多了两次内存间接寻址),但在大多数场景下这个开销完全可以忽略。

动态绑定也解释了多态的两个条件为什么是那两个:

  • 必须是指针或引用 :只有指针/引用才不会切片,保证 vfptr 还是派生类的那个
  • 必须是虚函数:只有虚函数才会走虚表查找这条路,普通函数编译时直接绑定地址

6.4 一道经典面试题解析

cpp 复制代码
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()
{
    B* p = new B;
    p->test();    // 输出什么?
    return 0;
}

答案:B->1

分析:

  1. p->test()pB*test 是虚函数,但 B 没有重写 test,所以调用的是 A::test()
  2. A::test() 里调 func():此时 this 指针指向的是 B 对象,满足多态条件(虚函数 + 指针),所以调的是 B::func()
  3. 但是默认参数是静态绑定的 :编译器在编译 A::test() 时,看到的是 func() 没传参,就用 A::func 的默认值 1,不会在运行时查虚表找默认参数
  4. 结果:调用 B::func,但参数是 A 的默认值 1,输出 B->1

结论:虚函数的函数体由虚表决定(运行时),但默认参数由声明时的静态类型决定(编译时)。不要在重写的虚函数里改默认参数值,行为会让人困惑。


七、总结

多态

├── 编译时多态:函数重载、函数模板(静态绑定)

└── 运行时多态:虚函数 + 基类指针/引用(动态绑定)

├── 构成条件

│ ├── 基类的指针或引用调用

│ └── 被调函数是虚函数且完成重写

├── 虚函数重写细节

│ ├── 三要素完全相同(返回值、函数名、参数)

│ ├── 协变:返回值可以是基类→派生类指针/引用

│ ├── 析构函数:基类析构必须加 virtual,防止资源泄漏

│ ├── override:编译期检查是否真正构成重写

│ └── final:禁止重写或禁止继承

├── 纯虚函数 = 0:强制派生类实现,类变为抽象类

└── 底层原理

├── 每个含虚函数的对象有 vfptr(虚函数表指针)

├── vfptr 指向该类的虚表(函数指针数组,存于代码段)

├── 派生类虚表 = 复制基类虚表 + 覆盖重写项 + 追加新虚函数

└── 调用过程:对象 → vfptr → 虚表 → 函数地址 → call

面试高频考点汇总:

  • 多态的两个必要条件是什么,为什么
  • 析构函数为什么要加 virtual(结合内存泄漏例子说清楚)
  • override 和 final 的作用,为什么工程里要用 override
  • 重载/重写/隐藏的区别
  • 虚函数表的结构,vfptr 存在哪,vtable 存在哪
  • 动态绑定的汇编级别执行步骤
  • 默认参数和虚函数的经典陷阱(B->1 那道题)
相关推荐
charlie1145141911 小时前
通用GUI编程技术——图形渲染实战(五十)——命中测试与鼠标事件路由:精确交互
c++·windows·架构·交互·图形渲染
砍材农夫1 小时前
python 如何一次性安装项目所有依赖包(pip和uv)
开发语言·python·pip·uv
IpdataCloud1 小时前
信贷审核中如何验证用户地址与IP属地一致性?用IP查询工具实现反欺诈
开发语言·tcp/ip·金融·php·ip
hetao17338371 小时前
2026-05-25~06-11 hetao1733837 的刷题记录
c++·算法
洛水水1 小时前
【力扣100题】82.有效的括号
c++·算法·leetcode
云水-禅心1 小时前
解决MacOS 安装Python之后默认版本指向不正确问题
开发语言·python·macos
冰暮流星1 小时前
javascript之this关键字
开发语言·前端·javascript
rit84324991 小时前
基于Qt的串口上位机控制蓝牙小车程序
开发语言·qt
百度Geek说1 小时前
CodingAgent 的原始森林困境:一张地图能解决什么?
开发语言·javascript·ecmascript·coding agent