C++编程风格(修订版)

978-7-115-38336-5
作者: 【美】Tom Cargill
译者: 聂雪军
编辑: 傅道坤
分类: C++

图书目录:

详情

本书讲解了C++语言中较深层次的程序设计思想和使用方法,包含大量的软件工程概念和设计模式,重点介绍大规模编程相关的内容。本书的示例代码都是从实际程序中抽取出来的,作者通过对这些代码进行分析,讲解了如何正确地编写代码以及避开一些常见的误区和陷阱,并提炼出了一些关于程序设计风格和编码风格的规则。如果开发人员在编程时能够遵循这些规则,将有助于开发出更好的C++程序。

图书摘要

C++编程风格(修订版)

[美]Tom Cargill 著

聂雪军 译

人民邮电出版社

北京

其他

版权声明

Authorized translation from the English language edition,entitled C Programming Style,9780201563658 by Tom Cargill,published by Pearson Education,Inc.,publishing as Addison-Wesley,Copyright©1992 Pearson Education,Inc.

All rights reserved.No part of this book may be reproduced or transmitted in any form or by any means,electronic or mechanical,including photocopying,recording or by any information storage retrieval system,without permission from Pearson Education Inc.CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD.,and POSTS & TELECOMMUNICATIONS PRESS Copyright©2015.

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签。无标签者不得销售。

内容提要

本书讲解了C++语言中较深层次的程序设计思想和使用方法,包含大量的软件工程概念和设计模式,重点介绍大规模编程相关的内容,例如增加代码的可读性、可维护性、可扩展性以及提高代码执行效率等的方法。本书的示例代码都是从实际程序中抽取出来的,作者通过对这些代码进行分析,讲解了如何正确地编写代码以及避开一些常见的误区和陷阱,并提炼出了一些关于程序设计风格和编码风格的规则。如果开发人员在编程时能够遵循这些规则,将有助于开发出更好的C++程序。

本书描述平实,示例丰富,适合有一定编程经验的计算机程序设计与开发人员参考。

2012年再版译序

C++的语法特性并不复杂,但想要用好这些特性却不容易。Bjarne Stroustrup曾说过:“……学习C++最难的部分莫过于体会编程语言构件的内涵以及集中于应用中的概念,即要学会抽象思维,并且设计时要着重于类而非操作序列……”因此,要想写出优秀的C++代码,掌握C++语法固然重要,但更重要的是掌握面对不同问题时的分析思路和解决方案,这通常也可以统称为编程风格。

本书介绍了一些常见的C++编程风格,重点在于讲解如何掌握C++程序的设计原理和编程实践。本书的内容与特点在2006年版的译序中已经给出了简要介绍,在此不再赘述。在2012年版的重新修订过程中,完成的工作如下所示。

(1)内容勘误

该书在2006年出版后,陆续有读者指出书中存在一些翻译有误或者不到位的地方。在这次修订过程中,我收集了网上论坛、电子邮件以及平时与同事交流中反馈的问题,一并进行了修正。

(2)语言提炼

在此次修订过程中,我将2006年的译稿逐字逐句重新阅读了一遍,试着从读者的角度来发现译稿中语言繁琐或表达不畅的地方,力争在确保翻译内容精确的同时,尽可能提高文字的简洁性和阅读的流畅性。

与6年前相比,我在重新阅读本书时对相同问题有了更深的理解和思考,正可谓“温故而知新”。这是我在修订过程中的最深体会,希望无论是新读者还是老读者,都能从本书中获得新的收获。本书的内容较为浅显易懂,对于想进一步提高编程水平的新手C++程序员来说,可以作为一个不错的开端。

在经过此次修订后,本书的质量将进一步提高,但其中或许还有不妥或者疏漏的地方,欢迎各位读者随时反馈与指正。

聂雪军

2012年7月于武汉

2006年译序

这是一本能够提升你C++编程功力的书籍。

C++是一种功能强大的编程语言,这已是不争的事实。目前,许多关于C++的书籍都只是对语言本身进行了详细的介绍,而对于如何正确地使用C++语言却介绍得不多。这就好比我们拥有了一种强大的武器,却无法有效地发挥它的最大威力。例如,虚函数和动态绑定是C++的强大功能之一,但我们必须首先识别出正确的抽象,才能有效地使用虚函数;否则,盲目地使用虚函数反而会降低程序的性能。与其他的C++语言书籍相比,本书更像是一本通过C++来阐述软件工程思想和设计模式的书籍。书中所讲述的大多数问题都是与大规模编程相关的,更多的重点是放在程序中不同组件之间的交互行为上,而不是诸如命名规则、注释风格等细节问题上。对程序员来说,实现某个功能并不是件难事,困难的是如何使设计和编码更为合理,这其中包括:代码的可读性、可维护性、可扩展性以及执行效率等。通过对本书的学习,你将会掌握正确的设计思想和使用C++的方法。

本书的最大特点就是与实际紧密相连。书中的示例代码都是从实际程序中抽取出来的。译者在翻译本书的时候,总有一种似曾相识的感觉:书中所提到的许多错误,在译者还是新手的时候都曾遇到过,因此能够与作者的分析和讲解产生共鸣。在任何一名新手的成长过程中,总是要经过痛苦的实践和许多不眠之夜,才能够积累一些经验和教训。而在本书中,作者将这些最宝贵的东西一并呈现给了读者。

本书的另一特点就是平实性。书中既没有华丽的辞藻,也没有尖锐的观点,而只是通过普通的叙述方法,将我们在编码过程中所遇到的问题加以剖析。细细读来,就好像是经验丰富的大师正在给新手们讲解如何正确地编写代码,以及避开一些常见的误区和陷阱。

书中所给出的编程规则对于程序员在设计和编码时是很有帮助的。但是切记,任何规则都不是绝对的,在每一条规则后面都有着特定的使用条件。即使是同一条规则,在不同的环境中,所起到的作用也可能是截然不同的。我们应该灵活地使用这些规则,而不是生搬硬套。否则,规则将会变成教条,这就违背了作者的初衷。

译者深知“一份耕耘,一份收获”,因此在翻译过程中不敢有一丝懈怠。对每个不易理解的地方,译者总是仔细斟酌,反复推敲,尽最大努力去将那些精妙的思想用浅显的语言表达出来。然而,由于时间和水平有限, 翻译中的疏漏和错误在所难免, 还望读者和同行不吝指正。

聂雪军

2006年5月于北京

Kernighan和Plauger的经典之作—The Elements of Programming Style—从出版至今,已经有大约20年的时间,书中给出的一组规则直到现在仍然可以作为最好的编程指导思想。当前,计算机程序正变得日益庞大和复杂,而我们使用的编程语言也已经发生了很大的变化。现在,我们不仅需要关注程序中每个模块的算法和数据结构,还要关注如何将程序中所有的模块更好地组合在一起。DeRemer和Kron提出了两个术语:“大规模编程(programming-in-the-large)”和“小规模编程(programming-in-the-small)”。这两个术语分别用来描述程序的“大规模”特性和“小规模”特性。“小规模编程”主要研究程序中“代码只有几页长”的组件,例如一个C++类。而“大规模编程”则是研究如何将“小规模”的组件组合成一个完整的程序——用C++的术语来说,就是研究类之间的关系。在Kernighan和Plauger的书中,重点讲解了“小规模编程”,对“大规模编程”的讨论虽然有所涉及,但却非常有限,基本上可以概括为以下两点。

1.将程序模块化。

2.有效地使用子程序。

本书介绍的编程风格侧重于“大规模编程”,但仅限于C++编程领域。本书所面向的读者,是那些已经学习过C++语言,并正在努力将语言的各种特性——尤其是面向对象的特性——应用于解决编程问题的程序员。虽然书中的讨论仅限于C++,但对于其他语言来说,其中许多编程经验同样是适用的。本书尽可能地保持了语言的独立性以方便读者阅读。

本书采用了Kernighan和Plauger的讲解方法,通过对程序进行分析和改写来提炼出编程风格的规则。书中使用的所有程序都是从介绍C++编程的教科书、期刊和手册中节选出来的,我并没有专门为本书编写示例程序。其中有些程序是原封不动地照搬过来,而有些程序则做了一些修改。这些修改包括改正一些无伤大雅的小错误,以及在保留程序结构的基础上,对那些未获得版权的程序进行的修改。

本书本着“就事论事”的态度来分析代码。我们都是通过阅读和分析彼此的代码来达到学习目的。本书并非要对某个程序员提出批评,而只是努力指出好的程序与坏的程序之间的差别。毫无疑问,本书中的“好”程序也可能有其自身的缺陷。我们鼓励读者对这些程序进行更严格的分析,并找出进一步改善编程风格的方法。

致谢

本书是基于C++ At Work和各种USENIX 会议提供的资料而逐渐形成的。感谢SIGS的Rick Friedman以及USENIX的John Donnelly和Dan Klein,是他们为我提供了这些资料,同时也感谢编写这些资料的人们。特别感谢Solbourne公司的程序员们,他们为了给我提供第一份资料而绞尽脑汁。本书还得益于The C++Journal中的文章,因此我要感谢编辑Livleen Singh。我与Dave Taenzer曾有过大量的讨论,这些讨论对于本书中的许多主题都有着很好的影响。同样还要感谢Addsion-Wesley出版社的John Wait对本书的信心和耐心。

David Cheriton、James Coggins、Cay Horstmann、David Jordan、Brian Kernighan、Doug Lea、Scott Meyers、Rob Murray、Kathy Stark和Mike Vilot等审阅了本书的初稿,并提出了许多提高本书质量和消除错误的建议。衷心地感谢他们的工作。

感谢我挚爱的Carol Meier,在我编写本书的时候,她自始至终都支持着我。在本书的构思和写作期间,我们迎来了我们的第一个孩子。

最后,我还要感谢以下的作者和出版社,感谢他们允许我引用以下书籍中的资料:

Davis,S.R.1991,Hands-On Turbo C++.Reading, MA:Addison-Wesley.

Dewhurst, S.C.和Stark,K.T.1989.Programming in C++.Englewood Cliffs,NJ:Prentice Hall.

C++ for C Programmers, Ira Pohl(Redwood City, CA:Benjamin/Cummings Publishing Company,1989)p.92.

Stroustrup,B.1991 .The C++ Programming Language,2d ed.Reading,MA:Addsion-Wesley/

Wiener,R.S., 和 Pinson,L.J.1988.An Introduction to Object-Oriented Programming and C++.Reading,MA:Addsion-Wesley.

Wiener,R.S.,和Pinson,L.J.1990,The C++Workbook.Reading,MA:Addision-Wesley.

Shapiro, J.S.A C++ Toolkit.本书的部分内容来源于A C++Toolkit,Shapiro, J.S,1991年版权所有。对这本书中部分内容的使用已经得到了许可。A C++Toolkit一书由Prentice Hall公司出版。

参考文献

Kernighan和Plauger[2]的书籍对所有程序员来说都是值得推荐的。Deremer和Kron[1]提出了“大规模编程”和“小规模编程”这两个术语。

1.DeRemer,F.and Kron,H.“Programming-in-the-large versus Programming-in-the-samll”, Proceeding of the International Conference on Reliable Software,ACM/IEEE,April 1975, Los Angeles,California.

2.Kernighan,B.W.and Plauger,P.J.1974(2d ed., 1978).The Elements of Programming Style.New York, NY: McGraw-Hill.

Tom Cargill

Boulder,Colorado

前言

本书采用一种统一的方法来给出所要学习的内容。通过研究示例程序——“编程风格示例”——来引入每个学习主题,这些示例程序通常在某些重要的方面存在着缺陷。在分析程序时,我们采取了与做代码交叉审查时一样的思路:在审查同事的代码时,我们要找出哪些问题是最需要改正的,以及对程序的哪些部分进行修改才能最大程度提升程序的整体性能。在本书中,我们将对每个示例程序做详尽的阅读和分析。读者在阅读书中对示例程序的分析之前,可以首先从自己的角度去分析程序中的问题,然后试着给出自己的解决方案。在分析完示例程序后,我们还将把最初的程序和修改后的程序进行比较;在某些示例程序中,随着对程序的逐步改进,程序的代码量和复杂性都会显著降低。最后,我们还可以对修改后的最终程序做更严格的分析,并努力去找出更多的改进方法。

对于从分析代码中学到的每个主要知识点,都可以用一条规则来进行描述。所有这些规则在内容和侧重点上都有着很大的区别。其中一些规则是具体的、技术上的规则,它们告诉程序员如何避开C++中常见的编程陷阱。而其他规则是关于如何在面向对象程序设计中构建抽象以及抽象之间的关系。所有这些规则都与C++程序设计中的某些特性有着密切关系。

我们需要重点理解在每条规则后面的动机和背景,而不是盲目坚持这些规则。在某些情况下,程序员可以为了达到一个更重要的目标而打破这些规则。事实上,本书中的某些规则之间是相互冲突的。例如,有的规则强调程序的简单性,而有的规则强调程序的完整性,它们往往是互相冲突的。程序员不仅要知道这些规则,而且还要理解其中的具体含义和应用环境,并且能够判断在什么时候应该打破这些规则。

在本书给出的示例程序中有着许多不同的地方,我们将在本章对程序中各种不同的编写方式进行解释。

C++是一种特殊的编程语言,因为在许多不同层次的抽象上,都可以用C++来编程。例如,我们可以通过C++对计算机硬件设备进行控制,这通常是需要用汇编语言才能达到的低层次抽象;此外,C++支持类、继承和多态,而这又是一种层次非常高的抽象。因此,在所有的面向对象语言中,从程序员所能控制的抽象层次上来看,C++是一种非常优秀的语言。

C++对C进行了许多扩展,其中大多数扩展都是和大规模编程相关的。C++中引入了成员函数、访问控制、重载、继承和多态等特性,这些特性都是用来描述程序中不同组件之间或者一组程序之间的关系,而并不是用来描述如何在单个组件中进行编码。因此,在本书的C++编程风格中,我们讨论的大部分问题都是关于如何描述组件之间的关系。事实上,如果要在编程风格和软件设计之间划分出明显的界限,那么将是一件很困难的工作。这是很自然的,因为与像C这样的语言相比,用C++来编程能够更明显地反映出软件的设计思想。我们通常是通过编程语言来表达具体的软件设计思想,而C++程序总是能够告诉我们大量重要的设计细节。

本书假定读者对C++语言已经有了不错的掌握,这样我们就只需要解释少量的细节问题。读者可以通过自己喜欢的入门书籍来学习那些尚不熟悉的语法和语义。只有当遇到一些深奥的技术细节时,我们才会进行明确解释。对于某些C++的细节问题,我们建议读者去参考Eillis和Stroudtrup的著作,或者翻阅The Annotated C++ Refrence Manual,Addisio-Wesley,1990。作为参考手册来说,我们很难学完Ellis和Stroustrup的著作中所有的内容,因此应该学会针对特定的问题有选择性地进行阅读。

词法风格

在C++中可以使用许多不同的词法风格,例如缩进约定、在什么地方使用空格等。在本书中,我并没有指出哪一种词法风格是更好的。大多数的程序都是遵循着最初的词法风格。例如,在一些程序中将指向字符的指针声明为char* p,而在有些程序中使用的是char *p。有时候用0来表示空指针,而有时候则用NULL来表示。不过,在每个示例程序中,我们都将只用一种词法规则,并且在修改代码时也遵循这个规则。

我们并没有指出哪一种词法风格是更好的,理由有两个。首先,虽然在程序的可读性上,词法风格很重要,但与大规模编程的问题相比,这种重要性就显得苍白无力。例如,与“程序中是需要一个类还是两个类,或者这些类是否通过继承来进行关联”相比,我们在类的开始部分到底是声明公有成员函数还是私有成员函数就显得不那么重要。事实上,从某种意义上说,词法风格也可以是无足轻重的,我们可以通过一些自动工具——其中包括多种排版技术——将任意程序从一种词法风格转换到另一种词法风格。因此,在本书中将不会对词法风格进行讨论。

其次,许多关于词法风格的问题都是主观性的。例如,对某些程序员来说,多余的括号可以带来帮助,而对其他的程序员来说,多余的括号则妨碍了程序的编写。有些程序员喜欢看到返回表达式被写为return (x),而有些程序员则更喜欢使用return x。对于不同的程序员来说,所有这些做法都是基于同一个理由:“这样写更易于阅读”。程序员,无论是个人还是处于某个团队,必须在词法风格的问题上作出他们自己的选择。换句话说,即使我在词法风格上有我自己的观点,但其他程序员也可以不考虑它们。或许,唯一一条客观的原则就是:在软件项目的整个开发过程都应该只采用一种词法风格,并且应该自始至终都遵循这种词法风格。

内联函数与const声明

在示例程序中,有些成员函数被声明为内联函数,虽然这样的声明看上去对程序整体性能的提高并没有什么帮助。一些程序员只是例行公事地将代码量较小的成员函数声明为内联函数:将这些成员函数的定义放在类的声明中,这样可以减少一些源代码的大小。然而,这通常是一种误解。在编译的时候,内联函数将在被调用的地方进行展开,这通常比相应的函数调用要占据更多的代码空间,尤其当我们在内联函数中又调用了其他的内联函数时。由于内联函数展开所增加的代码量有可能降低程序的执行速度,因为代码量的增加将可能妨碍有效的代码缓冲操作。只有当内联函数确实能够带来程序性能的提升时,才应该被用于代码中。然而,对于那些无法判断是否会带来性能提升的内联成员函数,我们还是应该将它们保留在最初的代码中,并且不予以分析。通常,在每个示例程序中都会有一个关于正确性或者一致性的问题需要讨论,这个问题远比任何关于程序性能的问题要重要。因此,我们将内联函数的相关问题交由读者来分析。

在本书的示例程序中,对于const修饰符在类型和成员函数上的用法也是不同的。如果在程序中小心地使用const,那么就能在编译期检测出一些错误。不过,由于在使用const的时候存在着不同的风格,而且在一个已经完成的程序中加入const是非常困难的,因此,我们并没有将const修饰符增加到程序中去。const的出现或缺少并不会影响程序的整体结构,而整体结构才是我们关心的主要问题。

虚函数

虚函数的动态绑定是C++中一个强大并且重要的功能。本书的大部分内容都是讨论在什么情况下应该将虚函数从程序中删除,而不是讨论在什么情况下将虚函数增加到程序中。我们将首先给出一些使用了虚函数的C++程序,然后对这些程序进行观察和分析,最后将重点放在如何消除虚函数上。使用虚函数的示例程序是非常多的,然而能够通过虚函数来改进性能的程序却是非常少的。因此,概括起来就是:在示例程序中,我们将重点说明如何避免虚函数,而不是说明如何使用虚函数。

注释

在本书中,大多数程序的注释都很少。事实上,从软件产品的设计标准来看,这些注释是不够的。如果有更多的注释,那么读者在阅读程序时可能会更快,但这却违背了我们研究代码的初衷。如果注释不是很多,那么读者就必须直接从代码中获得更多的信息,从而把更多的注意力放在程序的细节上。通过对程序细节的分析来推断程序的设计思想,这对于理解大多数的示例程序来说都是非常重要的,这也是一个值得我们去努力培养的技术。

标准I/O与流

在本书的一些程序中,我们使用了标准的C语言I/O库(例如,printf)对字符进行格式化输出,而在其他程序中则使用了流类库(例如,cout)。在大多数情况下,无论程序使用哪种方式,都可以编写出简洁的代码。

参数化类型和异常

在编写本书的时候,在已发布的C++编译器中都能支持参数化类型(模板),而异常处理则还不支持[1]。对于这些C++特性来说,我们应该持谨慎的态度。我们应该等到大多数程序员都接受了这些特性后,再去观察和分析使用这些特性的程序,而不是凭主观想象去推测这些特性所可能带来的混乱。只有当这些特性在程序中出现以后,才可以给出一些有意义的指导原则,用来告诉程序员在使用这些特性进行编程时如何去避开常见的陷阱。

练习

在大多数章节的后面都给出了练习,其中一些练习是进一步阅读和分析程序。在一些练习的代码中,所存在的缺陷与该章示例程序中的缺陷是相同的。在其他的练习中,则给出了一些写得不错的程序。然而,这些练习并非要求读者按照所给出的方式来编写代码。读者应该通过实践来找到适合自己的编程方式,而不是盲目遵从书中给出的方式。

程序清单

在本书中,所有示例程序都是从实际的源文件中节选出来的,这些源文件至少能够在一种C++平台上进行编译和运行。其中大多数程序都是在Cfront 2.0下运行的,还有一些是在G++和Borland C++ 3.0下运行的。

参考文献

在编写本书的时候,对C++的定义描述参考了Ellis和Stroustrup的著作[1]。

Ellis,M.A.,and Stroustrup,B.1990.The Annotated C++Reference Manual.

Reading,MA:Addison-Wesley.

[1].译者注:这是因为作者编写本书的时候较早,这些特性在目前所有主流的C++编译器上都已完全支持。

1 抽象

在软件开发中,抽象处于一种中心地位,而类则是C++中最重要的抽象机制。类描述的是所有从这个类实例化出来的对象的共同属性,并且刻画了这些对象的共同行为。在C++程序设计中,正确识别抽象是一个很关键的步骤。如果想获得高质量的抽象,那么程序员就必须要充分地理解程序中各种对象的内在属性。

为了研究类这种抽象机制,我们首先来看一个示例程序,并评价这个程序的优缺点,特别要注意在程序中是如何来设计类的。然后,我们用不同的类与类之间的关系来改写程序。通过对程序结构的重新思考以及改写示例程序,我们可以得到关于编程风格的一些普遍规则,并且这些规则对于改进其他的程序来说同样适用。

1.1 编程风格示例:计算机的定价

我们来观察程序清单1.1中包含的类。在程序中包含的抽象是计算机、计算机组件以及它们的价格和折扣。程序的功能是确定一台计算机中各种配置的价格。在配置一台计算机时,需要作出两个选择:一个是扩展卡的选择;另一个是显示器的选择。扩展卡的插槽必须是以下三个选项之一:光驱、磁带机或者网络接口;显示器则必须是单色或者彩色。我们在阅读程序时,要同时考虑如何对其进行简化,程序中的类是不是给出了正确的抽象,有没有其他的方法能够通过改变程序中的抽象来降低程序的复杂性。

程序清单1.1 最初的程序

程序清单1.1 最初的程序(续)

程序清单1.1 最初的程序(续)

程序清单1.1的程序输出如下:

在Computer类的构造函数中,每个参数都是一个相应的枚举常量:

在构造函数中使用了switch语句,根据不同的参数值来创建相应的对象:

如图1.1所示,程序中所有的类构成了两个继承层次,其中一个继承层次用来选择插在槽中的扩展卡,另外一个用来选择显示器。在本书所有的继承层次图中,基类都是位于派生类的上面。

Card和Monitor这两个基类都是抽象类:

程序清单1.1中的程序是否有必要写得这么冗长和复杂?这个程序是不是一定需要8个类,并且其中有7个类含有虚函数,才能解决计算机定价的问题?或许我们可以找出更简单的抽象,从而写出更简单的程序。

1.2 找出共同的抽象

Card和Monitor这两个类的接口是相似的:它们都含有纯虚函数price()和name()。不同的地方在于Card类中还有一个虚函数rebate()。通过研究这些类之间的相同点和不同点,我们可以理清其中的关系,从而写出更佳的程序。

Card和Monitor这两个类是相似的,但在继承层次中并没有被联系在一起。在Card和Monitor中都含有成员函数price()和name()。之所以在这两个类中包含了相同名字的成员函数,是因为它们都是计算机的组件。如果用一个更上层的基类,比方说Component,来表示这种共同的抽象,那么将更有实际意义,如图1.2所示。

在引入Component之后,只有Card和Monitor的声明发生了变化,程序的其余部分并没有改变。新的声明如下所示:

虽然引入Component使得在程序中又增加了一个类,但它通过提取出一个共同的抽象将Card和Monitor统一在了一起。在增加了Component之后,程序为解决问题建立了更优的模型。

我们是否需要将rebate()也作为Component的一个成员函数呢?通过观察程序中的类,可以清楚地知道:Card对象可以打折,而Monitor对象则不行。那么,这种差异是本来就存在的,还是只是一种巧合呢?如果存在某个规定(例如政府的规定),禁止对显示器打折,那么这种差异就是本来就存在的。而在本程序中,是否可以打折只是一种碰巧的情况:所有的计算机组件都可以打折,但碰巧这里所有的显示器都不能打折。如果我们将rebate()也作为Component的成员函数,那么基类将变成:

虽然price()和name()都是纯虚函数,但Component::rebate()并不是。函数price()和name()在基类中的默认实现是没有意义的——这种信息必须在对象被实例化之前由派生类来指定。而相对来说,在基类中将折扣指定为零则是一个不错的默认实现——折扣将保持为零,直到我们在派生类中改写这个虚函数。

统一的抽象使我们能够对函数Computer::netPrice进行简化。在原来的程序中,函数Computer::netPrice的实现细节依赖于前面所讨论过的情况,即Card有折扣,而Monitor没有:

事实上,在函数Computer::netPrice中的代码不应该显式地依赖于组件是否存在折扣,而应该对每种组件进行统一处理:

在上面对每种组件统一的计算price() – rebate()中,还隐含了Component的另外一个抽象。如果将netPrice()也作为一个成员函数添加到Component中,那么在函数Computer::netPrice()中只需调用Component的其他成员函数即可:

到目前为止,我们对程序进行了3处改进。首先,引入了一个共同的基类Component,它将所有的组件类都归纳到单一的继承层次。其次,将rebate()作为Component的一个成员函数,这使得不同的Component对象有着更为统一的行为。我们也因此就不再需要在程序中区分哪种组件有折扣,哪种组件没有折扣。最后,在Component的接口中增加了函数netPrice(),这就意味着客户代码根本不需要自行计算折扣额。之所以能够获得这3个改进,是因为在程序的所有类中存在着一些共性,而这些共性可以被独立出来并放在一个基类中。通过上述改进,我们的确识别出了所有类的共同属性,并把它们移到一个共同的基类中。我们可以将上述讨论概括为以下的规则:

将共同的抽象提取出来并放到基类中。

1.3 类之间的区别

让我们把注意力暂时从类的共同属性上移开。我们可能会问:类与类之间为什么会有不同?例如,NetWork类和CDRom类到底有什么不同之处呢?区别就在于类的声明。在这两个类中既没有增加新的成员,也没有其他的状态或者行为。它们的区别在于各自对虚函数price()、name()和rebate()有着不同的定义。对于程序中不同派生类的对象,虚函数并没有改变它们的行为,因此,NetWork对象和CDRom对象之间唯一的区别就在于它们各自虚函数返回的值是不同的。

通常来说,对象的行为是指它对外部激励的反应。如果从外部激励反应的角度来考察一个对象,我们强调的是对象的独立性——在程序执行期间,每个对象都是程序的一个自治实体。最普遍的外部激励形式就是成员函数调用,而对象将通过执行成员函数来作出反应,或者是完成某个功能,或者是返回一个值,或者二者都是。多态——虚函数——可以使得不同类型的对象对相同的外部激励产生不同的反应。然而,在本程序中,虚函数并不会使得不同类型的对象在行为上有所不同。

我们可以从另一个角度来考察这些派生类之间的区别。在继承层次中,对于叶节点上的类来说,在每个类创建的对象中,所包含的信息都是与其相应的类结合在一起的。例如,在Tape对象中没有数据成员,因此每个Tape对象都是互相等价的。所以,实例化多个Tape对象是没有意义的,因为它们的行为肯定都是一致的。所有在继承层次叶节点上的类都是如此,在任何一个类创建的所有对象之间都是无法区别的。每个Tape对象都能代表所有的Tape对象,在Tape类和Tape对象之间几乎没有差别。

泛性(Generality)是程序的基本属性之一。能够解决泛性问题的程序和模块,比那些只能解决特定问题的程序要有用得多。这条经验对于传统的过程式程序设计和现在的面向对象程序设计来说都是适用的。通过使用参数,我们可以使函数变得更加一般化,因此也就更有用——我们可以在调用函数时提供实际的参数值,从而使函数的泛性行为转变为具体行为。同理,当一个类能够描述许多对象的共同属性而不仅限于某种特定对象的属性时,这个类也就更加有用。在实际工作中,程序并不会为每个需要创建的对象都提供一个不同的类,而是用一个类来表示一组对象。

一个类应该能够描述一组对象。

1.4 属性与行为

最初编写这个程序的程序员陷入了一个常见的思维陷阱,他认为,在用C++来进行程序设计时,继承和虚函数是唯一的方法。于是,他在程序中过度地使用了继承,从而导致某些类的声明过于具体,甚至只能描述一种对象。如果在不同的对象之间有不同的行为,那么继承和多态是很用的工具。然而,在本程序中,对象之间的不同之处在于它们的属性,而并非行为。

要表示组件对象之间的区别,使用简单的数据成员和非虚的函数就足够了。考虑下面的计算机组件类Component:

在上面的代码中,成员函数price()、name()和rebate()不再是虚函数,而是被声明为内联函数,分别用来返回相应的数据成员。现在,组件对象之间的区别就在于表示属性的数据成员值,而不是组件的类型。不同的属性值是由传递给构造函数的参数来指定的。

如果派生类之间的区别在于属性,则用数据成员来表示;如果在于行为,则用虚函数来表示。

现在的Component类足以解决最初的计算机配置问题。对于每一个计算机组件,都可以只用一个Component对象来描述组件的名字、价格和折扣。如果组件没有折扣,则可以将数据成员rebate设置为零。在程序清单1.2中给出了使用上述Component类的完整程序。注意,现在程序中的NetWork、CDRom、Tape、Color和Monochrome都是对象,而不是类。在Component类中定义了对象的共同属性,并且每个对象都包含了它所要描述的具体信息。Computer类也发生了相应的改变:它将不再创建和销毁对象。

程序清单1.2 由对象描述的Component类

程序清单1.2 由对象描述的Component类(续)

1.5 再次引入继承

从某种程度上来说,上面这个程序在改写过程中丢失了一些优点。在最初的程序中,扩展卡的默认折扣只需在函数Card::rebate()中指定一次即可,对扩展卡来说这个默认值是45。而在现在的Component定义中,所有非零的折扣都必须在声明对象时指定。在程序中,并没有某个地方可以记录扩展卡的默认折扣,并且程序也确实需要将扩展卡和显示器区分开来。我们可以通过继承来分别为扩展卡和显示器提供不同的构造函数,从而提供合适的特化(Specialization)。因此,我们必须再次引入Card类和Monitor类,并给出带有默认折扣值的构造函数。在图1.3中给出了相应的继承层次。

派生类的特化被局限在构造函数中。在Card和Monitor的构造函数中,我们将折扣声明为函数的默认参数。

通常,如果派生类是基类的特化,也就是说,存在着派生类对象“是一种(Is A Kind Of)”基类对象这种关系时,就需要使用公有继承。在程序中,Card是一种Component;Monitor也是一种Component。在上面的示例中,只有在构造对象时才会使用到特化——在对象构造完成之后,所有Component对象的行为都将是一致的。将派生类之间的不同之处局限在初始化过程中——构造函数的特化——是一种使用继承的正确方法。

如果通过公有继承来产生派生类,那么这个派生类应该是其基类的特化。

现在我们可以注意到,Card和Monitor的区别在于一个默认的属性,这个属性在程序中表现为各自构造函数中的默认参数值。在本程序的最初版本中,Card和Monitor的区别在于默认的行为,并且这个行为在程序中是用虚函数的方式来表示的。

1.6 去掉枚举

在下面5个Component对象中,有3个Card对象和2个Monitor对象。其中只需指定一个折扣,这是因为只有CDRom对象的折扣与默认的折扣不同。

在再次引入了Card和Monitor之后,我们可以通过另一种方式来进一步简化程序。在函数Computer::Computer的参数中指定了一个CARD值和一个MONITOR值。虽然这些枚举值在构造函数与其所需要的信息之间增加了一个间接层,但却并没有增加任何灵活性。由于在构造函数中需要各个Component对象,因此我们不得不将每一个枚举值都映射到一个对象。维护这种映射关系是一件很复杂的工作。例如,假设我们要增加另一种类型的Monitor,比如GreyScale,那么程序需要(1)在MONITOR枚举类型中增加一个GREY_SCALE;(2)声明一个GreyScale对象;(3)在构造函数处理MONITOR参数的Switch语句中增加一个GRAE_SCALE分支。然而,我们可以去掉枚举并直接处理对象,这样将会使程序的扩展变得更加简单。

Card和Monitor是两种截然不同的类型。如果将函数Computer::Computer的参数改为指向对象的指针,那么就可以去掉枚举和switch语句。构造函数可以直接获得它所需要的信息,而不需要将枚举映射到对象。在去掉映射后,当我们要增加一个组件时,只需简单地声明一个对象即可。经过上述修改之后,在构造函数中就去掉了CARD和MONITOR枚举变量,而Computer的构造函数将变成:

由于现在的参数变成了一个指向Card的指针和一个指向Monitor的指针,因此一个Computer仍然仅由一块扩展卡和一个显示器构成。程序清单1.3中给出了完整的示例程序。

将程序清单1.3中的程序与程序清单1.1中的程序相比,我们会发现前者的代码量大致是后者的一半左右。通常,小程序比大程序要更加易于阅读和修改。对本程序而言,更好的抽象不仅改善了程序的架构,同时也减少了程序的代码量。当然,代码量的减少并不是我们的目标,因为用最少行数的代码来编写程序往往并不会产生最好的解决方案。然而,优良的设计却总是比拙劣的设计有着更短的代码,这是因为在好的设计中通常都使用了正确的抽象。

程序清单1.3 构造函数的特化

程序清单1.3 构造函数的特化(续)

小结

在程序的最初版本和最终版本中都提供了默认的折扣。在程序清单1.1的程序中是通过基类虚函数的默认行为来实现的,而在程序清单1.3的程序中是通过派生类构造函数的默认参数值来实现的。由于折扣是一种属性,因此用数据成员来表示折扣是一种更直接的方法。

最初程序中的两个缺陷都是与继承相关的。第一个缺陷是过于特化——每一个类都被具体到只能描述一种对象。通常,一个类应该能够描述一组对象的共性,而不仅仅只是单个对象。第二个缺陷是使用了虚函数来表示属性之间的不同。使用数据成员来表示属性之间的区别,要比使用虚函数的表示更加易于编程。如果在所有的对象中都存在着共同的抽象时,那么越简单的继承层次就能形成越精确的模型。

由于继承和虚函数的功能强大,并且是一种新颖的技术,因此每个程序员都不愿错过每一次能使用这些技术的机会。然而,并不是每一个问题都需要虚函数才能解决。使用数据成员来表示对象在属性上的区别也是一种不错的方法。程序员们不应该只是因为C++提供了更为复杂的编程技术而放弃那些标准的和可靠的编程技术。

多态并不是所有程序设计问题的解决方案。

参考文献

抽象是Booch[1]著作中的核心内容。还可以参见Budd[2]和Wirfs-Brock[6]各自著作中的第1章。在[3]、[4]和[5]中关于“数据抽象”和“抽象数据类型”的讨论也是不错的。

练习

1.1 分析程序清单1.4中的类。函数main()的输出信息如下所示:

应用在本章中所学到的规则,对程序中的抽象以及相应的类进行评价(标识符Pb和Au是化学元素符号,分别代表铅和金,这两个名字来自于拉丁文plumbum和aurum)。

程序清单1.4 类Pb与类Au

程序清单1.4 类Pb与类Au(续)

2 一致性

对于任何一个类,都可以从两个主要方面来进行观察:类的接口和类的实现。类的接口也就是类的公有成员集合,它决定了这个类创建的对象能够为程序其他部分中的客户代码提供什么样的服务。而类的实现则是完成了这些服务的功能,它们通常是作为类的私有成员被封装起来,客户代码无法进行访问。在设计一个类时,程序员通常需要从这两个方面来进行考虑。接口必须能够代表一致的抽象,而实现则必须使对象在行为上与这个抽象保持一致。本章将从接口和实现这两个角度来讨论一致性的问题。

在任何时候,一个对象都是处于某种状态(State),这种状态是由对象中所有数据成员的值来确定的。无论我们是通过类的外部接口,还是类的内部实现来观察一个对象时,状态对于理解这个对象来说都是很关键的。我们可以通过接口将对象从一种状态驱动到另一种状态,从而满足客户代码的需求。例如,通过将信息储存在表格对象中,客户代码可以任意改变表格的状态,使其适应于随后的检索操作。而类的实现必须维持这个表格对象的状态,这样客户代码就能够从检索操作中得到正确的结果。

接口和实现可以通过不同的模型来表示对象状态,这也分别被称之为逻辑状态和物理状态。逻辑状态模型通常是物理状态模型的简化,多个物理状态可以对应于一个逻辑状态。例如,如果表格对象对最近的检索结果进行缓冲以作为一种优化,那么在物理状态中就包含了这个缓冲,然而,我们在公有接口中是看不见这个缓冲的,因为它并不属于逻辑状态。

2.1 编程风格示例:string类

我们来分析程序清单2.1中的string类,将注意力放在string对象的状态,以及在string类的接口和实现中的一致性。我们要从类的定义中找出不一致的地方。在代码中存在着许多问题,当我们遇到第一个不一致的地方时,不要停下来,而是继续进行查找。

程序清单2.1 最初的string类

string对象的表示形式是一个字符类型的指针s和一个代表字符串长度的整数len。在string类中共有4个构造函数、1个析构函数以及3个其他的成员函数:assign()、print()、contact()。在下面的代码中总共使用了5个string对象。

上述代码的输出为:

2.2 明确定义的状态

在string类的前两个构造函数中存在着同样的问题。

如果在创建string对象时调用了上述两个构造函数之一,那么这个string对象的初始状态将是未定义的。在下面的代码中将输出两个字符串,其中在创建string对象时分别调用了上面两个构造函数。

对x和y来说,调用函数print()的结果是未定义的,因为由x.s和y.s指向的字符数组中的内容是未定义的。在这两个构造函数中,都为字符数组分配了内存,但却没有对分配的内存进行初始化。流输出对象cout将持续输出字符,直到在内存中遇到第一个空字符(\0)才停止输出。

构造函数的目的是为了初始化对象,因此一个构造函数至少应该使对象处于明确定义的状态。

构造函数应该使得对象处于明确定义的状态。

在string类的前两个构造函数中,对初始状态的稳妥处理就是使用空字符串,即s[0]应该被设置为空。

对于这两个构造函数,我们还可以作一个改进,不过这个改进略显次要。这两个构造函数的功能几乎是等价的:在每个构造函数中都分配了一个字符数组,并将指向数组内存的指针保存在s中,以及将数组的长度保存在len中。二者唯一的区别就是其中一个构造函数使用了默认值来指定数组的大小,而另一个构造函数则使用参数值来指定数组的大小。除此之外,这两个函数都是相同的。因此,我们可以有更好的选择,即使用带有默认参数值的构造函数。通过用下面的构造函数来代替前面两个构造函数,就可以改正到目前为止所遇到的两个问题:

通常来说,用默认参数的形式来代替函数重载的形式,可以使程序更加易于维护,因为此时程序中将只会存在一个函数体。在本示例程序中,我们对构造函数采用了函数重载的形式。对于其他的成员函数或者非成员函数,这个规则同样适用。

考虑使用默认参数的形式来代替函数重载的形式。

2.3 物理状态的一致性

在string类的第三个构造函数中将对函数的字符串参数进行复制。在这个构造函数中对s进行了初始化,并将string对象置于明确定义的状态,但它对len的处理方式与前两个构造函数是不一致的。在前两个构造函数中,len是所分配的字符数组的长度。而在第三个构造函数中,len却是字符串的长度——也就是字符数组的长度减1。那么len到底应该是动态分配数组的长度还是字符串的长度呢?len的这两种含义都是有意义的,但在所有的构造函数及其他的成员函数中,我们必须只能使用一种含义。除非len的含义是唯一的,否则成员函数将无法以一致的方式来解释string对象的状态。

我们可以在前两个构造函数中,或者在第三个构造函数中来改正这个问题。通过观察在最后一个构造函数(拷贝构造函数)以及成员函数assign()和concat()中对len的使用方式,我们可以知道len的含义究竟是数组的长度还是字符串的长度。现在,我们来依次观察这三个函数。首先,在拷贝构造函数中,len的含义是数组的大小:

从拷贝构造函数对len的使用方式中,我们可以看到问题是在于第三个构造函数,也就是带有字符指针参数的构造函数。其次,在成员函数assign()中,len是被设置为字符串的长度,这却表明问题是在于前两个构造函数:

最后,在成员函数concat()中,情况将变得更糟糕,在这个函数中len的使用方式与其他成员函数中的使用方式都不相同。

一方面,如果len是数组的长度,那么在concat()中所分配数组的长度就会比所需数组的长度大1。另一方面,如果len是字符串的长度,那么在concat()中所分配数组的长度就会比所需数组的长度小1。因此,无论len是以上哪种含义,在函数concat()中分配的数组都存在着多1或者少1的错误。对于这种情况,程序员可能无法决定该如何来使用len,因此也就无法解决这个问题。在最初的程序中,string对象的状态对于数据成员len的含义并没有一致的定义。

2.4 类不变性

对于每个类,我们都可以写出一组类不变性(Class Invariant)条件,在类的每个对象的生存期内,这些条件都应该是成立的。例如,如果len是用来表示字符串的长度,那么string类的一个不变性条件就是

类不变性与循环不变性是相似的。循环不变性在循环的起始阶段就开始起作用,并且在循环的每次迭代中都将保持这个不变性。因此,循环不变性将一直持续到循环结束。同理,在每个对象的生存期之内也需要保持类不变性。我们首先在构造函数中建立起类不变性,然后在其他成员函数中维持这个不变性,这样就在对象的整个生存期内都保持了类不变性。

用一致的方式来定义对象的状态——这需要识别出类不变性。

在其他一些编程语言中,例如Eiffel,提供了显式的语言机制来表示和检查类不变性。然而,在C++中并没有提供正式的语言机制来支持类不变性,而是由程序员来决定如何实现类不变性。从这一点来说,类不变性就更加类似于循环不变性。如果一个C++程序员在头脑中始终记着循环不变性,那么他在编写循环时就有两种选择。在编写完循环之后,可以将不变性作为循环代码的注释,或者将不变性作为一个断言整合到代码中(通过在标准头文件assert.h中定义的assert宏,我们可以很容易地在代码中增加断言)。将不变性作为类的注释和将不变性作为循环的注释是一样简单的,然而将类不变性作为断言整合到类的代码中则要更困难一些。在类的源代码中,需要增加类不变性断言的地方可能不止一个。由于在每个成员函数中都需要保持类不变性,因此,在每个可以改变对象状态的成员函数中都需要对断言进行测试。其中一种方法就是将所有的类不变性断言集中到一个特殊的成员函数中,并在其他成员函数的开始部分或者结束部分(也可以在这两个部分)调用这个特殊的成员函数。不过,很少有C++程序员会去做这种麻烦的工作,相对而言,将类不变性作为注释是更为普遍的做法。

在程序清单2.2中给出了一个string类,在这个string类中len的值和strlen(s)的返回值保持一致。在类的声明中有一条注释语句,用来说明len的类不变性。类的每个成员函数都将遵循这个不变性。

程序清单2.2 string类中的len是一致的

程序清单2.2 string类中的len是一致的(续)

2.5 动态内存的一致性

在程序清单2.2的string类中仍然存在着一些问题和不一致的地方。其中,在动态内存管理上的不一致性与我们在前面所看到的不一致性是一样的,都是严重的问题。对于所有动态分配的内存,我们都需要回答两个问题:首先,动态内存是不是足够大以容纳将要存储的信息?其次,是不是所有的动态内存都是可回收的?

在默认构造函数中分配的字符数组肯定可以容纳空字符串:

这个构造函数所基于的假设是:在创建对象时将会为字符串分配内存,并且这个内存足以容纳在对象生存期内需要保存的任意字符串。成员函数assign()与这个假设也是一致的:

在assign()中调用strcpy()对参数字符串进行拷贝时,并没有考虑到目标字符数组的长度或者大小。编写客户代码的程序员必须保证——在创建对象时,无论调用的是哪个构造函数——在构造函数中所创建的数组必须能够容纳在assign()中复制的任意字符串。

然而,在成员函数concat()中采用了一种不同的方法:在创建每个字符串时,总是动态地决定所需数组的精确大小。函数concat()忽略了在创建string对象时已经分配好的字符数组,即使这个已分配的数组是足够大的:

在assign()和concat()这两个函数的表现行为上存在着不一致性。它们的区别在于,在为string对象设置新值时,是否会动态分配字符数组:assing()永远不会分配,而concat()则总是会分配。

接口一致性

上面哪种控制数组大小的方法是更好的?和许多软件决策一样,没有哪种方法是绝对的“正确”或者绝对的“错误”。这两种方法都有各自的优点。保持在构造函数中分配的数组不变(assign()中的做法)是一种高效的方法,因为在后续的操作中就无需再调用内存分配函数。对每个字符串值都动态地决定数组的大小(concat()中的做法)则是一种更安全的方法,因为这种方法杜绝了数组的“越界”行为。

这两种方法都可以用在类中,但我们只能使用其中的一种,以保持类一致性,而不应该将这两种方法混合使用。否则,在使用这个类时,程序员将不得不去了解在接口中不同操作之间的不同约定。如果一个程序员只使用过concat(),并且知道了数组的大小是动态增长的,那么他就会假定assign()也是同样的行为,因此,当在assign()中发生数组内存的越界问题时,他所感到的沮丧应该是可以预见的。

类的接口定义应该是一致的——避免产生困惑。

2.6 动态内存的回收

string类在管理动态内存时还存在着第二个问题:“内存泄漏”,当所有使用new来分配的动态内存并没有都使用delete来进行释放时,就会发生内存泄漏。string类中的内存泄漏既不在构造函数中,也不在析构函数中。我们可以看到,在每个构造函数中仅执行一次new,以获得一个指向字符数组的指针,并把这个指针保存在s中。在string对象中总是包含有一个数组来存储字符串,并且这块内存在对象生存期结束时,将会通过在析构函数中调用delete[] s来进行释放。

在concat()中存在着内存泄漏,因为在函数中分配新的数组时,并没有释放对象中当前的数组。当concat()执行下面的语句时:

s马上被一个新的指针值覆盖,而前一个指针值则被抛弃了,这就使得前一个指针所指向字符数组成为了一块垃圾内存。

为了改正这个内存泄漏问题,concat()必须保证原有的数组一定要被删除。我们在每次使用new时都将创建一个动态对象(在本例中是一个动态数组),对于每个动态对象,很关键的一件事就是建立一个“所有者”——当不再需要动态内存时,所有者应该负责销毁动态对象。最简单的所有者策略就是,将执行new操作的对象作为动态对象的所有者,并且必须由所有者来负责删除动态对象。根据这个策略,string类就必须负责删除所有在构造函数或者其他的成员函数中分配的字符数组。

对于每个new操作,都要有相应的delete操作。

对于concat()中的问题,解决方案并不像在执行new之前增加一条delete[] s语句这样简单。下面的代码并不足以解决concat()中的问题:

上面的concat()中有什么样的错误?假设x和y是string类的实例,并且我们通过表达式x.concat(y,x)来调用concat()函数。那么,在concat()中,函数将通过两种方法来访问对象x:this指针和参数b都是指向x。我们可以看到,在执行concat()时,x.s指向的数组在通过b.s传递给strcpy()之前,就已经通过this->s删除了。等到执行strcpy()时,字符数组已经释放了,甚至还可能被concat()中的new进行了重新分配。

要改正concat()中的内存泄漏,delete语句只能添加在新的字符串创建之后:

2.7 编程风格示例:第二种方法

我们暂时先不去考虑去解决string类中的其他问题,而是将注意力转移到另一个不同的字符串类。在这个类中,我们避免了大多数的上述问题。我们来分析程序清单2.3中的SimpleString类。虽然SimpleString相对于string进行了改进,但仍然存在着一些缺陷。

程序清单2.3 最初的SimpleString类

程序清单2.3 最初的SimpleString类(续)

与string类一样,SimpleString通过一个字符类型的指针_string和一个整数_length来表示字符串。当SimpleString对象不为空时,数据成员_length表示的就是字符串的长度。在SimpleString对象中可以不包含任何字符串,这是通过一个空的字符类型指针和零长度来表示的。在SimpleString中,并不存在字符数组的长度多1或少1、内存泄漏等问题,在类的表示和接口中也不存在不一致的问题。然而,在SimpleString中还是存在着一些缺陷。

冗余

SimpleString中的数据成员是以一致的方式来记录字符串的长度。_length的类不变性需要包括两种情况:如果_string是非空指针,那么_length就是字符串的长度;如果_string是空指针,那么_length就应该为零。

在每次改变了SimpleString对象的状态之后,_length的值都需要正确进行计算。然而,这个长度信息却从来没有用到过。在SimpleString中,当每次需要字符串的长度时,这个值都将在Strdup()中重新进行计算。例如,在拷贝构造函数中,虽然_length的值是通过对参数进行复制来得到的,但仍然需要在Strdup()中调用strlen()来计算字符串的长度。

在SimpleString的实现中,大概有1/4的代码需要用来维护_length的正确性。如果这些代码并没有提供很有用的功能,那么应该从类中去掉它们。我们可以通过去掉_length来改进SimpleString。

避免对从不使用的状态信息进行计算和存储。

如果在SimpleString中需要使用_length来避免重新计算字符串的长度,那么情况将与我们在前面讨论过的有所不同。虽然从信息理论上来看,_length是冗余的,但它可以加快SimpleString的使用速度,从而能够提供有用的功能。因此,上面的这条规则不能解释为“尽可能少地去存储信息”,它的确切含义是,只有当信息在后续操作中需要被用到时,才应该存储。

动态内存以及operator=

在SimpleString类中定义了两个赋值运算符:

虽然在SimpleString中没有了内存泄漏,但这两个运算符函数都可能会过早地删除了内存中的字符串。我们在string::concat()中已经看到了,在安排删除动态内存的时间上需要谨慎。如果在赋值运算两边的操作数是同一个SimpleString对象,并且调用了operator=(const SimpleString&),那么这个表达式的结果将是未定义的。虽然程序员不大可能会显式地写出像x=x这样的表达式,但在程序中可能会间接地导致这种赋值运算的发生。如果a和b碰巧都是引用了同一个SimpleString对象,那么a=b就等价于是x=x。无论是何种情况导致了这种赋值运算,如果在调用operator=时,函数参数与this指针所指向的是同一块内存,那么在旧的字符串作为参数传递给Strdup()之前,就已经被删除了,因此返回的结果是未定义的。正确的方法是将参数与this的值进行比较,如果二者相等就不进行任何操作。

在定义operator=时,我们要注意x=x这种情况。

对于operator=(const char*)中的delete操作,可能会产生问题的情况是x=x.string(),或者虽然以a=b.string()的形式出现,但a和b中的字符指针指向的是同一个地址。在执行x=s的赋值运算时,如常量字符类型指针s的值等于x.string(),那么将会产生和operator=(const SimpleString&)中同样的问题。再次指出,无论是上述何种情况,如果在调用operator=时,函数的参数指针等于将被删除的字符串,那么Strdup()的结果将是未定义的。对于string::concat()函数来说,解决的方法是将delete操作推迟到字符串被复制之后再进行。

注意在上面两个运算符函数中代码的相似性。这两个函数都是先拷贝一个字符串,然后删除原有的字符串。因此,我们应该在一个运算符函数中调用另一个运算符函数,而不是在两个不同的地方进行相同的工作。就像我们在string类中用一个带有默认参数的构造函数来代替最初的的两个构造函数一样,我们没有理由为同样的代码维护两份拷贝。

最后一个细节问题

对SimpleString的最后一个改进是关于辅助函数Strdup(),这个函数的作用是对字符串进行复制并存储在一块新分配的内存中。Strdup()中的问题并不在于一致性,而是在于SimpleString类中所有调用Strdup()的地方,调用代码都遵循着同样的形式:在每次调用之前,都要进行测试以保证参数指针是非空的;在从Strdup()中返回之后,返回结果都是保存在_string中。因此,我们可以对类的实现进行简化,用一个私有成员函数来代替Strdup(),我们在这个私有函数中首先对参数指针进行测试,然后对字符串进行拷贝,最后在_string中存储新的指针值。这个修改是通过将重复的代码放到一个通用的函数中以消除重复代码。我们在这里所应用的规则来自于Kernighan和Plauger的著作[第15页]:

用一个通用的函数来代替重复的表达式序列。

程序清单2.4中给出了改写之后的SimpleString,其中包含了我们在上面所讨论的修改以及其他的一些改进。

程序清单2.4 改写之后的SimpleString

程序清单2.4 改写之后的SimpleString(续)

小结

在创建一个类时,一致性是很重要的一个方面。我们必须既能够通过类的外部接口,也能够通过类的内部实现来获得一致性。

从外部来看,当客户代码通过类的公有接口来操作对象时,对象的行为必须是一致的。客户代码是通过对象的逻辑状态来处理对象的。简单的逻辑模型更易于理解,而一致性的行为将有助于简化。最初的string类是难以使用的,因为在其中的一个成员函数中总是动态地分配足够的内存,而在另一个成员函数中则是假设客户代码总是存储当前数组所能容纳的字符串。

从内部来看,类的实现在对象的状态上必须保持一致。在数据成员之间的类不变性关系是一致性的一种表现形式,而用于动态内存管理的一致性策略则是另一种表现形式。通常,内部的物理状态模型要比逻辑模型更加复杂。内部复杂性增加的原因是为了努力保持一致性。在对象实现中的混乱性将会导致不一致的行为,甚至可能会在行为上产生严重的错误。

参考文献

在Rumbaugh[3]的著作中详细讨论了对象状态。C++语言本身并没有对类不变性的支持,我们可以参见Meyer[2]的著作中关于Eiffel语言的类不变性的描述。

练习

2.1 在本章的代码中,哪一个内联函数的返回值是不确定的?

2.2 观察程序清单2.5中的string类。在这个类中使用了引用计数,如果不同的string对象有着相同的值,那么它们有时会共享同一个表示。注意,当一个string对象进行自我赋值时,在函数operator=(const string &)中采用的处理方式。在这个类中,存在着哪些我们在其他字符串类中看到的问题?

程序清单2.5 另一个string类

程序清单2.5 另一个string类(续)

程序清单2.5 另一个string类(续)

程序清单2.5 另一个string类(续)

3 不必要的继承

虽然我们在第2章中仔细地将类的接口与实现区分开来,但在编写继承的代码时却没有这样做。如果我们想理解派生类与基类之间的继承关系,那么很重要的一点就是对继承关系中的接口部分和实现部分进行独立的分析。在本章中,我们将讨论一个从表面上看来非常适合使用继承的示例。不过,在对基类和派生类的接口及实现进行详细研究之后,我们将对这个类的代码进行修改。

3.1 编程风格示例:堆栈

程序清单3.1 最初的Stack、CharStack和IntStack(续)

在程序清单3.1的程序中定义了一个用于处理字符堆栈的CharStack类,以及用于处理整数堆栈的IntStack类。我们将对这两个类进行分析和评价,并且判断这些类中的抽象是不是正确的、接口是否起到了相应的作用,以及类之间的继承关系是不是合适的。

有些读者在看到CharStack和IntStack时的第一反应可能是,这些类应该使用参数化类型来编写,这也是ANSI(参见Ellis和Stroustrup的著作,第341页)建议的方法。在这里我们暂时先不考虑这种做法,而是把注意力集中于对本章给出的代码进行分析,理由主要有两个:首先,在考虑使用其他方法来改写这个程序之前,我们要重点了解这个程序在使用C++的核心特性时存在什么样的问题。在这个程序中,问题能够更容易地暴露出来,而使用参数化类型则达不到这个目的;其次,在编写本书时,参数化类型并没有得到广泛的应用,它们在某些情况下并不能很好地整合到C++中。而且,如果我们还没有在如何使用参数化类型上积累足够的编程经验,那么对于参数化类型编程风格的讨论就显得为时过早。在本章的结尾部分,我们给出了使用模板来改写的程序。

程序清单3.1 最初的Stack、CharStack和IntStack

程序清单3.1 最初的Stack、CharStack和IntStack(续)

在上述程序中,核心的设计思想是:由于CharStack和IntStack都是堆栈,因此它们有一个共同的基类Stack。继承的层次结构如图3.1所示。

只有在仔细分析了这个程序之后,我们才会发现程序中的继承是不必要的,并且容易使人产生误解,因此应该去掉。事实上,在本程序中使用的公有继承将破坏堆栈的封装性。

3.2 继承作用域准则

基类Stack的公有接口如下所示:

在派生类中,我们可以看到和基类成员函数有着同样名字的成员函数。注意,其中的Stack::pop()和Stack::push()都不是虚函数。同时,我们还注意到,派生类成员函数中的参数类型与相应的基类成员函数中的参数类型并不匹配。例如,Stack::push()不带参数,而IntStack::push()的参数是一个整数。对于这样的函数,根据C++作用域准则,这就意味着派生类的成员函数将隐藏基类的成员函数,因为在派生类中引入了新的作用域级别。在下面的示例代码中我们可以看到作用域准则的效果。

成员函数f并不是被重载。由于在派生类中定义了一个函数f,因此表达式d.f(1.5)所调用的函数就一定是派生类中的函数f。而表达式d.g(1.5)调用的则是基类的成员函数,这是因为在派生类中并没有定义成员函数g。编译器在搜索函数g的定义时,将首先在派生类中搜索,如果没有找到,那么将继续在基类中搜索。在使用派生类的对象时,我们可以只通过名字g来调用函数Base::g,但却不能简单地通过名字f来调用Base::f。

IntStack的公有接口如下所示:

根据在上面给出的作用域准则,我们调用的是IntStack或者CharStack对象自身的函数push()和pop(),而并非是从Stack继承而来的函数。IntStack和CharStack都继承了Stack的公有接口,但却用它们自身的函数把接口都隐藏了起来。在本章的后面部分将再次讨论这个问题。

3.3 继承关系

现在让我们来看看程序中的继承关系。Stack通过一个保护数据成员vec来为每个派生类提供索引服务,其中vec指向的是一个void类型指针数组。当在派生类中执行入栈和出栈的操作时, Stack将调节私有数据成员top以在数组中移动。在派生类中又分配了一个数组来存储堆栈中的值,并将数组的地址保存在数据成员data中。此外,在派生类中还对vec进行了初始化,这样对于每一个i(只要在数组边界内),vec[i]将指向data[i]。从Stack::push()和Stack::pop()返回void类型指针将分别告诉派生类将对data中的哪一个元素进行入栈和出栈的操作。

图3.2中给出了相关的数据结构,注意其中指针的形式是一致的。在这个数据结构中几乎没有包含任何信息。在派生类的构造函数中将执行下面的语句:

对于在data所指向数组中的每一个元素,在vec中都有相应的指针。在构造函数执行完之后,这些指针的值将不再改变。基类将通过这些指针来告诉派生类到什么地方去访问数据。在这种处理方中式存在着一些问题:在类中存在着过多的指针,但只是包含了少量的信息。

如果我们将注意力转移到函数IntStack::pop()中的类型转换上,那么就可以从另一个角度来看这个问题。

在上面的类型转换中,我们将Stack::pop()返回的void类型指针转换为int类型指针,以访问由IntStack::data指向的数组中的元素。类型转换是不安全的,为了避免这种潜在的危险,在编写程序时最好不要使用类型转换。我们可以很容易地去掉上面的类型转换,但这样作将会暴露出vec中的问题。通常的情况是,Stack::pop()返回vec[i],其中vec[i]指向的是data[i],然后再由IntStack::pop()来返回data[i]。如果Stack::pop()直接返回的是i而不是vec[i],那么就可以不需要进行类型转换。在获得了i值之后,IntStack::pop()就可以立刻得到data[i],而无需进行类型转换。因此,在Stack类的成员函数push()和pop()中,应该返回的是派生类真正需要的整数下标值,而不是在使用前需要进行转换的void类型指针。在程序清单3.2中给出了一个更简单的Stack,我们注意到,在IntStack和CharStack的成员函数push()和pop()中将不再进行类型转换。同时还注意到,Stack的名字被改为StackIndex,因为这个名字可以更好地描述这个类的抽象。

程序清单3.2 简化后的堆栈抽象

程序清单3.2 简化后的堆栈抽象(续)

程序清单3.2 简化后的堆栈抽象(续)

StackIndex类的新接口能够更好地反映出它所提供的抽象。这个抽象就是移动索引,从而告诉派生类到什么地方去访问数据。堆栈的索引行为信息被限制在StackIndex类中,而堆栈中具体的元素信息则被限制在派生类中。派生类与基类唯一的交流是通过整数类型的索引来进行的。在不损失功能的情况下,堆栈的抽象得到了进一步的简化。它的功能就只是维护一个索引,而由其他的派生类来提供具体的堆栈存储。

找出简单的抽象。

最初的Stack::vec在StackIndex中是多余的,因此我们在类的定义中去掉了它。每个派生类都能够从StackIndex::push()和StackIndex::pop()返回的索引中得到需要的信息,因此也就不再需要在基类中保存指向在派生类中声明的数据的指针。图3.3中给出了现在的数据结构。简化后的程序可以进一步提高内存使用效率。在去掉了Stack::vec之后,程序就大大减少了在实现堆栈时需要的内存空间。在一个典型的32位系统结构中,整数的大小是4个字节,因此指针也是4个字节,与最初的版本相比,现在一个IntStack对象只使用了一半的内存空间:每个元素需要的是4个字节,而并非最初的8个字节。CharStack对象使用的内存空间则减少到了1/5,从每个元素由需要5个字节减少为只需要1个字节。

3.4 封装

新的堆栈抽象更简单,程序的代码量也更小,并且也使用了更少的内存。然而,在StackIndex及其派生类的关系中还存在着尚未暴露出来的问题。在本章的前面部分,我们已经注意到,派生类的成员函数push()和pop()隐藏了它们从基类继承而来的同名成员函数。不过,我们也可以在IntStack和CharStack对象上来调用这些基类函数,这就需要使用函数的完全解析名字:StackIndex::push()和StackIndex::pop()。事实上,在它们各自的派生类成员函数中,也正是通过这种方式来调用StackIndex::push()和StackIndex::pop()的。

不过,即使不使用作用域解析运算符,我们还是可以访问到基类的成员函数push()和pop()。例如,当通过StackIndex类型的指针或者引用来使用IntStack或CharStack的对象时,那么调用的将是基类的成员函数push和pop。然而,这种基类成员函数的可访问性将会严重地破坏堆栈的封装性:程序中的客户代码可能会使得堆栈对象处于不一致的状态。在下面的函数中给出了破坏IntStack封装性的3种方法:

通过调用基类的公有成员函数,violate()将堆栈的索引推进了一位,但却没有提供一个数值压入到堆栈中。这样导致的结果就是堆栈的大小增加了1,但由于在入栈的时候并没有提供一个值,因此在随后调用pop()时,所返回的值将是未定义的:这个值将是进行压入操作时,在相应数组元素内存位置上的任意值。

我们已经看到了由于客户代码可以直接操纵堆栈的实现,因此将导致了堆栈对象处于未定义的状态,这样堆栈抽象的封装性就被破坏了。在我们修改代码来消除基类中的指针数组时,这个问题并没有被暴露出来。其实,在最初的代码中,这个封装性的漏洞就已经存在了。现在我们必须堵上这个漏洞。

与继承相关的还有一个问题:基类的析构函数并没有被声明为虚函数。如果动态创建了一个IntStack对象,并通过基类型的指针来删除这个对象时,那么将只会调用基类的析构函数。从这个意义来看,析构函数的行为与其他成员函数的行为一样:析构函数的调用取决于指针的类型。

由于派生类的析构函数没有被调用,因此程序在使用IntStack或者CharStack时就存在着潜在的内存泄漏。在第2章中,string类的内存泄漏问题是由于在成员函数中遗漏了对delete的调用。而在本程序中,即使派生类的析构函数是正确的,也还会产生内存泄漏。只有当派生类的析构函数被调用时,函数中的delete才会执行,而现在当我们通过基类型的指针来删除派生类的对象时,派生类的析构函数并不会被调用。如果为堆栈数据分配的数组没有被删除,那么在程序中将会不断积累垃圾内存并将最终耗尽内存空间。在软件运行的早期,内存泄漏很难被检测出来,而小规模的测试并不会消耗过多的内存,因此也无法产生明显的错误现象。在第7章中将给出如何通过程序来监视内存的使用情况以及如何发现内存泄漏。

我们可以通过在基类中声明一个虚的析构函数来改正这个潜在的内存泄漏,但这样做只是一种权宜的解决方案,并不能反映出代码中真正的结构性问题。对于这些类,我们可以找出更好的解决方案。我们将在第4章和第9章中再次讨论基类的虚析构函数问题。

事实上,我们可以有两种解决方案,这两种方案都可以同时解决封装性的漏洞问题和内存泄漏问题。第一种方案是将StackIndex作为一个私有继承的基类。私有继承不但能够防止基类的公有接口成为派生类公有接口的一部分,还能够防止将基类型的指针或者引用指向派生类的对象。如果将StackIndex作为一个私有的基类,那么在编译函数violate()时将产生一系列的错误信息:

任何对私有基类成员的访问都是非法的,同样,任何将私有基类型的指针或者引用指向派生类的对象也是非法的。这样,在析构过程中的问题也就同时得到了解决。由于私有基类型的指针不能指向派生类的对象,因此通过基类型的指针来对派生类对象进行的delete操作也就不存在。

3.5 接口与实现

为什么要使用继承?如果对继承关系作进一步的分析,我们会发现程序中的继承其实可以完全去掉。因此,第二种解决方案就是使用成员对象而不是继承。

在第2章中已经讨论过,一个C++类有着两个重要的方面:用于描述类行为的公有接口,以及行为的私有实现。大多数继承所采用的都是公有继承的形式:派生类同时继承了基类的接口和实现。不过,我们还可以有选择性地进行继承,即派生类可以只继承接口或者只继承实现。在私有基类中,派生类继承了所有的实现,但没有继承任何接口。而在继承公有的抽象基类时,派生类继承了所有的接口,但所继承的实现可能是不完整的或者不存在的。

我们需要对每一个继承都进行谨慎的评估。例如,究竟是需要继承接口,还是需要继承实现,抑或是对二者都需要进行继承。在本程序中,CharStack和IntStack仅需要继承实现。虽然CharStack的接口类似于IntStack的接口,但二者还是不同的。虽然它们各自的成员函数都有着相同的名字,但其中一个成员函数的参数是char类型的值,而另一个成员函数的参数是int类型的值。CharStack和IntStack并没有共同的接口;客户代码既不能把它们各自的对象进行统一处理,也不能交换使用。CharStack和IntStack的抽象都堆栈抽象的特化,但它们并不是StackIndex抽象的特化,IntStack对象并不是一种StackIndex对象。从客户代码的角度来看,StackIndex、IntStack和CharStack提供的是不同的服务。StackIndex处理的是索引,IntStack管理的是一个整数值的堆栈,而CharStack管理的是一个字符堆栈。从客户代码的角度来看,这种“是一种(Is Kind Of A)”的关系是没有意义的,因此在这些类中的公有继承就是不合适的。

派生类与其私有基类之间的关系其实类似于客户类与服务器类的关系。将StackIndex作为一个私有基类,并将IntStack和CharStack从StackIndex继承下来的主要目的,是为了使用StackIndex的索引管理服务,因此派生类可以被看作是StackIndex的客户。我们还有另外一种不用继承来表达这种关系的方法,那就是在IntStack和CharStack的对象中,分别使用一个StackIndex实例作为私有成员。

现在,StackIndex是CharStack和IntStack的一个私有成员对象,而并不是被作为基类。如程序清单3.3所示,我们对程序结构的改动是非常小的,同样类型的对象依然提供着同样的服务。区别仅在于现在提供服务的是成员对象,而并非私有基类,因此我们通过成员变量的名字来使用服务器类,而不是类的名字。在本例中,选择私有基类的形式与选择私有成员对象的形式相比,只是一种个人喜好——这两种方法在功能上是完全等价的。不过,为了简单起见,成员对象通常是一种更好的选择,因为与继承相比,成员对象的语义要更加清晰。

识别出对实现的继承;可以使用私有基类或者(更好的方法是)使用成员对象。

程序清单3.3 用成员对象代替继承

程序清单3.3 用成员对象代替继承(续)

程序清单3.3 用成员对象代替继承(续)

重载与默认参数

在结束这个示例之前,我们要注意在CharStack和IntStack中重载构造函数的相似性。在第2章讨论string类的重载构造函数时,就提到过应该使用默认参数的形式来代替函数重载的形式,如程序清单3.4中的CharStack所示。对于每个类来说,将多个构造函数合为一个构造函数可以简化代码的维护工作。虽然在有些时候,将函数重载的形式转换为默认参数的形式并不是很直观的,但我们应该尽可能地去考虑这种转换。我们来回顾一下以下这条规则:

考虑使用默认参数的形式来代替函数重载的形式。

最后,注意在程序清单3.4的CharStack::CharStack中,循环在每次迭代时仍然调用了函数strlen()。如果这个构造函数被证明是一个性能瓶颈,那么可以很容易改正这个问题。

程序清单3.4 用默认参数来代替函数重载

3.6 模板

IntStack和CharStack的共同属性可以用另一种不同的方式来表达,即C++的模板机制。模板也被称之为参数化类型,在程序清单3.5中给出了堆栈的模板。

程序清单3.5 Stack模板

程序清单3.5 Stack模板(续)

Stack模板定义了一组类。在使用Stack模板来声明一个对象时,必须同时提供一个类型来替换模板声明中的类型T。例如,

在上面的语句中,声明了一个对象stackOfChar,这个对象是一个存储10个char类型值的堆栈,而在下面的语句中:

声明了一个对象stackOfInt,这个对象是一个存储20个int类型值的堆栈。函数push()中的参数类型和pop()的返回类型也都是T。

将模板增加到C++的主要推动力是因为模板可以支持通用的集合类。我们不仅可以创建整数类型的堆栈和字符类型的堆栈,还可以创建浮点类型的堆栈、char指针类型的堆栈等。

用模板来实例化的对象与那些使用IntStack和CharStack来实例化的对象在行为上只存在细微的差别。在最初的CharStack构造函数中,可以带有第二个参数,用于指定将要压入栈中的字符串。但在IntStack的构造函数中并没有这个参数。因此,如果使用模板来同时表示这两个类,那么这个差别将无法表达出来。

小结

在本程序中进行的第一个也是最重要的修改就是针对于Stack类所定义的抽象。最初,接口是通过指向客户类中数组的指针来定义的。然后,我们将接口以整数索引的形式来表示,从而简化了堆栈的抽象。而由此得到的StackIndex类能够更准确地描述堆栈的共同属性,并忽略了一些不重要的细节表示。通常,越简单的抽象往往是越好的——只要它能够保持足够的信息。对于本程序而言,越简单的抽象也就是越安全的,因为这消除了很多的类型转换,并且也会因为去掉了指针数组而变得更加高效。

本程序中第二个主要的修改就是改正了由于使用公有继承而带来的封装性漏洞。当派生类仅需要从基类中获得实现时,使用私有的StackIndex成员是一种更简单的方法,CharStack和IntStack都可以通过这种方法来获得StackIndex的服务。

参考文献

在Gorlen和Wirs-Brock等人的著作中讨论了客户—服务器模型。“客户”这个术语也可以表示编写客户代码的程序员。而服务器有时也被称之为“提供者”。

练习

3.1 StackIndex既可以作为IntStack和CharStack的私有基类,也可以作为它们的私有成员对象。试着给出一些不能在这两种形式之间进行选择的情况。首先,创建一个类,这个类需要从另一个类中获得服务,并且只能使用私有基类的形式。其次,再创建另一个类,并且这个类只能通过私有成员对象的形式来获得服务。

相关图书

代码审计——C/C++实践
代码审计——C/C++实践
CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程