C++ 补完计划(三):泛型与多态
前言
本文将会是一个大杂烩,打算将 C++ 学习以来关于泛型以及多态的各种内容全部塞在这里。可能会没有什么条理性,但反正也没人看我的博客,那干脆就随心所欲一点咯。
今天的 C++ 已经是个多重泛型编程语言(multiparadigm programming lauguage),一个同时支持过程形式(procedural)、面向对象形式(object-oriented)、函数形式(functional)、范型形式(generic)、元编程形式(metaprogramming)的语言。
这些能力和弹性使C++成为一个无可匹敌的工具,但也可能引发使用者的某些迷惑,比如多态。在这几种编程泛型中,面向对象编程、范型编程以及很新的元编程形式都支持多态的概念,但又有所不同。 C++支持多种形式的多态,从表现的形式来看,有虚函数、模板、重载等,从绑定时间来看,可以分成静态多态和动态多态,也称为编译期多态和运行期多态。
编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。
本文即讲述这其中的异同。注意泛型编程和元编程通常都是以模板形式实现的,因此在本文中主要介绍基于面向对象的动态多态和基于模板编程的静态多态两种形式。另外其实宏也可以认为是实现静态多态的一种方式,实现原理就是全文替换,但C++语言本身就不喜欢宏,这里也忽略了“宏多态”。
面向对象的动态多态
动态多态的设计思想:对于相关的对象类型,确定它们之间的一个共同功能集,然后在基类中,把这些共同的功能声明为多个公共的虚函数接口。各个子类重写这些虚函数,以完成具体的功能。客户端的代码(操作函数)通过指向基类的引用或指针来操作这些对象,对虚函数的调用会自动绑定到实际提供的子类对象上去。
从上面的定义也可以看出,由于有了虚函数,因此动态多态是在运行时完成的,也可以叫做运行期多态,这造就了动态多态机制在处理异质对象集合时的强大威力(当然,也有了一点点性能损失)。
动态多态的使用
如一个最经典的例子:
1 | void f() |
通过 compiler explorer 的编译结果可以看出明显的区别。
在编译函数 fun(Base * b)
时,函数参数 b 表面上是个指向 Base 类型的指针。但是,由于多态机制的存在,“指向 Base 类型”只是个马甲,实际上这个 b 可能指向 Base 类型的变量,可能指向的是 Derived 类型的变量,甚至也有可能指向的是一个定义在其他源文件里的、编译器暂时还不知道的某个子类。再甚至,还有可能是一个程序员还没有编写出来的类型。它们可能直接复用了基类 Base 的 virtualFun,也有可能是自己定义了新版的 virtualFun 覆盖掉了上一个祖先类的版本。不管怎样,在编译器编译 fun 函数的这一刻,尚无法知道 b 实际指向对象的 virtualFun 是谁。因此,只能去虚表中取函数指针(mov rdi, rax
),然后再调用(call rdx
);在编译函数 fun(Base & b)
时同理。
这便是我们所讲的“运行期绑定”(或者动态绑定/迟绑定,都是同一个概念,不同叫法)。
但在编译 fun(Base b)
与 fun(Derived d)
时则完全不同,可以看出 fun 明确地调用了对应类型作用域下的函数(Base::virtualFun()
与 Derived::virtualFun()
)。
以 fun(Base b)
为例,这是因为,此例下的 b 变量是真真实实的 Base 类型。函数调用方向 fun 里传的是参数,无论是什么样形形色色的子类,在值传递下,都是将子类中继承自 Base 的那部分单独拎出来,复制出一份副本,成为这里的 b。所以 b.virtualFun() 这里,尽管调用的是一个虚函数,但是决不会涉及到运行期的绑定。因为 b 的类型和它实际的 virtualFun 已经是确定的了。
因此,只有通过指针或者引用才能表现出多态性,值语义是不能表现出多态的。
虚函数运行期绑定的性质只有在指针或者引用下能用,通过值调用的虚函数是编译器静态绑定,是没有运行期绑定的性质的。
override & final
override 与 final 是 C++11引入的两个新的关键字,它们的作用分别为:
- override:保证在派生类中声明重写的函数,与基类的虚函数有相同的签名;
- final:阻止类的进一步派生 和 虚函数的进一步重写。
override
override 大家都很熟悉这里就不再赘述了,加入 override 后上面的例子就可以这样写:
1 | struct Derived : Base |
在最顶层的虚函数上加上 virtual 关键字后,其余的子类覆写后就不再加 virtual 了,但是要统一加上override(建议)。
final
如果不希望某个类被继承,或不希望某个虚函数被重写,则可以在类名和虚函数后加上 final 关键字,加上 final 关键字后,再被继承或重写,编译器就会报错。
这一关键字对于多态有着很大的影响。
我们继续以上面的例子做拓展:
1 | void f() |
这里可以看到,我们用 final
修饰了 Derived::virtualFun()
,这就意味着再由 Derived
派生的类都无法继续重写这个方法,因此在 fun(Derived * p)
编译器就会明确地调用 Derived::virtualFun()
;这里就变成了编译时绑定,也就不呈现动态多态的特性了。
但需要注意的是,这只是确保了由 Derived
再往下派生的类不会重写该方法,但由 Base
直接派生的类并不会受到限制,因此 fun(Base * b)
依然可以表现出多态性。
同样道理,用 final
修饰一个类也可以达到这种效果。
1 | void f() |
这里 fun(Derived * p)
也不会表现出多态性。
因此可以得出结论:(since C++11) final 对虚函数的多态性具有向下阻断作用。经 final 修饰的虚函数或经 final 修饰的类的所有虚函数,自该级起,不再具有多态性。
动态多态的实现方式
虚函数表
动态多态术的核心是虚函数表(虚表)。
当一个类在实现的时候,如果存在一个或以上的虚函数时,那么这个类便会包含一张虚函数表。而当一个子类继承并重载了基类的虚函数时,它也会有自己的一张虚函数表。
虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。
这一点对我们理解为什么虚函数不能是模板函数非常重要。
虚表指针
虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
虚表通常存在于全局数据区(Global Data Area),也称为静态数据区。这是因为虚表是编译时生成的,并且在程序运行期间保持不变。而虚表中指向的函数存在于代码段。
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr
,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
对于类间的继承,如果子类中不存在虚函数,那么它们将与父类公用一个虚表,如果子类重写或定义了自己的虚函数,那么子类将会产生一个新的虚表。同时,对于重写的虚函数,虚表中对应的函数指针将会被替换,可以参考 C++ 补完计划(二):类的大小。
注意,由于一个对象在创建时,虚表指针(如果有)就已经指向了该类所对应的虚表,因此即使使用父类的指针或者引用来使用该对象,在调用虚函数时依然会使用该类型的虚表中对应的虚函数,通过这种方式也就实现了动态多态。
还是以上述的类为例:
1 | struct Base |
这里由于 Derived
类重写了 virtualFun()
,因此该类会生成一张自己的虚表,我们记作 Derived::vtb
,同时父类也有一张自己的虚表,记作 Base::vtb
,在创建一个 Derived
类型的对象时,对象中的虚表指针就已经指向了 Derived::vtb
;因此,即使将其指针当作 Base *
传入 fun(Base * b)
,依然通过 Derived::vtb
调用 g()
;这就是动态多态的实现原理了。
一些问题
-
在(基类的)构造函数和析构函数中调用虚函数会怎么样
从语法上讲,调用没有问题,但是从效果上看,往往不能达到需要的目的(不能实现多态);因为调用构造函数的时候,是先进行父类成分的构造,再进行子类的构造。在父类构造期间,子类的特有成分还没有被初始化,此时下降到调用子类的虚函数,使用这些尚未初始化的数据一定会出错;同理,调用析构函数的时候,先对子类的成分进行析构,当进入父类的析构函数的时候,子类的特有成分已经销毁,此时是无法再调用虚函数实现多态的。
基于模板编程的静态多态
C++中的泛型编程是一种编程范式,它着重于创建通用、灵活的代码,以适应各种数据类型和算法,而不是针对特定数据类型编写具体的实现。泛型编程使得代码更具通用性和复用性,同时提高了代码的抽象程度。
在C++中,泛型编程主要通过模板(Template)来实现,其中最常用的是类模板和函数模板。模板允许编写通用代码,通过类型参数化或值参数化,以便在编译时生成针对不同类型的特定代码实例。
函数模板
函数模板是C++中的一种特殊构造,允许编写通用的函数定义,以适用于多种参数类型与返回类型,而不是针对特定的数据类型编写多个函数。函数模板通过参数化类型或值来定义函数,从而可以在编译时生成针对不同类型的特定函数版本。如:
1 | // 交换整型函数 |
函数模板的使用有一些需要注意的地方。
类型推导
我们可以不指定函数模板的类型参数而直接使用函数模板,但如果使用了自动类型推导,再函数模板调用时将不会发生隐式类型转换,显示指定类型可以发生隐式类型转换。
1 | // 普通函数 |
调用顺序
调用规则如下:
- 如果函数模板和普通函数都可以实现,优先调用普通函数
- 可以通过空模板参数列表来强制调用函数模板,即
<>
- 如果函数模板可以产生更好的匹配,优先调用函数模板
- 函数模板也可以发生重载
1 | //普通函数与函数模板调用规则 |
函数模板的特化
模板的特化可以分为全特化与偏特化,全特化是所有的模板参数都被进行特化,偏特化也就是局部的参数特化。在 TinySTL 的 Iterator 中我们分析了偏特化对于 Iterator 类的作用,使得迭代器可以处理原生指针与 const 指针,具体可参考 这篇博客。
对于函数模板,需要注意的是,函数模板只支持全特化,不支持偏特化,而类模板同时支持二者;主要原因是因为,函数可以重载, 也就可以实现类型偏特化一样的功能, 而类不可以重载,偏特化就相当于类的重载。全特化的模板参数列表应该是为空, 函数和类都可以实现全特化。如下面这个例子:
1 | template <class T> |
且从上述运行结果可以看出,特化了的模板函数调用优先级要高于普通模板函数,我们可以总结一下函数调用的优先级:
无模板函数 > 全特化模板函数 > 普通模板函数
类模板
如果说函数模板编写了通用的函数定义,那么类模板就是编写通用的类定义了,通过将内部的成员变量等类型抽象为参数的方式构造出类模板,以处理各种不同类型的数据,这一点想必在分析完 STL 后大家都深有体会,STL 中的所有容器都是由类模板定义的,可以存放不同类型的数据。如:
1 | //类模板 |
类模板在使用时也有许多需要注意的地方。
类模板没有自动类型推导
- 类模板没有自动类型推导的使用方式,在使用时一定要指定所有的模板参数类型。
- 类模板在模板参数列表中可以有默认参数
1 | // 类模板 |
成员函数的实例化时机
类模板实例化时并不是每个成员函数都实例化了, 而是使用到了哪个成员函数, 那个成员函数才实例化,而普通类的成员函数则在一开始就会创建。
1 | class Person1 |
以上这个例子,如果不加 m.func2()
这一句,是可以编译通过的,但实际上 Person1
类型的对象根本没有 showPerson2()
方法;但加了这一句代码后就会运行出错。因此可以得出结果,该成员函数只在调用时才会实例化,创建时并不会立刻实例化。
类模板中定义函数模板
可以把类模板和函数模板结合起来, 定义一个含有成员函数模板的类模板。但是要注意,在类外实现时要把类的模板参数与函数的模板参数全部声明出来。
1 | template<class T> |
但需要注意的是,虚函数不能是模板函数。
原因大致为:编译器在编译类的定义的时候就必须确定该类的虚表大小,而模板只有在运行调用时才能确认其大小,两者冲突. 结果显而易见。
如果想使用模板虚函数,需要用到 “类型擦除” 等手段,详见 C++ 虚函数不能使用模板类有什么替代方案?,有空再进行分析。
类模板中声明 static 成员
类模板中可以声明static成员, 在类外定义的时候要增加template相关的关键词, 并且需要注意每个不同的模板实例都会有一个独有的static成员对象.
1 | template<class T> |
模板中的static
是在每个不同的类型实例化一个, 相同类型的实例化对象共享同一个参数. 所以这里的t1, t2中的 t 都是同一个实例化变量, 是共享的。
模板拷贝构造函数
模板与不同模板之间不能直接的赋值(强制转换), 毕竟模板一般都是类和函数都不是普通的类型. 但是类有拷贝构造函数, 所以我们可以对类的构造函数进行修改, 也就成了模板构造函数。
定义了模板拷贝构造函数也就相当于赋予了模板类进行不同实例化模板类之间类型转换的能力。
1 | template<class T> |
动态多态与静态多态的对比
动态多态
-
优点
- 面向对象设计,对是客观世界的直觉认识;
- 实现与接口分离,可复用;
- 处理同一继承体系下异质对象集合的强大威力;
-
缺点
- 运行期绑定,导致一定程度的运行时开销;
- 编译器无法对虚函数进行优化;
- 笨重的类继承体系,对接口的修改影响整个类层次;
静态多态
-
优点
- 由于静多态是在编译期完成的,因此效率较高,编译器也可以进行优化;
- 有很强的适配性和松耦合性,比如可以通过偏特化、全特化来处理特殊类型;
- 最重要一点是静态多态通过模板编程为C++带来了泛型设计的概念,比如强大的STL库。
-
缺点
由于是模板来实现静态多态,因此模板的不足也就是静多态的劣势,比如调试困难、编译耗时、代码膨胀、编译器支持的兼容性,不能够处理异质对象集合。
不同点与相同点
-
不同点
本质不同,静态多态在编译期决定,由模板具现完成,而动态多态在运行期决定,由继承、虚函数实现;动态多态中接口是显式的,以函数签名为中心,多态通过虚函数在运行期实现,静态多台中接口是隐式的,以有效表达式为中心,多态通过模板具现在编译期完成。
-
相同点
都能够实现多态性,静态多态/编译期多态、动态多态/运行期多态;都能够使接口和实现相分离,一个是模板定义接口,类型参数定义实现,一个是基类虚函数定义接口,继承类负责实现;