整理一下多态的概念,以及自己对多态的理解与思考,简单分析一些多态的实现,根据实际场景进行理解。
概念
在维基百科中对多态的定义为:多态指为不同数据类型的实体提供统一的接口,或使用一个单一的符号来表示多个不同的类型。这里实际上分为了一个函数多态和变量多态。变量多态是指:基类型的变量(对于C 是引用或指针)可以被赋值基类型对象,也可以被赋值派生类型的对象。函数多态是指,相同的函数调用界面(函数名与实参表),传送给一个对象变量,可以有不同的行为,这视该对象变量所指向的对象类型而定。多态也可定义为“一种将不同的特殊行为或实体和单个泛化记号相关联的能力”。大多数情况下,变量多态是函数多态的基础,所以讨论最更多的是函数多态,变量多态更多的是用来服务函数多态的。
根据多态的实现角度还分为静态多态和动态多态。静态多态的具体实现是在编译期确定的,编译器根据数据类型选择执行不同的函数实体。动态多态是在运行时确定的,根据运行时实际的变量类型选择执行不同的函数实体。
静态多态
- 函数重载 为不同类型的数据提供相同的函数接口,本质上是函数多态。一个典型的例子就是c IO流运算符的重载,可以通过统一的std::cout将数据内容在控制台输出。
- c 模板 提供参数化类型的数据结构和方法,本质上依然是函数多态。经典的例子就是容器。
动态多态
- 通过基类引用派生类来调用派生类的函数实现 经典c 多态的使用方式,利用继承和虚函数实现,属于c 面向对象的集大成应用,结合了变量多态和函数多态。
分析下静态多态和动态多态各自的短板
- 静态多态每次添加接口的不同实现,都要对代码进行重新编译,包括新接口定义和调用部分的源码,以及所有使用相关头文件的源码。而动态多态则可以只对接口定义部分的源码进行编译,其余部分不受影响。
- 动态多态运行时需要通过虚指针和虚表,会带来额外的开销。
为什么需要多态
技术的创新都是为了更好的解决问题,多态也是如此,所以接下来讨论下为什么需要多态和多态解决了什么问题。
假设我们需要用c语言实现一个函数,该函数可以打印不同类型的具体数据。参考代码如下:
代码语言:c复制struct T1{
int num;
};
struct T2{
int num1;
int num2;
};
struct T1 a;
struct T2 b;
void print_T1(struct T1* a) {
printf("%dn", a->num);
}
void print_T2(struct T2* b) {
printf("%d %dn", b->num1, b->num2);
}
这里通过为每一种类型单独声明一个函数来满足打印不同类型数据的要求。实际上这就是c 编译器实际做的事情:函数重载,一种静态多态的实现。函数名和参数共同构成函数签名,只是在c 开发者眼中调用的是同一个接口。
这时我们又有了新的需求,出于某种目的我们需要利用一种方式将不同的数据类型的变量组织起来,方便进行管理,同时需要满足访问到某个变量时依然可以打印数据。
数据的管理我们这里使用数组,那么首先需要一个通用的变量表示方式,在c语言中最方便的就是指针了,通过指针指向不同类型的数据就是一种变量多态。之后在访问到具体变量时还需要能够根据其实际的数据类型调用对应的函数。这里有两种实现方式:
代码语言:c复制// 实现方法1
enum Type {
Type_T1,
Type_T2
};
struct T1 {
Type type;
int num;
};
struct T2 {
Type type;
int num1;
int num2;
};
struct T1 a = {Type_T1, 1};
struct T2 b = {Type_T2, 1, 2};
void common_print(void* p) {
if(*(Type*)p == Type_T1) {
print_T1(p);
} else if(*(Type*)p == Type_T2) {
print_T2(p);
}
}
void* arr[2] = {&a, &b}; // 利用数组管理
for(int i=0; i<2; i ) {
common_print(arr[i]); // 将所有变量数据进行打印
}
这种方法可以满足要求,但是弊端也很明显,随着数据类型的增加,common_print函数会变得越来越臃肿,同时还需要维护一个类型枚举。
代码语言:c复制// 实现方法2
typedef void (*Func)(void*);
struct Base {
Func f;
};
struct T1 {
struct Base base;
int num;
};
struct T2 {
struct Base base;
int num1;
int num2;
};
struct T1 a = {print_T1, 1};
struct T2 b = {print_T2, 1, 2};
struct Base* arr[2] = {&a, &b}; // 利用数组管理
for(int i=0; i<2; i ) {
arr[i]->f(arr[i]);
}
可以看到方法2比方法1看上去简洁了很多,实现方式更加优雅,易于维护而且拓展性更好,如果需要增加其他功能的话,只需要在struct Base
中添加一个函数指针即可,不需要修改目标类型。实际上这种方式就有点类似c 实现动态多态的方式,结合了面向对象思想,使数据与行为连接在一起。
这里函数指针的使用可以同时看做变量多态或函数多态,区别只在于把它当做变量还是函数。
总结一下:多态的目的是为不同数据类型的实体提供统一的接口,而c 的语法支持则使得多态的实现更加优雅,易于维护且拓展性更好。
c 动态多态
实现原理
在c 中,每个拥有虚函数的类都有一个虚表,虚表中存储虚函数到实际执行函数的映射。在每一个实例化的带有虚函数的对象中,在其内存地址的起始位置存放指向虚表的指针。当对象调用虚函数时,通过该指针找到对应的虚表,通过查找虚表去执行实际的函数。
所以当一个基类指针(或引用)指向派生类对象时,并没有修改派生类的内存,起始位置依然存放的是指向派生类虚表的虚指针。所以最后通过基类型执行虚函数时,实际调用的是派生类的实现。
动态多态适用场景
- 多实例管理 举个例子,当前有个animal的基类,由animal可以派生出horse,cattle和sheep类,同时还有一个农场类Farm。农场需要对牲畜进行组织和管理,包括跟踪数量的变化,定时投喂等等,而且随时可能有新品类的牲畜进来(新品类是什么可能Farm类并不知道)。 这时就可以利用一个vector<animal*>对所有动物进行记录(变量多态),可以进行动态的增减,在固定的时间对每个实例调用feed()方法进行投喂,每个派生类有自己的feed()方法实现(函数多态)。 这就是一种多态在多实例管理上的应用。
- 抽象接口类 提供抽象的接口,运行时引用具体的实例。 传统的在头文件中声明类的方式或多或少暴露了一些实现细节,尤其是私有函数和成员变量。接口类使用虚基类声明,只暴露出public接口,具体的实现可以以库的形式提供,可以完美屏蔽细节。另一个优势就是可以有效减少代码编译时间,只需要把具体的实现源码单独拉出去编译即可,完全不影响剩下的源码,而传统的方式则会把所有引用头文件的源码重新编译一遍。
实际上这两种都是多态在接口类的应用,一种是为了做通用的组织和管理,另一种是为了屏蔽接口实现。所以说多态适用于任何需要通用接口(类型接口或函数接口)的场景。