那时候还没有“接口”这个词。
大家写的更多是“约定”。
写在邮件里。
写在 README 里。
写在某个老同事的脑子里。
然后项目一大。
约定就开始漏风。
在没有纯虚函数的年代:大家怎么写“可扩展”的代码
C 时代的人其实很务实。
要扩展,就塞函数指针。
要复用,就传一堆回调。
你写一个“驱动框架”。
别人把 init、read、write 这些函数地址塞进来。
struct DriverOps {
int (*init)(void*);
int (*read)(void*, void* buf, int n);
};
这能跑。
也很灵活。
但有个小问题。
这些函数指针,不是“必须填”。
你忘了填,编译器也不拦你。
程序会用另一种更直接的方式提醒你。
在运行时。
C with Classes 之后:我们终于能把“约定”写成类
后来 C with Classes(再后来叫 C++)出来了。
大家开始用“基类 + 派生类”来组织代码。
想要的感觉很简单:
我写一段循环。
我拿着一个基类指针。
你是什么类型,你自己负责把自己“做对”。
这就是虚函数那套故事。
但故事讲到这里,还差一步。
差的是:怎么把“你必须实现某个函数”这件事,变成硬规则。
先复现那个老坑:我以为你会重写,你却默默用了默认实现
先写一个基类。
我给它一个“看起来很合理”的默认实现。
#include <iostream>
struct Shape {
virtual void draw() {
std::cout << "[Shape] default draw\n";
}
virtual ~Shape() = default;
};
struct Circle : Shape {
// 新手同事:忘了写 draw()
};
static void paint(Shape& s) {
s.draw();
}
int main() {
Circle c;
paint(c);
}
能编译。
也能跑。
输出还挺“合理”。
但你心里可能会一紧。
你想要的是 Circle 的行为。
结果它安静地退回了默认实现。
这就是当年很多团队的真实体验:
代码没报错。
bug 也不炸。
只是“悄悄不对”。
当时大家怎么补锅:靠自觉,靠 review,靠祈祷
你可以在文档里写:
“所有派生类必须实现 draw()。”
也可以在 code review 里盯。
但这件事很像在工地门口贴标语。
贴了。
不代表所有人都看见。
更不代表所有人都照做。
纯虚函数:把“必须实现”写进语法里
C++ 给了一个很直白的写法。
在虚函数后面写 = 0。
struct Shape {
virtual void draw() = 0;
virtual ~Shape() = default;
};
这一行的意思是:
这不是“我先给你一个默认实现”。
这是“我只要契约”。
只要你想当 Shape。
你就必须提供 draw()。
编译器会替你做催债。
先把名词掰开
virtual void draw() = 0; 叫纯虚函数(pure virtual function)。
含有纯虚函数的类,叫抽象类(abstract class)。
抽象类的特点也很直白:
它不能被直接拿来创建对象。
// Shape s; // 编译错误:抽象类不能实例化
你可以把抽象类当成:
一张“接入协议”。
我不关心你内部怎么画。
我只关心你有没有把 draw() 这个口子留出来。
回到刚才那个坑:现在它会在编译期就被抓住
我们把 Shape 改成纯虚函数后。
再看刚才那段 Circle。
struct Circle : Shape {
// 还是忘了写 draw()
};
这次不是“悄悄不对”。
而是直接不让你过。
你会在编译期看到错误。
这对新手很友好。
对老手也很省心。
因为它把“团队约定”变成了“语言规则”。
抽象类常见的用法:接口类(interface class)
在很多 C++ 项目里,你会看到一种很“瘦”的基类。
里面几乎只有纯虚函数。
再加一个虚析构。
struct Logger {
virtual void log(const char* msg) = 0;
virtual ~Logger() = default;
};
这就是最朴素的“接口类”。
它自己不干活。
它只规定:你要接进来,就得会 log()。
一个容易误会的细节:= 0 不是“函数地址等于 0”
刚从 C 过来的人,会把 = 0 看成“把什么东西赋成 0”。
不是。
它是语法。
意思是:这个函数没有在这里给出可用的动态绑定实现。
你必须在派生类里给出实现,才算“补齐”。
冷知识:纯虚函数也可以有实现体
这个点有点反直觉。
纯虚函数是“必须重写”。
但它依然可以有一个“基类版本”,给派生类复用。
#include <iostream>
struct Shape {
virtual void draw() = 0;
virtual ~Shape() = default;
};
void Shape::draw() {
std::cout << "[Shape] helper\n";
}
struct Circle : Shape {
void draw() override {
Shape::draw();
std::cout << "[Circle] real draw\n";
}
};
你看。
“必须重写”这条规则还在。
但你可以把一些共用步骤放在基类里。
派生类通过 Base::func() 显式调用它。
这招不一定常用。
但它挺像 C++ 的性格:
规则很硬。
也给你留了后门。
什么时候该用纯虚函数?
当你想表达的是“能力要求”,而不是“默认行为”。
就用纯虚函数。
当你想表达的是“我给你一个默认版本,你可以不改”。
就用普通虚函数。
把‘必须’写进编译器,比写进文档更靠谱。
小结
纯虚函数解决的不是语法问题。
它解决的是协作问题。
当代码开始分层,开始有人只拿到一个 Base*。
你需要一份稳定的契约。
以及一条硬规则:缺了就不准上路。
这就是 = 0 的价值。