
面向对象编程的三大特性------封装、继承、多态------共同构建了灵活而可扩展的软件体系。继承允许我们复用基类的代码,而多态则允许我们以统一的接口操作不同类型的对象。函数重写(override)正是实现多态的核心机制:派生类重新定义基类中已声明的虚函数,使得通过基类指针或引用调用该函数时,实际执行的是派生类的版本。
没有函数重写,继承仅停留在代码复用的层面,无法实现"一种接口,多种实现"的运行时多态。例如,一个图形绘制系统可能有 Circle、Rectangle 等多个子类,它们都继承自 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 逐步更新(从基类到派生类)。
动态绑定的执行过程
当通过基类指针调用虚函数时:
- 通过对象的 vptr 找到虚函数表。
- 在表中根据虚函数的索引(编译时确定)取出对应的函数指针。
- 执行该函数。
由于 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 的重写会导致基类接口契约被破坏,产生难以调试的错误。
编程建议
- 基类 :所有要重写的函数加上
virtual;析构函数虚化。 - 派生类 :重写虚函数时使用
override关键字(且无需再写virtual)。 - 不需要重写 时,考虑将函数声明为
final。 - 抽象类:用纯虚函数定义接口,避免无意义的默认实现。
- 构造函数/析构函数:避免在其中调用虚函数。
- 默认参数:不要重定义默认参数,或统一使用同一默认值。
- 性能:对高频调用的虚函数,评估是否可改为模板或 CRTP 静态多态。
- 文档:清晰注释虚函数的预期行为,便于派生类正确重写。