动态绑定与虚函数¶
虚函数是C++实现运行时多态的核心机制。它允许通过基类指针或引用调用派生类中覆盖的函数,使得程序能够在运行时根据对象的实际类型决定调用哪个函数版本。
从问题出发:为什么需要虚函数¶
静态绑定的局限性¶
回顾上一节的类型兼容性示例,我们发现在没有虚函数的情况下,通过基类指针调用的函数版本由指针类型决定,而非对象实际类型。
问题场景
问题分析
在上述代码中,show(&d) 调用的是 Base::display() 而不是 Derived::display()。这是因为:
- 编译器在编译时就确定了
p->display()应调用哪个函数。 p的类型是Base*,因此调用的是Base::display()。- 这种在编译时确定函数调用的方式称为静态绑定(Static Binding)。
但面向对象程序设计期望的是:根据对象的实际类型调用相应的函数。这就是 动态绑定(Dynamic Binding) 的需求。
核心需求
我们需要一种机制,使得:
- 通过
Base*或Base&操作对象。 - 如果对象实际是
Derived类型,调用Derived版本的函数。 - 如果对象实际是
Base类型,调用Base版本的函数。
虚函数正是为实现这一需求而设计的。
虚函数的定义与基本使用¶
什么是虚函数¶
虚函数(Virtual Function) 是在基类中用 virtual 关键字声明的非静态成员函数。它告诉编译器:在调用这个函数时,应该根据对象的实际类型来决定调用哪个版本。
虚函数的声明语法
- 虚函数在基类中声明,使用
virtual关键字。 - 派生类中可以覆盖(override) 基类的虚函数。
- 派生类覆盖时,
virtual关键字可以省略(但建议保留以增加可读性)。
虚函数的简单示例¶
虚函数实现运行时多态
运行结果:
静态绑定与动态绑定¶
静态绑定(Static Binding)¶
静态绑定(也称为早期绑定(Early Binding))是在编译时确定函数调用的目标。
静态绑定的特点
- 编译器根据指针或引用的静态类型决定调用哪个函数。
- 调用目标在编译时就已经确定。
- 效率高,没有运行时开销。
- 适用于非虚函数、重载函数、普通函数。
动态绑定(Dynamic Binding)¶
动态绑定(也称为晚期绑定(Late Binding))是在运行时根据对象的实际类型确定函数调用的目标。
动态绑定的特点
- 编译器在编译时不决定调用的具体函数版本。
- 程序运行时,根据对象的实际类型决定调用哪个函数。
- 需要虚函数的支持。
- 有轻微的运行时开销(查虚表)。
- 是实现多态的基础。
动态绑定的条件
要实现动态绑定,需要同时满足三个条件:
- 虚函数:被调用的函数必须是虚函数。
- 指针或引用:必须通过基类指针或引用来调用。
- 覆盖:派生类必须覆盖基类的虚函数。
静态与动态绑定示例
Override与函数覆盖¶
覆盖的条件¶
override关键字写在函数签名的结尾,表示派生类对基类中同名函数的重写覆盖。
派生类中的虚函数要覆盖(override) 基类的虚函数,必须满足:
覆盖规则
| 条件 | 说明 |
|---|---|
| 函数名相同 | 派生类和基类的函数名必须一致 |
| 参数列表相同 | 参数个数、类型、顺序必须完全一致 |
| 返回类型相同 | 普通情况下返回类型必须相同 |
| cv限定符相同 | const、volatile 限定必须相同 |
| 引用限定符相同 | & 或 &&(C++11)必须相同 |
关于返回类型的特殊规则:协变返回类型
如果基类虚函数返回基类指针或引用,派生类覆盖时返回派生类指针或引用是允许的。这称为协变返回类型(Covariant Return Type)。
派生类虚函数的声明风格¶
三种风格
class Derived : public Base {
public:
void display() const { // 虽然也是虚函数,但不直观
cout << "Derived::display()" << endl;
}
};
如果不写 virtual,只要函数签名与基类的虚函数匹配,它仍然是虚函数。但这种方式可读性差,读者无法一眼看出这是一个虚函数覆盖。
override 关键字(C++11)¶
override 关键字用于显式声明一个函数要覆盖基类的虚函数。如果声明的函数实际上并未覆盖基类虚函数,编译器会报错。
override 防止错误
override 的价值
- 编译时检查:确保派生类确实覆盖了基类的虚函数。
- 防止错误:避免因签名不匹配而导致"意外"创建了新函数而非覆盖。
- 代码可读性:明确表达"这是一个覆盖"的意图。
虚函数表(vtable)与动态绑定的实现¶
虚表的基本概念¶
C++ 编译器通过虚函数表(Virtual Table,简称 vtable 或虚表) 来实现动态绑定。
虚表的核心机制
- 每个多态类有一个虚表:包含该类所有虚函数的入口地址。
- 每个对象有一个虚指针(vptr):指向所属类的虚表。
- 构造对象时设置 vptr:构造函数中为对象的 vptr 赋值。
- 调用时查表:通过虚指针找到虚表,再根据索引找到函数地址并调用。
虚表示意图¶
类 Base:
┌─────────────────────────────────────┐
│ Base 对象 │
│ ┌─────────┐ ┌─────────────────┐ │
│ │ vptr │───→│ Base 虚表 │ │
│ ├─────────┤ ├─────────────────┤ │
│ │ data │ │ &Base::func1() │ │
│ └─────────┘ │ &Base::func2() │ │
│ └─────────────────┘ │
└─────────────────────────────────────┘
类 Derived(继承自 Base):
┌─────────────────────────────────────┐
│ Derived 对象 │
│ ┌─────────┐ ┌─────────────────┐ │
│ │ vptr │───→│ Derived 虚表 │ │
│ ├─────────┤ ├─────────────────┤ │
│ │ Base数据 │ │ &Derived::func1│ │
│ ├─────────┤ │ &Base::func2() │ │
│ │Derived数│ └─────────────────┘ │
│ └─────────┘ │
└─────────────────────────────────────┘
动态绑定的执行过程
当通过 Base* 调用 func() 时:
- 从对象中获取
vptr(虚指针)。 - 通过
vptr找到对应类的虚表。 - 在虚表中查找
func的入口地址。 - 通过该地址调用实际的函数。
这个过程在运行时完成,因此称为"动态绑定"。
虚函数的"遗传性"¶
一旦一个函数在基类中被声明为虚函数,它在派生类的继承体系中始终保持虚函数特性,无论派生类是否显式使用 virtual 关键字。
虚函数的遗传性
最佳实践
虽然派生类中不写 virtual 也能保持虚函数特性,但建议在派生类中也使用 virtual 关键字(配合 override),以增强代码的可读性和可维护性。
哪些成员函数可以是虚函数¶
虚函数的使用限制
| 成员函数类型 | 能否为虚函数 | 说明 |
|---|---|---|
| 非静态成员函数 | ✓ 可以 | 最常见的虚函数 |
| 静态成员函数 | ✗ 不可以 | 属于类而非对象,无 this 指针 |
| 构造函数 | ✗ 不可以 | 对象构造时虚表尚未建立 |
| 析构函数 | ✓ 可以 | 虚析构函数是最重要的虚函数之一 |
| 内联函数 | 不推荐 | 内联是静态的,虚函数是动态的,两者语义冲突 |
| 友元函数 | ✗ 不可以 | 友元不是类的成员函数 |
为什么构造函数不能是虚函数¶
原因分析
- 虚表尚未建立:调用构造函数时,对象的虚指针(vptr)尚未初始化,无法完成动态绑定。
- 对象类型明确:构造对象时,对象的类型在编译时就是确定的,不需要动态绑定。
- 语义冲突:构造函数的作用是初始化对象,虚函数的目的是实现多态,两者目的不同。
虚析构函数¶
将析构函数声明为虚函数是 C++ 编程的重要准则:如果类可能被用作基类,析构函数应声明为 virtual。
当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,派生类的析构函数将不会被调用,导致资源泄漏。
非虚析构函数的问题
当通过基类指针删除派生类对象时,如果析构函数不是虚函数,只会调用基类的析构函数,派生类的析构函数不会被调用,导致资源泄漏。
正确的做法:虚析构函数
最佳实践
如果类有可能被用作基类(即存在派生类),应将析构函数声明为 virtual。 这是 C++ 编程的黄金规则之一。
即使类没有派生类,将析构函数声明为虚函数也通常无害(但会增加虚函数表开销),建议在基类中做此声明。
虚函数与默认参数¶
重要陷阱:虚函数与默认参数
虚函数的默认参数是在编译时根据静态类型确定的,而非运行时根据动态类型确定。
最佳实践
避免在虚函数中使用默认参数。 如果必须使用,确保基类和派生类中的默认参数值一致。
final 关键字(C++11)¶
final 关键字有两种用法:
1. final 类:禁止被继承¶
2. final 虚函数:禁止被覆盖¶
使用建议
- 当设计需要完全确定性的行为时,使用
final防止意外修改。 - 当类不应被扩展时,标记为
final。
综合示例¶
虚函数综合示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
小结¶
-
虚函数是用
virtual关键字声明的非静态成员函数,是实现运行时多态的基础。 -
静态绑定 vs 动态绑定:
- 静态绑定:编译时确定函数调用,效率高,适用于非虚函数。
- 动态绑定:运行时根据对象实际类型确定函数调用,需要虚函数支持。
-
虚函数的覆盖规则:函数名、参数列表、返回类型、cv限定符必须完全一致(协变返回类型除外)。
-
虚函数表(vtable) :编译器通过虚表和虚指针(vptr)实现动态绑定。
-
虚析构函数:如果类可能被用作基类,析构函数应声明为
virtual,避免资源泄漏。 -
C++11 关键字:
override:显式声明覆盖,让编译器检查是否正确覆盖。final:禁止类被继承或虚函数被覆盖。
-
注意事项:
- 构造函数不能是虚函数。
- 静态成员函数不能是虚函数。
- 避免在虚函数中使用默认参数。