C++编程思想14.pdf
《C++编程思想14.pdf》由会员分享,可在线阅读,更多相关《C++编程思想14.pdf(22页珍藏版)》请在淘文阁 - 分享文档赚钱的网站上搜索。
1、下载下载第1 4章多态和虚函数多态性(在C+中用虚函数实现)是面向对象程序设计语言继数据抽象和继承之后的第三个基本特征。它提供了与具体实现相隔离的另一类接口,即把“w h a t”从“h o w”分离开来。多态性提高了代码的组织性和可读性,同时也可使得程序具有可生长性,这个生长性不仅指在项目的最初创建期可以“生长”,而且希望项目具有新的性能时也能“生长”。封装是通过特性和行为的组合来创建新数据类型的,通过让细节 p r i v a t e来使得接口与具体实现相隔离。这类机构对于有过程程序设计背景的人来说是非常有意义的。而虚函数则根据类型的不同来进行不同的隔离。上一章,我们已经看到,继承如何允许
2、把对象作为它自己的类型或它的基类类型处理。这个能力很重要,因为它允许很多类型(从同一个基类派生的)被等价地看待就象它们是一个类型,允许同一段代码同样地工作在所有这些不同类型上。虚函数反映了一个类型与另一个类似类型之间的区别,只要这两个类型都是从同一个基类派生的。这种区别是通过其在基类中调用的函数的表现不同来反映的。在这一章中,我们将从最基本的内容开始学习虚函数,为了简单起见,本章所用的例子经过简化,只保留了程序的虚拟性质。C+程序员的进步C程序员似乎可以用三步进入C+:第一步:简单地把C+作为一个“更好的C”,因为C+在使用任何函数之前必须声明它,并且对于如何使用变量有更苛刻的要求。简单地用C
3、+编译器编译C程序常常会发现错误。第二步:进入“面向对象”的C+。这意味着,很容易看到将数据结构和在它上面活动的函数捆绑在一起的代码组织,看到构造函数和析构函数的价值,也许还会看到一些简单的继承,这是有好处的。许多用过C的程序员很快就知道这是有用的,因为无论何时,创建库时,这些都是要做的。然而在C+中,由编译器来帮我们完成这些工作。在基于对象层上,我们可能受骗,因为无须花费太多精力就能得到很多好处。它也很容易使我们感到正在创建数据类型制造类和对象,向这些对象发送消息,一切漂亮优美。但是,不要犯傻,如果我们停留在这里,我们就失去了这个语言的最重要的部分。这个最重要的部分才真正是向面向对象程序设计
4、的飞跃。要做到这一点,只有靠第三步。第三步:使用虚函数。虚函数加强类型概念,而不是只在结构内和墙后封装代码,所以毫无疑问,对于新C+程序员,它们是最困难的概念。然而,它们也是理解面向对象程序设计的转折点。如果不用虚函数,就等于还不懂得O O P。因为虚函数是与类型概念紧密联系的,而类型是面向对象的程序设计的核心,所以在传统的过程语言中没有类似于虚函数的东西。作为一个过程程序员,没有以往的参考可以帮助他思考虚函数,因为接触的是这个语言的其他特征。过程语言中的特征可以在算法层上理解,而虚函数只能用设计的观点理解。14.1 向上映射在上一章中,我们已经看到对象如何作为它自己的类型或它的基类的对象使用
5、。另外,它还能通过基类的地址被操作。取一个对象的地址(或指针或引用),并看作基类的地址,这被称为向上映射,因为继承树是以基类为顶点的。我们看到会出现一个问题,这表现在下面的代码中:函数t u n e()(通过引用)接受一个 i n s t r u m e n t,但也不拒绝任何从i n s t r u m e n t派生的类。在m a i n()中,可以看到,无需映射,就能将w i n d对象传给t u n e()。这是可接受的,在instrument 中的接口必然存在于w i n d中,因为w i n d是公共的从i n s t r u m e n t继承而来的。w i n d到i n s
6、t r u m e n t的向上映射会使w i n d的接口“变窄”,但它不能变得比i n s t r u m e n t的整个接口还小。这对于处理指针的情况也是正确的,唯一的不同是用户必须显式地取对象的地址,传给这个函数。14.2 问题W I N D 2.C P P的问题可以通过运行这个程序看到,输出是 i n s t r u m e n t:p l a y。显然,这不是所第14章 多态和虚函数275下载希望的输出,因为我们知道这个对象实际上是 w i n d而不只是一个i n s t r u m e n t。这个调用应当输出w i n d:p l a y。为此,由i n s t r u m
7、 e n t派生的任何对象应当使它的p l a y版本被使用。然而,当对函数用C方法时,W I N D 2.C P P的表现并不奇怪。为了理解这个问题,需要知道捆绑的概念。函数调用捆绑把函数体与函数调用相联系称为捆绑(b i n d i n g)。当捆绑在程序运行之前(由编译器和连接器)完成时,称为早捆绑。我们可能没有听到过这个术语,因为在过程语言中是不会有的:C编译只有一种函数调用,就是早捆绑。上面程序中的问题是早捆绑引起的,因为编译器在只有 i n s t r u m e n t地址时它不知道正确的调用函数。解决方法被称为晚捆绑,这意味着捆绑在运行时发生,基于对象的类型。晚捆绑又称为动态捆
8、绑或运行时捆绑。当一个语言实现晚捆绑时,必须有一种机制在运行时确定对象的类型和合适的调用函数。这就是,编译器还不知道实际的对象类型,但它插入能找到和调用正确函数体的代码。晚捆绑机制因语言而异,但可以想象,一些种类的类型信息必须装在对象自身中。稍后将会看到它是如何工作的。14.3 虚函数对于特定的函数,为了引起晚捆绑,C+要求在基类中声明这个函数时使用v i r t u a l关键字。晚捆绑只对 v i r t u a l起作用,而且只发生在我们使用一个基类的地址时,并且这个基类中有v i r t u a l函数,尽管它们也可以在更早的基类中定义。为了创建一个v i r t u a l成员函数,
9、可以简单地在这个函数声明的前面加上关键字 v i r t u a l。对于这个函数的定义不要重复,在任何派生类函数重定义中都不要重复它(虽然这样做无害)。如果一个函数在基类中被声明为 v i r t u a l,那么在所有的派生类中它都是 v i r t u a l的。在派生类中v i r t u a l函数的重定义通常称为越位。为了从W I N D 2.C P P中得到所希望的结果,只需简单地在基类中的 p l a y()之前增加v i r t u a l关键字:276C+编程思想下载这个文件除了增加了v i r t u a l关键字之外,一切与W I N D 2.C P P相同,但结果明显
10、不一样。现在的输出是w i n d:p l a y。扩展性通过将play()在基类中定义为v i r t u a l,不用改变t u n e()函数就可以在系统中随意增加新函数。在一个设计好的 O O P程序中,大多数或所有的函数都沿用t u n e()模型,只与基类接口通信。这样的程序是可扩展的,因为可以通过从公共基类继承新数据类型而增加新功能。操作基类接口的函数完全不需要改变就可以适合于这些新类。现在,i n s t r u m e n t例子有更多的虚函数和一些新类,它们都能与老的版本一起正确工作,不用改变t u n e()函数:第14章 多态和虚函数277下载278C+编程思想下载可以
11、看到,这个例子已在w i n d之下增加了另外的继承层,但 v i r t u a l机制正确工作,不管这里有多少层。a d j u s t()函数不对于b r a s s和w o o d w i n d重定义。当出现这种情况时,自动使用先前的定义编译器保证虚函数总是有定义的,所以,决不会最终出现调用不与函数体捆绑的情况。(这种情况将意味着灾难。)数组A 存放指向基类instrument 的指针,所以在数组初始化过程中发生向上映射。这个数组和函数f()将在稍后的讨论中用到。在对t u n e()的调用中,向上映射在对象的每一个不同的类型上完成。期望的结果总是能得到。这可以被描述为“发送消息给一
12、对象和让这个对象考虑用它来做什么”。v i r t u a l函数是在试图分析项目时使用的透镜:基类应当出现在哪里?应当如何扩展这个程序?然而,在程序最初创建时,即便我们没有发现合适的基类接口和虚函数,在稍后甚至更晚,当我们决定扩展或维护这个程序时,我们也常常会发现它们。这不是分析或设计错误,它只意味着一开始我们还没有所有的信息。由于C+严格的模块化,这并不是大问题。因为当我们对系统的一部分作了修改时,往往不会象C那样波及系统的其他部分。14.4 C+如何实现晚捆绑晚捆绑如何发生?所有的工作都由编译器在幕后完成。当我们告诉它去晚捆绑时(用创建虚函数告诉它),编译器安装必要的晚捆绑机制。因为程序
13、员常常从理解 C+虚函数机制中受益,所以这一节将详细阐述编译器实现这一机制的方法。关键字v i r t u a l告诉编译器它不应当完成早捆绑,相反,它应当自动安装实现晚捆绑所必须的所有机制。这意味着,如果我们对b r a s s对象通过基类i n s t r u m e n t地址调用p l a y(),我们将得到第14章 多态和虚函数279下载恰当的函数。为了完成这件事,编译器对每个包含虚函数的类创建一个表(称为V TA B L E)。在V TA B L E中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地置一指针,称为v p o i n t e r(缩写为V P T
14、R),指向这个对象的V TA B L E。通过基类指针做虚函数调用时(也就是做多态调用时),编译器静态地插入取得这个V P T R,并在V TA B L E表中查找函数地址的代码,这样就能调用正确的函数使晚捆绑发生。为每个类设置V TA B L E、初始化V P T R、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数,这个对象的合适的函数就能被调用,哪怕在编译器还不知道这个对象的特定类型的情况下。下面几节将对此做更详细地阐述。14.4.1 存放类型信息可以看到,在任何类中,不存在显式的类型信息。而先前的例子和简单的逻辑告诉我们,必须有一些类型信息放在对象中,否则
15、,类型不能在运行时建立。实际上,类型信息被隐藏了。为了看到它,这里有一个例子,可以测试使用虚函数的类的长度,并与没有虚函数的类比较。280C+编程思想下载不带虚函数,对象的长度恰好就是所期望的:单个i n t的长度。而带有单个虚函数的o n e _ v i r t u a l,对象的长度是n o _ v i r t u a l的长度加上一个v o i d指针的长度。它反映出,如果有一个或多个虚函数,编译器在这个结构中插入一个指针(V P T R)。在one_virtual 和 t w o _ v i r t u a l s之间没有区别。这是因为V P T R指向一个存放地址的表,只需要一个指针
16、,因为所有虚函数地址都包含在这个表中。这个例子至少要求一个数据成员。如果没有数据成员,C+编译器会强制这个对象是非零长度,因为每个对象必须有一个互相区别的地址。如果我们想象在一个零长度对象的数组中索引,我们就能理解这一点。一个“哑”成员被插入到对象中,否则这个对象就有零长度。当v i r t u a l关键字插入类型信息时,这个“哑”成员的位置就被占用。在上面例子中,用注释符号将所有类的int a去掉,我们就会看到这种情况。14.4.2 对虚函数作图为了准确地理解使用虚函数时编译器做了些什么,使屏风之后进行的活动看得见是有帮助的。这里画的是在1 4.3节W I N D 4.C P P中的指针数
17、组A 。这个i n s t r u m e n t指针数组没有特殊类型信息,它的每一个元素指向一个类型为instrument 的对象。w i n d、p e r c u s s i o n、s t r i n g和b r a s s都适合这个范围,因为它们都是从i n s t r u m e n t派生来的(并且和i n s t r u m e n t有相同的接口和响应相同的消息),因此,它们的地址也自然能放进这个数组里。然而,编译器并不知道它们比i n s t r u m e n t对象更多的东西,所以,留给它们自己处理,而通常调用所有函数的基类版本。但在这里,所有这些函数都被用 v i r
18、 t u a l声明,所以出现了不同的情况。每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个V TA B L E,如这个图的右面所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为v i r t u a l的函数的地址。如果在这个派生类中没有对在基类中声明为 v i r t u a l的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在b r a s s的V TA B L E中,a d j u s t的入口就是这种情况。)然后编译器在这个类中放置 V P T R(可在S I Z E S.C P P中发现)。当使用简单继承时,对于每个对象
19、只有一个V P T R。V P T R必须被初始化为指向相应的 V TA B L E。(这在构造函数中发生,在稍后会看得更清楚。)一旦V P T R被初始化为指向相应的V TA B L E,对象就“知道”它自己是什么类型。但只有当虚函数被调用时这种自我知识才有用。通过基类地址调用一个虚函数时(这时编译器没有能完成早捆绑的足够的信息),要特殊处理。它不是实现典型的函数调用,对特定地址的简单的汇编语言 C A L L,而是编译器为完成这个函数调用产生不同的代码。下面看到的是通过i n s t r u m e n t指针对于 b r a s s调用a d j u s t()。第14章 多态和虚函数2
20、81下载图 14-1指针数组对象i n s t r u m e n t引用产生如下结果:编译器从这个i n s t r u m e n t指针开始,这个指针指向这个对象的起始地址。所有的 i n s t r u m e n t对象或由 i n s t r u m e n t派生的对象都有它们的V P T R,它在对象的相同的位置(常常在对象的开头),所以编译器能够取出这个对象的V P T R。V P T R 指向 V TA B L E的开始地址。所有的V TA B L E有相同的顺序,不管何种类型的对象。p l a y()是第一个,w h a t()是第二个,a d j u s t()是第三个
21、。所以编译器知道a d j u s t()函数必在V P T R+2处。这样,不是“以i n s t r u m e n t:a d j u s t地址调用这个函数”(这是早捆绑,是错误活动),而是产生代码,“在V P T R+2处调用这个函数”。因为V P T R的效果和实际函数地址的确定发生在运行时,所以这样就得到了所希望的晚捆绑。向这个对象发送消息,这个对象能断定它应当做什么。14.4.3 撩开面纱如果能看到由虚函数调用而产生的汇编语言代码,这将是很有帮助的,这样可以看到后捆绑实际上是如何发生的。下面是在函数f(i n s t r u m e n t&i)中调用i.a d j u s t
22、(1);某个编译器所产生的输出:C+函数调用的参数与C函数调用一样,是从右向左进栈的(这个顺序是为了支持 C的变量参数表),所以参数1首先压栈。在这个函数的这个地方,寄存器 s i(intel x86处理器的一部分)存放i的首地址。因为它是被选中的对象的首地址,它也被压进栈。记住,这个首地址对应于t h i s的值,正因为调用每个成员函数时t h i s都必须作为参数压进栈,所以成员函数知道它工作在哪个特殊对象上。这样,我们总能看到,在成员函数调用之前压栈的次数等于参数个数加一(除了s t a t i c成员函数,它没有t h i s)。现在,必须实现实际的虚函数调用。首先,必须产生 V P
23、T R,使得能找到V TA B L E。对于这个编译器,V P T R在对象的开头,所以t h i s的内容对应于V P T R。下面这一行mov bx,word ptrsi取出s i(即t h i s)所指的字,它就是V P T R。将这个V P T R放入寄存器b x中。放在b x中的这个V P T R指向这个V TA B L E的首地址,但被调用的函数在V TA B L E中不是第0个位置,而是第二个位置(因为它是这个表中的第三个函数)。对于这种内存模式,每个函数指针是两个字节长,所以编译器对 V P T R加四,计算相应的函数地址所在的地方,注意,这是编译时建立的常值。所以我们只要保证
24、在第二个位置上的指针恰好指向 a d j u s t()。幸好编译器仔细处理,并保证在V TA B L E中的所有函数指针都以相同的次序出现。一旦在V TA B L E中相应函数指针的地址被计算出来,就调用这个函数。所以取出这个地址并马上在这个句子中调用:call word ptr bx+4282C+编程思想下载图 14-2指针最后,栈指针移回去,以清除在调用之前压入栈的参数。在 C和C+汇编代码中,我们将常常看到调用者清除这些参数,但这依处理器和编译器的实现而有所变化。14.4.4 安装vpointer因为V P T R决定了对象的虚函数的行为,所以我们会看到 V P T R总是指向相应的V
25、 TA B L E是多么重要。在V P T R适当初始化之前,我们绝对不能对虚函数调用。当然,能保证初始化的地点是在构造函数中,但是,在W I N D例子中没有一个是有构造函数的。这样,缺省构造函数的创建是很关键的。在 W I N D例子中,编译器创建了一个缺省构造函数,它只做初始化 V P T R的工作。在能用任何 i n s t r u m e n t对象做任何事情之前,对于任何i n s t r u m e n t对象自动调用这个构造函数。所以,调用虚函数是安全的。在构造函数中,自动初始化V P T R的含义在下一节讨论。14.4.5 对象是不同的认识到向上映射仅处理地址,这是重要的。如
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- C+ 编程 思想 14
限制150内