C++ 多态完全指南:从基础语法到底层原理

目录


前言:同一个窗口,不同的人买不同的票

想象你正在写一个票务系统。窗口只有一个 BuyTicket() 函数,但不同身份的人买票,行为和价格完全不同

  • 普通人买票 → 全价
  • 学生买票 → 打五折
  • 军人买票 → 优先出票

你肯定不想写成这样:

void BuyTicket(Person* p) {
    if (p 是普通人)     全价买票();
    else if (p 是学生)  打折买票();
    else if (p 是军人)  优先买票();
    // 每新增一种身份,就得改这段代码——噩梦!
}

C++ 给出的答案是多态(Polymorphism)——用基类指针调用同一个函数,实际执行的代码由指向的对象类型在运行时决定。这正是 C++ 面向对象三大特性中最"高级"的一个,也是面试必考题

这篇文章将带你从概念到原理、从代码到底层内存模型,一步步吃透 C++ 多态。


一、多态的概念:静与动

C++ 中的多态分为两类:

类型别称绑定时机典型实现
静态多态编译时多态编译期间确定函数地址函数重载、函数模板
动态多态运行时多态运行期间到虚表中查找函数地址虚函数 + 基类指针/引用

静态多态你已经很熟悉了——同一个函数名,传 int 调一个版本,传 double 调另一个,编译器在编译时就帮你匹配好了。

动态多态才是本文的主角。它的核心是:传不同的对象给同一个基类指针/引用,完成不同的行为


二、多态的构成条件:缺一不可

要实现运行时多态,必须同时满足三个条件

  1. 必须是基类的指针或引用调用虚函数
  2. 被调用的函数必须是虚函数virtual 修饰)
  3. 派生类必须完成虚函数的重写/覆盖

为什么必须是指针或引用?因为只有指针和引用能指向派生类对象(还记得继承中的切片吗),值传递会把派生类对象切成基类,多态就失效了。

2.1 第一个多态程序

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

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

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

void Func(Person* ptr) {
    ptr->BuyTicket();   // 同一行代码,不同行为!
}

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

    Func(&ps);   // 输出:买票-全价
    Func(&st);   // 输出:买票-打折(五折)
    Func(&sr);   // 输出:买票-优先

    return 0;
}

关键点:ptr->BuyTicket() 这行代码始终没变,但实际调用的函数ptr 指向的对象的真实类型决定,而不是由 ptr 的声明类型 Person* 决定。这就是多态。

2.2 经典例子:动物的叫声

class Animal {
public:
    virtual void talk() const {}
};

class Dog : public Animal {
public:
    virtual void talk() const {
        cout << "汪汪" << endl;
    }
};

class Cat : public Animal {
public:
    virtual void talk() const {
        cout << "(>^ω^<)喵" << endl;
    }
};

void letsHear(const Animal& animal) {
    animal.talk();   // 同一行代码,传猫猫叫,传狗狗叫
}

int main() {
    Cat cat;
    Dog dog;
    letsHear(cat);   // (>^ω^<)喵
    letsHear(dog);   // 汪汪
    return 0;
}

注意这里我们用的是引用const Animal&)而不是指针,效果完全一样——指针和引用在多态中是等价的


三、虚函数与重写:规则虽简单,踩坑也容易

3.1 虚函数的基本语法

类成员函数前加 virtual 关键字即可:

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

注意:非成员函数不能加 virtualvirtual 只能修饰类的成员函数。

3.2 虚函数重写(Override)的规则

派生类中定义一个与基类虚函数返回值类型相同、函数名相同、参数列表完全相同的函数,就构成重写(也叫覆盖)。

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

class Student : public Person {
public:
    // 返回值相同 + 函数名相同 + 参数相同 = 构成重写
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
};

3.3 一个关于 virtual 关键字的坑

派生类重写虚函数时,不加 virtual 也能构成重写——因为继承下来的虚函数在派生类中仍然保持虚函数属性:

class Student : public Person {
public:
    // 即使没写 virtual,这也构成重写!(不推荐)
    void BuyTicket() { cout << "买票-打折" << endl; }
};

考试选择题经常拿这点挖坑:选项里给你一个没加 virtual 的派生类函数,问你"这构成多态吗"——答案是构成,函数体后面的重写才是关键。但实际写代码时强烈建议加上 virtual,读代码的人一眼就知道这是虚函数。

3.4 一个经典面试题:虚函数 + 默认参数

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();   // 请问输出什么?
    return 0;
}

思考 30 秒…答案在下面揭晓。

答案:B->1

分析:p->test() 调用的是继承自 A 的 test(),A::test() 中 func() 相当于 this->func()thisA* 类型,指向的是 B 对象,满足多态条件(基类指针 + 虚函数),所以调用 B::func()。

那为什么 val 是 1 不是 0?因为默认参数的值是在编译时根据调用者的静态类型决定的this 的静态类型是 A*,所以 val 取的是 A::func 的默认参数 1,而不是 B::func 的默认参数 0。

这个题考察的知识点组合非常精妙:多态调用 + 默认参数的静态绑定特性。


四、override 和 final:编译器帮你查错

4.1 override:让编译器帮你检查是否真的重写了

C++ 对重写的要求很严格:返回值类型、函数名、参数列表必须完全一样。如果不小心写错了呢?

class Car {
public:
    virtual void Drive() {}   // 注意函数名是 D-r-i-v-e
};

class Benz : public Car {
public:
    // 手滑写成了 Dirve——这不是重写,这是定义了一个新函数!
    virtual void Dirve() { cout << "Benz-舒适" << endl; }
};

上面的代码编译不会报错,但多态调用时永远走基类的 Drive(),Bug 极难排查。

C++11 引入 override 关键字来解决这个问题:

class Benz : public Car {
public:
    virtual void Dirve() override {  // 编译报错!编译器告诉你 Dirve 没有重写任何基类虚函数
        cout << "Benz-舒适" << endl;
    }
};

最佳实践:所有重写虚函数的函数,都加上 override

4.2 final:禁止后代再重写

如果某个虚函数不希望被后续的派生类进一步重写:

class Car {
public:
    virtual void Drive() final {}   // 到此为止,不允许再被重写
};

class Benz : public Car {
public:
    virtual void Drive() {}   // 编译错误!Drive 被标记为 final
};

final 也可以修饰整个类,表示这个类不能被继承(在继承篇中已经讲过)。


五、协变:返回值可以"子类化"

一般的重写要求返回值类型完全相同,但 C++ 允许一个例外——协变(Covariance)

基类虚函数返回基类对象的指针或引用时,派生类重写的虚函数可以返回派生类对象的指针或引用。

class A {};
class B : public A {};

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

class Student : public Person {
public:
    virtual B* BuyTicket() {   // 返回 B* 而不是 A*——构成协变!
        cout << "买票-打折" << endl;
        return nullptr;
    }
};

协变在实际开发中用得不多,了解即可。但面试中偶尔会作为"虚函数重写有没有例外"的考点出现。


六、析构函数与虚函数:内存泄漏的无声杀手

这是面试中最高频的多态考点之一,一定要结合代码讲清楚。

6.1 问题场景

class A {
public:
    ~A() { cout << "~A()" << endl; }
};

class B : public A {
public:
    ~B() {
        cout << "~B()->delete:" << _p << endl;
        delete _p;
    }
protected:
    int* _p = new int[10];
};

int main() {
    A* p1 = new A;
    A* p2 = new B;   // 基类指针指向派生类对象

    delete p1;   // OK,调用 ~A()
    delete p2;   // 只调用了 ~A(),没有调用 ~B()!
                 // B::_p 指向的内存泄漏了!
    return 0;
}

问题出在哪里?delete p2 时,编译器看到 p2A* 类型,就只调用了 A::~A()——而 B::~B() 中释放 _p 的代码根本没有执行。

6.2 解决方案:虚析构函数

class A {
public:
    virtual ~A() { cout << "~A()" << endl; }
    // 加了 virtual,构成虚函数
};

基类析构函数加了 virtual 后:

  • 编译器会将 ~A()~B() 统一命名(编译后都叫 destructor
  • ~B() 自动构成对 ~A() 的重写
  • delete p2 时通过多态机制从虚表中找到 ~B() 的地址,正确调用
delete p2;
// 输出:
// ~B()->delete:0x...    ← 先调派生类析构,释放资源
// ~A()                  ← 再调基类析构

铁律:如果一个类被设计为基类(要被继承),那么它的析构函数必须是 virtual 的。

这保证了无论通过什么类型的指针 delete,都能正确调用到实际对象类型的析构函数。

一个常见的判断方法:如果类中有虚函数(说明这个类本来就被设计为多态基类),析构函数必须也是虚函数。


七、纯虚函数与抽象类:定义接口,不定义实现

7.1 语法

在虚函数声明的末尾加上 = 0,这个函数就变成了纯虚函数

class Car {
public:
    virtual void Drive() = 0;   // 纯虚函数,不需要实现
};

包含纯虚函数的类叫抽象类。抽象类不能实例化对象

Car car;   // 编译错误!抽象类不能被实例化

7.2 用途:强制派生类实现接口

class Car {
public:
    virtual void Drive() = 0;
};

class Benz : public Car {
public:
    virtual void Drive() override {
        cout << "Benz-舒适" << endl;
    }
};

class BMW : public Car {
public:
    virtual void Drive() override {
        cout << "BMW-操控" << endl;
    }
};

int main() {
    // Car car;    // 不能实例化

    Car* pBenz = new Benz;   // 但可以用基类指针指向
    pBenz->Drive();           // Benz-舒适

    Car* pBMW = new BMW;
    pBMW->Drive();            // BMW-操控

    return 0;
}

如果派生类继承了抽象类但没有重写纯虚函数,那派生类自己也变成抽象类,同样无法实例化。这就起到了强制派生类实现特定接口的作用。

设计启示:纯虚函数 + 抽象类 = C++ 中的"接口"。虽然 C++ 没有像 Java 那样的 interface 关键字,但纯虚函数可以完美实现接口语义。


八、多态的底层原理:虚函数表到底长什么样

这是整个多态知识体系中最硬核的部分,也是区分"会用"和"理解"的分水岭

8.1 一个令人困惑的 sizeof

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

先算一下:int 占 4 字节,char 占 1 字节,内存对齐下应该是 8 字节。但实际运行结果是 12(32位)或 16(64位)。

多出来的那部分就是一个隐藏的指针——__vfptr(virtual function pointer table pointer),即虚函数表指针

8.2 虚函数表的结构

Base对象内存布局(32位):
┌─────────────────┐
│   __vfptr  ───────→ 虚函数表(虚表/vftable)
│   (4 bytes)     │   ┌──────────────────────┐
├─────────────────┤   │  &Base::Func1  (地址) │
│   _b = 1        │   ├──────────────────────┤
│   (4 bytes)     │   │  0x00000000  (结束标记)│
├─────────────────┤   └──────────────────────┘
│   _ch = 'x'     │
│   (1 byte)      │   虚函数表是一个存函数指针的数组,
│   + 对齐3byte   │   最后一般有一个 0x00000000 结束标记。
└─────────────────┘

关键概念

  • 每个含有虚函数的类,它的所有对象都共享同一张虚函数表
  • 虚函数表是一个函数指针数组,存储该类所有虚函数的地址
  • 每个对象有自己的 __vfptr,指向所属类的虚函数表

8.3 派生类的虚函数表——覆盖是如何发生的

class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    void func5() { cout << "Base::func5" << endl; }   // 普通函数
protected:
    int a = 1;
};

class Derive : public Base {
public:
    virtual void func1() { cout << "Derive::func1" << endl; }  // 重写
    virtual void func3() { cout << "Derive::func3" << endl; }  // 新增虚函数
    void func4() { cout << "Derive::func4" << endl; }          // 普通函数
protected:
    int b = 2;
};

派生类的虚函数表是这样生成的:

  1. 先拷贝基类的虚函数表(func1 的地址, func2 的地址)
  2. 把重写的虚函数地址覆盖上去(把 func1 的位置替换成 Derive::func1 的地址)
  3. 追加派生类新增的虚函数地址(func3 的地址追加到后面)
Base 虚表:                     Derive 虚表:
┌──────────────────┐           ┌──────────────────────┐
│  &Base::func1    │           │  &Derive::func1 ←覆盖 │
├──────────────────┤           ├──────────────────────┤
│  &Base::func2    │           │  &Base::func2   ←保持 │
├──────────────────┤           ├──────────────────────┤
│  0x00000000      │           │  &Derive::func3 ←新增 │
└──────────────────┘           ├──────────────────────┤
                               │  0x00000000           │
                               └──────────────────────┘

注意:func5()func4() 是普通函数,不在虚函数表中。它们通过编译时静态绑定直接调用。

8.4 多态调用到底是怎么发生的

回到最开始的例子:

void Func(Person* ptr) {
    ptr->BuyTicket();   // 这一行,底层做了什么?
}

静态绑定(不满足多态条件时):

; 如果 BuyTicket 不是虚函数,编译器直接生成:
mov  ecx, dword ptr [ptr]
call Person::BuyTicket    ; 地址在编译时就确定了

动态绑定(满足多态条件时):

; ptr 是基类指针 + BuyTicket 是虚函数 → 运行时查虚表
mov  eax, dword ptr [ptr]   ; (1) 取 ptr 指向的对象首地址
mov  edx, dword ptr [eax]   ; (2) 取对象首地址处的 __vfptr 值(即虚表地址)
mov  ecx, dword ptr [ptr]   ; (3) 准备 this 指针
mov  eax, dword ptr [edx]   ; (4) 取虚表中第一个函数的地址
call eax                     ; (5) 调用这个地址——这时才确定是哪个函数

步骤 (4) 是关键:到虚表中取函数地址,而不是用编译时固定的地址。

  • 如果 ptr 指向 Person 对象 → 虚表存的是 Person::BuyTicket → 执行全价买票
  • 如果 ptr 指向 Student 对象 → 虚表存的是 Student::BuyTicket → 执行打折买票

多态的本质:在运行时通过虚函数表间接查找函数地址,而不是在编译时直接硬编码函数地址。

8.5 虚函数和虚函数表存在内存的哪里?

运行这段代码可以得到验证:

int main() {
    int i = 0;
    static int j = 1;
    int* p1 = new int;
    const char* p2 = "xxxxxxxx";

    printf("栈:%p\n", &i);            // 栈区地址
    printf("静态区:%p\n", &j);         // 静态区地址
    printf("堆:%p\n", p1);            // 堆区地址
    printf("常量区:%p\n", p2);         // 常量区/代码段地址

    Base b;
    Derive d;
    Base* p3 = &b;
    Derive* p4 = &d;
    printf("Base虚表地址:%p\n", *(int*)p3);    // 和常量区地址接近
    printf("Derive虚表地址:%p\n", *(int*)p4);  // 和常量区地址接近

    return 0;
}

典型运行结果(VS 下):

栈:        010FF954
静态区:    0071D000
堆:        0126D740
常量区:    0071ABA4
Base虚表:  0071AB44    ← 和常量区地址非常接近
Derive虚表:0071AB84    ← 和常量区地址非常接近
虚函数地址:00711488    ← 和常量区地址也非常接近
普通函数地址:007114BF

结论(VS 环境下):

  • 虚函数表存在代码段/常量区
  • 虚函数(编译后的指令)同样存在代码段
  • 虚函数和普通函数在存储位置上没有本质区别——区别只在于虚函数的地址被额外存入了虚表

九、重载、重写、隐藏对比

对比维度重载(Overload)重写/覆盖(Override)隐藏(Hide)
作用域同一作用域(同一个类内)不同作用域(基类和派生类之间)不同作用域(基类和派生类之间)
函数名相同相同相同
参数列表必须不同必须相同(协变除外)可以相同也可以不同
返回值可以不同必须相同(协变除外)可以不同
virtual不需要基类必须 virtual不需要
调用方式编译时根据参数确定运行时通过虚表确定编译时根据静态类型确定

一个例子把三者串起来:

class Base {
public:
    void func(int)     { cout << "Base::func(int)" << endl; }    // 1
    void func(double)  { cout << "Base::func(double)" << endl; } // 2: 和1构成重载
    virtual void virt() { cout << "Base::virt()" << endl; }      // 3: 虚函数
};

class Derive : public Base {
public:
    void func(int)     { cout << "Derive::func(int)" << endl; }  // 4: 隐藏了1和2!
    virtual void virt() override                                  // 5: 重写了3
        { cout << "Derive::virt()" << endl; }
};
  • 1 和 2 → 重载(同一个类,函数名相同,参数不同)
  • 3 和 5 → 重写(基类 virtual + 派生类相同签名)
  • 4 → 隐藏了 1 和 2(派生类同名函数掩盖了基类的所有同名函数,不管参数一不一样)

写在最后

多态是 C++ 面向对象能力的核心引擎。从表层的 virtual 关键字到底层的虚函数表指针间接寻址,理解多态能让你在面试中自信地画出内存布局,也能让你在工作中设计出真正可扩展的系统。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值