【C++】函数重写

面向对象编程的三大特性------封装、继承、多态------共同构建了灵活而可扩展的软件体系。继承允许我们复用基类的代码,而多态则允许我们以统一的接口操作不同类型的对象。函数重写(override)正是实现多态的核心机制:派生类重新定义基类中已声明的虚函数,使得通过基类指针或引用调用该函数时,实际执行的是派生类的版本

没有函数重写,继承仅停留在代码复用的层面,无法实现"一种接口,多种实现"的运行时多态。例如,一个图形绘制系统可能有 CircleRectangle 等多个子类,它们都继承自 Shape 基类。通过重写 Shape::draw() 虚函数,我们可以用 Shape* 指针统一管理所有图形,并在运行时根据具体对象类型调用正确的绘制函数。这种能力是面向对象设计的基石。

函数重写的基本概念

什么是函数重写

函数重写(Override)是指在派生类中重新实现基类中声明为 virtual 的成员函数。重写必须满足以下条件:

  • 函数名称、参数列表(包括参数类型、顺序)、常量性(const)、引用限定符等完全一致
  • 返回类型要么相同,要么是"协变"(covariant)类型(见 3.3)。
  • 基类函数必须使用 virtual 关键字声明(C++11 起允许使用 override 显式标记)。

示例

cpp 复制代码
class Animal {
public:
    virtual void speak() const {
        std::cout << "Animal speaks." << std::endl;
    }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void speak() const override {   // 重写基类虚函数
        std::cout << "Dog barks." << std::endl;
    }
};

int main() {
    Animal* ptr = new Dog();
    ptr->speak();   // 输出 "Dog barks."
    delete ptr;
}

函数重写 vs 函数隐藏

初学者极易混淆"重写"与"隐藏"。隐藏 发生在以下情况:派生类定义了一个与基类非虚函数 同名的函数,或者参数列表不同的同名函数。此时基类的同名函数被隐藏,但没有多态效果,通过基类指针调用时仍然调用基类版本。

隐藏示例

cpp 复制代码
class Base {
public:
    void func() { std::cout << "Base func\n"; }   // 非虚函数
};

class Derived : public Base {
public:
    void func() { std::cout << "Derived func\n"; } // 隐藏,非重写
};

int main() {
    Base* b = new Derived();
    b->func();   // 输出 "Base func",静态绑定
}

核心区别

  • 重写必须是虚函数,且函数签名完全相同。
  • 隐藏则无视虚特性,只要名字相同即隐藏。

函数重写 vs 函数重载

重载(Overload)是在同一作用域内 定义多个同名但参数列表不同的函数,属于静态多态(编译时决定)。重写则是不同作用域(基类与派生类)的虚函数重新定义,属于动态多态(运行时决定)。

特性 重载 (Overload) 重写 (Override)
作用域 同一类/同一作用域 基类与派生类不同作用域
函数签名 参数列表必须不同 参数列表必须完全相同
关键字 无特殊关键字 基类函数需 virtual
绑定时期 编译时静态绑定 运行时动态绑定
多态类型 编译时多态 运行时多态

虚函数与动态绑定的核心语法

virtual 关键字

virtual 是开启动态绑定的开关。只有被声明为 virtual 的函数才可能被派生类重写并表现出多态性。

  • 一旦函数在基类中被声明为 virtual,它在所有派生类中保持虚特性(即使派生类省略 virtual 关键字,仍为虚函数,但为清晰应保留)。
  • 构造函数不能是虚函数(虚表机制依赖对象构建完成)。
  • 静态函数不能是虚函数(静态函数属于类,没有 this 指针,无法参与动态绑定)。

注意 :重写函数在派生类中不必再次写 virtual,但 C++11 引入的 override 更推荐使用,不仅增强可读性,还能让编译器进行签名检查。

override 与 final 说明符(C++11)

override

在派生类虚函数声明后添加 override,显式告知编译器这是重写。若签名不匹配或基类无对应虚函数,编译报错。

好处

  • 防止因拼写错误或参数类型不符而导致"本想重写却变成隐藏"的 bug。
  • 自文档化,提升代码可读性。
cpp 复制代码
class Derived : public Base {
public:
    void show() const override { ... }   // 正确重写
    void display() override { ... }      // 若基类无 display 虚函数,编译错误
};

final

final 用于修饰虚函数或类,阻止进一步重写/继承。

  • 虚函数后加 final:该虚函数不可在后续派生类中重写。
  • 类名后加 final:该类不可被继承。
cpp 复制代码
class Base {
    virtual void foo() final;   // 不允许重写
};

class Derived final : public Base { };   // 不可再被继承

协变返回类型

C++ 允许重写虚函数时返回类型可以是"协变"类型------即基类虚函数返回某类类型(或指针/引用),派生类重写时返回该类的派生类类型(指针/引用)。这是重写规则中唯一的例外。

示例

cpp 复制代码
class Base {
public:
    virtual Base* clone() const { return new Base(*this); }
};

class Derived : public Base {
public:
    Derived* clone() const override {   // 返回 Derived*,协变
        return new Derived(*this);
    }
};

注意:协变仅适用于返回类型为指针或引用的情况,且要求类型之间具有可转换的继承关系。

重写与访问控制

虚函数的访问级别(public、protected、private)不影响重写能力,但调用方式受限于指针/引用的静态类型访问权限。

示例

cpp 复制代码
class Base {
private:
    virtual void secret() { std::cout << "Base secret\n"; }
public:
    void callSecret() { secret(); }   // 公有接口内部调用私有虚函数
};

class Derived : public Base {
private:
    void secret() override { std::cout << "Derived secret\n"; }
};

int main() {
    Base* b = new Derived();
    b->callSecret();   // 输出 "Derived secret"  ------ 动态绑定仍发生!
}

派生类重写了基类的私有虚函数,但通过基类的公有成员函数调用该虚函数时,仍会调用到派生类的实现。这体现了接口继承与实现继承分离的设计思想。

底层原理:虚函数表与动态绑定

虚指针(vptr)与虚函数表(vtable)

当类包含至少一个虚函数时,编译器会为这个类生成一张虚函数表(vtable) 。vtable 是一个函数指针数组,存储该类所有虚函数的地址。每个对象中会隐含一个虚指针(vptr),指向所属类的虚函数表。

布局示意

cpp 复制代码
class Base {
public:
    virtual void f1();
    virtual void f2();
    void f3();   // 非虚,不进 vtable
};
// 内存模型(32位下可能):
// Base 对象:[ vptr | 其他成员数据 ]
// vtable 内容:[ &Base::f1 | &Base::f2 | ... ]

当派生类重写虚函数时,它的虚函数表会覆盖对应槽位的函数地址,替换为派生类版本的地址。若派生类新增虚函数,则附加在表末尾。

关键点

  • 每个类拥有一份虚函数表(只读、类级共享)。
  • 每个对象有自己的 vptr(通常在对象起始地址)。
  • 构造对象时,vptr 被初始化为指向当前类的虚表;在构造函数执行过程中,vptr 逐步更新(从基类到派生类)。

动态绑定的执行过程

当通过基类指针调用虚函数时:

  1. 通过对象的 vptr 找到虚函数表。
  2. 在表中根据虚函数的索引(编译时确定)取出对应的函数指针。
  3. 执行该函数。

由于 vptr 指向实际对象类型的虚表(而非指针静态类型的虚表),因此实现了运行时多态。

汇编级视角(简化):

asm 复制代码
mov eax, [ecx]          ; 取 vptr (ecx 为 this 指针)
call [eax + 4 * index]  ; 调用虚函数

多重继承下的虚函数表

多重继承下,派生类会拥有多个 vptr(每个基类一个,或采用更复杂的布局)。每个基类子对象包含自己的 vptr,指向不同的虚函数表。编译器需要调整 this 指针以适应不同基类的调用。

示例(伪代码):

cpp 复制代码
class Base1 { virtual void f1(); };
class Base2 { virtual void f2(); };
class Derived : public Base1, public Base2 {
    void f1() override;
    void f2() override;
};

Derived 对象内存通常布局为:[ Base1 subobject (含 vptr1) | Base2 subobject (含 vptr2) | Derived members ]。vptr1 指向的虚表包含 Derived::f1,vptr2 指向的虚表包含 Derived::f2。当通过 Base2* 调用 f2 时,编译器需将指针调整到 Base2 子对象的起始地址,以确保 vptr2 的正确性。

性能开销与优化

虚函数的代价:

  • 空间:每个对象增加一个 vptr(通常 4/8 字节),每个类增加一张虚表(只读数据段)。
  • 时间:一次间接寻址(比普通函数调用多一次内存访问),且通常无法内联(除非编译器进行去虚化优化)。

在性能敏感场景(如游戏引擎),可通过手动去虚化、模板、CRTP 等静态多态技术避免运行时开销。

纯虚函数与抽象基类

纯虚函数的定义

通过在虚函数声明末尾添加 = 0 来声明纯虚函数。包含纯虚函数的类称为抽象类,不能实例化对象

cpp 复制代码
class Shape {
public:
    virtual void draw() const = 0;   // 纯虚函数
    virtual ~Shape() = default;
};

纯虚函数可以有定义(函数体),但通常仅用于提供默认行为,派生类仍需重写(除非派生类也是抽象类)。

抽象类的特点与作用

  • 抽象类作为接口基类协议,强制派生类实现特定功能。
  • 抽象类可以包含数据成员和普通成员函数。
  • 若派生类未重写所有纯虚函数,则派生类仍为抽象类。

用途

  • 定义规范:所有派生类必须提供某些操作。
  • 实现多态根节点:无法实例化,但可以通过指针/引用操作派生类对象。

接口继承与实现继承

纯虚函数代表接口继承 :派生类继承的是函数声明,必须自己提供实现。而非纯虚函数(普通虚函数)代表实现继承:基类提供默认实现,派生类可选择是否重写。

设计原则:优先使用纯虚函数定义接口,保持基类轻量,避免派生类意外继承不该有的实现。

析构函数的重写

虚析构函数的必要性

关键规则当基类指针指向派生类对象,并通过该指针删除对象时,若基类析构函数非虚,则只会调用基类析构函数,导致派生类资源泄漏。

cpp 复制代码
class Base {
public:
    ~Base() { }   // 非虚
};

class Derived : public Base {
    int* data;
public:
    Derived() : data(new int[100]) { }
    ~Derived() { delete[] data; }   // 不会被调用!
};

int main() {
    Base* p = new Derived();
    delete p;   // 未定义行为,通常只调用 Base::~Base()
}

将基类析构函数声明为虚函数,则 delete 时会根据实际对象类型调用正确的析构链(先派生类,再基类)。

最佳实践但凡设计为基类的类,都应声明虚析构函数 。即使它没有任何资源需要释放,也要提供一个空的虚析构函数(或 = default)。

析构函数的特殊重写规则

析构函数重写与普通虚函数略有不同:

  • 名称不同(派生类和基类的析构函数名字不同),但仍构成重写,因为编译器内部以特殊方式处理。
  • C++11 起,可为派生类析构函数标记 override,编译器会检查基类是否有虚析构函数。
cpp 复制代码
class Base {
public:
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    ~Derived() override { ... }   // 合法,重写基类虚析构函数
};

重写中的特殊场景与陷阱

默认参数与虚函数

陷阱 :虚函数的默认参数是静态绑定的,即根据指针/引用的静态类型决定默认参数,而非根据实际对象类型。

cpp 复制代码
class Base {
public:
    virtual void show(int x = 1) {
        std::cout << "Base: " << x << std::endl;
    }
};

class Derived : public Base {
public:
    void show(int x = 2) override {
        std::cout << "Derived: " << x << std::endl;
    }
};

int main() {
    Base* p = new Derived();
    p->show();   // 输出 "Derived: 1"  ------ 使用 Base 的默认参数 1
}

建议:不要在虚函数中使用不同的默认参数;或者避免在虚函数中使用默认参数。

内联与虚函数

inline 关键字是对编译器的建议,但虚函数通常无法内联,因为函数的实际地址在运行时才确定。然而,通过对象直接调用虚函数(非指针/引用)时,编译器可能进行去虚化优化并内联。

cpp 复制代码
Derived d;
d.show();   // 编译器知道 d 的实际类型,可内联
Base* p = &d;
p->show();  // 难以内联(除非进行全局优化)

构造与析构函数中调用虚函数

重要 :在构造函数或析构函数中调用虚函数,不会发生动态绑定,而是调用当前正在构造/析构的类所对应的函数版本。这是因为此时对象的 vptr 尚未完全设置(构造时先基类部分,后派生类部分;析构时相反)。

cpp 复制代码
class Base {
public:
    Base() { show(); }      // 调用 Base::show()
    virtual void show() { std::cout << "Base\n"; }
};

class Derived : public Base {
public:
    Derived() { show(); }   // 调用 Derived::show()
    void show() override { std::cout << "Derived\n"; }
};

int main() {
    Derived d;
}
// 输出:
// Base
// Derived

原因 :构造 Derived 时,先构造 Base 子对象,此时 vptr 指向 Base 的虚表,因此 Base() 内调用的是 Base::show。随后构造 Derived 成员,vptr 更新为 Derived 虚表,Derived() 内调用 Derived::show

静态函数无法重写

静态成员函数属于类,没有 this 指针,不参与动态绑定。派生类可以定义同名静态函数,但仅是隐藏,不是重写。

常见错误

忘记 virtual 关键字

基类函数未加 virtual,派生类重新定义------变成隐藏,无多态效果。

解决 :基类中必须显式使用 virtual;C++11 后派生类使用 override 可帮助检查。

签名不匹配导致隐藏而非重写

常见错误:参数类型不匹配(如 int 写成了 double),const 缺失,或返回类型不协变。编译器将其视为独立新函数,隐藏基类版本。

解决 :总是使用 override 关键字,编译器会报错。

不使用 override 的风险

手写重写时,若基类虚函数签名稍后修改,派生类函数可能不再构成重写,但编译器无警告(除非使用 override)。导致逻辑错误。

建议对所有重写函数都加上 override 。仅需在基类声明 virtual,派生类只需 override 即可(不必重复 virtual)。

基类析构函数非虚

这是最严重的内存泄漏源头之一。确保任何可能被继承的类的析构函数为虚 。如果类不需要多态,可标记为 final 并禁止继承。

重写时违反里氏替换原则

里氏替换原则(LSP)指出:派生类对象应能替换基类对象且不改变程序的正确性。重写虚函数时不应:

  • 抛出新的异常(除非基类异常规范允许)。
  • 削弱前置条件(要求更严格的输入)。
  • 增强后置条件(返回更强的结果可接受,但需谨慎)。

违反 LSP 的重写会导致基类接口契约被破坏,产生难以调试的错误。

编程建议

  1. 基类 :所有要重写的函数加上 virtual;析构函数虚化。
  2. 派生类 :重写虚函数时使用 override 关键字(且无需再写 virtual)。
  3. 不需要重写 时,考虑将函数声明为 final
  4. 抽象类:用纯虚函数定义接口,避免无意义的默认实现。
  5. 构造函数/析构函数:避免在其中调用虚函数。
  6. 默认参数:不要重定义默认参数,或统一使用同一默认值。
  7. 性能:对高频调用的虚函数,评估是否可改为模板或 CRTP 静态多态。
  8. 文档:清晰注释虚函数的预期行为,便于派生类正确重写。
相关推荐
Titan20241 小时前
C++异常学习笔记
c++·笔记·学习
柒儿吖2 小时前
DDlog 高性能异步日志库在 OpenHarmony 的 lycium 适配与分步测试
c++·c#·openharmony
民国二十三画生2 小时前
C++(兼容 C 语言) 的标准输入语法,用来读取一行文本
c语言·开发语言·c++
柒儿吖2 小时前
基于 lycium 在 OpenHarmony 上交叉编译 utfcpp 完整实践
c++·c#·harmonyos
sTone873752 小时前
std::function/模板/裸函数指针选型指南
c++
Codiggerworld2 小时前
从字节码到JVM:深入理解Java的“一次编写,到处运行”魔法
java·开发语言·jvm
无聊的小坏坏2 小时前
一文讲通:二分查找的边界处理
数据结构·c++·算法
禾叙_2 小时前
【netty】Channel
开发语言·javascript·ecmascript
云深处@3 小时前
【C++11】包装器,智能指针
开发语言·c++