⚡C++ 对象模型与多态

overridefinal 与重写 / 重载 / 隐藏

面试回答

常见问法

  • 重载、重写、隐藏有什么区别?
  • 为什么现代 C++ 强烈建议写 override
  • 派生类参数改了一点,为什么不是重写而是隐藏?
  • final 能修饰什么?实际意义是什么?
  • 基类有多个同名函数时,为什么子类写一个同名函数会把基类的一组函数“挡住”?

回答

这几个概念很容易混,但它们发生的层级完全不同:

  • 重载(overload):发生在同一作用域,函数名相同,但参数列表不同。本质是编译期多态。
  • 重写 / 覆盖(override):发生在继承体系中,派生类重新定义基类的虚函数,要求函数签名匹配,运行时通过虚函数表实现动态绑定。
  • 隐藏(name hiding):派生类里只要出现了和基类同名的函数,不管参数是否相同,都会先把基类这个名字遮住;这是一种名字查找规则,不等于多态。

override 的核心价值不是“语法更好看”,而是把“我想重写”变成编译器可检查的承诺。 如果你本来想重写基类虚函数,但因为参数、const、引用限定符等细节写错了,没加 override 时它可能悄悄变成隐藏;加了 override,编译器会直接报错,帮你在编译期发现问题。

struct Base {
    virtual void f(int) {}
};

struct Derived : Base {
    void f(int) override {}   // 真正重写
};

最典型的坑是“看起来很像重写,其实只是隐藏”:

struct Base {
    virtual void g(int) {}
};

struct Derived : Base {
    void g(double) {}         // 不是重写,是隐藏
};

如果写成:

struct Derived2 : Base {
    void g(double) override {} // 编译报错:并没有重写任何基类虚函数
};

这就是 override 的工程价值:防止隐藏伪装成重写

final 则表示“到此为止”:

  • 修饰虚函数:禁止继续在更下层派生类里重写
  • 修饰:禁止该类再被继承
struct B {
    virtual void run() final {}
};

struct C final {};

一句适合面试的总结是:

重载看参数列表,重写看虚函数匹配,隐藏看名字查找;override 是正确性工具,final 是设计约束工具。

追问

1)为什么参数不同会变成隐藏,而不是重写?

因为重写的前提是先匹配到基类的虚函数接口。 而名字查找时,派生类里一旦出现同名函数,基类同名函数会先被遮住。此时你在派生类作用域里看到的是“这个名字已经被子类接管了”,参数不同只说明它不是同一个函数签名,所以不会形成重写,只会形成隐藏。

2)重写到底要求哪些部分匹配?

常见要点:

  • 函数名相同
  • 参数列表相同
  • 基类函数必须是 virtual
  • const 限定要一致
  • 引用限定符要一致,比如 &&&
  • 返回类型要相同,或者满足协变返回类型
  • noexcept / 异常说明也要兼容

例如下面不是重写:

struct Base {
    virtual void h() const {}
};

struct Derived : Base {
    void h() override {}   // 报错:少了 const
};

3)为什么建议“所有重写都写 override”?

因为它能防住很多真实工程中的隐蔽 bug:

  • 参数类型写错
  • 少写了 const
  • 引用限定符不一致
  • 想改接口,结果把多态破坏了
  • 代码审查时更容易看出设计意图

它不是“语法糖”,而是低成本、高收益的静态检查手段

4)final 在工程里什么时候用?

常见场景:

  • 某个虚函数已经给出最终语义,不希望下游改行为
  • 某个类就是叶子类型,不打算继续继承
  • 明确表达设计边界,减少误用
  • 某些场景下还能帮助编译器做更激进的优化,但这不是主要目的

5)如果我想在子类里继续使用基类那组同名重载怎么办?

using 把基类重载集引入当前作用域:

struct Base {
    void f(int) {}
    void f(double) {}
};

struct Derived : Base {
    using Base::f;   // 把基类的 f 重载集带进来
    void f(std::string) {}
};

这样 Derived 里就能同时看到三组 f


原理展开

1. 重载、重写、隐藏分别发生在哪一层

重载:同一作用域内的接口扩展

重载本质是一个名字,对应多组参数形式

struct A {
    void print(int) {}
    void print(double) {}
};

编译器在编译期根据实参选择合适版本。 它不要求 virtual,也不依赖继承,核心是函数签名不同

适用场景:

  • 同一操作支持多种参数类型
  • 构造函数多种初始化方式
  • 提供统一语义、不同输入形式的接口

重写:继承体系中的动态多态

重写一定要满足两个条件:

  1. 基类函数是 virtual
  2. 派生类函数确实匹配基类虚函数接口
struct Base {
    virtual void draw() { std::cout << "Base\n"; }
};

struct Derived : Base {
    void draw() override { std::cout << "Derived\n"; }
};
Base* p = new Derived;
p->draw();   // 动态绑定,调用 Derived::draw

适用场景:

  • 统一接口,不同类型有不同实现
  • 框架回调、策略模式、运行时多态
  • 基类指针 / 引用操作不同子类对象

隐藏:名字查找先发生,重写判断后发生

隐藏最容易出坑,因为它看起来像“我改了一个同名函数”,但实际效果未必是多态。

struct Base {
    void foo(int) {}
    void foo(double) {}
};

struct Derived : Base {
    void foo(std::string) {}
};

此时在 Derived 作用域中,Base::foo(int)Base::foo(double) 都被隐藏了。

Derived d;
// d.foo(1);          // 报错,看不到 Base::foo(int)
d.foo("hello");

如果你希望继续保留基类那组名字,需要显式写:

struct Derived : Base {
    using Base::foo;
    void foo(std::string) {}
};

2. override 为什么是现代 C++ 的必备习惯

它解决的是“意图和事实不一致”的问题

很多 bug 不是不会写虚函数,而是以为自己在重写,实际上没有

比如:

struct Base {
    virtual void process(int) const {}
};

struct Derived : Base {
    void process(int) {}   // 没有 const,不是重写
};

这段代码如果不写 override,可能照样编译通过,但行为不符合预期:

Base* p = new Derived;
p->process(42);   // 调的是 Base::process,而不是 Derived::process

如果写成:

struct Derived : Base {
    void process(int) override {}   // 编译报错,及时暴露问题
};

override 检查的不是“名字像不像”,而是“语义上是否真的覆盖”

它会帮你检查:

  • 是否真的存在可重写的基类虚函数
  • 函数签名是否匹配
  • cv/ref 限定是否一致
  • 返回类型是否符合覆盖规则

所以在工程里,通常建议:

  • 基类里该多态的函数写 virtual
  • 派生类里所有重写都写 override
  • 不要在派生类里重复写 virtual 代替 override

例如:

struct Base {
    virtual void run();
};

struct Derived : Base {
    void run() override;   // 推荐
};

而不是:

struct Derived : Base {
    virtual void run();    // 可编译,但不如 override 明确
};

因为 virtual 只说明“这是虚函数”,override 还能说明“它确实覆盖了基类版本”。


3. final 的设计价值,以及实际怎么选

final 修饰虚函数:锁定行为

struct Base {
    virtual void start() final {}
};

struct Derived : Base {
    // void start() override {}   // 报错:不能继续重写
};

适合场景:

  • 模板方法模式中某一步骤不允许被改
  • 出于安全、协议一致性、状态机正确性,某行为必须固定
  • 框架对外暴露扩展点,但有些关键函数不允许下游破坏

final 修饰类:禁止继承

class Logger final {
public:
    void log() {}
};

// class FileLogger : public Logger {}; // 报错

适合场景:

  • 这个类本来就是叶子类
  • 不希望用户通过继承改语义
  • 设计上优先组合而不是继承
  • 某些资源管理类、工具类、值类型,不应该成为基类

工程实践中的选择原则

什么时候用 override 只要是重写,就应该写。几乎没有例外。

什么时候用 final 当你想表达“这里是设计边界”时才用,而不是到处乱加。 final 是一种约束,约束越多,扩展空间越小,所以要基于设计意图而不是“怕别人乱改”随便加。


对比总结

概念发生位置核心条件是否要求 virtual是否体现多态典型用途常见风险
重载(overload)同一作用域同名、参数列表不同否,编译期决定一个语义支持多种参数形式重载过多导致调用歧义
重写(override)继承体系派生类覆盖基类虚函数,签名匹配是,运行期动态绑定运行时多态、统一接口多实现签名写错导致没重写成功
隐藏(hiding)继承体系派生类定义同名函数,遮住基类同名函数不一定很少主动使用,多数是副作用误以为发生了重写
关键字可修饰对象作用主要价值工程建议
virtual基类成员函数声明其支持动态绑定建立多态入口放在基类接口上
override派生类虚函数声明“我在重写”并要求编译器检查防止误写成隐藏所有重写都应写
final类、虚函数禁止继续继承或继续重写表达设计边界按设计约束使用,不要滥用
易混点正确认识示例
同名就是重写错。先看是不是基类虚函数、签名是否匹配g(int) vs g(double) 不是重写
参数不同就是重载只在同一作用域才是重载;继承里可能是隐藏子类新增 f(double) 会把基类 f(int) 挡住
写了 virtual 就够了不够。override 才能验证“是否真重写”void h() const vs void h()
看到继承就默认多态错。只有通过虚函数接口才有运行时多态非虚函数不会动态绑定

易错点

  • 重载、重写、隐藏混成一个概念,只按“同名函数”判断。

  • 以为“参数差一点点也算重写”,实际上只要不匹配,就可能变成隐藏。

  • 不知道这些细节也会影响重写是否成立:

    • const
    • & / && 引用限定符
    • 返回类型规则
    • noexcept
  • 误以为子类定义一个同名函数,只影响对应签名;实际上它可能把基类整组同名重载都隐藏掉。

  • 认为 override 只是代码风格,不写也没关系。

  • 在派生类里只写 virtual 不写 override,导致设计意图不清晰。

  • 认为 final 只是优化提示。它首先是接口/继承边界约束

  • 忽略默认参数的静态绑定特性:虚函数动态绑定的是函数体,默认参数绑定看静态类型

示例:

struct Base {
    virtual void f(int x = 1) { std::cout << x << "\n"; }
};

struct Derived : Base {
    void f(int x = 2) override { std::cout << x << "\n"; }
};

Base* p = new Derived;
p->f();   // 输出 1,不是 2

调用的是 Derived::f,但默认参数取的是 Base 视角的值。 这是面试里很加分的细节。


记忆技巧

  • 重载:同一层里“一个名字,多种参数”
  • 重写:继承里“子类接管父类虚函数实现”
  • 隐藏:子类“把父类同名先挡住了”

可以背一句:

重载看参数,重写看虚函数,隐藏看名字查找。

再背一句工程口诀:

基类给 virtual,子类写 override,不想再改用 final

还有一个判断顺序口诀:

先查名字,再看匹配;匹配不上,就别谈重写。


面试速答版

重载、重写、隐藏的区别在于层级不同。 重载是在同一作用域里,同名但参数不同;重写是在继承体系里,派生类覆盖基类虚函数,要求签名匹配;隐藏是子类一旦定义了同名函数,会把基类同名函数先遮住,不一定发生多态。

override 的意义是让编译器检查“我是不是真的在重写基类虚函数”,防止因为参数、const 等细节写错,结果悄悄变成隐藏。 final 则表示禁止继续重写或继续继承。

工程上一般建议:基类多态接口写 virtual,子类所有重写都写 override,确实要封死扩展点时再用 final


面试加分版

这题我会分三层答。

第一,重载、重写、隐藏不是一回事。 重载发生在同一作用域,是同名不同参,本质是编译期决议;重写发生在继承体系里,前提是基类函数是 virtual,而且派生类签名真正匹配,它对应运行时多态;隐藏则是名字查找规则,子类只要出现同名函数,就会先把基类同名函数遮住,所以“同名”不代表“重写”。

第二,为什么现代 C++ 强调 override。 因为真实工程里最常见的问题不是不会写虚函数,而是“以为自己写的是重写,实际上只是隐藏”。比如基类是 virtual void f(int) const,子类写成 void f(int),如果不加 override,代码可能还能编译,但多态就失效了;加了 override 编译器会立刻报错。所以我会把 override 理解成正确性工具,不是风格糖。

第三,final 是设计约束。 它可以修饰虚函数,表示这个重写链到此结束;也可以修饰类,表示这个类不允许继续继承。工程上只有当我明确要表达“这是稳定边界、不能再扩展”时才用 final,否则不会滥用。

如果再补一条实践经验,我会提: 在继承场景下,子类定义同名函数可能把基类整组重载都隐藏掉,如果还想保留基类那组接口,要显式写 using Base::f;。这也是很多人把“隐藏”误判成“重写”或“重载”的根源。