C++编程惯用法——高级程序员常用方法和技巧

978-7-115-29084-7
作者: 【美】Robert B. Murray
译者: 王昕
编辑: 傅道坤
分类: C++

图书目录:

详情

在本书中,作者大量采用了实际开发中的C++代码来作为示例,向读者展示了那些有用的编程策略,并对那些有害的编程做法进行了警示。本书每章末尾都有一个表格罗列了本章的主要内容,以帮助读者更好的理解。

图书摘要

版权信息

书名:C++编程惯用法——高级程序员常用方法和技巧

ISBN:978-7-115-29084-7

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Robert B. Murray

  译    王 昕

  责任编辑  傅道坤

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Robert B. Murray: C++ Strategies and Tactics

Copyright © 1993 by Pearson Education, Inc.

ISBN: 0201563827

All rights reserved. No part of this publication may be reproduced, stored in a retrieval system, or transmitted in any form or by any means, electronic, mechanical, photocopying, recording, or otherwise without the prior consent of Addison Wesley.

Published by arrangement with Addison Wesley Longman, Pearson Education, Inc.

版权所有。未经出版者书面许可,对本书任何部分不得以任何方式或任何手段复制和传播。

人民邮电出版社经Pearson Education, Inc.授权出版。版权所有,侵权必究。


在本书中,C++专家Robert B . Murray与我们分享了他宝贵的经验和建议,以帮助初中级C++程序员得到进一步的提高。

本书总共分为 11 章,先后介绍了抽象、类、句柄、继承、多重继承、考虑继承的设计、模板的高级用法、重用、异常以及向C++的移植等相关的诸多话题。在书中,作者大量采用了实际开发中的代码作为示例,向读者展示了那些有用的编程策略,并对那些有害的做法进行了警示。为了帮助读者更好地理解,在每一章结束前,在该章中介绍过的主要内容都被放到了一个列表中,此外,书中还给出了一些问题来激励读者们进行更多的思考和讨论。

本书适合具有一定C++编程经验的程序员和项目经理阅读,也适合对C++编程的高级主题感兴趣的读者参考。


在专家看来,C++可以帮助软件设计者和程序员构造出模块化、易维护以及高性能的系统。然而,对新手来说,这门语言的复杂程度是一个不小的威胁。C++中有着许多不同的语言特性,只有具有一定经验之后,我们才会知道各种特性的适用情况。

本书的目的就是加速读者的这种学习进程。大部分成功的C++程序员并不会简单地从语言规则中复述出某些章节或是条款,相反,他们总是习惯于自己以往工作中所获得的行之有效的那些惯用法和技巧。我们的目的就是帮助那些C++的新手学习到那些在实践中最有效的惯用法。同时,我们也会在书中指出那些我们经常接触到的C++缺陷。

在本书中,我们的讨论并不试图覆盖整个语言。那些有关语言语义的精确定义可以查看参考手册。我们主要帮助读者构造出可以被那些不是C++语言专家的人所理解的程序。我们不但讨论了那些使程序变得优雅及高效的技巧,也展示了使它们更容易被理解和维护的方法。

本书中的想法和编程惯用法几乎都不是我发明的。我的目的就是把它们(在我8年的C++使用过程中从其他人那学到的并被我认为是最重要的策略和战术)用一种C++新手也能学会的方式展现给读者。这些收获中的部分来自于实际的开发项目中(这些项目被我们从C移植到C++上)的经验,另外一些则来自于和其他高手的讨论。

关于模板以及函数库设计的许多好的想法(包括那些隐藏在本书所给出的容器类后的想法)都来自于最初由Martin Carroll、Andrew Koenig以及Jonathan Shopiro所设计的USL标准组件库[1]中的类。对于在本书中出现的任何错误,则都由我负责。Andrew Koenig是一个C++语言专家,他对我的帮助非常大。另外,那些参与过我所举行过的几期“C++策略与战术”研讨会的人也帮助启发了我书写本书,并对书中的想法进行提炼。其他的重要的想法来自于Tom Cargill、John Carolan、Jim Coplien、Mark Linton、Gerald Schwarz,当然,还有Bjarne Stroustrup,要不是他发明了C++语言,本书也就不可能出现了。

Brian Kernighan阅读过本书的多次草稿,他极好的反馈意见起了很大的帮助作用。同时我还要感谢David Annatone、Steve Buroff、Tom Cargill、Bill Hopkins、Cay Horstman、Lorraine Juhl、Peter Juhl、Stan Lippman、Dennis Mancl、Scott Meyers、Barbara Moo、Lorraine Weisbrot Murray、Bjarne Stroustrup、Clovis Tondo、Steve Vinoski以及Christopher Van Wyk,他们对本书早期的草稿提出了意见。此外,Lorraine Weisbrot Murray[2]对我的鼓励、理解、支持以及爱使得这一切努力都成为了可行的。

[1]译注:关于USL标准组件的文章参见于1993年6月的《C++ Report》中Martin Carroll所写的《Design of the USL Standard Components》。

[2]译注:Lorraine Weisbrot Murray是Robert B. Murray的妻子(经Robert B. Murray证实)。


C++是一门规模庞大的编程语言。只了解C++规则的程序员就和只知道棋子如何移动的棋手一样(失败)。为了获得成功,还要学习一些相关的法则和策略。

本书所面对的读者是使用C++语言的初级及中级程序员们,他们一方面期待学到更多有关使用这门语言的知识,一方面又没有时间去参加一个大学学期那么长的一门课程。我们假设读者已经了解了一些C++的基础知识——如:什么是成员函数?如何使用public、private以及protected等。本书的读者没有必要是一个C++专家,在每次提及一些该语言中比较高级的话题(如多继承)时,我们都会给出一个“回顾”栏目对此进行简短的总结。我们关注的是那些被证明在实际中十分有效的策略。在本书中,我们将避免多谈理论,而尽量为读者给出例子及实用的建议。

我们尽量保证不偏离惯例。在本书中,没有一个想法或技术会超出语言的基础,它们大多来自于有着多年C++经验的程序员日常的实践中。很显然,最新的想法总是具有最大的风险。我们希望能够避免将C++的初级用户引导到那些想法上去。除了第10章之外[1],本书中所有的技术都经过了实践的证明。

我们也无意将读者变成C++专家。C++中也包含了一些阴暗的角落,我们会在书中将它们一一指出并教导读者如何避免它们。写出依赖于语言中定义含糊、意义微妙的规则的程序是不好的,即便作者本人清楚它的意义并保证它可以正确运行,下一个来维护这段代码的人也可能未必能够做到这点。比较好的做法是:坚持使用语言中那些被广泛使用及理解的部分来书写程序。

本书中的示例代码都通过了编译(或是取自一个通过编译的文件)。为了使代码尽可能短小,我们在此略掉了那些和主题无关的周边代码(如#include指令)。

本书中的示例大多只处理接口,而不是实现。因此,为了清晰起见,大部分的示例代码都略去了类的私有部分:

class  Something {
     //私有部分被省略……
public:
     Something();
);

在第1章中,我们讨论了如何选择一种合适的高阶抽象方式来实现设计,并着重阐述了抽象和其实现之间的区别。

后续的两章主要关注如何在C++的类中实现较高层次的抽象。第2章的讨论涵盖了自高阶的设计策略到底层的接口和实现细节。在第3章中,我们阐述了几种句柄在类实现中的用法。

接下来的 3 章介绍了继承。第4章中讨论了公用、保护、私用继承的区别,并给出了如何选择使用各种继承方式的参考意见。第5章中的讨论主要针对超过基类不止一个的继承,我们讨论了应该如何使用它以及有关多继承的一些难以掌握的细节之处。在第6章中,我们讲述了一些有关如何构建类的方法,以使得其他人可以把这些类作为他们的基类来发展他们自己的继承体系。

模板,作为C++语言的一个新特性,其功能非常强大,它必将对人们以后编写C++程序造成极大的影响。我们在第7章和第8章中详细地讲解了模板。由于大部分C++用户对于模板还不甚了解,我们开始只是以基础为始,先描述一些用来构建有效模板的技巧,接着才构建各种各样的模板。包括:使用模板来实现智能指针,一些简单的容器,最后是一个接近实际应用的容器(一个List模板)。然后我们才讨论一些关于模板的高级技巧,如:如何对那个List模板进行重新组织,使其更快、更小,并且具有更多功能。

第9章中讨论了如何把一段可工作代码加入到重用库中:重用的困难程度要远远超过其字面意思。我们在本章中也展示了如何使用前几章中介绍的技巧来提高代码的健壮性、使其更容易使用以及运行得更快。

C++中新的语言特性—异常—可以使我们在编写程序时不再需要经常去进行出错情况的检测,但它也很容易被人们误用。在第10章中,我们讲述了如何使用异常以及不适合使用它的情况。工业界目前也正在学习如何使用异常,这也使这一章显得稍微有点投机。

将一个项目从C平台移植到C++平台所牵涉到的不止是更改一个编译器那么简单:我们必须在项目开发中习惯一种新的开发软件的方式。我们将会在第11章中简单讲述一些有关采用C++开发的项目中经常碰到的技术及人们自身上的问题。

除本章外,本书中的其他章节都以一个“小结”加一个“问题”作为结束。在“小结”中,我们将会以列表的方式给出每一章中的主要概念。那些问题是用来促使读者去思考以及讨论有关技术和人们自身方面的问题,以使得他们可以更容易地理解和记忆每一章中给出的内容。问题中的一部分具有“标准”答案,但更多的则没有,我们衷心地希望读者就此进行的讨论会对他们产生帮助。

在本书编写过程中,C++语言的当前“官方”标准文档是Ellis和Stroustrup所编写的《Annotated C++ Reference Manual》(也经常简称为ARM)[2]。ARM是ISO/ANSI C++标准委员会(一个由来自工业界以及学术界的自愿者所组成的团体,他们正在制订一个会被ANSI和ISO同时采纳的C++语言标准)用以制订标准文档的基础。除非ISO/ANSI委员会明确提出了某些不同之处,否则在本书中所提到的“语言规则”都是基于ARM之上的。实际上,(ISO/ANSI委员会与ARM之间的)大部分的区别都微不足道。我们也鼓励所有的C++用户都能够遵循标准化结果。

[1]译注:在本书写作过程中。异常还只是C++中的一个新增特性,但到了现在,在C++中异常已经发展得比较成熟了。

[2]译注:目前C++的标准文档是1998年7月所发布的ISO 14882文档(也称C++98)。


数据抽象(data abstraction)是面向对象设计的一个重要概念。数据抽象要优先于面向对象的设计;然而,随着C++这样直接支持数据抽象的语言变得流行起来,它的应用范围也变得越来越广泛。

抽象数据类型(abstract data type,也称为ADT)是一种由用户定义、拥有明显不同的两部分的类型:

一个公用的接口(public interface),用于指定用户使用该类型的方式,而一个私用的实现(private implementation),在类型内部使用,以提供公用接口所给定的功能。

在C++中,private和public关键字用于指定类声明中哪部分是实现,哪部分又是接口。通过这种方法,编译器就可以确保类的使用者不会绕过类的接口而直接访问其私用成员。然而,另一个事实就是,类有私用成员也不表示它的设计就很好。

对于private和public来说,它们本身还存在着一个远比低层次语言规则更重要的思想。这个思想还从未被C++编译器显式地检测过,但它对于设计模块化、易维护的程序来说却是至关重要的。它表述的是一种概念,那就是——每个编程问题的解决方案都可以被划分为两部分:一个抽象模型(abstract model),它是在程序员和程序用户之间都能取得一致意见的、用于描述该问题以及其解决方案的智力模型;对于该模型的实现(implementation),就是程序员使用特定的方法使计算机可以表述出这种抽象。在本章中,我们将会通过对一个类的设计、细化以及记录过程,来逐步体现出抽象的过程。如我们将看到的那样,“简单地书写一页手册,然后让代码来适合它”这种方式在程序设计中是行不通的。

下面是一些理由,用于说明为什么一个经过详细考虑并记录下来的抽象模型对于程序设计来说很重要:

它有助于帮助其他人来理解如何使用你所设计的类。如果你正试图使用一个链表类,你的主要的(也是最初的)考虑应该不是包含这个类的头文件的名字,也不是该类中成员函数的名字以及其参数类型等。相对来说,你会更注重那些有关该类抽象模型的基础问题,如:它提供了什么操作?能不能在链表上进行回退?能不能在常数时间内访问到链表的头和尾?同一对象能不能在一个链表中出现多次?链表中包含的是对象本身呢,还是对对象的引用?如果链表包含的是对象的引用,那么由谁来负责创建及销毁这些对象?当链表中的某个对象被销毁时,链表应该有什么样的调整?等等。

对这些问题的回答将会直接影响到那些使用该链表类的应用程序的设计。(例如,如果你的应用程序需要能够在链表上双向移动,那么一个单向链表类在此处就不再适合了。)

如果你不能理解并记录下你的类所支持的抽象模型,那么用户就可能会选择不使用你所提供的类(更糟的是,用户决定使用你的类,然后却发现他们做了一个错误的选择)。

抽象模型是你与你的用户之间的一个协议。由于抽象模型对用户的程序设计有着很重要的影响,你将发现对它进行不向上兼容的改动会有多困难,有时那样的改动甚至就是不可能做到的。例如,一个去除掉链表中后退功能的决议,对公开使用的List类来说将会是一场灾难。为什么这样说呢?因为某些List的用户可能会将其应用程序的设计完全围绕着它能够支持后退这种抽象来进行。这种在抽象模型中的不兼容改动肯定会使某些已有的用户陷入困境。如果一个类已经被广泛使用,那么它的每一个特性都将至少被一个用户所使用,如果你轻率地决定去除掉某个特性,必将在特定的用户群中引起骚乱。

这就意味着,在你决定将你的库发布给客户时,对库的抽象模型进行细化和完善尤为重要。在实现中产生的错误通常都可以很容易地在下一个版本中得到更正;但在抽象模型中产生的问题(除去冗余错误之外)则将持续存在于类的整个生命周期中。

在记录抽象模型的过程中,我们常常可以发现设计中的重要缺陷。在软件项目设计的早期,我们通常会过高地估计我们对于问题以及我们所提出的解决方案的理解程度。在将早期的模糊想法用精确的言语表示清楚的过程(即抽象模型的记录过程)中,我们就能更关注于那些我们以前从未考虑到的方面。毕竟,在认识不清晰的时候,我们是无法提出合理的解决方案的。

清晰的抽象模型文档有助于其他人重新构造出你的类的新版本(这包括继承或是重新实现两种方式)。要想实现出一个能够与现有代码共同工作的新版本的类,你所需做的不止是在成员函数的名字以及类型签名上做到和最初的代码完全匹配;更重要的是,新版本的类必须还能够符合旧版本的抽象模型的要求。

一旦理解了抽象模型,我们就可以避免“以实现来驱动设计”的情况出现。不管其承认与否,许多的软件设计者在设计一个新类的接口时,他们的脑海中都已经有了一个“明显”的实现方案。这就会使得他们在设计中不自觉地将抽象模型向这种实现方式靠拢。这种做法不但不能提供一个用户易于理解的、并且认为就应该如此的接口,它还会使实现的细节遍布于接口之中,导致以后对实现进行改动变得异常困难。

当然,其他极端的做法也同样会导致麻烦的出现(如:设计出一个完全不顾及实现方案的可能性的接口)。一个接口,不管它有多么优雅,如果它不可能被实现出来,或是实现它需要一些让人无法接受的性能上的损失,那么对用户来说它就起不到任何帮助作用。在这些考量之间取得一个合理的平衡是类设计中最具有挑战性的部分。最后说明一点,设计抽象模型和设计实现方案应该是两个独立的行为。但尽管如此,这并不意味着我们需要用不同的人手来分开处理这两部分,重要的是,开发人员必须知道自己在某个特定的时间时,自己到底是在负责抽象呢,还是负责实现?

仅仅考虑抽象(而不是实现),我们有几种常用的方法。在抽象的过程中决定“什么应该有,什么不应该有”是面向对象设计者的一个关键技巧。在下一小节中,我们将开始构造出一个简单的抽象模型。

在本节中,我们将对用于表示电话号码的类的抽象模型进行最初的探讨。我们期望用这个类来代指各种电话应用程序(如交换机系统、账单系统等)中的电话号码。

我们应该怎么开始我们的第一步呢?一个不错的方法就是:用一句话来描述该对象是用来干什么的(注意,不是“是什么”)。这种描述方式应该尽可能的抽象,尽量不要涉及对象的内部结构。这种“官面总结(executive summary)”应该尽可能的简单,简单到即便是行政人员也可以轻而易举地理解它。

例如,下面就是对于电话号码的一个糟糕的官面总结:

“电话号码包括3位地区号码,后面紧接着3位交换机号码,最后是4位数字。”

这样的描述并没有提及电话号码是用来做什么,或者它和谁交互的问题。相反的是,它简单地把电话号码的结构描述了一遍。这不但没有解释清楚电话号码是做什么的,它还限制了电话号码的范围。例如,通过modem所拨打的电话号码可以含有非数字的字符(如&就用于告诉modem来等待第二次的拨号音,另外还有#和*这两个特殊的按键)。更严重的就是,它还限制了在美国和加拿大之间能够合法通话的电话号码集。

下面的修改就要稍微好一些:

“一个电话号码指定了世界上的某一部特定电话。”

此处我们并没有丝毫提及电话号码的结构,它只是阐述了另一个用于电话号码的抽象。

当你写下这样的句子后,请花一些时间来仔细琢磨它。请像律师对待一份合法的合同那样仔细检查其中的每个单词。这句话中有什么明显的漏洞吗?它所隐含表达的意思又是什么?

让我们先来注意“指定”这个词。它意味着什么呢?一个电话号码是不是唯一指定一部电话呢?换句话说,能不能有两部以上的电话共用同一个号码呢?事实上,在许多的商业机构中,大部分电话都是共用一个电话号码的:在有电话进来时,会有一个本地的交换机将它转到一部空闲的电话上面去。同样,对于一个特定的电话来说,电话号码也不是唯一的。当你拨打555-1234时。对方的电话就取决于你拨打时所处的区域。

这样,我们就得知了:一个电话号码并不代表唯一一部电话,它的含义取决于使用它的区域。为了反映出这种更新,我们进行了如下的官面总结:

“当与呼叫电话关连起来时,电话号码就是决定被呼电话的关键。”

这样,我们就把电话号码与呼出电话的关系给涵盖进去了,并且也隐式地表达了被呼电话的唯一性。但让我们仔细看看这句话的后半部分。是不是说你拨打电话后就可以得到被呼电话呢?也许如此吧,但更精确的说法是:拨打电话会导致一个到被呼电话的连接的建立。这两种说法之间有什么不同呢?它们之间的不同完全取决于电话用户是否会对连接本身有兴趣。那么,在两台电话间的连接有什么属性会让人们感兴趣呢?当然有了:一个连接有一个开始时间(start time),一个结束时间(end time),以及一定的开销。它们不属于这两台电话,而仅仅是电话连接本身的属性。

拨号必定产生一个被呼电话的结论并不总是正确的:有时线路可能比较繁忙;有时也可能因为某些其他原因会导致呼叫失败……所有这些都不会产生我们所期望的被呼电话。有鉴于此,我们将上面的抽象结论继续细化为:

“当从呼叫电话拨号后,电话号码就是决定可能连接到的被呼电话的关键。”

上面的句子与我们开始时所给出的抽象表述相比,它更贴近实际应用中的抽象模型。然而,要想真正地了解它的含义,我们还必须定义一些它所依赖的其他抽象模型。例如:

“(电话间的)连接表示的是在两部(或多部)电话间的逻辑连接。”

没有出现在抽象模型中的东西和存在于模型中的一样重要。在我们上面给出的那个电话号码的抽象模型中,我们并没有假设出:一个电话号码有多少位;它是否包含非数字字符;电话与电话号码之间是否是一一对应的以及它们间的连接是如何建立的。

在保持实用性的前提下,我们通过尽可能地将该模型最小化以使其尽可能通用化。我们提高了它应付今后未知变化的能力,以避免频繁地修改我们的抽象模型(但实现细节可能有所改变)。例如:蜂窝电话的出现或者是标准电话系统中新增的按键都不致迫使我们修改我们的抽象模型。

在吃不准某个特殊的概念是否应该包含于抽象模型中时,通常的安全做法是不去考虑它,而只是把它包含入我们的抽象模型中去。这是因为,由于缺少造成的错误通常都可以用一种向上兼容的方法进行修复——一般来说我们只需在类中新增一个成员函数就可以了。扩展接口总是比缩减接口要容易得多。从另一方面来说,从类中去除掉一个成员函数通常很难做到与现有代码兼容,因为可能已经有的用户代码中就调用了这个被去除的函数。

作为初始设计过程的部分,设计人员应该仔细地考虑抽象模型与其他模型间的交互,问自己一系列相关的问题。这并不意味着我们需要一个正式的问题清单,根据应用程序的不同,可能还存在着其他的一些同样重要的问题需要设计人员去早早检测。在本小节中,我们只给出那些经常出现的问题。

问自己这些问题还有着另外一个重要的好处。设计者经常犯的错误就是:这些问题中的部分通常都具有一个看起来十分明显的答案,不过事实上这个答案却是错误的。当你在设计时问自己这些问题时,最好在那些能够很容易就得到答案的问题上停下来,并更加深入地对它们进行思索。这个(显而易见的)答案是不是对的呢?试图去设想一个可以证明它是错误的场景。只要你能够在这种思索过程中找到哪怕只是一个错误场景,你都有可能会因此避免一个将来会给你带来高昂代价的设计错误。

现在我们来考虑电话和连接之间的关系。一个电话是否可以有多个连接呢?通常的答案都是肯定的,通话中的一方可以接通新的电话而不挂断已有连接。我们同样也要考虑相反的另外一个问题:是否可以有多于一部的电话参与到一个连接中呢?在此处的答案毫无疑问也是肯定的,因为这就是电话的核心用途!当我们关注于这个话题时,我们应该考虑的是,是否可以有多于两部的电话同时参与到一个连接中呢?虽然可能有点会令人感到惊讶,但这个问题的答案同样也是肯定的,电话会议在同一时间时的参与者可以多于三个。因此我们得到的结论是:电话和连接之间的关系是一种多对多的关系。

作为另外一个例子,我们来考虑电话号码与账单地址之间的关系。一个电话号码只能属于一个客户,由此它也只有一个相关的账单地址。然而,同样一个地址却可以有多个电话号码与之相关联。这就是一种多对一的关系(多个电话号码对应于一个账单地址)。

如果连接是多对一或者多对多,那么让我们来想想:究竟这个“多”暗示着多少个对象呢?此时,我们并不需要一个精确的数字,我们想知道的只是,“多”究竟是代表着以下哪一种:

需要小心的是:即便是定义得很好的抽象模型,它最终还是会被别人以一种设计者从来都没有考虑过的方式使用。如果我们假设“多数”永远不会超过几十,并由此在实现中使用了一个二次方程式的算法,那么我们的用户在上千个对象的场景下使用我们的实现时,就将得到令人惊讶兼恶心的结果。

我们是否必须在对象被创建时确立下来关系的存在呢?当关系被确立后,新创建的对象是否可以参与到其中呢?

显然,电话号码可以独立于连接而存在;但连接无论在何时都必须至少涉及一个电话号码。对于连接的另一端的电话,我们对于它有什么要求?是否应该在连接建立的同时就提供它,还是在连接建立后再把它加入连接?从另一方面来考虑的是,一个连接是否可以只涉及一个电话存在?检索(而不是呼叫)电话号码的行为是否会建立一个连接?

回答这些问题需要我们对使用这些对象的应用有着相当的了解。我们需要知道足够多的电话学知识,以此来决定应用程序是否需要在连接正在建立或者中断时对它进行访问。为了讨论方便,我们假定这样的连接有着实际上的作用;例如:计费软件可能需要访问它来记录每次呼叫结束后应该花费的费用。这意味着被呼叫方应该在连接建立时就加入到连接中去。

这个模型同样也可以很好地应用到三方通话中去。此时的连接并不是在同时被建立的,而是随着时间的过去才被建立起来。每次当一个新的成员被加入到通话中时,他们只需要加入已有的连接就可以了。

此时,我们已经对前面的“官面总结(executive summary)”进行了一个细化。那个总结暗示着:一个连接只能存在于两部(或多部)的电话之间。现在我们认识到,那种说法并不全对:我们对于那些可以通过电话网络在一组电话(一部或多部)间相互交谈的方式更感兴趣。于是,我们又可以将总结改为如下形式:

“连接表示的是一组(一部或多部)电话间通过电话网络相互进行交流。”

基于我们对它的不断深入了解,得到这样的抽象细化是一件很正常的事情。在此例中,“连接”这个名词仍然有其意义;但它并不总是对的,我们应该随时准备,在原有的名字不能充分地阐述其意义时为抽象另取一个合适的名字。

不同的抽象以不同的方式相关联。我们可以把三种最常见的关系归纳为:is-a、has-a以及use-a:

is-a关系

当两个类之间存在着下述关系时,我们就说它们之间存在着is-a关系:其中一个类所描述的对象属于另外一个类所描述的对象集。例如:Studebaker是一种(is-a)Car。

is-a关系的另外一种说法就是子类型化。子类型(subtype)是某些更为通用的类型[也称为超类型(supertype)]的特殊化;子类型的对象同样也是超类型的对象。这些术语源自于Smalltalk。许多C++程序员则使用C++中具有相同意义的两个词:派生类(derived class)来代替子类型,基类(base class)来代替父类型。

例如:“按键电话”和“电话”之间的关系是什么?每部按键电话同样也是一部电话。按键电话只是电话中的一个子集(图1.1)。

图1.1 按键电话is-a电话

在C++中,我们使用公用继承来表示is-a关系:类Push_button_phone应该派生自类Telephone:

class Telephone{
  // Telephone stuff...
};
class Push_button_phone:public Telephone {
  // Push_button_phone stuff...
};

子类型(或者说派生类)可以对父类型(或者说基类)进行扩展;一部按键电话可以做一些其他电话做不到的事情。但子类型永远也不能对父类型有所限制(例如:在派生类将一个在基类中的公用成员函数改为私用);同样,电话能够做到的事情,按键电话都应该能做到。(如果不是这样的话,按键电话也就不应该是电话中的一个子集。)

has-a关系

has-a关系意味着包含,如果在概念上事物A包含事物B,那么A也就拥有B。电话包括一个扬声器和一个麦克风;按键电话还包括一个键盘。

与is-a不同的是,在has-a关系中,没有一个对象是另外一个对象的特例,取而代之的是,一个对象是另外一个对象的一部分。一个Push_button phone并不是一个Keypad,而是拥有一个Keypad。

C++中的has-a关系的通常实现方式是将被包含的对象作为包含它的对象的一个成员。然而,实际上我们并不一定需要这样做。虽然在概念上来讲,Keypad被包含于Push_button_phone中,但这并不意味着我们在实现这种关系时必须使用真正的包含。在实际中,总有很多好的理由让我们不必那么做(我们将在第3章讲述这一点);数据抽象的一个好处在于,对象的实现方式并不需要完全与抽象结构相匹配。我们不应该把这种方式(指用成员来实现has-a关系)来作为主要的考量标准,因为我们所处理的是抽象之间的关系,而不是它们的实现细节。

use-a关系

use-a关系应用得最普遍:在这种关系中,没有一个对象会是另外一种对象(is-a),也没有一个对象包含另外一个对象(has-a)。取而代之的是,这两个对象仅在程序中某些点进行简单的联系。在我们的电话例子中,电话和连接间具有的就是use-a关系。

相互使用的对象间通常通过调用彼此的成员函数来进行联系。当然,还存在着其他的联系方式(如:共享内存,或者是某种消息传输机制)可以做到这一点。

给定两个对象之间的一个关系,我们是否就可以通过其中的一个对象来得到另外一个对象的信息呢?也就是说,如果我们已经知道了电话和连接之间的关系,那么我们是否可以从连接中得到有关电话的信息,或者是从电话中得到连接的信息呢?这个问题的答案对于编写实现该抽象模型的程序的性能来说,起着本质的影响作用。

单向关系可以用C++中的指针来实现。给定一个对象,你就可以通过该指针得到另外一个对象。这种行为的反转(从一个对象得到指向它的对象)则非常困难;因为它通常都涉及从一大堆对象中进行检索,判断一个对象是否正好指向给定的那个对象。根据具体情况的变化,这种做法有时也并不完全那么不好,导致的开销也不那么大,或者是完成的困难程度并不是不可能达到的。

双向关系的使用要简单得多了:给定任意一个对象,我们都可以直接地找到另外的那个对象。然而,建立一个双向关系的开销也要比建立单向关系的开销大得多。我们需要对这两个对象同时进行一定程度的修改,双向关系可能会导致要求更多的空间,更多的运行时间;这一点在关系随着时间同时变化时更为明显。改变一个单向关系可能只涉及一个指针的改变。由于往两个对象中增添新的关系可能会导致已有关系的失衡,改变一个双向关系的操作可能涉及三个(甚至更多的)指针变化。在两个对象间,单向关系要显得更快、更小,也更容易被编程实现,但它却限制了用户使用抽象模型的能力。这也是实现细节可以(也是必要)影响设计思路的一个常见的地方。

再回到我们关于电话的那个例子中来,电话和连接之间的关系在大多数情况下都应该是双向的:只要给定一部电话,我们就可以找到连接端的另外一部电话。如果经验不足,我们则可能会采取另外一种方法,那就是:查找每一部已有的电话,判断它们是不是处在同一个连接中。

通常我们的选择都不是那么简单明了。例如,考虑一个编译器,在其中表达式被作为一个树状的结构来处理,我们可以将a = b + c表示成图1.2中所示的树。那么在树中,节点和它们的子节点之间的关系是单向的还是双向的呢?在实际中,为了提高效率,这个关系通常都是单向的,程序也可以以此来编写。例如,如果我们都是自顶而下地来处理每棵树,那么在树中保留一个指向父节点的指针则显得有点多余。

图1.2 表达式树

给定两个对象间的关系,我们是否可以在这两个对象中保留多份该关系呢?例如:“父亲”这个关系只能在两个对象间存在一份,一个对象只能是或者不是另外那个对象的父亲。诸如“他是我父亲父亲”这样的表述显得毫无意义。而对于关系“文章引用的书籍”来说,当文章中引用同一本书中的内容超过两次后则可以存在多份这样的关系。

对于每个对象来说,关系中是否就一定要至少包含一个对象呢?例如,如果有一个用来表示职工的类Employee,那么关系“向……汇报”是否就一定要包括所有的职工呢?对于部门的主管来说,它应该如何处理?我们的类的结构是否支持主管向整个部门进行汇报的功能?把主管的汇报对象改为空(或者是他自己)会不会更容易一些?

你可能会对我们为什么需要考虑部门主管这种情况感到惊讶。毕竟,大部分的职工还不是部门主管。现在在设计中考虑这种特殊的情况会不会显得有点早呢?

边界条件存在的地方总是不能满足抽象模型的假设基础。它们可以帮助我们更加关注这些假设条件。如:假设条件是否有效?在何种情况下,假设会失败?是否存在着一种合理的解决办法来处理这些情况?举例来说,如果我们假设每个职工都向另外一个职工汇报工作,那么什么时候这种假设会失效?在这种情况下,它的结果又将如何?

边界条件通常都会导致程序bug的产生,而且这些bug也不是很容易被及时发现。与其在事发后再来寻找原因,还不如早早地把边界条件列入我们的考虑范围。早早考虑并不意味着我们需要在设计阶段就把每个边界条件的行为都固定下来。随着设计的进行,对问题的理解加深,我们可能会对如何处理部门主管的行为进行修改。但是,为了避免在系统测试或者发布后再来解决这个问题,我们最好还是在前期设计就将边界条件纳入我们的考虑范围。

由Kent Beck和Ward Cunningham设计的CRC卡片是一种用来帮助设计人员在设计阶段前期进行设计的工具,此时设计人员应该做的事情就是:从问题域中找到合适的对象并理解它们之间的关系。

CRC卡片的含义十分简单,也没有什么技术含量在里面。当一群人聚在一起开始进行高层设计时,他们会为每个潜在的类建立一张索引卡片,在该卡片上面,他们会写上类的名字,它主要的功能,以及它将会和哪些类进行交互。

有了这些卡片后,设计者就可以利用它们来模拟不同的场景。每个参与者负责其中一个或多个对象的角色,然后描述他的对象在面对其他对象的请求时应该做出的反应。通过这种角色模拟,设计者就可以进一步细化系统中类的职能和协作,并把它们记录在卡片上。整个设计小组可以也基于这种讨论来为那些有着不精确(或者是模糊)含义名字的类取一个更贴切的名字。

由于卡片上面的空间有限,这就鼓励设计者们尽可能采取简单的抽象模型—我们很难在一张索引卡片上面描述出复杂的抽象模型。这种卡片也有助于设计者将类进行分类,以构造合适的继承体系。我们可以将那些获得大家一致通过的卡片放到桌子上面,并接着对那些剩下的卡片进行重复的讨论,直到大家取得一致意见为止。

进行这样的角色模拟有时可能会出现一些偏离项目的话题,但它同时也鼓励大家进行“头脑风暴(brainstorm)”,将自己认为想的比较好的东西在非正式的场合中提出来和大家一起讨论。虽然大部分的离题讨论都将无疾而终,但有时偶尔的一两个火花也会产生那些在传统且正式的设计复审时产生不了的突破。

CRC卡片并不能代替完备的、用来描述类的行为细节的文档。它们主要的作用也就存在于设计的初始阶段:它们可以帮助设计人员正确地得到高层框架的信息,更早(而不是更晚)地理解和分辨出那些重要的问题。

注意:它们不可能像索引卡片那样被整理,你可以将它们粘在墙上,并保留对它们进行重新排列的权利(就像你使用Post-It随手贴一样)。

1.我们的电话号码的抽象模型是否还需添加其他新的抽象模型来更好地完善它?

2.如果拨号由于某种原因(例如线路忙)失败了,那么应该有什么发生呢?我们的抽象模型在这种情况下的反应应该是怎么样的呢?

3.我们的Connection类假设至少有一部电话被加入到连接中去。现在我们考虑对它进行一些修改以适应下面这种情况:在创建Connection对象时,我们并没有给出任何的电话对象。在这种情况下,呼叫是如何产生的?这样做是使得我们的抽象模型更简单了呢?还是更复杂了?

4.从下面的分组中找出具有is-a关系的对来:

5.Child和Father之间的关系是一对一、一对多,还是多对多?对于这个问题的回答,你主要应该阐明的条件和假设是什么?


在类的设计中,最重要的一部分就是对该类所表述的抽象模型有着一个清晰的理解:它和谁交互,它能做什么?我们在上一章中就关注于这个话题。一旦我们得到了这样的一个抽象模型,那么下一步我们要做的就是进行详细设计和类的实现了。在本章中,我们将围绕在这个阶段中的一些最常见的惯例以及bug进行讲述。

回顾:初始化和赋值

在C++中,当一个新对象被创建时,会有初始化操作出现;而赋值是用来修改一个已经存在的对象的值的(此时没有任何新对象被创建)。

初始化出现在构造函数中,而赋值出现在operator=操作符函数中。

C++中还有着一种特殊的初始化:用同一个类产生的另外一个对象的值来为将要被创建的对象进行初始化。执行这样的初始化操作的构造函数也被称为复制构造函数,它们通常都有着如下的格式:X::X(const X&)。

如果我们没有在类中声明一个复制构造函数,那么编译器就会为我们合成一个。这个缺省的复制构造函数会将新对象中的每个数据成员初始化为源对象中相应成员的值。

当我们使用传值的方式来调用函数时,编译器会自动产生调用复制构造函数的代码。被生成的临时对象在函数返回时将会(通过调用析构函数)被摧毁:

在上面的代码中,我们通过调用Complex::Complex(const Complex&)传递了c的一个拷贝给abs。当abs返回时,这个拷贝将会被Complex的析构函数(如果有的话)所摧毁。

构造函数中有着比我们所看见的还要多的细节。除了程序员所编写的代码之外,构造函数还可以调用其他的构造函数来初始化对象中的基类对象和数据成员。即使程序中并没有明确的调用,编译器也可以向代码中插入构造函数的调用代码(例如:当程序中有着隐式的数据转换时)。在本节中,我们将关注一些和构造函数相关的常见问题,这些问题中的部分会导致程序的执行速度变慢,另外一些则可能会导致程序中bug的产生。

在阅读本章时,你最好能够时刻记住“初始化”和“赋值”这两者之间的区别(详情参见下面的“回顾”)。你同时最好也不要被“缺省构造函数”(不需要任何参数就可以被调用的构造函数,它要么是在声明时就没有参数,要么就是在声明时它的所有参数都被赋予了缺省的值)和“缺省的复制构造函数”(由编译器自动合成的复制构造函数)这些术语给迷惑住。

Thing t = x;     //初始化(有新对象被创建)
t = x;        //赋值(已有对象的值被改变)
//一个复数类:
class Complex{
//此处忽略细节
public:
   Complex(double,double);
   Complex(const Complex&);    //复制构造函数
};
double abs(Complex);        //使用传值(而不是传址)的方式向函数传递参数
Complex c(0.0,1.0);
double d = abs(c);

当我们编写了一个新类时,请注意这两点:缺省的复制构造函数和赋值操作的行为是否符合我们的预期要求。如果不是的话,我们就将不得不声明和定义我们所需要的这两个函数。

通常(但不是所有的)情况下,当对象的所有状态都存储在对象中时,缺省构造函数的所作所为都和我们所预期的一样。例如,我们来看看复数类Complex的实现,它的对象中用两个double存储了复数的状态:

class Complex {
private:
   double real;
   double imag;
public:
   Complex(double r,double i):real(r),imag(i){}
//此处忽略细节
};

由于我们没有明确声明复制构造函数,因此编译器将会为我们合成一个缺省的复制构造函数。它的行为就是复制这两个数据成员,而这正是我们这个类所期望的操作。

在另外一方面,假设我们有一个类String,在其中有一个char*的数据成员,我们用它来指向String类对象所代表的字符串:

//在String.h中:
class String {
private:
  char*data;
public:
  String (const char*cp="");
  ~String(){ delete [] data;}
};
//在String.c中:
String::String(const char* cp)
:data(new char[strlen(cp)+1]) {
  strcpy(data,cp);
}

在上面的例子中,String类的缺省复制构造函数将会仅仅对那个指针进行复制,最终导致两个String对象指向同一块内存。这种结果并不是我们期望的,一旦第一个String对象被摧毁,那么被指向的内存也将同时被释放,这时剩下来的那个对象将发现,它所拥有的指针成员指向的是已经被释放了的内存!

如果缺省的行为和我们预期的不一样,我们就必须明确地声明和定义一个复制构造函数:

//在String.h中:
class String {
private:
  char*data;
public:
  String(const char*="");
  string(const String&);
  ~String(){delete[]data;}
};

//在String.c中:
String::String (const String& s)
:data (new char[strlen(s.data)+1]){
  strcpy (data,s.data);
}

这样我们就可以确保每个String都将拥有一份数据的私有拷贝。

在某些情况下,即使有新对象被创建时,由于某些特殊的操作的缘故,我们也不能使用缺省的复制构造函数。假设我们有一个类File,我们用它来表示一个文件描述符(也就是一个用于文件I/O操作的句柄)。有些应用程序可能需要知道在某个给定的时间内到底存在着多少个File的对象(这样做可能是为了避免打开的文件数超过允许的文件描述符上限)。我们可以很容易做到这一点:只要让File维护一个当前存在的File对象个数的计数器就可以了。在File的构造函数中我们会对这个计数器进行递增,在析构函数中对计数器则是递减:

//在File.h中:
class File{
  static int open_files;
//此处忽略细节
public:
  File(const String&filename, const String& modes);
  File(const File&);
  ~File();
  static int existing(){return open_files;}
};
//在File.c中:
int File::open_files = 0;
File::File(const String& filename, const String& modes){
  ++open_files;
  //此处忽略细节
}

File::~File(){
  --open_files;
  //此处忽略细节
}

静态成员函数File::existing()将返回现有的File对象的计数。

为使这样一个方案生效,每个File构造函数都必须更新计数器。缺省复制构造函数不做这个工作,所以我们必须自己来写:

File::File(const File&f){
  ++open_files;
  //此处忽略细节
}

上面代码中的静态函数File::existing()将会返回当前存在的File对象的个数。

对于缺省复制构造函数是否能够工作这个问题,我们并没有一个通用的规则。一种从经验中得到的方法就是:对那些包含指针的类要“另眼相待”。如果被指向的对象是“属于”该产生的对象,那么缺省的复制构造函数就有可能是错误的,因为它只是简单地复制了指针而不是指针所指向的对象。

即使我们的代码从来不会去调用复制构造函数,我们也不能忽略掉它。请记住,那些使用我们所编写的类的用户可能会去调用类的复制构造函数(他们有可能是通过创建新对象来显式地调用它,也可能是通过用传值方式向函数传递参数来隐式地调用它)。如果确实因为某些原因,使得为类实现复制构造函数变得非常困难,那么请把它声明为私用的,并且不要为它提供任何的定义:

class Cant_be_copied {
private:
  Cant_be_copied(const Cant_be_copied&);//没有定义体
//此处忽略细节
};

一旦我们这样做了,我们至少可以确信那些无意间调用到复制构造函数的代码将会被编译器(用户代码)或者连接器(类成员或者友元代码)给找出来。虽然这样做并不是很好,但比起那些默默地执行编译器合成的缺省复制构造函数的错误代码来说,它要好得多了。

回顾:成员初始化

缺省情况下,在构造函数的函数体被执行前,对象中的所有成员都已经被它们的缺省构造函数所初始化了。那些没有构造函数的成员则将拥有一个未定义的初始值。

编写构造函数的人可以对这种行为进行更改,方法是:在构造函数定义中的参数列表结束的括号后面增添一个冒号以及一个初始体(initializer)列表。每个初始体都包括一个名字以及一个参数列表,这其中的名字就是要创建的类中成员或其基类的名字:

下面的代码会告诉编译器只使用一次函数调用来初始化name这个成员:

那些内建类型的成员也可以用这种语法来进行初始化,此时的参数列表必须是一个用来指定初始值的简单表达式。

当类中的某个数据成员本身也是一个类对象时,我们应该避免用赋值操作来为该成员进行初始化:

class Employee {
private:
   String name;
public:
   Employee(const String&);
};
//效率不高的构造函数:
Employee::Employee(const String& n){
   name = n;
}

虽然这样做构造函数也能得到正确的结果,但它的效率却不能达到它本来应该达到的标准。当一个新的Employee对象被创建时,成员name先将会被String的缺省构造函数所初始化,然后在Employee的构造函数中,它的值又会因为赋值操作而再一次改变。这是两个不同的步骤,不过我们可以把它合并到一个步骤中去:我们可以通过使用初始化语法(initialization syntax)来显式地为name进行初始化(详情参见下面的回顾):

class String {
public:
     String();
     String(const String&);
     //此处忽略细节
};
  
class Employee {
private:
     String name;
public:
     Employee(const String&);
};
 
Employee::Employee(const String& nm)
: name(nm) {
//…
}
Employee::Employee(const String& n)
: name(n) {
}
String::String(nm);

现在这个构造函数只需要进行一次关于String的操作(也就是一次初始化),而原来的那个则需要两次(一次初始化以及一次赋值)。在我的计算机上面,对Employee的构造函数进行这样的改变可以获得大概30%的效率提升。

当我们在编写构造函数的定义时,请在写完正式的参数列表后停下来一会,想一想有多少成员可以使用构造函数的初始化语法。通常我们都可以发现所有的成员都可以用这种方法来进行初始化,而当我们写到构造函数的函数体时,将会发现其实我们什么也不需要做!这是一个好的信号:我们的构造函数已经为我们类的成员们选择了一个正确的初始值。

不是类对象的成员

初始化语法同样也可以用来对不是类对象的成员进行初始化:

class Employee {
private:   
   String name;
   int  salary;
public:
   Employee(const String&,int);
};

Employee::Employee(const String& nm, int sal)
:name(nm),salary (sal) {
}

由于内建类型没有构造函数,使用初始化语法来对整型成员salary进行初始化并不能获得比赋值更高的效率,但这样做(使用同样的方法来为所有的数据成员进行初始化)会使得代码的可读性更高。

成员初始化的顺序

C++中规定,一个类中成员的初始化顺序和它们在类中被声明的顺序(而不是构造函数定义中的顺序)必须是一致的。通常情况下,这种顺序问题都不会有什么影响,但在某些场合下,它将导致产生问题——例如:某个成员的初始化过程中使用了另外成员的值。

让我们来考虑一个改进后的Employee类,它包括Employee的名字和身份认证代码:

class Employee {
private:
   String name;
   long  id;
public:
   Employee(const char*name);
   Employee(long id);
//此处忽略细节
};

我们编写了两个构造函数,一个以职工的名字作为参数,一个以职工的身份认证代码作为参数。每个构造函数都会从一个职工花名册中查找参数中没有给出的那份信息。

下面是我们对构造函数定义的一种(不正确的)尝试:

extern String lookup_employee(long);
extern long lookup_employee(const String&);

Employee::Employee (const char*n)
:name(n), id(lookup_employee(name)){
}

Employee::Employee(long i)
:id(i),name(lookup_employee (id)) {//运行时错误
}

上面的第二个构造函数可以顺利地通过编译,但却不能正常工作。因为在类声明中,成员name出现在成员id的前面,这确保它每次都会被第一个初始化——即便是我们在构造函数的定义中把id的初始化代码写在它的前面!这意味着在第二个构造函数中,lookup_employee实际上在id被初始化前就被调用——这时它的参数也就将是一个毫无意义的随机数值。在此例中,这个问题很容易被解决:我们可以使用外界传递过来的参数,而不是类中的成员:

Employee::Employee (long i)
: id(i),name(lookup_employee(i)) { //OK
}

但不是每次我们都可以这么容易地解决问题;在某些情况下,我们可能不得不对类的定义进行重新整理。如果成员的顺序意义重大的话,我们最好在头文件中把它记录下来,这样在随后的对类的声明进行重新排列过程中,我们并不会导致某些构造函数的工作出现问题[1]

编译器忽略掉构造函数中的初始体顺序这个现象看起来好像有违我们的直觉,但这却是C++要求“对象的析构过程必须和其创建过程相反”得到的结果。如果构造函数中指定了一个特殊的构造顺序,那么析构函数将不得不去查询构造函数的定义,以获得如果对成员进行析构的顺序。由于构造函数和析构函数可以在不同的文件中定义,这就将给编译器的实现者造成一个难题。更糟糕的是,一个类可以有两个或者更多的构造函数,对于这些构造函数的定义,我们并不能保证它们中的成员出现的实现都是一致的。然而,我们可以确保类的声明在所有的文件中都将有效,并且不同的文件中出现的声明都是一致的(否则就将造成整个程序的行为无法预期);所以我们使用类的声明来解决成员的构造和析构顺序。

类成员的引用

如果在类中的某个非静态数据成员是一个引用的话,因为所有的引用都必须被明确地初始化,所以我们必须在该类的每个构造函数中都使用初始化语法:

class Payroll_entry {
private:
   Employee& emp;
public:
   Payroll_erxtry(Emplyee&);
};

Payroll_entry::Payroll_entry(Emplyee& e)
{ //编译错误:“emp”必须被初始化
}

此时我们必须问自己,为什么我们要将emp声明为Employee&,而不是Employee*呢?除了语法上的不同之外,将emp声明为引用而不是指针将会在两方面对它的使用造成限制:

通过将emp声明为引用,如果我们的代码中试图违反上面的两条规则,编译器就将用编译期的错误来帮我们找到它们。使用引用而不是指针从编译器那能够获得的只是编译期的检测,它们实际产生的代码其实并无区别。

和复制构造函数一样,如果我们没有声明赋值操作符函数,C++编译器会自动为我们合成一个。缺省的赋值操作符会为每个数据成员进行赋值。缺省的赋值操作符的适用场合和缺省的复制构造函数一样。如果缺省的复制构造函数是错误的,那么缺省的赋值操作符几乎也可以被确定为错误的(反之亦然)。

赋值操作符和复制构造函数通常都有着几乎同样的逻辑。它们之间的主要区别在于:

下面是String类的复制构造函数和赋值操作符:

String::String(const String& s)
: data(new char[strlen(s.data)+1]{
   strcpy(data,s.data);
}

const String&
String::operator=(const String&s){
   if(&s!= this){
    delete [] data;
    data = new char[strlen(s.data)+1];
    strcpy(data,s.data);
   }
   return*this;
}

请注意上面的赋值操作符。其中对于&s和this的比较是为了防范如下的代码:

String s;
s=s;

如果没有上面的比较,对于String对象的自赋值,我们将会先删除String中的数据,然后又试图对这些刚被删除的数据进行拷贝。我们在编写赋值操作符时,必须时刻考虑到自赋值的可能性,并确保自己的代码在这种情况下可能做出正常的反应(通常情况下是什么都不做)。我们可能不期望其他人编写这样的代码:

s = s;

但不管是使用引用还是指针的代码,我们必须保证它们拥有同样的效果。

一旦我们确信正在处理两个不同的对象,那么与复制构造函数不同的是,赋值操作符必须将原有值中所控制的资源释放掉。(这也就是我们代码中delete的作用。)在这之后,除那个返回值之外,赋值操作符的行为通常都和构造函数中的一致。我们通常都会把那个公有的逻辑提取出来放到一个私用的成员函数中去,并在复制构造函数和赋值操作符中调用这个函数;在本例中,这个公有的逻辑只是一个简单的函数调用,所以我们就直接复制了这个调用。

赋值操作符应该返回一个被赋值对象的常量引用。这使得用户可以写出如下的代码:

Complex x,y;
//…
x = y = Complex(0,0);

被重载的操作符和内建的操作符有着同样的优先级和结合顺序。=是右结合的,所以下面的赋值:

y=Complex(0, 0);

将会被首先执行,它所产生的结果又将会被赋值给x。

通过返回一个常量引用,我们就可以阻止将赋值结果作为左值的做法:

(a=b)=c;

这样做不但看起来比较奇怪,它所产生的结果同样也会让人大吃一惊。例如,假设a和c都引用到同样的目的,这样做会导致什么结果呢?

让赋值操作返回一个值也有着一个缺陷:即使我们并不使用它,我们也需要用一些代码来返回这个值:

a = b;//返回值未被使用

在实践中,这种做法几乎都是可取的。那些额外的代码通常都是一条简单的机器指令(记住:我们返回的是一个引用,它实际上也就是个指针,而不是一个全新的对象)。在大部分情况下,由这一条指令带来的性能影响是可以忽略不计的。如果真的是十分注重效率的话,那么赋值操作符应该被定义为inline;在此时,任何一个还过得去的优化器都可以优化掉这条没有被使用的指令。

假设有如下一个复数类:

class Complex {
public:
   double real;
   double imag;
   Complex (double r, double i) : real (r), imag(i) {}
   //其他的被忽略
};

这个类可能可以工作,但它的接口却有着重大的缺陷。问题出在那两个公用的数据成员:real和imag上面。这个接口保证Complex对象中的实部和虚部会以浮点数的形式存储在对象中。由于用户代码可以直接存取到公用的数据成员:

Complex c(3.0,4.0);
double d = c.real;
c.imag = 0.0;

所以一旦我们需要改变信息的存储格式或存储位置时,这样的改动会变得相当困难。

假设在后面的某个版本中,我们希望将Complex的实现(而不是接口)中的信息存储方式由笛卡儿坐标格式改为极坐标格式。(也许我们有着的某些算法在处理使用极坐标格式表示的数字时效果最好。)如果笛卡儿坐标是公用数据,我们就将陷入泥潭:我们将不得不改变所有的用户代码或者是在每个Complex对象中同时维持极坐标和笛卡儿坐标格式。

如果我们在一开始就避免使用公用数据,我们就不会碰到这样的问题了:

class Complex {
private:
   double real_d;
   double imag_d;
public:
   Complex(double r,double i):real_d(r),imag_d(i){}
   double real() const { return real_d; }
   duoble imag() const { return imag_d; }
   void real(double r) { real_d = r; }
   void imag(double i) { imag_d = i; }
 
   //此处忽略细节
};

一旦接口中指定可以通过Complex对象得到浮点型的实部和虚部之后,如何在对象中存储这些浮点数就变成了类的私有实现细节。用户代码要想取得这些数据,就必须调用类的成员函数,而不是直接去访问数据成员。

Complex c(3.0, 4.0);
double d = c.real();
c.imag (0.0);

现在对于用户来说,唯一的不同就是他们为了获取值将不得不多输入两个字符(即括号)。由于Complex::real是一个内嵌函数,所以这两种形式产生的代码应该是一致的——将取值操作封装在一个内嵌函数中不会带来任何效率上的损失。同样,我们也可以将修改值的操作封装到内嵌函数中去。

现在,我们的类中再也没有公用数据了,为了将信息以其他格式存储而修改类的实现也变得容易起来:

//文件Complex.h
class Complex {
private:
   double r;
   double theta;
public:
   Complex(double re, double im); //不再是内嵌函数了
   double real() const { return r*sin(theta); }
   double imag() const { return r*cos(theta); }
   void real(double); //不再是内嵌函数了
   void imag(double); //不再是内嵌函数了

   //此处忽略细节
};

//文件Complex.c
Complex::Complex(double re, double im)
   : r(sqrt( re*re + im*im )),
    theta (atan2 (im,re)){
}

  //"real(double)"和"imag(double)"留给读者作为练习

此时,用户代码必须重新编译,程序的性能也会有一定的影响,但程序的接口没有改变。原来可以正常工作的程序仍然可以正常工作。

我们也可以将实现改为把信息存储到其他的位置上去:

class Complex_rep {
private:
   friend class Complex;
   double real_d;
   double imag_d;
   Complex_rep(double r, double i): real_d(r),imag(i){}
};

class Complex {
private:
   Complex_rep*rep;
public:
   Complex(double r, double i)
   : rep(new Complex_rep(r,i)){}
   ~Complex() { delete rep;}
   //此处忽略细节
};

我们将在第3章中解释为什么这么做。

公用数据还会使得我们的类难以用来保证表示不变量。表示不变量是一个谓词,对于某个完全被构建好的对象,它都将得到真值。

例如,假设我们有一个用来表示有理数的类Rational,它里面有两个整型数(一个用于分子,一个用于分母):

class Rational {
private:
   int num_d;
   int denom_d,
public:
   Rational(int n, int d);
   int num()const { return num_d;}
   int denom()const { return denom_d;}
   voie num(int n) {num_d = n;}
   void denom(int);//改变分母
};

如果我们的类具有这么一个表示不变量(分母denom_d不可能为0),这将会使得我们的算法变得更简单,并且更高效。我们在每个可能改变分母的函数中都对新的分母进行检测,如果它是0的话,我们就向外报告一个错误[2]

static void
check_zero(int d){
   if (d == 0) {
     cerr << "Zero denominator in Rational\n";
     abort();
   }
}

Rational::Rational(int n, int d)
: num_d(n),
  denom_d(d) {
   check_zero(d);
)

void
Rational::denom(int d){
   check_zero(d);
   denom_d = d;
}

有了这两个函数我们就可以保证不变量的成立,因此在Rational的其他成员函数中,我们并不需要担心出现分母为0的情况。如果我们将分母声明为一个公用数据成员,我们就无法来保证不变量的成立,用户代码也就可以在任意时刻将分母赋值为0。有关Rational的所有操作也都将不得不对这种可能性进行检测。

综上所述,我们得到如下的结论:避免公用数据成员的出现。公用成员将使得我们很难去更改类的实现(如改变信息的存储格式或者存储位置);它也无法保证表示不变量的长期有效性。

回顾:隐式类型转换

我们可以通过如下两种方式来声明一个从类型F到类型T的隐式转换:

  • 在T中声明一个仅接受一个类型为F的参数的构造函数(这其中也包括那些使用了缺省参数值的构造函数,如:T::T(F,int=0))
  • 在F中声明一个operator T的转换函数

如果在函数调用中无法找到形式参数与实际参数类型完全匹配的函数声明,编译器就将试图通过在实参上进行隐式转换来找到合适的函数。对于作用域中所有同名的函数,编译器会试着将用户自定义的(至多一个)隐式转换作用到每个实参之上来寻找匹配程度最高的函数声明。如果编译器找到这样的一个函数,那么它就会在目标代码中使用这个转换,最后调用该函数;否则,编译器就会向外报告一个编译错误。

在上面的代码中,r就被隐式地转换为一个double值,就好比如下的用户代码:

隐式类型转换(详情参见下面的回顾)会使得编译器能够在暗中修改我们所写的代码。由于这个原因,我们必须谨慎地提供和使用它们:人们很难理解那些有着大量隐式类型转换的代码。而且,使用这种转换的场合越多,我们就越可能得到有着歧义的函数调用,因此也就需要更多的显式转换来消除这种歧义。在本节中,我们将向读者展示一些经常发现在使用隐式类型转换的代码中的问题。我们同样也会向读者给出一些关于“该在何时使用隐式类型转换”的建议。

class Rational {
public:
//…
     Rational(int,int);
     oporator double();
);
double sqrt(double);
 
main() {
     Rational r(4,1);
     double sq = sqrt(r);
};
double dval = r.operator double();
double sq = sqrt(dval);

带有单个参数的构造函数同时也是一个隐式转换,这个事实让人觉得很不方便,因为我们希望的是该构造函数应该用于明确的对象创建过程中。只有在概念上将同样的信息由一种格式转换成另外一种格式的前提下,我们才允许有这样的隐式转换。如果在概念上,被转换后的对象与构造函数的参数不同,这就很让人费解。我们将通过一些例子来向大家展示这一灰色区域:

class String {
//此处忽略细节
public:
   String(const char* = "");
};

void print_heading(const String&);
//…
print_heading( "Annual Report");

在调用print_heading时,我们就会得到一个隐式的从char*到String的转换;由于String和char*在概念上来说是同一抽象模型的两个不同表示方法,所以这样的操作是合理的。

class Rational {
//此处忽略细节
public:
   Rational(long num=0, long denom = 1);
);

int operator==(const Rational&, const Rational&);

int 
nonzero(const Rational& r){
   return r == 0;
}

上面这个例子虽然不是很清晰,但同样也可以让人明了。0在这被隐式转换成一个Rational(在Rational的构造函数中,第二个参数有着一个缺省的值1)。然而,在这样写的代码中,那个比较操作看起来要比它实际上的操作要显得更高效一些。它的显式版本如下:

int 
zero(const Rational& r){
   return r == Rational(0,1);
}

上面的代码就可以给我们一个更好的关于该函数的实际性能如何的描述,并且它还可以向我们暗示出如下的优化:

int
zero (const Rational& r){
   static const Rational zero(0,1);
   return r == zero;
}

这使得我们可以节省下每次都创建和摧毁一个新的Rational对象zero所产生的开销。只有那些精明的程序员才可以从函数的最初版本中发现这种优化。

下面是另外一个例子:

#include <stdlib.h>
//随机数产生器:
class Random_gen {
//此处忽略细节
public:
  Random_gen(long seed);
};

void play_game(Random_gen);
main(int argc, char* argv[]){
if (argc>1)
   play_game(atoi(argv[1])); //让人迷惑的地方
}

上面的程序中本来是不应该出现隐式转换的。一个用于产生随机数的对象和它用来产生随机数的种子(seed)应该分属于不同的对象;尤其是当它使用了一个long作为其构造函数参数时,它的类型就和这个种子完全不一样了。在上面的代码中,“传递给play_game的参数并不是atoi函数的输出”这个事实很容易被人所忽略。这段代码应该使用显式调用构造函数的方式来重写:

int 
main(int argc, char*argv[]){
if (argc>1){
   Random_gen gen(atoi(argv[1]));
   play_game(gen);//现在好多了
}
//…
}

上面的代码和最初的代码在执行时是一样快的(不管我们有没有写构造函数的调用代码,编译器都会为我们产生它),但它更能体现程序的意图。

有着多个类型转换操作符的类在被使用时更容易产生编译时期的二义性问题。例如,有一个只带有一个类型转换操作符的类如下:

class String {
   char* rep;
public:
   String(const char*="");
   operator const char*() const { return rep; }
   //此处忽略细节
};

main() {
   String s("hello world");
   cout << s << endl;
}

上面的代码可以正常工作;由于我们没有声明String的输出操作符,代码中的String对象就被转换成一个const char*用于输出。

如果我们往String中再添加一个类型转换操作符,会有什么现象出现呢:

class String {
   char* rep;
public:
   String(const char* = "");
   operator const char*()const{return rep;}
   operator int()const { return atoi(rep);}  //新增的转换操作符
   //此处忽略细节
};

看上去这个新增的转换操作符会给我们带来很多的便利,因为它可以将String用于那些需要int的场合中;但是它却会破坏我们已有的代码:

main() {
   String s("hello world");
   cout << s << endl;//编译期错误:
             //无法确定进行哪种转换
}

现在对于String的输出,编译器的解析步骤将变成在“将String转换成const char*再输出”和“将String转换为int再输出”这两种行为中挑选一种,因此我们就得到了一个编译错误。为了让上述代码顺利通过编译,我们不得不使用显式转换:

cout<< (const char*) s;

在String中同时提供两种隐式转换使得程序中出现歧义的可能性大为增加。

不过这并不意味着我们不能将String转换成为int,我们所要做的只不过是不再使用隐式的方式进行这种转换而已:

class String{
   char* rep;
public:
   int as_int () { return atoi(rep);}
   //此处忽略细节
};

现在,如果希望进行从String到int的转换,我们就需要明确地调用as_int函数:

void 
process_key_value(const String& key,const String& val) {
   if (numeric_key(key)){
     int value = val.as_int ();
     //…

这并不是个坏消息,使用明确的函数调用会使得程序更容易被人所理解。

对于涉及自定义类型的隐式转换,它们的规则比较固定也容易让人理解。但对于在内建的整型类型之间的隐式转换,它们的规则就显得令人难以捉摸,并且在语言的发展过程中,这些规则也有着一些变化。例如:

void f(long);
void f(int);
main() {
   f('X');//此处调用的是哪个f?
}

上面的代码中调用的是f(int),但我们再来看如下的代码:

void f{long};
void f(unsigned int);
main() {
   f('x');//此处调用的是哪个f?
}

它会产生一个编译时期的错误(有着歧义的函数调用)。可能你对于语言规则十分了解,知道为什么会有这种现象发生,但那些将来维护这段代码的人可能就不清楚它发生的原因了。我们应该避开语言中的那些含义模糊的特性,如果真的需要对不同的内建整型进行不同的操作,我们最好是为每个整型都提供一个参数类型和它完全匹配的函数:

void output(long);
void output(unsigned long);
void output(int);
void output(unsigned int);
void output(short);
void output(unsigned short);
void output(char);
void output(unsigned char);
void output(signed char);

现在,我们就可以确信下面代码的意思不但明显,而且也不会发生改变:

unsigned short s;
output(s);

在C++中,对于操作符的重载有着两种方法:将该操作符作为成员函数或者不作为成员函数。在本节中,我们将会对如何为一个特定的操作符选择合适的重载方式给出一些评判标准。

显而易见的是,如果我们正在为一个不属于我们的类重载一个操作符,这个操作符应该是一个非成员函数:如果类不属于我们,我们也就不能向其中增加成员了!在本节的剩余部分中假定了我们可以决定是否将操作符定义为一个成员函数。

对于成员和非成员的选择结果会影响到使用该操作符的代码,例如,作为成员函数的操作符可以使用this指针,并且也可以用非限定形式来使用类中的成员。此外,这个选择结果还会影响到用户眼中该操作符的行为。如果操作符被实现为一个成员函数,那么我们就无法对第一个(最左边的)操作符进行隐式类型转换。这种不同存在于用户眼中,但对于编码来说则并不明显,因此它也应该成为我们在成员和非成员之间进行选择的一个主要评判标准。

我们先来看一个有关一元操作符的例子。假设我们有一个类Vector(向量)[3],它创建自一个Direction(方向)对象和一个用double描述的数值:

class Direction {
//此处忽略细节
};

class Vector {
public:
   Vector(const Direction&, double magnitude = 0.0);
};

由于Vector的构造函数中第二个参数有缺省值,我们就可以从一个Direction对象经过隐式转换得到一个Vector对象。

现在我们希望为Vector重载一个一元操作符。如果我们把它定义为非成员函数,那么我们也就可以将它作用到Direction上去(因为Direction可以隐式地转换为Vector):

Vector operator-(const Vector&);
main () {
   Direction d;
   Vector V=-d; //合法的:operator-(Vector(d))
}

如果operator-是一个成员函数,那么隐式转换就将被禁止:

class Vector {
public:
   Vector (const Direction&, double magnitude = 0.0);
   Vector operator-()const;
};

main () {
   Direction d;
   Vector v = -d; //编译期错误
}

我个人认为禁止隐式转换是一件好事,如同我们在2.4节中所看到的,使用隐式转换会使得代码难以被维护和被理解。出于这个原因,我建议:在可能的情况下,最好将一元操作符实现为成员函数。

按照C++语言定义来说,这四种操作符必须被实现为成员函数。任何将它们中的某个实现为非成员的尝试都会造成一个编译期的错误:

extern int operator=(Complex&,const Complex&);
//编译期错误:operator=必须是成员函数

对于其他的二元操作符来说,是否将它们实现为成员函数取决于我们是否需要对左操作数进行隐式转换。对于赋值类操作符(如+ =)来说,我们希望的是禁止对左操作数进行隐式转换:

Complex c;
C+=5;

我们很难想象c在+=的操作实施前就被隐式转换成为其他的东西。如果上面的代码顺利地通过了编译,在它运行后c的值将不会得到改变,这一点肯定会让人迷惑不解。如果我们将赋值类操作符定义为成员函数的话,我们就可以禁止这种情况的出现,并且还可以保证它们和operator =(它必须是一个成员函数)具有一致的行为。

此外,对于那些非赋值类从操作符来说,禁止对左操作数进行隐式转换但却允许对右操作数进行隐式转换同样会让人困惑。例如:

class Complex {
//此处忽略细节
public:
   Complex(double = 0.0, double = 0.0);
   Compiex operator+(const Complex&)const;
};

main () {
   Compiex c(1.0):
   Complex d = c + 1.0;  //OK
   Complex e = 1.0 + c;  //编译期错误
}

注意:我们在Complex的构造函数中使用了有缺省值的参数。这使得我们可以从double通过隐式转换得到Complex(这时,构造函数的第二个参数将会是缺省值0.0)。上面代码中对于operator+的第二次调用出错的原因在于:在当前作用域中,没有一个合适的声明对应于这次函数调用;编译器不会对成员函数的左操作数进行隐式转换。如果我们将operator+声明为非成员函数,那么上面两种形式的调用都将顺利地通过编译。

当两个操作数都是操作的输入,并且没有一个会受到操作的影响,那么我们最好将该操作符定义为一个非成员函数。这可以确保在有隐式转换的条件下,这两个操作数都可以以同样的方式来进行操作。

图2.1中归纳了本节中对于操作符是否该定义为成员函数的建议:

操作符

建议

所有的一元操作符
= {} [] ->
+= −= /= *= ^= &= |= ~= %= >>= <<=
所有其他的二元操作符

成员
必须是成员
成员
非成员

图2.1 操作符重载:成员或非成员?

C++中允许用户写出这样的代码:使用不同(个数或类型)的参数来调用(似乎是)同一个函数:

f();
f(0);
f(x,y,z);

(当然,这些函数都应该对它们的参数有着同样的抽象行为。)在C++中有三种方法可以这么做:函数重载、使用有缺省值的参数以及使用省略符。对于在同一个函数名中同时使用这三种方法中的多种所出现的结果,C++语言规则有着明确的定义。不过它们比较难以掌握,并且还容易让人觉得惊奇。即使我们能够很好地理解它们,那些维护我们代码的人也可能不一定能够完全掌握它们。对于同一个名字来说,我们应该对它选择一种方案并且一直坚持使用这种方法。

省略符只应该用在类似于printf这样的需要无限(或近似无限)个重载版本的函数中。在使用了省略符的函数中并没有对那些不定参数的类型进行编译期的类型检测,在函数内部我们还必须使用定义于头文件stdarg.h中的充满魔幻色彩的宏[4]来获得那些不定参数。如果函数没有正确获得该参数,那么函数的返回值就将无法确定。

假设我们已经去除了省略符,在使用缺省值和重载之间的选择通常都很容易。使用缺省值可以让我们使用多种不同的方法来同一个函数;使用重载则适用于需要调用不同的函数这种场合之下。

如果这两者之间有一个是另外一个的简化方式,并且同一个实现可以用在这两种场合之下,那么我们最好选择使用带缺省值的方式。例如,我们有一个类String,它的构造函数带有一个const char*的参数,但是我们又希望它有一个缺省的构造函数。为了简便,我们可以使用一个带缺省值的参数:

class String {
//此处忽略细节
public:
   String (const char* = "");
};

String s;  //等同于“string s("");”

当多个事物在概念上相似,但实现形式又不一致时,我们应该选择使用重载:

void print(const Data&);
void print(const Location&);
void print(const Employee&);

上面三个print在概念上来说是一样的,但它们的实现方法不一样,所以我们必须对它们使用重载。

当我们为指针参数提供缺省值时,下面这个小的语法陷阱就很容易出现:

void f(const char*="");

编译器会将*=当作一个单独的token来处理,这样就将造成一个语法错误。我们应该在*后面增加一个空格:

void f(const char*="");

对于有着缺省值的引用对象来说,编译器也是同样处理的:

const String empty;
void f(const String& = empty);   //语法错误
void f(const String& = empty);    //OK

许多C++程序员在开始使用const时都是用它来定义一些常数;例如将:

//C版本:
#define BUFF_LENGTH 1024
int buffer[BUFF_LENGTH];

写成:

//C++版本:
const int BUFF_LENGTH = 1024;
int buffer[BUFF_LENGTH];

这样做可以获得的好处是:编译器(或者其他工具)可以知道BUFF_LENGTH的名字和类型。而且这样做不会给程序的执行时间和尺寸上带来什么额外的开销,如果我们不去使用BUFF_LENGTH的地址的话,编译器也就不需要为它申请专门的存储空间。

然而,const能做的并不仅仅是定义常数那么简单。通过声明一个“指向常量的指针”,我们就可以确保该指针指向的对象不会被改变。在本节中,我们将向读者展示指向常量的指针(或者引用)是如何影响我们的代码的。

请记住一点:将某件事物声明为const(或者是指向const的指针或者引用)会引起额外的编译期检测,但它并不会导致编译器产生额外的代码。

在C++中,函数参数是以值的方式进行传递的。这意味着在被调用的函数中,存在着一份实际参数的拷贝;我们在被调函数中对该拷贝进行的改变不会反映到调用函数中去。

当一个自定义类型的对象以传值的方式传递到函数中去时,通过调用该类型的复制构造函数,它将和函数的其他实参一样被复制。当函数返回时,编译器会产生一些代码(调用析构函数)来摧毁这份拷贝。这样的复制操作可能会造成额外的开销,尤其是当被调函数又将该对象传递给其他函数时更是如此。在这种情况下,每个最外层的调用都可能会产生该对象的好几份拷贝(以及相应的构造和析构代码)。

在大多数情况下,即便对象在概念上是以传值的方式传递给函数,被调函数可能也不需要有着它自己的对于该对象的独立拷贝,它可以提供一个常量引用来使用这个对象:

//正确但运行缓慢的代码:
//不必要的Telephone_number的拷贝:
void
dial(Telephone_number tn){
//此处忽略细节
}

//改善后的代码:使用了一个常量引用:
void
dial(const Telephone_number& tn){
//此处忽略细节
}

上面的第二份代码的速度要快一些,因为它避免了对输入参数进行复制并在函数返回时摧毁该复制的操作。此处使用const阻止了被调函数无意中对调用函数中对象的值进行修改。

使用引用给用户带来了一些重要的语法甜头:他们不再需要去记忆哪些参数是以指针的方式传递给了函数,哪些参数是以值的方式传递给了函数。如果在概念上来说,参数是以值的方式传递给函数,我们就可以以上面的方式来写调用语句,而“参数是以引用的方式传递给函数”这个实现细节就被隐藏起来了。

不同的C++程序员对于const的态度不一样。有些人把它看作是在编译期间寻找bug的一个重要工具;其他人则认为相对它的好处来说,const带来的麻烦要更多一些,并因此不去使用它。如果我们正在编写将要被他人使用的代码,第二种态度就不适合我们:即使我们自己不使用const,其他使用我们编写的类的人可能也会使用它,因此我们不得不将一些适当的事物声明为const以允许其他人的使用。

在那些接受指针参数的函数中,这种情况出现得最多。如果函数只是通过指针来读取(它并不会向被指向的对象存储或者更改它的内容),在函数声明时,我们应该将该参数声明为一个指向常量的指针:

class String {
publiC:
   String(const char*="");
//此处忽略细节
};

上面的声明保证了String的构造函数不会通过它的指针参数进行存储活动。(任何试图在String的构造函数中对它进行的存储活动都将导致一个编译期错误。)如果我们把String的构造函数声明为带有一个类型为char*而不是const char*的参数,那么所有有着常量指针的用户就无法用这个构造函数来构建一个String对象:

//在String.h中:
class String {
public:
   String(char* = ""); //应该为"const char*"
//此处忽略细节
};

//在用户代码中:

main() {
   const char* hello = "hello world";
   String s(hello);    //编译期错误:
                 //找不到合适的String构造函数
}

同样的情况也适用于接受引用参数的函数:如果函数不会通过引用来存储内容,那么它应该接受一个常量引用作为其参数。常量引用参数也使得将一个指向未命名的临时对象的引用传递给函数成为了可能。

Thing get_a_thing();

void look_at_thing(const Thing&);

void change_thing(Thing&);

look_at_thing(get_a_thing ());   //OK
change_thing(get_a_thing());    //编译期错误

对于change_thing的调用会产生一个编译期的错误:将一个未命名的临时对象作为一个非常量指针传递给函数是非法的。如果在被调函数中对引用参数的值进行了修改,但调用函数却忽略了这种修改,我们认为这种行为是一种bug;因此C++中也就增添了这么一条规则来禁止这种bug的产生。如果我们真的想那么做的话,我们就必须得创建一个具名对象:

Thing t(get_a_thing());
change_thing (t);  //OK

声明一个指向常量的指针关注的是该指针,而不是该指针指向的空间。编译器并不能确保被指向的数据不会被改变;它能确保的是,数据不会是通过该指针被改变的。我们仍然可以使用其他方式来改变被指向的对象的值:

Void
do_callback(const int* ip, void(*callback)()) {
   cout << *ip << endl;
   (*callback)();
   cout << *ip<< endl;
}

即使ip是一个指向const int的指针,我们仍然不能保证这两次打印的数字会是一样的。如:

int i = 5;
void 
bump_i(){
   ++i;
}

main(){
   do_cal1back (&i, bump_i);
}

它就将打印:

5
6

在两次打印之间,那个回调函数会修改i的值。

一个语法上的小缺点

当我们在do_callback中通过函数指针来调用那个回调函数时,我们先对函数指针进行解引用,然后再调用该函数:

(*cal1back)();

然而,如果我们直接通过()操作符来“调用”该函数,我们将得到和前面一样的结果:

callback();

这两种方式得到的代码将完全一样。

这只是一个语法上的甜头而已,我并不推荐大家这么做。当我们使用第一种形式时,我们可以很清楚地知道函数是通过函数指针来调用的;如果我们使用第二种形式,我们必须了解“函数”的类型实际上也就是“函数指针”的类型。相比而言,少输入几个字符所得来的好处还不值得我们去为它而生成难以理解的代码。

回顾:常量成员函数

通过在函数的声明体和定义体的参数列表后面添加关键字const,我们可以把一个成员函数声明为一个常量成员函数:

我们只能对常量对象调用常量成员函数:

在常量成员函数的定义体中,对象的所有数据成员都是常量,“this”指针也是一个“指向常量对象的常量指针”,而不是“指向对象的常量指针”。

那些不会修改对象值的函数应该被声明为const(详情参见后面的“回顾”)。这使得其他人可以使用我们编写的类来创建常量对象,并使用编译器来确保对这些对象所进行的成员函数调用不会修改它的值。

在未命名的临时对象上调用非常量成员函数

//在String.h中:
class String {
public:
   //此处忽略细节
   int langth() const;
   void capitalize(); //非常量成员函数
};
//在String.c中:
int 
String::length() const {
//此处忽略细节
}
 
void 
String::capitalize() { //非常量成员函数
//此处忽略细节
}

即使我们不能传递一个指向非常量未命名临时对象的引用,我们还是可以合法地在一个未命名的临时对象上调用一个非常量成员函数:

class String {
public:
   void capitalize(); //非常量成员函数
};
//…
String make_up_name();
make_up_name().capitalize();  // 可以但不应该这么调用
const String S("hello");
int len = s.length(); //OK
s.capitalize();//编译期错误:对常量对象调用非常量成员函数
 
String t("world");    //非常量String
len = t.length();     //OK
t.capitalize();      //OK

这种做法凸显了C++语言定义的一个失误,我期望ISO/ANSI C++标准委员会能够在随后的标准制定过程中把它给改正;不管如何,我们都不应该使用这种用法。如果我们希望从对象来调用一个非常量的成员函数,我们必须明确地定义这个对象并给它一个名字:

String name(make_up_name ());
name.capitalize();//稍好的做法

这将保证该名字所代表的对象在程序离开当前定义它的语句块前都不会被摧毁。

会改变对象状态的常量成员函数

C++的语言规则确保:除了明确地使用了类型转换,常量成员函数不会修改对象的状态(数据成员)。然而,某些在概念上为常量的操作可能也会改变对象中某些成员的值;对于这种情况,我们应该把它们作为实现细节向用户隐藏起来。通过使用这种方法,用户就可以在不清楚实现细节会修改对象中的某些私用数据成员的情况下,对一个常量对象进行某些在概念上来说不会更改对象状态的操作。

例如,假设我们正在使用的Complex类中存储有值的极坐标形式。我们可能需要将它的笛卡儿坐标的值也缓冲在对象中,以节约以后对它们重复计算所带来的运行时间。现在,每个对象都包含有一个布尔值,我们用它来判断缓冲值是否有效:

typedef unsigned char Boolean;
class Complex {
private:
   double r,theta;
   double real_cache,imag_cache;
   Boolean real_cache_valid;
   Boolean imag_cache_valid;
public:
   Complex(double real,double imag);
   double real_part()const;
   void real_part(double);

   double imag_part()const;
   void  imag_part(double);
};

我们在构造函数中将缓冲值标志位设为0,以表明缓冲值目前是无效的:

#include <math.h>
Complex::Complex(double re, double im)
:r(sqrt( re*re + im*im )),
 theta(atan2(im,re)),
 real_cache_valid(0),
 imag_cache valid(0)
{}

在概念上来说,我们应该把那些用来获取(而不是设置)这些值的函数定义为常量成员函数,但是它们实际上也会修改对象中的缓冲值:

double
Complex::real_part() const{
   if(!real_cache_valid) {
    (double&)real_cache = r*sin(theta);
    (Boolean&)real_cache_valid = 1;
  }
  return real_cache;
}

为了在一个常量成员函数中对数据成员进行修改,我们必须使用类型转换来去除该成员的常量性。在C++中,这种做法不但合法,而且只要类中带有一个构造函数,它的行为就将正确无误。(如果类中没有构造函数,那么我们用来去除常量性的类型转换将得到未定义的结果;之所以有这样的规则是因为我们希望编译器能够将类似于const int这样的事物放置到ROM中去。)

函数可以返回一个引用。返回引用和返回指针差不多;被引用的对象在函数返回时必须仍然存在。尤其是,我们不能返回一个自动变量(automatic variable)的引用,其原因和我们不能返回一个指向自动变量的指针一样:当函数返回时,所有的自动变量都将会被摧毁,这时我们所能得到的只是一个空悬(dangling)引用。

例如,由于被赋值的对象在赋值结束后仍然存在,我们的String类中的赋值操作符就可以返回一个指向*this的引用。然而,用来创建一个新的String对象的连接操作的函数则必须返回一个值:

String /*不是String&*/
operator+ (const String& left, const string& right){
   String result;
   //在此处构建"result",
   return result;
}

由于在operator+返回时,result会被摧毁,所以我们不能返回一个指向result的引用。我们必须用值的方式返回它,这将产生result的一个拷贝,函数会把该拷贝返回给调用函数。

在此我们的程序会有一些性能上的损耗,因为我们必须先构建一份对象的局部拷贝,然后再通过调用复制构造函数把它给返回给调用函数;不过由于这种用法的普遍性,许多的编译器都可以把这份额外的拷贝给优化掉[5]

我们也可以用构造函数来对那些有着static存储类型的对象进行初始化:

static String hello("Hello world");

C++编译系统确保:所有的静态对象在它们被使用之前都会被初始化。很多的编译器实现的做法是将所有的静态对象放到main函数被调用之前进行初始化,但C++语言规则中也允许我们在(动态的)链接时期用动态的链接器来初始化编译单元中的所有对象。

对于在同一个编译单元中出现的静态对象,它们的初始化顺序和它们在代码中出现的顺序是一致的:

  String dafault_name("foo");
  String default_src_file(default_name + ".c");

在上面的代码中,我们可以确保default_name的初始化操作要早于default_src_file的初始化操作。在同一文件中出现的静态对象,它们的析构顺序和它们的构造顺序正好相反,因此default_src_file必须早于default_name被析构。

在不同文件中的初始化操作的顺序则是未定义的:

extern String default_name;
String default_src_file(default_name +".c");  //Bug!

我们无法保证,default_name会早于default_src_file被创建。

对于上面的那个问题,我们并没有一个简单的解决方法。如果我们有着一个依赖于定义在其他文件中的其他静态对象的静态对象(如default_src_file),我们就应该试着将这两个对象放在同一个文件中(这样我们就可以得到它们的初始化的确切顺序)。如果我们做不到这点,我们就不得不将这些初始化操作延迟到main函数被执行时才执行它们:

extern String default_name;
String* default_src_file = 0;

Static void
init_names(){
   default_src_file = new String (default_name + ".c");
   //其他的初始化操作
}

main() {
   init_names();

如果我们的代码来自于一个函数库,在那里并没有我们所期望的main函数,那么我们还可以在每个使用了具有依赖关系的静态对象的函数中对该对象进行检测,看看它是否已经被初始化了:

extern String default_name;
String* default_src_fi1e = 0;

void
routine_that_uses_name(){
  if (default_src_file == 0)
     default_src_file = new String(default_name + ".C");
  //使用default_src_file

我们应该避免在一个给定的头文件中初始化对象,因为它会造成每个包含该头文件的编译单元都拥有这份初始化代码。模板特性(见第7章)的一个副作用就是:它会增加给定程序中编译单元的个数(因为通常每个模板对会被实例化为一个单独的编译单元)。当使用模板时,我们很容易就能得到上百个编译单元的程序。对于这样的程序来说,要求同一个初始化在每个编译单元中都有着冗余的运行时开销的方式会显得太昂贵了。

1.复制构造函数X::X(X)在C++中是非法的。请解释其原因。

2.我们的String类的构造函数通过调用

new char[strlen(cp)+1];

来为它的数据获取存储空间。不过在字符串中只有strlen(cp)个字符,那么多出来的那个字符是做什么用的呢?

3.我们的File类假设所有的File对象都表示一个唯一的文件描述符——对一个File进行复制会产生一个新的描述符。假设我们希望复制一个File对象时只产生一个和该对象共享已有描述符的对象——也就是说这个新的对象不会对已有描述符的数目进行递增。我们应该如何修改File类来达到这个目的呢?(提示:仅仅不让复制构造函数去修改那个计数器是不够的。)

4.一个编译器制造商可能是为了节省开发成本,没有去实现内嵌的展开:也就是说,所有的对于内嵌函数的调用都会被解释为一次真正的函数调用。对于C++来说,这样做是合法的,但对于使用该编译器的用户来说,这样做可能会是一场灾难。为什么?

5.有个程序员可能希望为double和Rational的任意组合提供一个加法操作符。请为他提出两种解决方案,并对它们的优缺点做出评价。

6.向上题中的加法操作增添对Complex的支持并重复上题中的讨论。

7.为我们已有的Complex类(使用了极坐标表示)实现设置其(笛卡儿坐标系中的)实部和虚部的函数。

8.我们的Complex类中用来设置其实部和虚部的函数的返回值是void。我们可以把它们的返回值改成什么样才有意义呢?是否存在着其他的答案可以使得问题更加清楚?如果没有的话,你会建议使用哪种方法呢?

9.假设我们正在使用String类,它提供了一个operator const char*的转换函数:

class String {
public:
   operator const char*() const;
   //此处忽略细节
};

再假设我们有一个函数remove_blanks,它带有一个类型为char*的参数,我们用它来去除它的参数所指向的字符串中的空格:

void
remove_blanks (char* cp){
   char* p = cp;
   while(*p) {
     if (*p != ' ')
        *cp++ = *p;
     ++p;
   }
   *cp = '\0';
}

最后假设有个用户,他试图用这个函数来去除掉一个String对象中的所有空格,为的是将这个String对象变短,而不是变长:

String s("hello world");
remove_blanks((char*)(const char*)s);

请问他这样做安全吗?为什么?

10.下面的类中有什么错误?

class Node{
//此处忽略细节
public:
   Node();                //叶节点
   Node(const Node&);         //一元
   Node(const Node&, const Node&);  //三元
};

我们应该如何更改它的接口来修正这个问题?

[1]译注:在这作者显得有点乐观,他假设修改类的开发人员会去认真地阅读头文件中的注释,只有在读过头文件中的注释并正确理解其中意义的前提下,我们才可能得到作者所期望得到的结果。

[2]如果我们使用的编译器支持异常,那么它应该抛出一个异常(详见第10章),而不是退出函数。

[3]译注:此处的向量是几何中的向量,而不是像标准库中那样用于表达一个数组的向量。

[4]译注:即va_start、va_list以及va_end。

[5]译注:即所谓的NVO。


相关图书

代码审计——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++开发指南

相关文章

相关课程