那会儿写 C++。
大家都挺自信。
尤其是刚把代码“改对”。
编译通过。
单元测试也绿了。
你心里想。
这事就算完了。
然后线上给你回了一巴掌。
不是崩溃。
更讨厌。
它“能跑”。
只是行为不对。
而且还很像“偶发”。
你开始怀疑人生。
也开始怀疑同事。
最后才发现。
问题出在一个字母上。
一个 const。
那时候。
C++ 还在 C++03 时代。
项目开始变大。
继承树开始变密。
大家也开始频繁重构。
你今天改了基类一个签名。
明天就可能在某个派生类里,悄悄埋下一颗雷。
没有 override 的年代:重写这件事主要靠眼力
在 C++ 里,你可以写一个“基类”,把它当成一个承诺。
它说:你只要实现这些函数,我就能用同一套代码驱动你。
这里会用到一个新词,“虚函数”。
你可以先把它理解成:通过 Base* 调函数时,程序会在运行时挑“真正对象”的那个版本。
#include <iostream>
struct Base {
virtual void draw() const { std::cout << "Base\n"; }
};
struct Derived : Base {
void draw() { std::cout << "Derived\n"; } // 你以为这是重写
};
int main() {
Derived d;
Base* p = &d;
p->draw();
}
这段代码能编译,也能跑,但输出却是 Base。
因为 Derived::draw() 并没有重写,它只是“另写了一个同名函数”。
这里的“签名”你先别紧张。
你可以先把它当成:函数名 + 参数列表 + const 这些“能区分函数”的东西。
少一个 const,在编译器眼里就是另一个函数。
你以为你在改同一把锁。
其实你在门旁边又装了一扇门。
在小项目里。
这种 bug 可能不常见。
因为层次浅,人脑还能兜得住。
可一旦开始写框架,开始写“别人会继承我”的代码,这类错误就会变多。
而且很难查。
因为它不是“写错语法”。
它是“写对了另一件事”。
当年很多团队的“防线”,其实就是注释。
或者在代码评审里互相提醒:“这个函数你确定重写到了吗?”
听起来很朴素。
但也很脆。
当年大家怎么掉坑:最常见的是“我以为我改的是同一个函数”
再来一个更现实的场景。
你在基类里加了点信息。
顺手把函数改成 const。
觉得挺合理。
struct Base {
virtual int size() const { return 1; }
};
struct Derived : Base {
int size() { return 2; } // 旧代码没跟上
};
改动很小,风险看起来也很小。
但从这一刻起,多态调用就悄悄变了。
Base* 看见的还是老版本,你在 Derived 里写的那个新实现根本没被用到。
你可能会说:“那我认真点看代码不就行了?”
可以。
但当年大家踩坑踩得多,就是因为人眼不稳定,代码评审也会漏。
尤其是重构时期。
尤其是凌晨。
先把三个词分清:重载 / 重写 / 隐藏
很多困惑其实来自三个词长得像。
你不用背定义。
先记住它们各自“发生在哪”。
重载。
发生在同一个类里。
同名。
参数不一样。
struct S {
void f(int) {}
void f(double) {}
};
重写。
发生在继承关系里。
基类有虚函数。
派生类提供同一个函数的“新实现”。
struct Base {
virtual void f(int) {}
};
struct Derived : Base {
void f(int) override {}
};
隐藏。
也发生在继承里。
但它更像是一个“名字层面的遮挡”。
派生类只要写了同名函数,就会把基类的同名函数先盖住。
哪怕参数不一样。
struct Base {
void g(int) {}
};
struct Derived : Base {
void g(double) {} // 这不是重写
};
int main() {
Derived d;
d.g(1); // 调的是 g(double),因为 Base::g(int) 被名字隐藏了
}
这类“隐藏”不一定是 bug。
但它很容易让新手误以为自己“在继承体系里改到了同一个函数”。
而 override 的存在。
本质上就是:
让你别靠眼力。
新手最容易困惑的两个词:静态类型 / 动态类型
你可能会疑惑。
刚才那个例子里。
p 明明指向的是 Derived。
为什么还能调用到 Base 的版本。
这里其实藏了两个“类型”。
静态类型。
是你写在代码里的那个。
比如 Base* p。
动态类型。
是运行时对象真正是什么。
比如 p 现在指向的那个对象,是 Derived。
如果函数不是虚函数。
编译器通常只看静态类型。
它会在编译期就把要调哪个函数决定好。
如果函数是虚函数。
它会在运行时根据动态类型去选实现。
你可以先把它理解成:
“运行时去翻一张表,找到真正该跳过去的函数地址。”
你现在不需要知道表长啥样。
只要记住:
虚函数这套机制的前提是。
派生类真的重写到了同一个函数。
一旦你写成了另一个签名。
那张表就不会替你“脑补”。
另一个新手容易卡的词:签名
签名不是指“函数名”。
它更像是“这扇门的完整门牌号”。
参数类型。
const。
以后你还会遇到 &/&&。
甚至 noexcept。
它们合在一起。
才是编译器判断“是不是同一个函数”的依据。
还有一个小细节。
派生类在重写时。
不需要再写 virtual。
struct Base {
virtual void f() {}
};
struct Derived : Base {
void f() override {} // 这里写不写 virtual 都行
};
很多人会下意识写成 virtual void f() override。
不算错。
只是多余。
override 本身已经把意图说得够清楚了。
override:把“我就是要重写”写给编译器看
C++11 做了一件很朴素的事。
它给你一个标记,让你可以直说:“这个函数,就是在重写基类虚函数。”
写法也很简单。
放在函数声明末尾。
struct Base {
virtual int size() const { return 1; }
};
struct Derived : Base {
int size() override { return 2; }
};
这时编译器会直接报错。
因为它找不到一个 Base::size() const 可以被你这行代码重写。
你被迫把签名写对。
struct Derived : Base {
int size() const override { return 2; }
};
它不改变运行时行为。
它只是在编译期替你做一次“对表”,检查你写的是不是你以为你写的那个。
关键结论是这一句。
你不是在求编译器“更聪明”。
你是在给它一个可以检查的承诺。
override 的价值也很像一个老梗。
它不替你写代码。
它只是在你“想当然”的那一刻,拉你一把。
override 还会在哪些地方救你一命
下面这些例子。
你现在看着可能有点“细”。
但它们都很现实。
而且共同点就一个:
你肉眼很难 100% 稳。
1) 基类忘了写 virtual
这是很多人第一次写 override 会遇到的。
struct Base {
void ping() {}
};
struct Derived : Base {
void ping() override {}
};
这会直接编译失败。
因为 override 只认“重写虚函数”。
基类不是虚函数。
你就别指望多态。
2) 参数类型差一点点
比如你以为 int 和 long 没啥区别。
在重写这件事上。
它们就是两码事。
struct Base {
virtual void set(int) {}
};
struct Derived : Base {
void set(long) override {} // 编译失败:没重写到
};
这不是编译器抠门。
是因为调用规则必须非常确定。
否则 Base* p 到底该跳到哪个函数。
就会变成玄学。
3) const 也是签名的一部分
你前面已经见过一次。
再把它说得更“新手友好”一点。
const 成员函数。
你可以先当成:这个函数承诺“不改对象的成员”。
你要是忘了这个承诺。
你就等于写了另一个函数。
4) 默认参数:一个很阴的角落
这个坑很像那句老话。
“你改了派生类,以为全世界都变了。”
其实没有。
struct Base {
virtual void log(int level = 1) {}
};
struct Derived : Base {
void log(int level = 2) override {}
};
int main() {
Derived d;
Base* p = &d;
p->log(); // 这里的默认参数用的是 Base 的 1,不是 Derived 的 2
}
动态绑定只管“选哪个函数实现”。
默认参数是在编译期就决定的。
它看的是指针/引用的静态类型。
也就是这里的 Base*。
5) 析构函数也能 override
这条我建议你记住。
接口类基本都要虚析构。
而派生类写上 override,能让意图更清楚。
struct Base {
virtual ~Base() = default;
};
struct Derived : Base {
~Derived() override = default;
};
你读到 ~Derived() override 的那一刻。
就知道它在一个多态体系里。
6) 返回类型:有一个“特例”能让你少写转型
这叫“协变返回类型”。
名字不重要。
重要的是你会遇到这种写法。
struct Base {
virtual Base* clone() const { return new Base(*this); }
};
struct Derived : Base {
Derived* clone() const override { return new Derived(*this); }
};
派生类可以把返回类型从 Base* 收窄成 Derived*。
这在“工厂/拷贝自身”一类接口里很常见。
7) 你以后会遇到的两个签名细节:noexcept 和 &
它们都算签名的一部分。
也都会被 override 检查。
noexcept 你可以先理解成:我承诺这个函数不抛异常。
& 你可以先理解成:这个函数只允许在“有名字的对象”上调用。
现在不熟没关系。
等你真的写到它们。
你会感谢 override 让问题提前爆在编译期。
8) 名字隐藏:你以为是“加功能”,其实把基类的同名函数盖住了
前面你已经见过“隐藏”。
这里补一个更工程一点的解法。
如果你确实想在派生类里新增一个同名重载。
但又不想丢掉基类那一套。
你可以把基类的名字“搬回来”。
struct Base {
void g(int) {}
};
struct Derived : Base {
using Base::g;
void g(double) {}
};
这样 Derived 里就同时看得见 g(int) 和 g(double)。
如果你非要一个直觉解释。
可以把它当成:
Derived 里本来只挂了一张“g 的门牌号”。
你一写 g(double)。
就把基类那张门牌号压到抽屉里了。
using Base::g; 就是把抽屉里那张再掏出来。
这不是 override。
但它经常和 override 同一个现场出现。
因为它们都在解决同一类问题:
别让“名字长得像”骗了你。
9) noexcept:你说了不抛异常,就别反悔
noexcept 你可以先把它当成一个承诺。
这个承诺会影响重写规则。
struct Base {
virtual void stop() noexcept {}
};
struct Derived : Base {
void stop() override {} // 这里通常会报错:你把 noexcept 弄丢了
};
这类错误。
没有 override 时也未必能蒙混过关。
但有了 override。
你会更早、更明确地看到“你没重写对”。
10) &/&&:只允许在某些对象形态上调用
这是更后面的内容。
你现在只要知道:它也会参与匹配。
struct Base {
virtual void use() & {}
};
struct Derived : Base {
void use() override {} // 编译失败:签名对不上(少了 &)
};
如果你在教程里第一次见到 & 写在函数后面。
先别慌。
把它当成“又一个签名细节”就行。
11) override 和 final 可以一起写
你既可以说“我重写到了”。
也可以顺手说“到我这里就别再重写了”。
struct Base {
virtual void run() {}
};
struct Derived : Base {
void run() override final {}
};
这行代码的语气有点像。
我改的是这个。
而且我希望它就停在这里。
11.5) final 有时候也会影响优化,但别把它当魔法
你可能听过一种说法。
“加了 final,编译器就能把虚调用优化掉。”
这话有一半是真的。
final 确实给了编译器更多信息。
有些情况下它能更大胆地做去虚拟化(devirtualization)。
但它不是承诺。
更不是你性能问题的救命稻草。
我更建议你把它当成:
让代码更好猜。
性能是顺便。
12) 两个常见误区:模板函数、静态成员函数都不能是虚函数
很多人刚从 C 过来。
会下意识觉得“函数都能虚”。
其实不行。
struct Base {
// virtual static void f(); // 不行
// virtual void g(int); // 可以
// template<class T> virtual void h(T); // 也不行
};
原因你现在不需要深究。
先记结论就行:
虚函数这套机制需要一个“固定的签名”。
而模板是“用的时候才生成”。
两者不是一个时空。
这个关键字为什么来得这么晚
你可能会好奇:这么好用,为什么 C++ 早期没有。
一个原因是历史包袱。
早年的 C++ 代码里,有人真的会用 override 当变量名,也有人用 final。
C++11 为了不把旧世界砸烂,把它们做成了“上下文关键字”。
大部分位置你仍然可以写 int final = 0;。
只是在类里写虚函数时,编译器才把它当关键字。
这事不浪漫。
但很 C++。
final:把“到这就行了”写给后来的人
override 是防止“你没重写成功”。
final 是防止“别人还想继续重写”。
你可以把它理解成:你在接口设计上画了一条线。
到这就别往下继承了。
或者这个函数就别再改了。
struct Base {
virtual void run() final {}
};
struct Derived : Base {
void run() override {} // 编译器会报错:不能重写 final 函数
};
也可以用在类上。
struct Logger final {};
struct FancyLogger : Logger {}; // 也会报错:不能继承 final 类
这不是为了“耍权威”。
更多时候,是为了让读代码的人少猜一点。
你告诉他:这条路我试过,走到这里就够了。
那 final 到底什么时候用
我不太建议把 final 当成“默认习惯”。
它更像是一句设计声明。
你在告诉别人:
这里我不希望再长出新的分支。
比如。
你写的是一个小工具类。
它没有给别人当基类的必要。
你干脆把门关上。
struct Tokenizer final {
void run() {}
};
或者。
你在一个多态体系里。
某个函数你希望“到此为止”。
别人如果要扩展行为。
请用别的方式。
比如组合。
比如再加一层接口。
这听起来有点强势。
但你换个角度看。
它也在减少误解。
毕竟很多时候。
派生类重写一个函数。
不是在“扩展”。
是在“破坏你原来那套不成文的约束”。
关键结论还是那句。
final 不一定让代码更快。
但经常让代码更好猜。
给新手的一份小清单:你可以怎么用它们
先给一个朴素建议。
只要你在派生类里真的打算重写。
就写 override。
你写得越早。
你越少靠记忆。
再给一个更保守的建议。
final 不要乱加。
它更像是一个“我想清楚了”的信号。
你可以从这几种情况开始用。
- 你不希望这个类被继承:工具类、值类型、小组件
- 你不希望这个函数再被改行为:它承载了某个不成文约束
- 你想让读代码的人少猜:告诉他扩展点在哪里,不在哪里
一个小习惯:把 override 当成“默认开关”
如果你在写派生类,只要你真的想重写,就写上 override。
它的成本几乎为零。
收益很实在。
它能把很多“未来才爆炸”的 bug,提前变成今天的编译错误。
一句话总结。
override 是给编译器看的。
也是给未来的你看的。
而 final 更像一个设计决定。
你需要它的时候,你大概率也知道原因。
最后送你一句我很喜欢的“老工程经验”。
你没法靠认真保证不出错。
但你可以让工具替你更早地发现错。