多态
需要预先学习:
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):
- 每个定义了虚函数的类,编译器为它生成一张虚函数表,本质是函数指针数组,按声明顺序存放该类各虚函数的地址
- 每个该类的对象,内存开头藏一个虚表指针(vptr),指向本类的虚函数表
- 调用虚函数时:先从对象里取出 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 会像虚函数调用一样在运行期分派:先执行派生类的析构函数清理自己,再逐层向上执行基类的析构函数。
注意:只要一个类打算作为基类使用,就应该把析构函数声明为虚函数。反过来,析构函数不是虚函数的类,通常不适合作为基类。


