1983 年,Bjarne Stroustrup 在贝尔实验室捣鼓出了 C with Classes 的继任者,他给它起了个新名字:C++。那个 ++ 不只是自增运算符的玩笑,更像是一种宣言——我要在 C 的基础上"加点东西"。
加什么?类、继承、还有今天要聊的主角:虚函数。
很多人第一次听说虚函数,都觉得这玩意儿有点"虚"——为什么要叫 virtual?为什么不叫 dynamic 或者 polymorphic?其实这个词来自 Simula 67(世界上第一门面向对象语言),Stroustrup 当年深受 Simula 影响,直接把这个概念和名字一起搬了过来。Virtual 在这里的意思是"表面上调用这个,实际上可能调用那个",有点"虚晃一枪"的意思。
但 Stroustrup 面临一个难题:C 程序员习惯了"零开销抽象"的哲学——你不用的东西不该让你付出代价。如果每个函数调用都要查表、跳转,性能敏感的 C 程序员会骂娘的。所以他做了一个关键决定:虚函数不是默认的,你得显式地写 virtual 关键字。这个设计一直延续到今天,也是 C++ 和 Java、Python 这些语言的根本区别——后者的方法调用默认就是动态的。
好,历史讲完了,我们来看看虚函数到底解决了什么问题。
一个让人抓狂的 Bug
想象你在 1990 年代写一个图形界面库(那时候 Qt 还没火起来),你定义了一堆图形类:
class Shape {
public:
void draw() {
std::cout << "Drawing a shape" << std::endl;
}
};
class Circle : public Shape {
public:
void draw() {
std::cout << "Drawing a circle" << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() {
std::cout << "Drawing a rectangle" << std::endl;
}
};
你写了一个通用的渲染函数:
void render(Shape& shape) {
shape.draw();
}
int main() {
Circle circle;
Rectangle rect;
render(circle); // 输出什么?
render(rect); // 输出什么?
}
你满怀期待地运行,结果屏幕上全是 "Drawing a shape"。圆形也是 shape,矩形也是 shape,什么都是 shape。你的精心设计的 Circle 和 Rectangle 的 draw() 函数根本没被调用。
这就是 C++ 默认的静态绑定(static binding)在作妖。编译器在编译时就决定了:render 函数的参数是 Shape&,那就调用 Shape::draw(),管你实际传进来的是圆是方。编译器只认类型签名,不管运行时的实际对象。
这个设计其实是从 C 语言继承来的——C 语言的函数调用地址在编译时就确定了,快是快,但不够灵活。Stroustrup 当年就是被这个问题困扰,才决定引入虚函数机制。
一个 virtual 关键字的魔法
解决方法简单得让人怀疑人生——在基类的函数前加一个 virtual:
class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape" << std::endl;
}
};
class Circle : public Shape {
public:
void draw() override { // override 是 C++11 引入的,可选但推荐
std::cout << "Drawing a circle" << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing a rectangle" << std::endl;
}
};
现在再运行,世界清净了:圆形画圆形,矩形画矩形。这就是多态(polymorphism)——同一个接口,不同的行为,运行时才决定调用谁。
这里有个细节:派生类的 draw() 函数后面跟了个 override 关键字。这是 C++11 加的,不是必须的,但强烈建议写上。为什么?因为它能帮你抓住一类隐蔽的 bug。
假设你手抖把 Circle 的 draw() 写成了 draw(int x),参数多了一个。没有 override 的话,编译器会认为你是在定义一个新函数,不会报错,但多态就失效了。加了 override 之后,编译器会检查:"你说要重写基类函数,但我找不到匹配的啊",然后报错。这个关键字是 Stroustrup 在 C++11 标准化时加的,他后来说这是他最喜欢的 C++11 特性之一——简单但有效。
多态的三个陷阱
虚函数看起来简单,但有几个坑,踩过的人都记忆深刻。
陷阱一:对象切片
Circle circle;
Shape shape = circle; // 看起来没问题?
shape.draw(); // 输出:Drawing a shape
这叫对象切片(object slicing)。circle 被复制给 shape 时,只有 Shape 部分的数据被复制了,Circle 特有的部分被"切"掉了。shape 现在就是个纯粹的 Shape 对象,不再是 Circle。多态需要指针或引用才能工作,因为只有指针和引用能保持对象的真实身份。
这个问题在早期的 C++ 代码里特别常见,因为很多从 C 转过来的程序员习惯了按值传递。Scott Meyers 在《Effective C++》里专门用一个条款讲这个,标题就叫"宁以 pass-by-reference-to-const 替换 pass-by-value"。
陷阱二:虚析构函数
这是个能让你的程序悄悄内存泄漏的经典 bug:
class Shape {
public:
~Shape() {
std::cout << "Shape destructor" << std::endl;
}
};
class Circle : public Shape {
private:
int* data;
public:
Circle() : data(new int[100]) { }
~Circle() {
delete[] data;
std::cout << "Circle destructor" << std::endl;
}
};
int main() {
Shape* shape = new Circle();
delete shape; // 问题!
}
运行一下,只输出了 "Shape destructor"。Circle 的析构函数没被调用,data 指向的内存泄漏了。
为什么?因为析构函数默认不是虚函数。delete shape 只看到 shape 的类型是 Shape*,就调用 Shape 的析构函数,完全不管实际对象是 Circle。
这个 bug 在大型项目里特别隐蔽,因为内存泄漏不会立即崩溃,只会让程序越跑越慢,最后莫名其妙地 OOM。我见过一个项目,因为忘了给基类加虚析构函数,跑了半年才发现服务器内存一直在涨。
解决方法很简单,给基类的析构函数加 virtual:
class Shape {
public:
virtual ~Shape() {
std::cout << "Shape destructor" << std::endl;
}
};
现在输出变成:
Circle destructor
Shape destructor
派生类的析构函数先执行(释放 data),然后是基类的析构函数。完美。
所以有个经验法则:如果一个类有虚函数,它的析构函数也应该是虚函数。或者更激进一点:只要一个类打算被继承,就把析构函数声明为虚函数。宁可多一点点开销,也不要冒内存泄漏的风险。
陷阱三:纯虚函数的 = 0 语法
说到纯虚函数,那个 = 0 的语法是不是看起来很奇怪?为什么不是 virtual void draw() abstract; 或者别的什么?
这又是一个历史遗留问题。Stroustrup 当年想要一个语法来表示"这个函数没有实现",但又不想引入新的关键字(C++ 已经够复杂了)。他灵机一动,用了 = 0 这个看起来像"赋值为空"的语法。这个设计在 1980 年代引起了不少争议,但最后还是保留了下来,一直用到今天。
class Shape {
public:
virtual void draw() = 0; // 纯虚函数,派生类必须实现
};
包含纯虚函数的类叫抽象类,不能实例化。你可以定义指向它的指针,但不能创建对象:
Shape shape; // 编译错误
Shape* shape = new Circle(); // 正确
抽象类的作用是定义接口契约——"你想当 Shape?行,但你得告诉我怎么 draw()"。这是面向对象设计的核心思想:针对接口编程,而不是针对实现编程。
虚函数表:编译器的魔法
好,现在问题来了:编译器怎么实现虚函数的?为什么加个 virtual 关键字,程序就能在运行时"知道"该调用哪个函数?
答案是虚函数表(vtable),这是 C++ 编译器最精妙的设计之一。
当你定义一个包含虚函数的类时,编译器会偷偷做两件事:
- 为这个类生成一个虚函数表——一个函数指针数组,存储所有虚函数的地址
- 在每个对象里塞一个隐藏的指针(通常叫
vptr),指向这个虚函数表
举个例子:
class Shape {
public:
virtual void draw() { }
virtual void move() { }
};
编译器生成的虚函数表大概长这样:
Shape 的 vtable:
[0] -> Shape::draw()
[1] -> Shape::move()
每个 Shape 对象的内存布局:
+--------+
| vptr | -> 指向 Shape 的 vtable
+--------+
| 其他成员 |
+--------+
当你调用 shape->draw() 时,编译器生成的代码大致是:
// 伪代码
(*(shape->vptr[0]))(); // 通过 vptr 找到 vtable,取出第 0 个函数指针并调用
派生类会继承基类的虚函数表,但会修改被重写的函数指针:
class Circle : public Shape {
public:
void draw() override { } // 重写
// move() 不重写
};
编译器生成的虚函数表:
Circle 的 vtable:
[0] -> Circle::draw() // 重写了,指向 Circle 的版本
[1] -> Shape::move() // 没重写,仍然指向 Shape 的版本
这就是多态的秘密:每个对象都知道自己的"真实身份"(通过 vptr),调用虚函数时,通过 vtable 找到正确的函数地址。
这个设计非常巧妙,但也有代价:
- 空间开销:每个对象多了一个指针(通常 8 字节)
- 时间开销:调用虚函数需要两次间接寻址(先找 vptr,再找函数指针),比普通函数调用慢一点
不过这个开销通常可以忽略。除非你在写游戏引擎的物理引擎或者高频交易系统,否则这点性能差异根本感觉不到。Stroustrup 的设计哲学是:你不用的东西不让你付出代价,但你用的时候,代价要尽可能小。虚函数表就是这个哲学的体现。
几个面试爱问的刁钻问题
面试官喜欢问一些边界情况,来测试你对虚函数的理解深度。
构造函数可以是虚函数吗?
不可以。原因很简单:构造函数的作用是创建对象,而虚函数的机制依赖于对象内部的 vptr。在构造函数执行时,对象还没完全构造好,vptr 还没正确设置,怎么实现多态?
而且构造函数也不需要多态。你总是明确知道要创建哪个类的对象:Circle* c = new Circle();,不存在"我想创建一个 Shape,但运行时才知道是 Circle 还是 Rectangle"的场景。
静态成员函数可以是虚函数吗?
不可以。静态成员函数不属于任何对象,没有 this 指针,无法访问对象内部的 vptr。虚函数的本质是"根据对象的实际类型来决定调用哪个函数",但静态函数根本不知道对象是谁。
虚函数可以有默认参数吗?
可以,但这是个大坑。默认参数是在编译时绑定的(静态绑定),而虚函数是在运行时绑定的(动态绑定)。两者混在一起会出现诡异的行为:
class Shape {
public:
virtual void draw(int x = 10) {
std::cout << "Shape: " << x << std::endl;
}
};
class Circle : public Shape {
public:
void draw(int x = 20) override {
std::cout << "Circle: " << x << std::endl;
}
};
int main() {
Circle circle;
Shape* shape = &circle;
shape->draw(); // 输出:Circle: 10
}
调用的是 Circle::draw()(动态绑定),但用的是 Shape::draw() 的默认参数 10(静态绑定)。这种行为违反直觉,容易出 bug。
所以建议:避免在虚函数中使用默认参数,或者确保基类和派生类的默认参数一致。
写在最后
虚函数是 C++ 最重要的特性之一,也是面向对象编程的基石。Stroustrup 当年设计虚函数时,在性能和灵活性之间做了精妙的平衡:默认不是虚函数(保证性能),但需要的时候可以显式声明(提供灵活性)。
这个设计影响了后来的很多语言。Java 和 C# 选择了相反的路线——方法默认是虚的(Java)或者需要显式声明 virtual 和 override(C#)。Python 和 JavaScript 更激进,所有方法调用都是动态的。每种选择都有权衡,但 C++ 的设计最符合"零开销抽象"的哲学。
面试时,除了讲清楚虚函数的用法,最好能聊聊虚函数表的实现机制,再提一下虚析构函数的重要性。如果能顺便吐槽一下 = 0 语法的历史包袱,面试官会觉得你是个有深度的程序员。
最后送你一句 Stroustrup 的名言:"C++ is a language that doesn't let you shoot yourself in the foot, unless you really want to." 虚函数就是这样——用对了是利器,用错了是地雷。