本篇笔记主要有关于 C++ OOP 的多态(polymorphism)特性。多态是 OOP 的核心特性之一,指的是相同的接口,不同的实现。在 C++ 中,多态允许我们通过基类的指针或引用,调用派生类的函数。
多态通常有两种形式:
静态多态,又叫做编译时多态,它是通过函数重载和模板来实现的。
动态多态,又叫做运行时多态,它是通过虚函数和继承来实现的。
简单来说,多态就是消息以多种形式呈现的能力。
动态多态
动态多态是通过虚函数和继承来实现的。在介绍函数的笔记中,我们已经介绍过虚函数的概念。这里我们再进行一次详细的复习,并且更深入地理解虚函数以及虚表的作用。
虚函数
虚函数(virtual function)是一种允许在运行时根据对象的实际类型决定调用哪个函数的成员函数。在基类中通过 virtual 关键字标记,并在派生类中通过 override 关键字重写,达到动态绑定的效果。例如,
在 Base 类中,我们定义了一个虚函数 func()。在派生类 Derived 中,我们可以通过 override 关键字重写该函数。
此时,如果我们在 main() 中输出结果,
注意,虽然 obj 是一个 Base 类指针,但是它的创建方式决定了它是一个 Derived,因此,在调用 func() 函数的时候,会使用 Derived 类中重写的 func()。
上例中体现的重点在于:
虚函数实现了动态绑定,即在运行时根据对象的实际类型,调用相应的函数,而并非在编译时决定。如果在编译时决定,则会被称为静态绑定。一个没有被 virtual 关键字标记的非虚函数就是静态绑定。
虚表
虚表(VTable, virtual function table)是每个包含虚函数的类会生成的数据结构。虚表中保存类中虚函数的地址。每个对象通过一个隐藏的指针(被称为 vptr)指向它所属类的虚表。当调用虚函数时,程序通过 vptr 查找虚表中的函数地址并执行。
我们还是通过上面的例子了解虚表的实现机制。
在这里,我们:
首先创建了一个 Derived 类的对象,名为 obj。obj 是指向 Base 的指针。
编译器检查 obj 是否调用虚函数 func()。
程序通过 vptr 指向 Derived 类的虚表。
在 Derived 类的虚表中找到 Derived::func() 的地址,调用派生类的实现。
虚析构函数
特别地注意,如果基类需要通过指针释放派生类对象,必须将基类的析构函数声明为虚函数,否则会导致资源泄露。例如,在下例中,
注意到,继承 Base 类的 Derived 类中有一个私有数组 data,在 Derived 类的构造函数中我们为 data 申请了空间,并在析构函数中释放了该对象。因此,在 main 函数中,obj 作为 Base 类指针,它在遭到 delete 的时候必须要能够正确地调用到 Derived 类的析构函数,因此 Base 类中的析构函数必须是虚函数。
再强调一次,必须是虚函数。
另外,析构函数与普通虚函数行为不同:调用虚析构函数时,会先调用派生类的析构函数,再调用基类的析构函数,而不是仅调用派生类的函数。这是为了确保销毁对象时资源会被正确地释放。
因此,上面 main() 的输出结果是,
构造函数
在 C++ 中,构造函数不能是虚函数。我们在之前的笔记中也曾经介绍过:
构造顺序:先调用基类的构造函数,再调用派生类的构造函数。
这是因为基类部分必须先构造完成,派生类才能在其基础上进行拓展。
在构造函数内调用虚函数时,不会调用派生类重写的版本,而是调用当前类中定义的版本,这样会保持编译时绑定。这样的行为的原因是因为在基类构造函数执行时,派生类的对象尚未完全构造完成,派生类部分的虚表仍未建立——必须要在构造函数调用完成后才会形成虚表指针。
Comentários