需要预先学习:


1. 多态

多态(polymorphism)的字面意思是"多种形态":同一个调用形式,根据对象的实际类型表现出不同的行为。

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

  • 静态多态(编译期多态):函数重载、模板,调用哪个版本在编译期就确定
  • 动态多态(运行期多态):虚函数,调用哪个版本在程序运行时才确定

1.1 静态多态

函数重载是静态多态的典型:同一个函数名,参数列表不同,编译时根据实参类型决定调用哪个版本。

#include <iostream>

void print(int x)    { std::cout << "整数:" << x << std::endl; }
void print(double x) { std::cout << "浮点数:" << x << std::endl; }

int main()
{
    print(10);       // 编译时确定调用 print(int)
    print(3.14);     // 编译时确定调用 print(double)

    return 0;
}

编译并运行代码后,看到这样的输出:

整数:10
浮点数:3.14

重载只靠参数列表区分(类型、个数),不能只靠返回值类型区分。std::cout << 能输出 int、double、std::string 等各种类型,也是靠重载实现的。


1.2 动态多态与虚函数

先看一个场景:基类指针指向派生类对象,调用会走到哪一步?

#include <iostream>

class Animal
{
public:
    void speak() { std::cout << "动物在叫" << std::endl; }
};

class Dog : public Animal
{
public:
    void speak() { std::cout << "汪汪" << std::endl; }
};

class Cat : public Animal
{
public:
    void speak() { std::cout << "喵喵" << std::endl; }
};

int main()
{
    Animal* p1 = new Dog;
    Animal* p2 = new Cat;
    p1->speak();     // 期待汪汪,实际输出……
    p2->speak();
    delete p1;
    delete p2;

    return 0;
}

编译并运行代码后,看到这样的输出:

动物在叫
动物在叫

指针的类型是 Animal*,编译器在编译期就按 Animal::speak 生成了调用,与指针实际指向什么对象无关。要让调用在运行期根据对象的实际类型决定,需要把基类的函数声明为虚函数(virtual function):

class Animal
{
public:
    virtual void speak() { std::cout << "动物在叫" << std::endl; }   // 声明为虚函数
};

只改这一行,重新编译运行:

汪汪
喵喵

规则:

  • 用 virtual 声明的成员函数是虚函数。通过基类指针或引用调用虚函数时,程序在运行期根据对象的实际类型选择调用哪个版本
  • 派生类重写虚函数时用 override 标记(C++11):编译器会检查是否真的重写了基类的虚函数,参数写错会直接报错
class Dog : public Animal
{
public:
    void speak(int) override { }   // 参数不一致,不是重写
};
error: 'void Dog::speak(int)' marked 'override', but does not override
  • 只有通过指针或引用调用才有多态。直接用对象调用没有多态,而且派生类对象赋给基类对象时会发生对象切片(object slicing)——多出来的派生类部分被切掉,只剩基类子对象:
Animal a = Dog();    // 对象切片,a 就是纯粹的 Animal
a.speak();           // 调用 Animal::speak,与 Dog 无关

1.3 虚函数表

运行期才知道该调谁,意味着编译期不能写死地址,需要一层运行期的间接。C++ 主流实现用的就是虚函数表(virtual table,简称 vtable):

  1. 每个定义了虚函数的类,编译器为它生成一张虚函数表,本质是函数指针数组,按声明顺序存放该类各虚函数的地址
  2. 每个该类的对象,内存开头藏一个虚表指针(vptr),指向本类的虚函数表
  3. 调用虚函数时:先从对象里取出 vptr 找到虚表,再按固定的偏移取出对应的函数指针,间接调用

虚函数表属于类,所有对象共享同一张表;虚表指针属于对象,每个对象各有一个:

Dog 对象的内存             Dog 的虚函数表
+--------------+         +-----------------------+
| vptr 虚表指针 |-------->| 0 号槽:&Dog::speak()   |
+--------------+         +-----------------------+
| 其他成员……    |
+--------------+

Dog 重写了 speak,所以 Dog 的虚函数表里,speak 的槽位填的是 Dog::speak 的地址;Cat 的虚函数表里填的则是 Cat::speak 的地址。同一个调用语句 p->speak(),查的表不同,跳转的目标就不同——这就是多态。

用 sizeof 验证

虚表指针让每个对象多出 8 个字节(64 位系统):

#include <iostream>

class Animal
{
public:
    virtual void speak() {}
};

class Plain
{
public:
    void speak() {}
};

class AnimalWithAge
{
public:
    virtual void speak() {}
    int m_age = 0;
};

class PlainWithAge
{
public:
    void speak() {}
    int m_age = 0;
};

int main()
{
    std::cout << "带虚函数的类: " << sizeof(Animal) << " 字节" << std::endl;
    std::cout << "不带虚函数的类: " << sizeof(Plain) << " 字节" << std::endl;
    std::cout << "虚函数 + int 成员: " << sizeof(AnimalWithAge) << " 字节" << std::endl;
    std::cout << "非虚 + int 成员: " << sizeof(PlainWithAge) << " 字节" << std::endl;

    return 0;
}

编译并运行代码后,看到这样的输出(64 位系统):

带虚函数的类: 8 字节
不带虚函数的类: 1 字节
虚函数 + int 成员: 16 字节
非虚 + int 成员: 4 字节

Animal 没有任何成员变量,却占 8 字节——多出来的就是虚表指针。AnimalWithAge 是 16 字节:8 字节 vptr 加 4 字节 int,再补 4 字节对齐。

用反汇编验证

用 g++ -O0 -S 把虚函数调用的代码编译成汇编(64 位 x86-64、gcc、关闭优化),可以看到"取 vptr → 查表 → 间接调用"的完整过程:

movq	-8(%rbp), %rax      ; 取出 p1(对象的地址)
movq	(%rax), %rax        ; 解引用得到 vptr(虚表地址)
movq	(%rax), %rdx        ; 从虚表 0 号槽取出函数指针
movq	-8(%rbp), %rax
movq	%rax, %rcx          ; 把对象地址作为 this 传入
call	*%rdx               ; 间接调用,跳到哪运行期才知道

对照非虚调用的汇编,地址在编译期就写死了:

movq	-8(%rbp), %rax
movq	%rax, %rcx
call	_ZN6Animal5speakEv   ; 直接调用 Animal::speak,地址写死

注:虚函数表是 ABI 层面的实现细节,不是 C++ 标准规定的。标准只规定"虚函数调用表现出运行期分派的行为",所有主流编译器(gcc、clang、MSVC)都采用 vptr + vtable 这套方案。


1.4 纯虚函数与抽象类

有时基类只负责定义接口,本身没有合理的默认实现——比如"动物叫"没有统一的声音,只有具体的狗叫、猫叫。这时可以把虚函数声明为纯虚函数(pure virtual function),只声明接口、不提供实现:

#include <iostream>

class Animal
{
public:
    virtual void speak() = 0;    // 纯虚函数:只声明,不实现
};

class Dog : public Animal
{
public:
    void speak() override { std::cout << "汪汪" << std::endl; }
};

int main()
{
    Animal* p = new Dog;
    p->speak();
    delete p;

    return 0;
}
  • 包含纯虚函数的类叫抽象类(abstract class),不能实例化:
Animal a;    // 错误!抽象类不能实例化
error: cannot declare variable 'a' to be of abstract type 'Animal'
  • 派生类必须实现所有纯虚函数才能实例化;否则派生类自身也仍是抽象类
  • 抽象类常用来定义一组接口,让不同的派生类各自实现,比如"所有能叫的动物都提供 speak()"

1.5 虚析构函数

通过基类指针 delete 派生类对象时,如果基类析构函数不是虚函数,派生类的析构函数不会被执行,派生类成员的资源就泄漏了:

#include <iostream>

class Animal
{
public:
    virtual void speak() { std::cout << "动物在叫" << std::endl; }
    ~Animal() { std::cout << "~Animal()" << std::endl; }
};

class Dog : public Animal
{
public:
    void speak() override { std::cout << "汪汪" << std::endl; }
    ~Dog() { std::cout << "~Dog()" << std::endl; }
};

int main()
{
    Animal* p = new Dog;
    p->speak();
    delete p;        // 只调用了 ~Animal(),Dog 的部分没有清理

    return 0;
}

编译并运行代码后,看到这样的输出:

汪汪
~Animal()

把基类析构函数声明为虚函数:

virtual ~Animal() { std::cout << "~Animal()" << std::endl; }

重新编译运行:

汪汪
~Dog()
~Animal()

delete 会像虚函数调用一样在运行期分派:先执行派生类的析构函数清理自己,再逐层向上执行基类的析构函数。

注意:只要一个类打算作为基类使用,就应该把析构函数声明为虚函数。反过来,析构函数不是虚函数的类,通常不适合作为基类。