Download presentation
Presentation is loading. Please wait.
1
C++程序设计— 多态与虚函数 主讲:资讯系张玉宏
2
多态(Polymorphism)的概念 多态是指类中具有相似功能的不同函数是用同一个名称来实现,从而可以使用相同的调用方式来调用这些具有不同功能的同名函数。这也是人类思维方式的一种直接模拟,可以利用多态的特征,用统一的标识来完成这些功能。这样,就可以达到类的行为的再抽象,进而统一标识,减少程序中标识符的个数。 资讯管理系
3
多态的概念 多态从实现的角度来讲可以划分为两类,编译时的多态和运行时的多态。前者是在编译的过程中确定了同名操作的具体操作对象,而后者则是在程序运行过程中才动态的确定操作所针对的具体对象。这种确定操作的具体对象的过程就是联编,也有的文献成为编联,束定或绑定(binding)。 资讯管理系
4
多态的概念 联编是指计算机程序自身彼此关联的过程,也就是把一个标识符名和一个存储地址联系在一起的过程;用面向对象的术语讲,就是把一条消息和一个对象的方法相结合的过程。按照联编进行的阶段的不同,可以分为两种不同的联编方法:静态联编和动态联编,这两种联编过程分别对应着多态的两种实现方式。 资讯管理系
5
静态联编 联编工作在编译连接阶段完成的情况成为静态联编。因为联编过程外程序开始执行之前进行的,因此有时也称为早期联编或前联编。在编译,连接过程中,系统就可以根据类型匹配等特征确定程序中操作调用与执行该操作代码的关系,其确定了某一个同名标识到底是要调用那一段程序代码。有些多态类型,其同名操作的具体对象能够在编译,连接阶段确定,通过静态联编解决,比如函数重载。 资讯管理系
6
动态联编 和静态联编相对应,联编工作在程序运行阶段完成的情况称为动态联编,也称为晚期联编(late bingding)或后联编。在编译,连接过程中无法解决的联编问题,要等到程序开始运行之后再来确定,它常用虚函数(virtual function)来实现。 虚函数是动态联编的基础,属于包含多态类型。虚函数是非静态的成员函数,虚函数经过派生之后,在类族中就可以实现运行过程中的多态。 资讯管理系
7
填空题 面向对象程序设计中的多态性包括静态多态性和动态多态性,前者由____________机制支持,而后者则由____________机制支持。 答:函数重载、虚函数 [解析]静态多态性又称编译时多态性,调用何函数应该在编译之前就知道了,所以必须由函数重载机制来支持。动态多态性又称运行时多态性,调用何函数只有在运行时才知道,所以由虚函数(与指针或引用)机制来支持。 资讯管理系
8
虚函数 在面向对象的C++语言中,虚函数(virtual function)是一个非常重要的概念。因为它充分体现了面向对象思想中的继承和多态性这两大特性,在C++语言里应用极广。比如在微软的MFC类库中,你会发现很多函数都有virtual关键字,也就是说,它们都是虚函数。难怪有人甚至称虚函数是C++语言的精髓。 那么,什么是虚函数呢,我们先来看看微软的解释: 虚函数是指一个类中你希望重载的成员函数,当你用一个基类指针或引用指向一个继承类对象的时候,你调用一个虚函数,实际调用的是继承类的版本。 ——摘自MSDN 资讯管理系
9
虚函数的定义 在类中,只需要在成员函数原型声明之前加上关键词virtual即可把某一个函数说明成一个虚函数。
class B0 //基类B0声明 {public: virtual void display(); }; virtual void B0::display()//不再需要virtual {cout<<"B0::display()"<<endl;} //error C2723: 'display' : 'virtual' storage-class pecifier illegal on function definition. 资讯管理系
10
虚函数 虚函数是动态联编的基础。 说明为虚函数必须是非静态的成员函数。
具有继承性,基类中定义了虚函数,派生类中无论是否说明关键词virtual,同原型函数都自动为虚函数。 在派生类中可以重新定义虚函数,从而使派生类中的函数版本代替基类定义的函数版本。 本质:不是重载定义而是覆盖定义。 调用方式:通过基类指针或引用,执行时会 根据指针指向的对象的类,决定调用哪个函数。 资讯管理系
11
覆盖与重载区别 覆盖(override)是指子类重新定义父类的虚函数的做法。而重载(overload)是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。其实,重载的概念并不属于“面向对象编程”,重载的实现是:编译器根据函数不同的参数表,对同名函数的名称做修饰,然后这些同名函数就成了不同的函数(至少对于编译器来说是这样的)。 如有两个同名函数: int abc(int); int abc(char*); 资讯管理系
12
覆盖与重载区别 那么编译器做过修饰后的函数名称可能是这样的:int_func、char_func(也就是说,对于编译器而言,通过函数签名的鉴别,它们是不同的函数)。对于这两个函数的调用,在编译器间就已经确定了,是静态的。也就是说,它们的地址在编译期就绑定了(早绑定),因此有人这样评价:严格意义上讲重载和多态无关! 引用一句Bruce Eckel的话:“不要犯傻,如果它不是晚邦定,它就不是多态。” ——Bruce Eckel C++编程思想 资讯管理系
13
重新定义与超越 在派生类定义与基类同名的成员函数称为重新定义(redefinition),可以改变成员函数的功能,如果重新定义的对象是虚函数的话,则称为超越(overriding) 资讯管理系
14
多态性与虚函数 真正和多态相关的是“覆盖”。当子类重新定义了父类的虚函数后,父类指针根据赋给它的不同的子类指针,动态的调用属于子类的该函数,这样的函数调用在编译期间是无法确定的(调用的子类的虚函数的地址无法给出)。 因此,这样的函数地址是在运行期绑定的(晚邦定)。结论就是:重载只是一种语言特性,与多态无关,与面向对象也无关! 多态性是允许你将父对象设置成为和一个或更多的它的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。 资讯管理系
15
资讯管理系
16
资讯管理系
17
结果及分析 用Visual C++或Borland C++编译并运行,输入一个小写字母c,得到下面的结果:
This is parent,function1 This is child,function2 为什么会有第一行的结果呢?因为我们是用一个Parent类的指针调用函数Fuction1(),虽然实际上这个指针指向的是Child类的对象,但编译器无法知道这一事实(直到运行的时候,程序才可以根据用户的输入判断出指针指向的对象),它只能按照调用Parent类的函数来理解并编译,所以我们看到了第一行的结果。 备注:通过赋值兼容原则,指向基类的指针可以指向派生类。但这种指针只能访问从基类中继承的公有成员,不能访问派生类本身的成员. 资讯管理系
18
结果及分析 那么第二行的结果又是怎么回事呢?我们注意到,Function2()函数在基类中被virtual关键字修饰,也就是说,它是一个虚函数。虚函数最关键的特点是“动态联编”,它可以在运行时判断指针指向的对象,并自动调用相应的函数。 如果我们在运行上面的程序时任意输入一个非c的字符,结果如下: This is parent,function1 This is parent,function2 //调用基类的函数成员 程序中仅仅调用了一个Function2()函数,却可以根据用户的输入自动决定到底调用基类中的Function2还是继承类中的Function2,这就是虚函数的作用。 资讯管理系
19
深入了解虚函数 在了解虚函数的基础之上,我们考虑这样的问题:一个基类指针必须知道它所指向的对象是基类还是继承类的示例,才能在调用虚函数时“自动”决定应该调用哪个版本,它是如何知道的?这种“动态联编”的机制是通过一个“vtable”实现的,那么vtable是什么?微软在关于COM的文档里这样描述: vtable是指一张函数指针表,如同C++中类的实现一样,vtable中的指针指向一个对象支持的接口成员函数。 ——摘自MSDN 资讯管理系
20
深入了解虚函数 每个虚函数都在vtable中占了一个表项,保存着一条跳转到它的入口地址的指令(实际上就是保存了它的入口地址)。当一个包含虚函数的对象(注意,不是对象的指针)被创建的时候,它在头部附加一个指针,指向vtable中相应位置。调用虚函数的时候,不管你是用什么指针调用的,它先根据vtable找到入口地址再执行,从而实现了“动态联编”。而不像普通函数那样简单地跳转到一个固定地址。 资讯管理系
21
虚函数的另一个例子 shape i Draw() Erase() Circle Triangle r a, b, c Draw()
Cylinder r,h Draw() Erase() 继承关系图 资讯管理系
22
资讯管理系
23
资讯管理系
24
资讯管理系
25
运行结果 资讯管理系
26
对象分割(object slicing) 在上例中,函数Turn()和Remove()的参数分别为引用(reference)和指针,为了利用upcast,以达到多态的效果,这是必要的两种做法。 如果把这两个函数改为传对象的值: void Make(Shape S1) {S1.Draw();} void Remove(Shape S1) {S1.Erase();} 虽然程序仍然编译通过,但执行的结果却没有多态的效果。这是因为upcast时虽然可以接受传值的做法,但原来的位置只能容纳得下基类的成员,在派生类定义的成员都必须舍弃。这种现象叫做对象分割,因为对象以传值的方式作为自变量时,有部分成员被分割,遗失不见了。 资讯管理系
27
资讯管理系
28
资讯管理系
29
运行结果 资讯管理系
30
VPTR和VTABLE 当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;编译器如何能够高效地提供这种行为呢?大多数编译器是使用virtual table和virtual table pointers。virtual table和virtual table pointers通常被分别地称为vtbl和vptr。 一个vtbl通常是一个函数指针数组。在程序中的每个类只要声明了虚函数或继承了虚函数,它就有自己的vtbl。 资讯管理系
31
VPTR和VTABLE 每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。这个看不见的数据成员也称为vptr,被编译器加在对象里,位置只有才编译器知道。从理论上讲,可以认为包含有虚函数的对象的布局如右图所示: 资讯管理系
32
VPTR和VTABLE 上幅结构图表示vptr位于对象的底部,但是事实上,不同的编译器放置它的位置也不同。UNIX下的gcc编译器vptr位于对象的底部,而BC,VC则放在对象的顶部。存在继承的情况下,一个对象的vptr经常被数据成员所包围。如果存在多继承,上述结构图会变得更复杂。 使用虚函数所需的第一个代价是:在每个包含虚函数的类的对象里,你必须使用额外的指针vptr。 这就是上述我们的例子中: cout<<sizeof(C1)/sizeof(int) ; 结果输出为什么是1原因,而事实上,我们没有看到对象C1有任何数据单元,这个整型数据就是vptr带来的。 资讯管理系
33
VPTR和VTABLE 引出虚函数所需的第二个代价:必须为每个包含虚函数的类的virtual talbe留出空间。类的vtbl的大小与类中声明的虚函数的数量成正比(包括从基类继承的虚函数)。 每个类应该只有一个virtual table,所以virtual table所需的空间不会太大,但是如果有大量的类或者在每个类中有大量的虚函数,vtbl会占用大量的地址空间。 资讯管理系
34
设两个含有 虚函数的类 C1、C2,各 自定义的对象 中vptr与vtbl的 关系示意图如 左图所示: 该类中声明为virual的成员
函数地址 资讯管理系
35
VPTR和VTABLE 在VTABLE中记录的是各个类里面,所有在该类或基类内声明为virtual的成员函数地址(成员函数的程序代码所在的地址) 如果在派生类中有超越基类的成员函数,则在VTABLE中记录的是基类函数成员的地址。如果在派生类中有超越基类的成员函数,则在VTABLE中记录的是派生类成员的函数的地址。这些地址都在VTABLE内按照他们在基础函数内的次序排序 资讯管理系
36
虚函数的工作流程 编译器生成的代码会做如下这些事情:
1. 通过对象的vptr找到类的vtbl。这是一个简单的操作,因为编译器知道在对象内哪里能找到vptr(毕竟是由编译器放置的它们)。因此这个代价只是一个偏移调整(以得到vptr)和一个指针的间接寻址(以得到vtbl)。 2. 找到对应vtbl内的指向被调用函数的指针。这也是很简单的,因为编译器为每个虚函数在vtbl内分配了一个唯一的索引。这步的代价只是在vtbl数组内的一个偏移。 3. 调用第二步找到的的指针所指向的函数。 资讯管理系
37
虚函数所需的第三个代价 虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令”,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。这是虚函数所需的第三个代价:放弃了使用内联函数。 (当通过对象调用虚函数时,它可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。) 资讯管理系
38
关于运算效率的考虑 当基类函有虚函数时,编译器会自动完成以下工作: 1.为基类和每个派生类都设定一个VTABLE
2.当基类和它的所有派生类都加入一个VPTR,并对所有由这些所定义对象的VPTR进行初始化的动作:将各个VPTR指向正确VTABLE的第一个字段。 3.对每个upcast的虚函数都调用专用的程序代码,以动态设定基类指针,指向正确的VPTR 这些额外的设定当然就会影响程序的执行效率,这也是多态所需要付出的代价。所以虚函数的使用并非是越多越好。 资讯管理系
39
纯虚函数 一般的基类都有完整的成员函数,因此也可以使用它来声明新的对象。但是我们有的时候只在基类定义一个抽象的函数,没有具体的含义,而派生类来延续这个基类的定义,即把基类抽象的函数具体化。 因此此时的基类定义一个对象,是没有意义的。为了阻止使用基类定义对象,并能在编译阶段就能发现这个现象,可以将virtual函数改写为pure virtual(纯虚) 函数。 资讯管理系
40
纯虚函数 纯虚函数是一种特殊的虚函数,它的一般格式如下: class <类名> { virtual <类型><函数名>(<参数表>)=0; … }; 在许多情况下,在基类中不能对虚函数给出有意义有实现,而把它说明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。 资讯管理系
41
抽象类 带有纯虚函数的类称为抽象类。抽象类是一种特殊的类,它是为了抽象和设计的目的而建立的,它处于继承层次结构的较上层。抽象类是不能定义对象的,在实际中为了强调一个类是抽象类,可将该类的构造函数说明为保护的访问控制权限。 抽象类的主要作用是将有关的组织在一个继承层次结构中,由它来为它们提供一个公共的根,相关的子类是从这个根派生出来的。 资讯管理系
42
抽象类 抽象类刻画了一组子类的操作接口的通用语义,这些语义也传给子类。一般而言,抽象类只描述这组子类共同的操作接口,而完整的实现留给子类。 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类没有重新定义纯虚函数,而派生类只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体类了。 资讯管理系
43
资讯管理系
44
资讯管理系
45
资讯管理系
46
运行结果 资讯管理系
47
虚析构函数 构造函数在对象的声明时自动被调用以分配适当的内存,而析构函数则是在对象离开作用域时自动被调用,释放内存,以供别用。但如果这些函数在动态分配,伴随指针的upcasting(即使用基类的指针指向派生类定义的对象),要完整地回收内存资源,就必须将析构函数也改成虚函数,成为虚析构函数(virtual desturctor)才能顺利完成 资讯管理系
48
虚析构函数 在析构函数前面加上关键字virtual进行说明,称该析构函数为虚析构函数。例如: class B {virtual ~B();
…}; 该类中的析构函数就是一个虚析构函数。 如果一个基类的析构函数被说明为虚析构函数,则它的派生类中的析构函数也是虚析构函数,不管它是否使用了关键字virtual进行说明。 说明虚析构函数的目的在于在使用delete运算符删除一个对象时,能保析构函数被正确地执行。因为设置虚析构函数后,可以采用动态联编方式选择析构函数。 资讯管理系
49
资讯管理系
50
资讯管理系
51
资讯管理系
52
运行结果 资讯管理系
53
结果分析 当对象以关键词new进行动态声明时,会将分配的内存的开头地址传回来。而下面的语句:
Shape *pS1 = new Circle; 上面的赋值实际上还隐含着一个upcasting(向上类型转换)的动作,因此依照派生类定义所分配的内存的地址存入基类Shape指针pS1内。 而对于动态声明的内存空间,如果不再使用时,可以用delete语句收回。 delete pS1; 资讯管理系
54
结果分析 为了完整回收,必须在基类的析构函数声明前面加上关键词virtual,形成虚析构函数: virtual ~Shape()
这样才能依序执行所有基类和派生类的析构函数,以释放所有的内存空间。 这是因为将析构函数虚拟化后,才能引进VTABLE、VPTR。通过VPTR正确指向,才可以依序自动调用正确的析构函数。一般而言,如果在基类说明了虚函数,就必须同时声明虚析构函数。 如果类Shape中的析构函数不用虚函数,则输出基类的构造函数,结果如下: Shape 解构函数 资讯管理系
55
结果分析 至于构造函数则不需要虚拟化,因为构造函数的执行顺序从基类到派生类,与继承的次序一致,而析构函数的执行顺序是从派生类到基类,在有upcasting的时候必须借助VTABLE和VPTR。 当然如果不进行upcasting赋值,而写成: Shape *pS1 = new Shape ; 而使用: delete pS1; 就可以回收所有内存,就没有必须说明虚析构函数了。 资讯管理系
56
虚 基 类 的 由 来 资讯管理系
57
虚基类的由来 在C++中,一个类不能被多次说明为一个派生类的直接基类,但可以不止一次的成为间接基类,例如我们前面讲到的例子,其继承关系图如下所示: class D class B class C class A 继承关系图 资讯管理系
58
虚基类由来 D x B A i C j 上述继承图形被称之为: “恐怖的多继承菱形”(the dreaded multiple inheritance diamond).及class A的数据成员在内存的分配如右图所示: 资讯管理系
59
虚基类 由图可见class A有两份来自从基类class D间接继承过来的数据成员x。实际上,在C++语言中,如果在多条继承路径上有一个公共基类,则在这条路径中某几条路径的汇合处将会产生该基类的多个拷贝。这种情况不仅造成了存储单元的浪费,而且还产生了二义性。 资讯管理系
60
相关背景资料 遗传学的规律告诉后代通过父亲和母亲生殖细胞中的染色体进行传递的。
近亲之间的婚配,就意味着他们有共同的祖先,如果携带致病基因的话,两个致病基因相遇机会增加,也就是说患遗传病的机会增加。亲缘关系越近,基因相同的可能性越大,婚后子代中患常染色体隐性遗传病和携带者的可能性则越大,这也是常染色体隐性遗传的传递特点. 资讯管理系
61
虚基类 事实上,由于派生类中的数据成员就像生殖细胞中的染色体,它们都从父类(基类)继承相同的数据成员,而他们是又不能相互覆盖的。而恐怖的菱形带来的就是C++交汇继承中的“近亲结婚”,它同样带来很多问题—— 存储单元的浪费,二义性。 若欲使某个公共基类只在派生类中仅生成一个拷贝,即解决C++交汇继承中的“近亲繁殖”,则应将该基类说明为虚基类。 资讯管理系
62
如何说明一个虚基类 说明虚基类的一般格式如下: class 继承类名:virtual<继承方式> 基类名 { //… };
资讯管理系
63
填空题 引入虚基类的目的是为了解决多重继承中的____________和____________问题。 答:二义性、多占用空间
[解析]在允许多重继承时可能出现两个问题,第一个是公有派生类中的成员通过不同基类调用它们上一级公共基类的同一成员,这就产生了调用的二义性;每一个基类都为它们的上一级公共基类存有备份,这就引起了公共基类的重复存储,也就多占了存储空间。引入虚基类的目的是为了解决多重继承中的这两个问题。 资讯管理系
64
虚基类 多继承经常导致对虚基类的需求。没有虚基类,如果一个派生类有一个以上从基类的继承路径,基类的数据成员被复制到每一个继承类对象里,继承类与基类间的每条路径都有一个拷贝。 程序员一般不会希望发生这种复制,而把基类定义为虚基类则可以消除这种复制。然而虚基类本身会引起它们自己的代价,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,一个或者更多的指针被存储在对象里。如下图所示: 资讯管理系
65
虚基类 这里A是一个虚基类,因为B和C虚拟继承了它。使用一些编译器(特别是比较老的编译器),D 对象布局如右图所示: 资讯管理系
66
资讯管理系
67
资讯管理系
68
运行结果 资讯管理系
69
资讯管理系
70
资讯管理系
71
运行结果 资讯管理系
72
继承时的构造函数 基类的构造函数不被继承,需要在派生类中自行定义。
历史回顾 继承时的构造函数 基类的构造函数不被继承,需要在派生类中自行定义。 定义构造函数时,只需要对本类中新增成员进行初始化,对继承来的基类成员的初始化由基类的构造函数完成。 但派生类构造函数的定义中,要提供基类构造函数所需要的参数。 资讯管理系
73
历史回顾 派生类的构造函数格式 派生类构造函数执行顺序是先执行所继承的基类的构造函数,再执行派生类本身构造函数,处于同一层次的各基类构造函数的执行顺序取决于定义派生类时所指定的各基类顺序,而与派生类构造函数中所定义的成员初始化列表的各项顺序无关。 资讯管理系
74
历史回顾 继承关系图 class D class B class C class A 资讯管理系
75
历史回顾 资讯管理系
76
虚基类之派生类的构造函数 对虚基类而言,由于派生类的对象中只有一个虚基类子对象。为保证虚基类子对象只被初始化一次,这个虚基类构造函数必须只被调用一次。由于继承结构的层次可能很深,规定将在建立对象时所指定的类称为最新派生类。C++规定,虚基类子对象是由最新派生类的构造函数通过调用虚基类的构造函数进行初始化的。如果一个派生类有一个直接或间接的虚基类,那么派生类的构造函数的成员初始列表中必须列出对虚基类构造函数的调用。如果未被列出,则表示使用该虚基类的缺省构造函数来初始化派生类对象中的虚基类子对象。 资讯管理系
77
虚基类之派生类的构造函数 从虚基类直接或间接继承的派生类中的构造函数的成员初始化列表中都要列出这个虚基类构造函数的调用。但是,只有用于建立对象的那个最新派生类的构造函数调用虚基类的构造函数,而该派生类的基类中所列出的对这个虚基类的构造函数调用在执行中被忽略,这样便保证了对虚基类的对象只初始化一次。 C++又规定,在一个成员初始化列表中出现对虚基类和非虚基类构造函数的调用,则虚基类的构造函数先于非虚基类的构造函数的执行。 资讯管理系
78
资讯管理系
79
资讯管理系
80
Person Student Teacher TeacherAssistant 典型的恐怖 多继承菱形 资讯管理系
81
思考一下:假设没有说明虚基类,其运行结果又是如何?
资讯管理系
82
不说明虚基类的运行结果 资讯管理系
83
资讯管理系
84
资讯管理系
85
运行结果 资讯管理系
86
结果分析: 1.由于虚基类在其派生类中只有一份数据,所以虚基类构造函数的参数必须由最新派生出来的类负责初始化.
2.与一般派生类不同的是,一般派生类不需要为间接基类提供构造函数的初始化,而只需要为直接基类打交道.对于非虚基类,在派生类的构造函数中初始化间接基类是不允许的,而对于虚基类则必须在派生类中队虚基类进行初始化. 资讯管理系
87
结果分析: 3.使一个基类成为虚基类,不是由基类自己来决定的,而是由它直接派生的各个派生类声明决定的.
4.在使用虚基类要注意的是,为保证虚基类在派生类中只继承一次,必须在该基类的所有的直接派生类中声明为虚基类,否则仍然存在多次继承.参看下图: 资讯管理系
88
非彻底虚基类 Person Student Teacher TeacherAssistant Woker virtula 资讯管理系
89
非彻底虚基类 上图中Student 和Teacher都说明是virtual,但是Woker没有,那么TeacherAssistant从Student 和Teacher只继承Person的一份数据成员,但是从Woker这里也继承了一份Person的数据成员,即TeacherAssistant有两份Person的数据成员。 所以说明一个虚基类要彻底。 资讯管理系
Similar presentations