那时候写代码。
大家更信指针。
也更怕指针。
你想写“通用代码”。
比如你写了个小绘图工具。
里面有圆。
有矩形。
还有些你自己临时加的形状。
你只想写一段循环。
让它们自己把自己画出来。
在坑里长大的“多态”
上世纪 80 年代初,Bjarne Stroustrup 在 Bell Labs 做系统软件。 他喜欢 Simula 那套“对象/继承”的组织方式。 Simula 是更早的语言,里面已经有“类”这套组织代码的办法。
工程要能交付,代码要能和 C 混着跑。 没人愿意为了一个“漂亮的概念”,重写整个世界。
于是 C with Classes 出现了。 后来它长成了 C++。
那时最朴素的需求,其实很日常。 我手里拿着一个“基类指针”。 我希望它能调用到真正对象的版本。
也就是你写 Shape*。
但运行时它可能指向 Circle。
你希望调用到 Circle 的实现。
关键不在“对象”。
关键在于:编译器到底在什么时候决定“调用哪个函数”。
决定得太早,你就会踩坑。
如果决定发生在运行时,我们后来叫它动态绑定(dynamic dispatch)。 dispatch 你可以先理解成:把一次调用“分发”到正确的实现。
先别急着记名词。 我们先把坑复现出来。
先复现:不写 virtual,会发生什么?
先写两个类。
基类里有个 draw()。
派生类也写一个同名的 draw()。
#include <iostream>
struct Shape {
void draw() {
std::cout << "Shape\n";
}
};
struct Circle : Shape {
void draw() {
std::cout << "Circle\n";
}
};
int main() {
Circle c;
Shape* p = &c;
p->draw();
}
如果你刚从 C 过来,直觉可能是:打印 Circle。
但它会打印 Shape。
不是编译器跟你作对。 是它太“老实”了。
编译器看到的是 Shape*。
它就把这次调用,直接绑定到 Shape::draw()。
这件事叫静态绑定(static binding)。 写在指针上的类型,往往就决定了你会调用到谁。
没有 virtual,同名函数不等于“重写”。
新手最容易卡住的地方:你以为“对象是 Circle”就够了
刚从 C 过来时,你很容易把上面那段代码读成一句话:
“我明明拿的是 Circle,为什么你不叫 Circle::draw()?”
问题就出在两件事上。
第一件事:类型有两种。
Shape* p = &c; 这一行里,p 这个变量写着 Shape*。
这叫静态类型(static type)。
但 p 里装着的地址,指向的确实是一个 Circle 对象。
这叫动态类型(dynamic type)。
没有 virtual 的时候,编译器只认静态类型。
它看到 Shape*,就把调用“定死”成 Shape::draw()。
第二件事:多态只对“通过基类视角去看对象”这件事负责。
也就是说。
你写 c.draw()。
这里根本不存在“基类视角”。
编译器当然会直接选 Circle::draw()。
你写 p->draw()。
你明确告诉编译器:我现在只有一个 Shape*。
如果还想在运行时选到 Circle::draw(),那就得用 virtual。
下面这个小对照,新手通常一眼就能把“我到底在用哪个视角”看明白。
#include <iostream>
struct Shape {
void draw() { std::cout << "Shape\n"; }
};
struct Circle : Shape {
void draw() { std::cout << "Circle\n"; }
};
int main() {
Circle c;
c.draw(); // Circle(因为静态类型就是 Circle)
Shape* p = &c;
p->draw(); // Shape(因为静态类型是 Shape*)
}
你会发现:
问题不是“对象是不是 Circle”。 而是“你是拿着什么类型去看它”。
当年大家怎么救火:手写函数指针
在没有 virtual 的年代,很多人会在 C 里这么干。
把“该调用哪个函数”放进数据里。
你可以把它理解成:手写一张“小小的函数表”。
#include <iostream>
struct Shape {
void (*draw)(Shape*);
};
static void shape_draw(Shape*) {
std::cout << "Shape\n";
}
static void circle_draw(Shape*) {
std::cout << "Circle\n";
}
int main() {
Shape s{&shape_draw};
Shape c{&circle_draw};
s.draw(&s);
c.draw(&c);
}
这就是最原始的“函数表”。 你把函数地址塞进去,调用时再跳过去。 能用,而且很 C。
但坑也很 C
这套写法,坑不在“难”。 坑在“它太容易工作”,所以也太容易悄悄坏掉。
比如你忘了初始化 draw。
程序还能编译,然后在运行时用一种很直接的方式提醒你:空指针。
再比如你把签名写错了。 或者把本该给 Circle 的函数,塞进了 Shape。
你会得到一种很熟悉的体验:
“跑着跑着就炸了。”
当年的人不是不知道这些坑。 只是他们更怕另一件事:每个项目都手写一遍同样的救火代码,然后每个项目都用不同的姿势掉坑里。
virtual 做的事:把“函数表”变成语言机制
virtual 本质上没有发明新魔法。
它做的是一件很工程化的事:把“手写函数表 + 手动跳转”这套方案,变成编译器替你维护的制度。
你可以把它理解成一句很朴素的话:
这个函数允许“晚一点再决定调用谁”。
于是你可以这样写。
#include <iostream>
struct Shape {
virtual void draw() {
std::cout << "Shape\n";
}
};
struct Circle : Shape {
void draw() override {
std::cout << "Circle\n";
}
};
int main() {
Circle c;
Shape* p = &c;
p->draw();
}
现在会打印 Circle。
因为调用点不再只“看指针类型”。
它会在运行时去问对象自己:你到底是谁?
virtual 让“选哪个函数”这件事推迟到运行时。
但别急着庆祝:virtual 有一堆“新手专属坑”
你一旦开始用 virtual,你会很快遇到几个特别常见的困惑。
它们不难。
但如果没人提前提醒,你会在同一个坑里反复摔。
1) virtual 只在“指针/引用”上触发
新手最常见的误解是:
“我已经继承了,我也写了同名函数,为什么还不多态?”
答案通常是:你在用值语义。
#include <iostream>
struct Shape {
virtual void draw() { std::cout << "Shape\n"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw() override { std::cout << "Circle\n"; }
};
static void paint_by_value(Shape s) {
s.draw();
}
static void paint_by_ref(Shape& s) {
s.draw();
}
int main() {
Circle c;
paint_by_value(c); // Shape
paint_by_ref(c); // Circle
}
为什么 paint_by_value(c) 会变成 Shape?
因为 Shape s 这一行,会把 Circle “切”成一个纯 Shape。
派生类那部分信息没了。
这叫对象切片(object slicing)。
你可以把它理解成:
你不是“拿着一个 Circle”。 你是“新拷贝了一份 Shape”。
2) 你以为自己 override 了,其实只是“长得像”
这也是老坑。
你在派生类里写了一个同名函数。 你以为叫“重写”。 但它的签名差了一点点。
比如基类是 const 成员函数,派生类忘了写 const。
#include <iostream>
struct Shape {
virtual void draw() const { std::cout << "Shape\n"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw() { std::cout << "Circle\n"; } // 注意:少了 const
};
int main() {
Circle c;
Shape& s = c;
s.draw(); // Shape(你以为会是 Circle)
}
这时候最好的自救方式不是“靠眼睛盯签名”。
而是:永远写 override。
你把 Circle::draw() 改成 void draw() const override。
编译器会立刻告诉你:你没有在 override 任何东西。
这也是为什么我更愿意把 override 叫做:
“给新手的安全带。”
3) 构造/析构期间,虚函数不会派发到派生类
这个坑尤其阴。
你可能会在基类构造函数里调用一个虚函数。 你以为它能“自动跑到派生类”。
但不行。
在构造 Shape 的那一刻,派生类那部分还没构造好。
编译器会把这次虚调用当成基类版本。
#include <iostream>
struct Shape {
Shape() {
draw();
}
virtual void draw() {
std::cout << "Shape::draw\n";
}
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw() override {
std::cout << "Circle::draw\n";
}
};
int main() {
Circle c; // 构造期间输出 Shape::draw
}
这不是编译器“偷懒”。 这是它在帮你避免更恐怖的事情: 在一个还没构造好的对象上执行派生类逻辑。
4) 只要你打算“通过基类指针 delete 派生对象”,基类析构必须是 virtual
你写多态,迟早会写到这段代码:
Shape* p = new Circle;
delete p;
这时候如果 Shape 的析构函数不是 virtual,结果就是未定义行为。
最直观的理解是:
你手里只有 Shape*。
没有 virtual,delete 这件事也只能按 Shape 的“静态类型”去处理。
派生类析构没被正确调用。
资源就可能泄露。
更糟的是:它还可能“看起来能跑”,直到某天突然炸。
所以一个很实用的经验是:
只要一个类里有任何一个 virtual 函数。
你就顺手给它一个 virtual ~T() = default;。
你会少掉很多“解释不清”的崩溃。
5) virtual + 默认参数:默认值是按“静态类型”选的
这个点很反直觉。
虚函数的“选哪个实现”是动态的。 但默认参数的“默认值是多少”是静态的。
#include <iostream>
struct Shape {
virtual void draw(int times = 1) {
std::cout << "Shape times=" << times << "\n";
}
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw(int times = 2) override {
std::cout << "Circle times=" << times << "\n";
}
};
int main() {
Circle c;
Shape* p = &c;
p->draw();
}
这行 p->draw() 最终会调用到 Circle::draw。
但它传进去的 times,是 Shape 的默认值 1。
所以输出是:Circle times=1。
如果你看到这里有点不舒服。 那恭喜你。 你已经开始用“语言层面的视角”看 C++ 了。
经验上更稳的做法是:
默认参数只在最外层接口出现一次。 派生类不要改默认值。 需要不同默认行为,就改成两个不同的非虚入口去封装。
再往前走一步:纯虚函数,就是“我只要契约,不要实现”
当你写到某一阶段,你会发现:
你其实并不想给 Shape::draw() 一个“默认实现”。
你想表达的是:
“能当 Shape 的,都必须会 draw。”
这时候你会看到一种写法。
struct Shape {
virtual void draw() = 0;
virtual ~Shape() = default;
};
这个 = 0 不代表“函数地址是 0”。
它是语法。
意思是:这是一个纯虚函数(pure virtual function)。
有纯虚函数的类,叫抽象类(abstract class)。 它不能被直接实例化。
// Shape s; // 编译错误:抽象类不能直接构造
你可以把它理解成:
这个类只负责定义“边界”。 负责定义“你要接入这个系统,必须提供哪些能力”。
它更接近你在别的语言里听到的那个词。
接口。
这背后,编译器通常悄悄做了三件事
下面这段不是标准条文。 但它是大多数编译器常见的实现思路。
通常实现会做三件事。
为类生成一张表。 表里放一组函数地址。
然后在每个对象里塞进一个隐藏指针。 让对象能指向那张表。
那张表通常被叫做 vtable(虚函数表)。 那个隐藏指针常被叫做 vptr。
名字你先不背也没关系。 你只要记住形状:对象里多了一个“指向表的指针”。 表里是一排函数地址。
最后把调用点改写成“先查表,再跳转”。
当你写 p->draw(),它就不再是“call 某个固定地址”。
这就是为什么虚函数调用通常有一点点开销:一次间接跳转,以及对象里多出来的一个指针。
但换来的是:你不用在每个项目里重复造轮子。 也不用靠团队自律去保证“函数指针一定被正确填好”。
一个顺手的洞见:virtual 其实是在买“边界稳定”
当你的代码开始分层,开始有“接口”和“实现”。
开始有人只拿到 Shape*,却需要安全地扩展新类型。
你会发现虚函数表的价值不在于“能调用到 Circle”。
而在于:你可以把调用者和实现者隔开,让他们用一份稳定的契约协作。
这份契约的名字。
叫接口。
小结
你刚学完 C,看到 virtual 很容易把它当成“面向对象的标配”。
其实它更像当年工程师总结出来的一条经验。
把一堆容易忘、容易写错、还很难代码评审出来的手工活。
直接收编进语言里。
如果你愿意用一句话记它。
virtual 不是“神秘的 OOP 魔法”。
它更像:
给函数指针这件事,办了个户口。