C++多态性原理详解(静态多态、动态多态、虚函数、虚函数表)
先给出定义:多态是同一个行为具有多个不同表现形式或形态的能力。
1 联编
联编也称绑定,是指在一个源程序经过编译链接成为可执行文件的过程中,将可执行代码“缝合”在一起的步骤。其中在程序运行前就完成的称为静态联编(前期联编);在程序运行时完成的称为动态联编(后期联编)。
静态联编支持的多态性称为编译时多态(静态多态),通过函数重载或函数模板实现;动态联编支持的多态性称为运行时多态(动态多态),通过虚函数表实现。
2 静态多态性
2.1 函数重载
首先考察代码工程中的一种情形:
void Swap1(int* a, int* b); void Swap2(float* a, float* b); void Swap3(char* a, char* b); void Swap4(double* a, double* b);
当用到几个实现功能相同,但细节不同的函数时,C语言中会如上例所示,用不同的函数名进行区分,但这样不仅影响美观,也不便于调用和代码管理。
于是在C++中引入了函数重载:重载允许在同一作用域中声明几个类似的同名函数,这些同名函数的形参列表(参数个数,类型,顺序)必须不同,常用来处理实现功能类似数据类型不同的问题。
上例在C++中可以改写为:
void Swap(int* a, int* b); void Swap(float* a, float* b); void Swap(char* a, char* b); void Swap(double* a, double* b);
在C++中不仅函数可以重载,运算符也可以重载。可以将运算符理解为一种函数名,例如a+=1可以视作:a.+=(1),其中a是对象,+=是其属性。既然运算符可以这样审视,那么自然可以进行重载。操作符重载示例如下,其中operator为声明操作符的关键字。
void operator +=(int number);
2.2 函数模板
考察2.1节中的函数重载。若重载函数仅有接口数据类型的不同,函数体、函数名都完全一样,那么可以使用函数模板进一步简化代码。
首先给出函数模板的格式:
template <typename 类型参数1 , typename 类型参数2 , ...> 返回值类型 函数名(形参列表){ 函数体 }
例如2.1节中的swap重载函数可用一个函数模板代替为:
template <typename T>void swap(T* a, T* b) { T temp = *a; *a = *b; *b = temp; }
其中template、typename均为声明函数模板用的关键字。用上面一个函数模板即可对任意合法的输入数据类型进行处理。
需要注意的是,目前编译里不支持模板函数的声明和实现分离,因此一般将模板函数体也写在头文件中。
3 动态多态性
动态多态允许用一个或多个派生类对象的属性配置父类对象。在多态性的支持下,父类对象的某个接口会随着派生类对象的不同而执行不同的操作。
3.1 实现原理
先考虑下面的代码工程。
class Father { public: virtual void func1(){ std::cout << "FUNC1:Father" << std::endl; } virtual void func2(){ std::cout << "FUNC2:Father" << std::endl; } void func3(){ std::cout << "FUNC3:Father" << std::endl; } }; class Son :public Father { public: virtual void func1(){ std::cout << "FUNC1:Son" << std::endl; } void func3(){ std::cout << "FUNC3:Son" << std::endl; } }; void testFunc(Father* ptr) { ptr->func1(); ptr->func3(); } int main() { Son* testSon = new Son; Father* testFather = new Father; std::cout << "==============子类测试=============\n"; testFunc(testSon); std::cout << "\n==============父类测试=============\n"; testFunc(testFather); }
其执行结果为:
==============子类测试============= FUNC1:Son FUNC3:Father ==============父类测试============= FUNC1:Father FUNC3:Father
首先阐明要通过子类属性配置父类的原因。如图1所示的例子,移动硬盘、U盘、SD卡都是从父类存储设备派生出来的子类,在实际应用中,只需设计一个存储外设接口,通过判断具体使用的是哪一种外设来确定该接口将执行的驱动程序。这种面向接口的设计思路可以增强复用性和模块化,否则一种外设就需要对应一种接口,过于冗杂。
下面通过分析代码结果,来说明多态的实现原理。
函数testFunc()希望展现出多态性,子类实例testSon在执行func1()时体现子类特性,但在执行func3()时却仍保留了父类特性——即不显示出多态,这是因为func1()被关键字virtual所修饰。关键字virtual申请使用后期联编,被其修饰的函数称为虚函数,而在后期联编下才支持动态多态,因此只有func1()显示出多态性。
至此,先小结构成动态多态的条件:(a) 调用函数的对象必须是指针或者引用;(b) 被调用的函数必须是虚函数。
后期联编通过虚函数表V-Table实现动态多态,虚函数表是一个实例的虚函数地址表。若某个实例存在虚函数,则该实例的内存中会自动分配虚函数表,指明该实例实际应该调用的函数。实例中通过虚函数指针,指向虚函数表所在的内存位置。
图2展示了各种类型的多态情况,实例化时将更新虚函数表——完成继承和覆盖,运行程序过程中,编译器将查找虚表,从而链接到该实例实际应该执行的函数。
示例代码为图2(b)的类型,图3所示是示例代码的变量监视区,可见父类和派生类虚函数表不同,派生类若覆盖虚函数则会对虚表进行更新(func1),父类中不被覆盖的虚函数(func2)仍被子类继承下来。
3.2 纯虚函数
通过令虚函数为0可以表示纯虚函数,纯虚函数只定义了函数接口,包含纯虚函数的类称为抽象类。
抽象类定义了一个类可能发出的动作的原型,但既没有实现,也没有任何状态信息。引入抽象类的原因在于,很多情况下基类本身实例化不合情理。例如动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身实例化没有意义,这时就可将动物类定义成抽象类。
class Animal { public: virtual void eat() = 0; virtual void run() = 0; virtual ~Animal() = default; };
由于抽象类只提供原型而无法被实例化,因此派生类必须提供接口的具体实现,否则亦无法被实例化。