C++多态那些事儿
多态是C++面向对象的三大特性(封装、继承和多态)之一,很多人可能并没有真正深入了解到底什么是多态。这篇文章就来从各个角度带大家理解C++多态到底是什么。
其实这三大特性并不是割裂的,而是相互依赖的。
首先我们来了解一下什么是联编,并且联编分为两种——动态联编和静态联编。
一. 先了解一下动态联编和静态联编
说到联编,其实在这里指的是函数名联编。通俗来讲,在程序中通过函数名调用了某个函数,编译器怎么知道需要执行哪一段代码块呢?对于C语言来讲,这很简单,一个函数名只会对应一段代码块。但是C++中引入了重载、重写等机制,导致一个函数名可能对应着不同的实现代码。那么编译器在调用函数时,应该执行哪一段代码呢?因此便引入了动态联编和静态联编。
而这两个概念是根据什么来区分的呢?在编译期or运行期确定执行的函数代码,把联编分为静态联编(编译期联编/早联编)和动态联编(运行期联编/晚联编)。
1.1 静态联编
在编译期,编译器就解决了程序的操作调用与执行该操作代码间的关系。
那么直观来看,能根据函数名和参数列表直接确定对应的函数代码:首先,对于普通的无重载无重写的函数而言是可以实现的,即C++默认采用静态联编;其次,对于在一个类中的函数重载,虽然函数名相同,但形参列表不同,则编译器也可以根据形参列表确定调用哪一段函数,因此遇到函数重载时编译器也会采用静态联编。
1.2 动态联编
与静态联编不同的是,编译程序在编译阶段并不能根据函数名确定具体调用哪一段函数代码,只有在程序执行时才能确定将要调用的函数。因此这就要求编译器生成能够在程序运行时选择正确的函数代码。
直观来看,仅仅根据函数名和形参列表是不能确定具体调用哪一段函数的,即当函数名和形参列表相同时会采用动态联编——这种情况对应的就是父类与子类之间的虚函数重写。重写的要求是函数名和参数列表完全相同,返回值相同或协变。因此只有在运行时访问虚函数表才能确定具体的函数调用地址。
因此我们知道,当编译器识别到函数是虚函数时,会采用动态联编。
二. 到底什么是多态
更多的教材和资料给出的是多态是如何实现的,比如说重载、重写等,在这里,我想给出到底多态的意义是什么,他的核心思想是什么。
多态,它最大的用途在于接口最大程度的复用,简而言之“一个接口,多种方法”。
多态可以分为静态多态(编译器多态)和动态多态(运行期多态)。
三. 静态多态的实现——重载
静态多态,对应着上文提到的静态联编。静态多态是多态的其中一种方式,一般通过重载来实现。
3.1 什么是函数重载
函数重载是指在同一作用域内,可以有一组具有相同函数名,不同参数列表的函数,这组函数被称为重载函数。
(1)作用域
在同一个作用域中,如类成员函数之间的重载、全局函数之间的重载。
(2)函数特征
函数名相同、形参列表不同(参数类型、数目或顺序)、与返回类型无关。
与是否有virtual无关,有或无virtual都可以是重载,因为只看参数列表是否不同。
与const有关,因为const实际上是形参为const,这导致形参列表可能不同。
(3)应用
- 不同的构造函数:无参构造函数、有参构造函数、拷贝构造函数。
- 运算符的重载
(4)注意事项
- 类的静态成员函数与普通成员函数可以形成重载。
3.2 函数重载的意义
重载函数通常用来命名一组功能相似的函数,这样做减少了函数名的数量,避免了名字空间的污染,对于程序的可读性有很大的好处。
实质上还是归结到多态的意义——接口最大程度上的复用。
3.3 静态多态优点
- 它带来了泛型编程的概念,使得C++拥有泛型编程与STL这样的强大武器。
- 在编译器完成多态,提高运行期效率。
- 具有很强的适配性与松耦合性,对于特殊类型可由模板偏特化、全特化来处理。
3.4 静态多态缺点
- 程序可读性降低,代码调试带来困难。
- 无法实现模板的分离编译,当工程很大时,编译时间不可小觑。
- 无法处理异质对象集合。
四. 动态多态——重写/覆盖/虚函数
我们知道动态多态又叫运行期多态,其采用的联编方式为动态联编,即在程序运行时才能确定具体对应的函数代码(上文已经介绍过动态联编,在此不再赘述)。
4.1 为什么叫动态多态/运行时多态
我们首先知道多态是用于接口最大程度的复用,静态多态和动态多态是两种不同形式的接口复用,其分类依据在于识别接口时的联编方式是静态联编or动态联编。而为什么动态多态要采用动态联编的方式呢?这是因为动态联编的接口——函数的函数名和形参列表都是一样的,在编译器不能区分出这些不同的接口,只有在运行期才能识别出,因此采用动态联编的方式。
4.1 动态多态的设计思想
首先我们要知道动态多态的作用域,其作用域是不同的,比如接口分别处于基类和派生类,因此其设计思想要归结到类继承体系的设计上去。对于有相关功能的对象集合,我们总希望能够抽象出它们共有的功能集合,在基类中将这些功能声明为虚接口(虚函数),然后由子类继承基类去重写这些虚接口,以实现子类特有的具体功能。这便是动态多态的接口复用的设计思想。
下面将讲述动态多态的实现机制——虚函数/重写/覆盖。
4.3 动态多态的实现机制
当某个类声明了虚函数时,编译器将为该类对象安插一个虚函数表指针,并为该类设置一张唯一的虚函数表,虚函数表中存放的是该类虚函数地址。运行期间通过虚函数表指针与虚函数表去确定该类虚函数的真正实现。虚函数的机制不再赘述,可以直接查阅资料获取。
下面我们给出相关的代码实现,这是实现动态多态的通用形式。
首先理解指针和内存的概念:
- 指针:看这个指针的类型是父类类型还是子类类型。若是父类类型,则该指针便认为自己所指的内存空间是父类对象,对应的访问也只能是父类的相关成员,访问时根据名字按位置访问,不能访问子类(即使指明作用域也不行);若是子类类型,则该指针认为自己所指的内存空间是子类对象,对应的访问也只是子类对应的内存空间里的内容,若某些子类函数覆盖了父类函数(虚函数重写),则根据位置访问的便只有子类的已重写函数了,若想访问父类,则需指明父类的作用域。
- 内存空间:是完全按照类的结构来分配内存空间的,一般是在new的时候执行(调用构造函数时划分内存空间)。
接着我们来讨论有关父类指针指向子类对象的一些内容:
父类指针指向子类对象,其实是对动态多态的运用。
首先给出代码:
Base* b = new Derive;
首先我们要理解,这里有一个指针的“向上转型”,即子类指针转换为父类指针,这个转换本身就是安全的。(当然对应的向下转型则会存在安全隐患)
关于虚函数的访问
根据上面的讲解,我们知道父类指针b认为自己指向的是父类对象,对应着父类的内存空间,因此访问范围仅局限于父类中的成员。那我们为什么还要这样做呢,直接为b分配一块子类对象的内存空间不就好了吗?
这个其实是在为虚函数重写的多态来服务的。我们把父类中的函数设置为虚函数,子类重写这些虚函数(重写完成之后,即使这些函数不带有virtual关键字,也默认为是虚函数,其地址也是存储在虚函数表中)。我们知道,每个类都对应有自己的虚函数表(父类对应父类的虚函数表,子类对应子类的虚函数表),虚函数表中存储着该类所有虚函数的地址。而在对象所在的内存空间中存储的虚函数表的地址——虚表指针(并且我们可以推测出同一个类实例化的不同对象的对应内存空间的虚表指针的值是相同的)。关于虚函数表中的具体存储就不再介绍了。通过这个机制我们可知:当b想访问某个虚函数时,它认为自己访问的是父类对应的函数,而实际上这个位置已经被子类重写的函数覆盖了,因此它实际访问的是子类的函数;而如果作为程序员,我们如果确实想通过b访问父类虚函数,可以通过强制作用域b->Base::func()
的方式访问父类函数。
那么回到最初,我们为什么要在不同作用域内设置接口呢(父类和子类之间接口的复用),即为何需要这种动态多态呢?
这便是动态多态的优势,如下介绍。
4.4 动态多态的优势
动态多态的优势在于它使处理异质对象集合称为可能。
我们通过以下代码举例说明:
//我们有个动物园,里面有一堆动物
int main()
{
vector<Animal*>anims;
//父类指针指向子类对象(理解为一个同质的指针指向异质对象,可以通过虚函数机制访问异质对象的不同功能)
Animal * anim1 = new Dog;
Animal * anim2 = new Cat;
Animal * anim3 = new Bird;
Animal * anim4 = new Dog;
Animal * anim5 = new Cat;
Animal * anim6 = new Bird;
//处理异质类集合
anims.push_back(anim1);
anims.push_back(anim2);
anims.push_back(anim3);
anims.push_back(anim4);
anims.push_back(anim5);
anims.push_back(anim6);
//访问其对应的虚函数
for (auto & i : anims)
{
i->shout();
}
//delete对象
for (auto & i : anims)
{
delete i;//delete的底层会调用析构函数,而析构函数也是虚函数,因此其实是会调用子类各自的析构函数
}
return 0;
}
4.5 动态多态的缺点
-
运行期间进行虚函数绑定,提高了程序运行开销。
需要如下步骤:
- 通过虚表指针访问虚函数表。(实质上访问的这个虚函数表就是子类的,因为内存空间的存储其实是子类对象,只不过父类指针认为这是一个父类对象而已)
- 从虚函数表中确定所需访问的具体函数地址。(通过这是第几个虚函数,确定这个函数地址在表中位移,从而通过地址访问到函数)
-
庞大的类继承层次,对接口的修改易影响类继承层次。
-
由于虚函数在运行期在确定,所以编译器无法对虚函数进行优化。
-
虚表指针增大了对象体积,类也多了一张虚函数表,当然,这是理所应当值得付出的资源消耗,列为缺点有点勉强。