面向对象的三大特性之多态的深入浅出

2017-03-07 14:26 阅读 716 次 评论 0 条

作为面向对象三大特性之一的多态无疑是最复杂的一个特性。封装可以使代码模块化,继承可以在原有的基础上进行改进,前两者的引入都是为了提高代码的复用性。那么多态呢?它的目的是为了接口重用,即当传递不同类的多个对象时,函数都可以通过同一个接口调用不同对象的实现方法。

什么是多态?

所谓多态就是不同对象收到相同的消息时,产生不同的动作。直观的说,多态性是指一个名字定义不同的函数,这些函数执行不同但又类似的操作,从而可以使用相同的方式来调用这些具有不同功能的同名函数。

举个简单的例子吧,比如一个对象中有许多求面积的行为,显然可以针对不同的图形(比如长方形,三角形,圆等),写出很多不同名称的函数来实现,这些函数的参数个数和类型可以不同。但事实上,这些函数的功能几乎完全相同,在C++中,可以利用多态性的特征,用相同的函数名来标识这些函数,就可以达到用相同的接口访问不同功能的函数,从而实现"一个接口,多种方法"。

对象的类型

在C++中,编译时多态性主要是通过函数重载和运算符重载实现的,运行时多态性主要是通过虚函数来实现的。 那么接下来我们就具体讲解一下静态多态与动态多态。

静态多态

编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推出要调用哪个函数,如果有对应的函数就调用该函数,否则出现编译错误。

下面大家看一下例子来加深一下理解:

动态多态

在程序执行期间(非编译器)判断所引用对象的实际类型,根据其实际调用相应的方法。使用virtual关键字修饰类的成员函数时,指明该函数为虚函数,派生类需要重新实现,编译器将实现动态绑定。

首先看一个程序,其输出结果与你的预想是否一样?

分析一下:首先执行Base *p = new Derived,我们定义了一个基类的指针指向了派生类的对象,接着p->FunTest(),通过基类的指针去调用FunTest()函数,我们发现,调用的并不是派生类的成员函数FunTest(),而是派生类从基类继承来的同名函数FunTest(),这显然不是我们想要的结果。在这个例子中,不管指针p指向基类对象还是派生类对象,p->FunTest()调用的都是基类的FunTest()函数。

使用对象指针的目的就是为了表达一种动态的性质,即当指针指向不同的对象(基类对象或派生类对象)时,分别调用不同类的成员函数。如果我们将函数说明为虚函数,就能实现这种动态调用的功能。因此引入的虚函数的概念:

那么为什么把基类中的函数声明为虚函数,程序的运行结果就正确了呢?原来关键字virtual指示C++编译器,函数调用"p->FunTest()"时,要在运行时确定所要调用的函数,即要对该调用进行动态连编。因此,程序在运行时根据指针p所指向的实际对象,调用该对象的成员函数。

动态绑定的先决条件

①基类必须是虚函数:虚函数的定义是在基类中进行的,他是在基类中需要定义为虚函数的成员函数的声明中冠以关键字virtual,从而提供一种接口界面。定义虚函数的方法如下:

②通过基类类型的引用或指针调用虚函数。

重写机制(覆盖)

先决条件:在基类的某个成员函数被声明为虚函数后,此虚函数就可以在一个或多个派生类中被重新定义。虚函数在派生类中重新定义时,其函数原型,包括返回类型,函数名,参数个数,参数类型的顺序,都必须与基类中的原型完全相同。

下面通过一个例子,加深一下对重写的理解:

重写是实现多态机制的一种重要方法,在上一例的程序中,我们发现FunTest()形成了重写,在main函数中ptr->FunTest()出现了三次,由于p指针指向的对象不同,每次出现都执行了虚函数FunTest()的不同版本,因此实现了运行时多态。

虚函数定义的重要说明

①由于虚函数使用的基础是赋值兼容规则,而赋值兼容规则成立的前提是派生类从其基类公有派生。因此,通过定义虚函数来使用多态性机制时,派生类必须从它的基类公有派生

必须首先在基类中定义虚函数。由于基类与派生类是相对的,因此,这项说明并不表明必须在类登记的最高层类中声明虚函数。在实际应用中,应该在类等级内需要具有动态多态性的基个层次中的最高层内首先声明为虚函数。

③在派生类对基类中声明的虚函数进行重新定义时,关键字virtual可以写也可以不写。但在容易引起混乱的情况下,最好在派生类的虚函数进行重新定义的时也加上关键字virtual。

④虽然使用对象名和点运算符的方式也可以调用虚函数,如:_d.FunTest();但是,这种调用时在编译时进行的静态连编,它没有充分利用虚函数的特性,只有通过基类的指针访问虚函数时才能获得运行时的多态性。

⑤一个虚函数无论被公有继承多少次,它任然保持其虚函数的特性

虚函数必须是其所在类的成员函数,而不能是友元函数,也不能是静态成员函数,因为虚函数调用要靠特定的对象来决定该激活哪个函数。

内联函数不能是虚函数。因为内联函数不能再运行中动态确定其位置,即使虚函数在类的内部定义,编译时仍将其看作是非内联的。

构造函数不能是虚函数,但是析构函数可以是虚函数,而且通常说明为虚函数。

构造函数为什么不能定义为虚函数?

首先,你应该清楚构造函数的作用是什么?构造函数的调用就是为了创建对象,而虚函数使用时会将对象的前4个字节用来存放虚表指针,构造函数都未创建对象,当然也就不能定义为虚函数了。

静态成员函数为什么不能是虚函数?

虚函数是与类对象捆绑的,而类的普通成员函数(包括虚函数)在编译时加入this指针,通过这种方式可以与对象捆绑,而静态函数编译时不加this,因为静态函数是给所有类对象公用的,所以没有在编译时加this,所以无法与对象捆绑,而虚函数就是靠着与对象捆绑加上虚函数列表才实现了动态捆绑。所以没有this指针虚函数无从谈起。

赋值运算符符"="是否可以是虚函数?

可以。首先赋值运算符定义成虚函数的目的就是实现多态机制,但是根据赋值兼容规则可以知道派生类的对象可以直接赋值给基类对象,而基类的对象不能直接赋值给派生类对象。鉴于这个情况,为了防止父类给子类赋值导致程序奔溃,因此建议不要讲赋值运算符的重载定义为虚函数。

友元函数为什么不能定义为虚函数?

友元函数并非类的成员函数,也就无从谈起定义为虚函数。

析构函数是否可以定义为虚函数,为什么?

最好定义为虚函数。先看一个简单的程序:

在上面的代码中基类的析构函数并非虚函数,我们用基类的指针去调用派生类的成员,在释放指针p的过程中,我们发现,仅仅释放了基类的资源,而没有调用派生类的析构函数。这种情况只能够删除基类的对象,而不能删除派生类的对象,因此会造成内存的泄漏。

当我们将基类的析构函数定义为虚函数时,发现程序的运行结果达到预期,资源相继被释放,不会造成内存泄漏等问题。

重定义(隐藏规则)

在搞清楚重写即覆盖机制后,我们在来引入一个重定义即隐藏规则。它是指派生类的函数屏蔽了与其同名的基类函数,其规则如下:
(1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏。
(2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏。

下面我们加入程序来更加清楚的认识隐藏机制:

从上面的运行出错可以说明基类的成员函数被隐藏起来了,即使基类与派生类同名成员的参数不一致,当然,也无关乎于virtual。

修改一下程序,我们先定义一个派生类的对象,用基类的指针对象指向派生类的对象,看会发生什么?(与覆盖容易混淆)

这次我们发现程序没有出错,并且调用的是基类的同名成员函数Fun(int x),这时就发生了隐藏,与之前不同之处在于,这次是从基类向派生类开始寻找的。

我们分析一下隐藏与覆盖在调用时的历程:

在隐藏方式中:用基类的对象指针和派生类对象指针调用同名函数时,系统会进行区分。基类指针调用时,系统会执行基类的同名函数,而派生类指针调用时,系统会隐藏基类的同名函数,去执行派生类的同名函数,即有静态类型决定。此时如果基类与派生类同名函数的参数一致,那么就变成了覆盖。

在覆盖方式中:用基类指针和派生类指针调用同名函数时,系统总会执行派生类的同名函数。

继承体系中同名成员函数的关系导图

纯虚函数

在成员函数的形参李彪后面写上=0,则成员函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化对象。纯虚函数在派生类中重新定义以后,派生类才能实例化对象。

下面通过一个例子来说明下一纯虚函数的使用:

针对上图,纯虚函数本没有函数体,当我在定义函数体的时候,即使这样依旧不调用纯虚函数,它不具备函数的功能,因此也不能被调用。基类中定义的纯虚函数时没有任何意义的,它只是用来提供派生类使用的公用接口。

虚表剖析

当类的成员函数声明为虚函数时,我们发现与预期的不一样,下面这个程序会较为清楚的解释:

对于有虚函数的类,编译器都会维护一张虚表,对象的前4个字节就是指向虚表的指针。

版权声明:本文著作权归原作者所有,欢迎分享本文,谢谢支持!
转载请注明:面向对象的三大特性之多态的深入浅出 | 术与道的分享
分类:编程素养 标签:
1024do.com导航_术与道导航平台

发表评论


表情