1. 先从需求说起:没有虚函数我们怎么写多态
1.1 三个类都要跑起来:一段最原始的调度
做C++开发的人,几乎没人能绕过虚函数。不管你是不是天天写接口、基类、派生类,只要你代码里出现过 virtual 关键字,或者你在用某个框架时,发现子类重写了一个父类的方法之后,程序自动调用到了你写的版本,那你就已经在和虚函数打交道了。
先回到一个最简单的需求场景。假设你正在写一个画图程序,要支持圆形、矩形、三角形三种图形,每种图形都要能计算面积、画出来。你可能会很自然地把它们做成三个相互独立的类:
cpp复制class Circle {
public:
void Draw() { std::cout << "draw circle\n"; }
double Area() { return 3.14159 * r_ * r_; }
private:
double r_ = 1.0;
};
class Rectangle {
public:
void Draw() { std::cout << "draw rectangle\n"; }
double Area() { return w_ * h_; }
private:
double w_ = 2.0, h_ = 3.0;
};
class Triangle {
public:
void Draw() { std::cout << "draw triangle\n"; }
double Area() { return b_ * h_ / 2.0; }
private:
double b_ = 2.0, h_ = 3.0;
};
这三个类独立工作没问题。但如果你的程序想把所有图形放进同一个容器统一处理,比如用户点了一个“全部重绘”,你就得把所有圆形矩形三角形都遍历一遍,调用各自的 Draw()。这时候你会发现一个尴尬的事实:没有一个统一类型能装下它们。
你可能第一反应是:我用 void* 不就行了,先存地址,用的时候判断是哪种类型再转回去。这确实是一种解法,但代码会写成下面这样:
cpp复制void DrawAll(void* shapes[], int type[], int count) {
for (int i = 0; i < count; ++i) {
if (type[i] == 0)
static_cast<Circle*>(shapes[i])->Draw();
else if (type[i] == 1)
static_cast<Rectangle*>(shapes[i])->Draw();
else if (type[i] == 2)
static_cast<Triangle*>(shapes[i])->Draw();
}
}
这种写法有几个非常明显的问题。第一,每次新增一种图形,你都得改 DrawAll 函数,加一个 else if。第二,调用者必须知道每一种类型,并且手动维护每个对象对应的类型编号,一旦类型编号和实际对象对不上,程序很可能直接崩溃。第三,所有逻辑都依赖“当前的对象类型”,但类型判断分散在各处,一旦写漏一个分支,就会出现“图形没被画出来”的隐蔽 bug。这种代码在规模小的时候还能顶得住,一旦图形种类多起来,维护成本会呈指数级上升。
所以我一直觉得,理解虚函数最好的出发点是:当你需要一个统一入口、但又希望不同的对象做出不同行为时,你逃不开多态,而C++里最核心的多态实现手段就是虚函数。
1.2 虚函数萌芽:让代码自己找到正确的函数
先用最简单的话说,虚函数就是:在基类里声明一个函数为 virtual,然后在派生类里用相同的函数签名重新实现它。当通过基类指针或引用调用这个函数时,程序不再根据指针的静态类型决定调用哪个版本,而是根据指针真正指向的对象类型,在运行时动态决定。
还是那个画图例子,引入一个公共基类:
cpp复制class Shape {
public:
virtual void Draw() { std::cout << "shape base\n"; }
virtual double Area() { return 0.0; }
};
class Circle : public Shape {
public:
void Draw() override { std::cout << "draw circle\n"; }
double Area() override { return 3.14159 * r_ * r_; }
private:
double r_ = 1.0;
};
class Rectangle : public Shape {
public:
void Draw() override { std::cout << "draw rectangle\n"; }
double Area() override { return w_ * h_; }
private:
double w_ = 2.0, h_ = 3.0;
};
有了这个公共基类之后,上面那个麻烦的 DrawAll 就能收敛成非常干净的形状:
cpp复制void DrawAll(Shape* shapes[], int count) {
for (int i = 0; i < count; ++i)
shapes[i]->Draw();
}
这段代码里,shapes[i] 的静态类型是 Shape*,但运行时它可能指向 Circle、Rectangle,也可能是以后新加的 Triangle。你调用 Draw(),动态类型如果是 Circle,执行的就是 Circle::Draw;如果是 Rectangle,执行的就是 Rectangle::Draw。新增一种图形类时,DrawAll 一行都不用改,这就是虚函数最核心的价值。
不过这里有个必须强调的细节:虚函数的正确分发,依赖的是“基类指针或引用”。如果你直接拿派生类对象去赋值给基类对象,切片就发生了,虚函数机制会失效。很多初学者在这上面栽过跟头:
cpp复制Shape s = Circle(); // 切片:Circle被切割成Shape
s.Draw(); // 调用的还是 Shape::Draw
想避免这种情况,必须用 Shape* 或 Shape& 去指向 Circle 对象,而不是用 Shape 对象直接接住派生类对象。这个区别不搞清楚,后面所有虚函数知识都会建立在错误的地基上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 虚函数机制拆开看:vptr、vtable和动态绑定
2.1 编译器在背后埋了什么
虚函数不是运行时“猜”出来的,它的实现依赖一种经典布局:每个含有虚函数的类,编译器会给它生成一个虚函数表,通常简称 vtable。vtable 里按声明顺序存着一组函数指针,指向这个类实际可用的虚函数版本。同时,每个含有虚函数的对象,会在内部被编译器悄悄安插一个隐藏指针,叫虚指针,简称 vptr,它指向该类对应的 vtable。
为了让你看得更直观,画一个简化的内存布局。假设有这样一个基类:
cpp复制class Base {
public:
virtual void f();
virtual void g();
void h(); // 非虚函数
private:
int a_;
};
那么一个 Base 对象在内存里大概是这种模样:
text复制对象Base的内存布局:
&object → vptr(指向Base的vtable)
+8 → a_
Base的vtable大概是:
text复制Base::vtable
[0] -> &Base::f
[1] -> &Base::g
注意 h() 不是虚函数,它不会出现在 vtable 里。编译器也知道 h() 的地址在编译期就能确定,所以调用 h() 时直接生成一条普通函数调用指令,不需要经过任何间接跳转。
当派生类出现后,情况就有意思了:
cpp复制class Derived : public Base {
public:
void f() override; // 覆盖 Base::f,g不覆盖
};
Derived的vtable就不再是重新从零创建的。它先继承 Base::vtable 的布局,然后把被覆盖的 f() 对应槽位换成 &Derived::f,没被覆盖的 g() 槽位继续保持 &Base::g:
text复制Derived::vtable
[0] -> &Derived::f ← 覆盖后替换
[1] -> &Base::g ← 未覆盖,沿用基类实现
这也解释了为什么 Derived 对象里的 vptr 指向的是 Derived::vtable,而不是 Base::vtable。哪怕你用一个 Base* p = &derived;,实际跑的时候,程序读取的是 derived 对象自身的 vptr,找到 Derived::vtable,于是调到了 Derived::f
