C++面向对象高效编程(第2版)

978-7-115-32934-9
作者: 【美】Kayshav Dattatri
译者: 叶尘
编辑: 傅道坤
分类: C++

图书目录:

详情

本书以帮助读者掌握C++面向对象高效编程方法为目的,介绍了C++编程中的各种概念和应用技巧。全书分为两部分,第一部分介绍面向对象编程的基础和应用,如数据抽象、继承、异常处理等;第二部分解释如何建立抽象的概念及其策略,并研究了C++对象模型。

图书摘要

PEARSON

C++面向对象高效编程(第2版)

[美]Kayshav Dattatri 著

叶尘 译

人民邮电出版社

北京

图书在版编目(CIP)数据

C++面向对象高效编程:第2版/(美)达特特里(Dattatri,K.)著;叶尘译.--北京:人民邮电出版社,2013.10

ISBN 978-7-115-32934-9

Ⅰ.①C… Ⅱ.①达…②叶… Ⅲ.①C语言—程序设计 Ⅳ.①TP312

中国版本图书馆CIP数据核字(2013)第197290号

版权声明

Kayshav Dattatri:C++ Effective Object-Oriented Software Construction

Copyright © 2000 Pearson Education,Inc.

ISBN:0130867691

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 Prentice-Hall PTR.

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

本书中文简体字版由人民邮电出版社经Pearson Education,Inc.授权出版。版权所有,侵权必究。

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

◆著 [美]Kayshav Dattatri

译 叶尘

责任编辑 傅道坤

责任印制 程彦红 杨林杰

◆人民邮电出版社出版发行  北京市崇文区夕照寺街14号

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

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

北京艺辉印刷有限公司印刷

◆开本:787×1092 1/16

印张:49

字数:1156千字  2013年10月第1版

印数:1-3000册  2013年10月北京第1次印刷

著作权合同登记号 图字:01-2011-7819号

定价:118.00元

读者服务热线:(010)67132692 印装质量热线:(010)67129223

反盗版热线:(010)67171154

广告经营许可证:京崇工商广字第0021号

内容提要

本书以帮助读者掌握C++面向对象高效编程范式为目的,详细介绍了C++编程中的各种概念和应用技巧。全书共分为两部分,第一部分(第1章至第10章)介绍面向对象编程的基础和应用,如数据抽象、继承、泛型类型、异常处理等内容;第二部分(第11章至第13章)深入探讨如何建立抽象及其策略,并研究了C++对象模型。书中包含大量的代码实例,读者不仅能从理论上得以提高,而且还能轻松地在实践中应用。

本书适用于C++程序员,也可供对面向对象程序设计感兴趣的编程人员及大专院校计算机专业师生参考。

当今软件系统的复杂程度剧增,面向对象编程为开发人员提供了强大的概念和工具。然而,为了解决棘手的问题,很容易写出极其复杂的面向对象程序。C++丰富的特性确实能让人事半功倍。但事实上,我见过的许多C++程序,更像是一个编译器测试套件,而不是一个实际问题的解决方案。对于小型系统而言,这样的程序不会出现问题,但是在不断运行的大型系统中,你将无法应付层出不穷的各种复杂问题。

如果因此而责备C++是不公平的。有些C++程序非常简洁明了、功能强大。开发人员必须根据实际情况明智地选择C++的不同特性,才不至于滥用。你不仅要理解每种特性的语义和性能开销,还要深入了解特性的优点、缺点,以及它的危险之处。

Kayshav Dattatri 在这方面有极其丰富的经验。在 C++出现的早期,我就已经认识Kayshav 了。我们在1990年的USENIOX C++会议上相识,当时会议上的许多论文都是AT&T(美国电话电报公司)发表的,估计一半的参与者都是 AT&T 的内部人员,而程序委员会也都是 AT&T 的人。稍后,我们发现自己都在 Taligent 公司工作。当时,Taligent已将C++应用于大规模的问题中,将其作用发挥到极限。这里是用C++构建实际应用软件解决问题的绝佳之地。

本书的所有内容都是经验之谈。书中的练习都建立在Kayshav多年的C++经验基础上。Kayshav不仅详尽地解释了面向对象的概念以及从理论上介绍C++的语言特性,还介绍了继承、mixin 类、模板类和异常这些方面的实践经验,探讨了模板实例化、共享库、线程安全性和许多其他问题。

虽然本书从基本概念开始介绍C++和面向对象编程。然而,我确信,无论是新手还是经验丰富的C++程序员,都能从实践角度在本书中学到新的知识。

Erich Gamma博士

Object Technology Internation公司 技术主管

译者序

第一次拿到这本书时,我的第一印象是挺厚实。离开学校以后,总是很难静下心来啃“大部头”。没时间的借口已经毫无新意,实际上是多看几页就觉得睡眠不足。如果你喜欢幽默风趣的行文,就不用费心思在这本书里找乐子了。没有什么比讲解概念,介绍理论更让人昏昏欲睡。起初我甚至可以想象自己边译边睡的情景,但万万没想到的是,经常从书房出来,天已经黑了。要说是作者文笔精彩绝伦,不如说是他的教授引人入胜。作者的文笔循规蹈矩,通篇教科书范儿。但是,跟着作者的思路,针对实际问题分析出解决方案的过程,引人入胜。

相信很多读者对教科书的印象很深。严格的定义、枯燥的理论、呆板的例证。然而,本书与那些沉闷的基础理论书籍大为不同。这是一本真正将理论结合实际的书,书中介绍的示例几乎都与现实生活息息相关。小至玩具书系统、文件处理软件,大至金融系统资金管理、核电站控制程序、卫星发射系统。正如作者所言,将 C++应用于解决实际的问题上,将OOP的潜力发挥到极限。作者在讲解某个知识点或介绍设计方案时,先通过简化后的示例让读者了解一些基本情况,引导读者思考关键的问题。读者通过阅读不断地积累知识,有助于理解作者在后面介绍的更好更简洁的解决方案。整本书的知识结构循序渐进、由浅入深。

虽然本书几乎从零开始介绍面向对象编程,对于 C++的相关概念也从最基础的部分讲起,但你仍然需要具备一些基本的编程经验。本书不仅深入分析 C++的相关知识点,还将其与设计策略相结合。作者讲解得如此细致和透彻,让读者知其然,知其所以然,真正做到了“授人以渔”。值得一提的是,书中介绍的很多解决方案都与设计模式相关。如果对面向对象不太熟悉,可能很难体会到其中的精髓。只有真正理解了编程思想,才能举一反三,在适当的场景运用相应的策略。否则,滥用一些技巧只会弄巧成拙,让程序更加不堪重负。

本书包含大量的代码示例和讲解,除了代码本身是英文,作者在后面的讲解会用到很多类名、对象名、异常类名、数据成员名、成员函数名、关键字等,有时英文在一页上占很大篇幅。阅读不是记忆测试,为了减轻读者的识辨负担,我在文字排版上采用不同的字体来区分(如语言 SmallTalk,一般类名 TStudent,异常类名NToAccessToMachine,函数名 IsEqual,关键字 static,等)。另外,相信不少读者都有看到脚注却找不到文中标注的尴尬。为此,我特意选择明显的字体标出(如类的对象 9)。对一些比较重要以及尚未统一译名的术语,中英并陈,英文在括号中以斜体标出(如数据封装(data encapsulation))。

另外,还需提醒读者注意的是,作者为简洁起见(突出重点),在书中所列的大部分代码只是框架,仅为了体现相应的编程思想和设计策略。如果读者要在编译器中调试,需要自行补充一些必备的函数及其实现。另外,书中的代码仅符合当时的 C++编码风格。C++发展至今,语言细节上发生了许多变化。如果读者照抄进最新的编译器,可能会无法编译或报错。然而,瑕不掩瑜。这的确是一本夯实基础、拓展思维的好书。

本书与原书页页对译,得以保留英文索引。文字、代码和图片的位置也与原书相同。

感谢傅道坤编辑,不仅热心解答我提出的问题,还一再容忍我延期交稿。特别感谢 Gott,不仅给我提供了大量的相关英文原版书籍,还认真地阅读我的译稿,提出了许多宝贵的意见和建议。感谢父母的支持和理解,让我能安逸地在电脑前敲键盘。

追求完美的痛苦是永远都不完美,要不是交稿日期一拖再拖,我可能无限期地拖下去。无论校对多少次,再看的时候总会有所改动。本书的翻译、作图、文字排版都是我。如果因为翻译或排版让读者不快,喷我好了。在阅读本书的过程中,如果发现任何错误或译文不妥之处,请发邮件至:yesenaaron@gmail.com。

作为译者,我从本书中收获颇多,希望所有的读者都能从中受益。本书无论是详尽的理论剖析,还是丰富的示例辅助说明,都可称得上是丰盛的知识盛宴。求知若渴的朋友们,请尽情地享用吧!

叶尘

2013年6月5日

致谢

本书是加州大学伯克利分校(University of California Berkeley)教学(和多家公司)的推广计划。感谢参与我所教授课程的数百名学生,我和他们中的许多人已成为好朋友。这些学生们让我从不同的角度观察事物,时常意识到自己对一些问题的简单想法是不对的。他们问我的许多问题,答案都在本书中。正是他们让我意识到,哪些是学生在学习 OOP和C++经常难以理解的地方。非常感谢Jack Grimes博士,他在百忙之中认真审阅本书,提供了很多反馈材料,他是本书创作灵感的源泉。感谢Rajiv Maheshwari、Christine Lu和Sumathi Kadur审阅书稿,感谢他们的辛勤付出。他们帮助我消除了很多bug,保证了内容的准确。感谢Rampalli Narasimhan提出的诚恳意见。特别感谢Erich Gamma博士与我分享他丰富的面向对象知识,他宝贵的建议和对Smalltalk的真知灼见是无价的。感谢Brooks Applegate博士带我进入Smalltalk的世界。

要特别提到 Taligent 有限公司的好友们。他们是对象技术专家中的杰出人士,他们与我分享的知识财富是无价的,不胜感激。与他们共事非常愉快,丰富了我的阅历。特别感谢Taligent设计原则的架构师(本书遵循Taligent使用的编码风格要素)David Goldsmith。

向我的妻子Narmada Iyer致以最真诚的感谢,感谢她的支持和爱。没有她不断的鼓励(和偶尔的不耐烦)我不可能完成本书的写作。她激励我不断前行,在我废寝忘食时提醒我注意时间。感谢她容忍我所有的癖好(很抱歉错过了假期)。

感谢Prentice Hall的优秀团队,特别是,Paul Becker容忍我一再地延期交稿。特别感谢Nicholas Radhuber耐心地接受我在最后阶段无数次地修改。没有他孜孜不倦地努力和付出,本书不会是现在这样。

非常感谢以下热心的读者指出第1 版的错误:Ojvind Bernandar、Christoph Sadil、Darstoph Wallach、Raza Muzaffar、Rakesh Garg、Anuradha Natarajan、Tom Zal、Fred Campbell、Uma Gopalan、William Ray、Nickolay Baturin、Steve Beckert、Margon Otway。特别感谢Luther Baker和Kiran Prabhakar在修正错误时提供的个人帮助。

没有Prentice Hall杰出团队的帮助,本书的第2版不可能面市。再次感谢Jeffrey Pepper和他的团队。特别感谢 Nicholas Rdhuber 编辑本书的图片和文字。还要感谢 Penny Baker在编辑过程中的贡献,你们真的很棒。

前言

面向对象软件开发已逐渐成为开发软件的首选。优秀的面向对象软件开发人员、设计人员、系统架构师对其需求与日俱增。要想成为一名成功的面向对象编程(OOP)人员必须忘却(摈弃)多年来面向程序编程的习惯,从新的角度分析问题。

面向对象编程要求程序员和设计者非常熟悉一些基本范式或概念。理解这些范式是在面向对象软件领域打下牢固基础的基本要求。支持OOP的语言都必须支持这些基本范式。换言之,学习 OOP,简单地说,就是学习许多语言(如 C++,Eiffel,SmallTalk,Java等)所支持的强大范式。本书的第一个目标是,让你在不过分深入语言语法要素的前提下,理解面向对象编程最重要的概念和原则。第一部分——概念、实践和应用,将涵盖这方面的内容。

掌握支持OOP的语言语法和学习OOP的概念不一样。对基本OOP范式一无所知的人,也能成为C++或Java的佼佼者。但是,理解OOP基本概念的人可以在任何支持OOP的语言中有效地使用这些概念。而且,他/她还知道何时加入特定的概念。任何掌握链表概念的人都会发现,它是在Pascal、C或Modula-2中实现链表的基础。比方说,如果你知道如何游泳,就可以在湖泊、池塘或游泳池中游泳。语言只是一个帮助你实现最终目标的载体。

学习OOP概念,仅仅是漫漫长路的第一个里程碑,不是程序员和设计人员的终极目标。你应该用这些概念去解决自己专业领域中的问题。财政计划人员想开发一个对象框架,以管理个人资金;商店想开发应用程序,以管理存货。但是,要把OOP的原则应用于不同的领域实属不易。例如,解决玩具课本的问题可能很琐碎,而解决某个专业领域的问题和构建系统则非常复杂和困难。学习专业领域的特定例子(例如文件系统、汽车代理管理系统、桌面排版系统、航班计划系统等)将会有所帮助。显然,我不是这些领域的专家,大部分读者也不熟悉这些相关领域,对它们也不感兴趣。这是在进行面向对象设计时,很多设计者和程序员所面临的典型问题。然而,无论涉及什么领域,总会有一些编程或设计方面(与相关领域无关)的经验原则,对软件专业人士解决复杂问题非常有用。专业人士必须对何时使用什么工具了如指掌。本书的第二部分——构建强大的面向对象软件,将通过许多简单的例子阐明一些高级OOP技术。这部分主要介绍有效使用面向对象设计的强大策略。专业开发人员并不满足于只学习一些诀窍和技巧,他们还希望深入了解每种技巧的利弊、替换方法,以及对移植和效率的影响等诸如此类的问题。本书详细讨论了各种必要和重要的技巧,学习完本书的全部内容后,不谙编程的新手也能成为该领域的专家。

尽管书中涉及一些语言议题,但本书的重点仍是概念不是语言的语法。本书在所有的主要问题中,都会讨论C++和其他面向对象语言之间的区别。将C++与其他OOP语言的特性做对比,能更好地理解OOP的概念(有时,可以帮助理解语言设计)。这些内容深入且广泛地介绍了OOP领域。尽管书中的例子都用C++编写,但并不意味着C++是唯一的选择。除 C++外,还有许多同样优秀的 OOP 语言。深入了解不同语言的设计意图会让你受益匪浅。考虑到熟悉其他OOP语言(非C++)的读者,本书从C++的角度介绍主要概念的细节,随后再讨论SmallTalk和Eiffel。

随着 C++日益流行(因为它有丰富的特性设置、严格的静态类型检查,而且支持编写工业级的软件),理想情况下,你应该精通C++和OOP的概念。当然,只学习C++语法比较乏味,我们要瞄准更高的目标。为了利用C++挖掘出OOP的所有潜力,以适应面向对象编程的开发者们需要用到很多特殊的模式、技术和技巧。目前,C++中还有许多时间测试技术,它时常能帮助我们明白为什么某些特性会以特定的方式存在于语言中,能让程序员更好地理解语言(从而更尊重语言的设计者),更加高效地使用语言。本书第一部分的后几章,将介绍这方面的内容。本书的例子展示了C++功能强大的策略。

初学者应该从第一部分开始阅读,该部分介绍范式、理论和应用程序。第一部分根据知识的相关性和难度组织章节。第1章初步介绍面向对象的基本概念;第2章详细讨论数据抽象,本章最后简要介绍了统一建模语言(UML)和Booch方法论;第3章和第4章进一步探讨对象模型和良好的接口设计;第5章和第6章详细介绍继承及其特性;第7章和第8章讨论一些简单的问题;第9章介绍泛型类型;最后,第10章详细讨论异常管理。在第二部分中,第11章阐述构建抽象的策略;第12章介绍如何高效使用继承;最后,第13章深入研究C++对象模型。

你可以随意阅读任意章节,但是,由于某些例子源于前4个章节,选读可能会破坏阅读的连贯性。第二部分的章节是独立的。

大多数实现代码都异常乏味。因此,为了突出重点保持简洁,本书尽量减少枯燥的实现代码,仅在确实对讨论有所帮助时,才在相应的位置添加一些。

本书可作为面向对象编程的入门书或高级读本,对初次接触OOP的程序员和学生有很大帮助。谙熟OOP概念的程序员会从本书的第二部分中受益良多。熟悉其他OOP(除C++外)语言的程序员,建议先阅读第一部分,再重点阅读第二部分。本书的第一部分可作为本科生OOP课程的教案,而第一部分和第二部分一起可作为研究生关于OOP和C++的研究生课程用书。本书的绝大多数内容不仅能极大地帮助初学者、初级/中级程序开发和设计人员,而且也能让资深的专家从中获益。在阅读大部分章节后,本书还可以成为你桌上的一本很好的参考书。

本书假设你已熟悉基本的C++语法,且具备普通的编程经验(仅在课堂做过练习就已足够)。关于语言语法方面,本书对 C++语言更复杂的部分(例如模板、异常和继承)做了详尽的说明。

本书第2版更正了第1版中发现的错误,更新了一些代码示例,以符合ANSI C++标准。除此之外,为了更加清楚地说明某些概念,本书第2版还重新编写了一些内容。

第一部分 概念、实践和应用

第1章 什么是面向对象编程

近年来,软件从业人员都将注意力转移到面向对象编程范式,甚至经理、主管和销售人员都对面向对象技术产生了浓厚的兴趣。面向对象软件俨然成为了万众瞩目的焦点,每个人孜孜以求的圣杯。面向对象到底是什么?它与我们已经使用了数十年的方法有何不同?软件开发者心存疑虑,他们认为,正是由于面向对象的出现,让他们历尽艰辛积累的技能再无用武之地。在这种情况下,理解下列内容会有所帮助:

●面向对象软件开发究竟是什么?

●它的优点是什么?

●它与传统软件开发方法有何不同?

●它对传统软件开发技能有何影响?

●如何才能设计出面向对象编程软件?

1.1 背景

程序员开发软件的历史已有数十年,他们使用各种不同的编程语言,如Algol、COBOL、Lisp、C、Pascal等,来实现各种规模的软件开发——从简单的小程序到复杂的大型系统。[我们把编写诸如汉诺塔的解决方法、纸牌游戏、简单的快速排序等小程序,仅作为课程学习的作业。虽然这些小程序没有任何商业价值,但能帮助我们理解新的概念和新的语言。相比之下,大型系统(如库存控制、字处理、医院患者管理、天气预报、个人资金管理等软件系统)涉及许多重大问题,这样的软件系统,需要由设计者和程序员组成的小组协同工作才能实现,然后由公司出售,赚取利润。我们从设计和实现小程序中学到的知识和经验,对解决大型问题会有很大帮助。] 日常生活中,我们使用的系统都由这些语言实现。在开发和使用它们的过程中,我们已获得许多知识和经验,为什么还要学习新的编程范式?继续阅读以下内容,答案就其中。

1.1.1 面向过程编程示例

如果给定一个问题(即问题的口头或书面说明),如何使用某种语言(如C)设计和实现解决这个问题?首先,我们将该问题分解为多个便于处理的部分,这些部分称为模块。然后,设计出许多数据结构保存数据,并实现一些函数(也称为过程、例程)来操作这些数据。函数可用于修改数据结构,将它们保存到文件中,或打印数据。在面向过程编程系统中,解决问题的办法就是将待解决之事转换成一组函数。也就是说,我们把注意力都集中在函数身上,没有函数就无法完成任何操作。这种以函数为主的编程方法称为面向过程编程(Procedure-Oriented Programming)。之所以称为面向过程,是因为它的重点在过程。这种编程方法从函数的角度来思考问题,因此也称为问题的功能分解。

注意:

在C和C++中,过程、函数、子程序和例程这些术语之间没有差别。然而,在Pascal、Modula-2和Eiffel中,函数是指返回计算值的例程,而过程是指接收某些参数并执行一项操作,但不向主调函数返回任何值的例程。在本书中,过程、函数和例程将互换使用,它们的含义相同。Algol、Fortran、Pascal 和 C 等编程语言都称为过程语言(procedural language)。

然而,经过更深入地研究面向过程的实现发现,数据结构更重要。我们最感兴趣的是保存在数据结构中的值,而非过程本身。过程只是修改数据结构的简单工具,没有数据结构,过程什么也做不了。而且,在程序的运行过程中,数据结构中的数据不断改变,而过程的代码丝毫未变。从这个角度而言,过程是静态的。我们之前费尽心思设计这些过程,殊不知重点根本不在这里。举个简单的例子,假设一个银行系统,允许客户有不同类型的银行账户(如存款账户、支票账户和贷款账户),允许客户存款、取款以及在账户之间转账。如果该系统用C实现,可以看到以下一组过程[1]

typedef unsigned long AccountNum;

typedef int bool;

bool MakeDeposit(AccountNum whichAccount, float amount);

float WithDraw(AccountNum whichAccount, float howmuch);

bool Transfer(AccountNum form, AccountNum to,

float howmuch);

我们可以规定AccontNum只为正整数,然后用一个简单的数据结构来管理账户:

// 这是一个极其普通的银行账户档案

struct Account {

char*  name;    /* 账户名 */

AccountNum accountId;

float    balance;

float    interestYTD; /* 数据利息的年份*/

char    accountType; /* 存款,支票,贷款等*/

/* 其他细节 */

};

可通过下面的函数创建一个Account结构的实例,为客户创建一个账户。

AccountNum CreateNewAccount(const char name[], char typeOfAccount);

1.1.2 银行账户的表示

创建账户的函数将返回新账户的账号。我们在研究这种解决方案时发现,客户将银行账户视为他们血汗钱的安全天堂,他们感兴趣的是账户中的资金和获得的利息,而非存取款功能。事实上,客户并不关心在银行系统中存款或取款的过程如何实现,他们只需要一种简单方便的方法完成操作。但是,作为程序员,我们却冥思苦想如何编写 MakeDeposit函数(以及其他函数),如何创建一个小型数据结构管理数据。换句话说,我们把注意力放在了客户毫不关心的问题上。正因如此,我们设计出来的银行系统导致客户与其银行账户间毫无关系,客户仅仅被看做是一系列的字符和数字。这样的系统甚至根本无需考虑核对账户持有人和账户中的内容,就可直接操控数据来操作账户。从函数角度看,银行账户仅仅是一串数字——账号。

1.1.3 银行账户的安全

进一步分析可以注意到,任何人(或者其他程序或者程序员)都可以创建一个账户,并操作它。因为账户仅作为一段数据保存,任何可以访问银行账户档案的人都可以修改它(甚至非法的)并取款。这是账户被当做一系列字符和整数的后果,保存在客户银行账户中的值没有任何保护措施。而且,也没有任何条款规定银行账户必须只能由可信任的银行职员修改,即使有,又由谁来执行?语言(如C或者Pascal)不能做到这一点,因为它们并不知道银行账户和普通整数的区别。

如果要打印客户的账户,需要添加一个新函数:

PrintAccount(Account thisAccount);

该函数将执行打印功能。但是,函数必须知道正在打印何种类型的账户(支票账户还是存款账户?)。这很容易,只需查看 accountType 中的值即可。假设开始时我们有三种账户(支票账户、存款账户和贷款账户),PrintAccount()函数理解这些类型,它将对其中的代码进行硬编码(hard coded)。到目前为止,一切运行正常。现在再添加一个新的账户类型——retirement_account。如果传递retirement_account给PrintAccount函数会出现什么情况?函数将无法正常工作。我们会看见以下的错误信息:

“Unknown acount type- Cannot Print”(“未知账户类型-无法打印”)

或者更糟:

“Illegal account type – Call Supervisor”(“非法账户类型-联系主管”)

之所以会出现这种情况,是因为在该系统中对账户的类型采用了硬编码方式,除非修改源代码,重新编译并再次链接,否则无法通过编译。因此,如果添加一个新账户类型,我们需要修改与该信息有关的所有函数,并重新进行编译——链接——测试过程。这些过程都十分冗长,而且极易出错。怎么会出现这样的问题?答案是:函数和数据结构被当做是彼此脱节的实体,因此函数难以理解数据结构中的改动。也就是说,对现有的实现进行改进非常困难。这样看来,我们只能以另一种方式建立银行系统,使得该系统在添加新账户类型不会影响其他账户类型,而且不会引起代码的重新编译。

以上谈到的问题都源于在最初的解决方案中误入歧途。我们将重点错误地放在自认为很重要的函数上,彻底忽略了对客户(即银行客户)而言更为重要的数据。换言之,我们之前全神贯注于如何做,其实应该将重点放在做什么上。这就是面向对象编程和面向过程编程的区别。

1.1.4 用面向对象编程解决问题

如果用面向对象编程(object-oriented programming)技术解决此银行账户的问题,我们会把注意力放在银行账户上。首先分析客户想用这个账户进行什么操作,对他们而言什么最重要,诸如此类的问题。简而言之,在面向对象编程中,重点是正在被操作的数据,而不是实现这些操作的过程。我们应该先找出用户(该例中是银行客户)想用数据(即银行账户)进行什么操作,然后再为其提供必要的操作。而且,数据和操作不像以前那样被当作彼此孤立的实体,现在它们被看做是一个整体。数据带有一组操作,允许用户对数据执行一些有意义的操作。同时,任何外部程序或过程无法直接访问数据本身。修改银行账户内数据唯一的方法,就是使用为修改数据而提供的操作,这些操作为用户提供修改银行账户的行为。现在,我们可以说,银行账户是一个类(class),我们可以创建任意数量的银行账户实例,每个实例都是银行账户类的对象(object)。因此,面向对象编程是一种由合作对象(cooperating object)(就是类的实例)构成程序的编程方法。而且,多个类之间可通过继承关系相互关联[2]

在面向对象编程中,关键要理解类和对象的概念。类是一个实体(entity),它拥有一组对象的共同属性(特性)。对象是类的实例(instance),类的所有对象都具有相同的结构和行为。可以把类看做是切甜饼的工具,而甜饼就是切甜饼的工具所创建的实例。这个比喻可能有些粗浅,但类似于类创建对象的过程。切甜饼的工具决定了甜饼的大小和形状(不是味道)。与此类似,类决定了创建对象的大小和行为。在面向对象的解决方案中,一切皆为类的对象[3]。这样看来,在面向对象编程中,我们只需考虑类和对象、类之间的关系,以及对象之间的关系[4]。Shalaer-Mellor[5]以另一种方式解释了面向过程编程和OOP(面向对象编程)之间的区别——功能分解按顺序提出了系统设计中的三要素(算法、控制流和数据描述),而在OOP中,此三要素的顺序完全相反。

现在,重新回到银行账户的问题上。我们重点关注的是银行账号,将它设计成一个类。在 C++中,BankAccount 类的框架如下所示。这里再次提醒读者,请不要过多担心 C++的语法。

class BankAccount {

public:

// 为简化起见,此处省略若干细节

void MakeDeposit(float amount);

float WithDraw();

bool Transfer(BankAccount& to, float amount);

float GetBalance() const;

private:

// 供BankAccount类使用的私有数据实现

float  balance;

float  interestYTD;

char*  owner;

int   account_number;

};

对客户(即希望打开银行账户和使用它的人)而言,最重要的是该类中 public 区域内可访问的操作(在图1-1 中以粗体表示)。而类中private区域内的声明只能在其内部使用,客户不可从外部访问。例如,MakeDeposit操作的实现如下所示。再次提醒读者,请不要担心C++语法。

// BankAccount类其中一项操作的实现

void BankAccount::MakeDeposit(float amount)

{

if (amount > 0.0)

balance = balance + amount;

}

只有在BankAccount类内部声明的操作,才能访问私有成员balance(和其他私有成员)。类的外部无法使用私有成员。我们将这种私有数据称为被封装的数据,以这种方式隐藏在类的内部称为数据封装(data encapsulation)。我们将在第2章中详细介绍数据封装。

1.2 理解对象模型

理解面向对象编程(OOP)范式最大的困难之一,是理解类和对象的概念。要灵活运用OOP,必须充分理解这些概念。

OOP 中的基本实体是类。我们继续以银行账户为例,每个 BankAccount 类对象都有相同的结构和行为。因此,所有的BankAccount类对象都可以使用MakeDeposit以及类中声明的其他操作(即可以通过类的任意对象使用类中的任何操作)。同时所有对象都有自己的私有数据副本(balance、account_number等)。各对象之间的差别取决于保存在私有数据中的值。例如,BankAccount类的alpha对象中,balance的值可能为500;而BankAccount类的另一个beta对象中,balance的值可能为10000,如图1-2所示。

对象是类的实例(如切好的甜饼)。类仅在程序的源代码中可见,而对象则参与到程序的运行中,它是类的活实例。对象占据内存空间,我们可以感受到对象的存在。创建类对象的过程称为实例化对象(instantiating an object)。

在面向过程编程中,我们总是讨论调用过程,总是以“调用过程 X(或调用 X)和调用Y”来谈论问题(这里的X和Y都指过程)。但是,在OOP中,我们说“通过某对象调用X”,而决不会说“调用X或调用Y”。假设有一个BankAccount类的对象myAccount,我们说“通过myAccount对象调用MakeDeposit过程”。换言之,过程通过对象调用。

没有对象,不能简单地调用过程。我们绝不会说“调用过程”,而必须说“通过对象调用过程”。操作(例如存款)必须通过对象调用过程(即函数)才可完成,不可能直接修改他人的数据。

在面向过程编程的模型中,程序员查看数据结构时,很难明白数据结构用于完成什么工作以及如何使用,甚至不清楚数据结构代表什么。由于数据结构并不是一个智能的实体,它的目的、用法和限制都难以理解,一些用于正确使用数据结构的信息被深埋在其他函数中,而这些函数又在别处。面向对象编程不会陷入这样的困境,因为类所要完成的工作全都以公有操作的形式清楚地陈述,而且在别处也不存在独立的数据结构。事实上,客户看不见独立的数据结构,客户所能见到的是类和一组清晰定义的操作。数据结构(隐藏在类内部)以及它能执行的操作已构成一个整体。换言之,客户利用类可做之事已在类中详细地说明,客户无需到别处查找。

1.3 术语

接下来,本节将介绍在类中的所有操作和数据的术语(见表1-1)。

C++

在C++中,类内部的函数称为成员函数(member function),类内部的变量称为数据成员(data member)。成员函数和普通函数类似,但前者属于某个类,因此前者是类的成员函数。同样,变量保存的数据属于某个对象,因此变量是数据成员。

Eiffel

Eiffel(和Ada)将函数称为操作(operation),将变量称为属性(attribute)。在Eiffel中,通过属性区别类的变量,但是在对象内部,属性被称为字段(field)。之所以称函数为操作,是因为客户通过对象使用它们来完成操作;之所以称变量为字段,是因为Eiffel中的对象与Pacsal中的记录(record)类似。

Smalltalk

在Smalltalk中,将函数称为消息(message),将变量称为实例变量(instance variable)。

1.4 理解消息、方法和实例变量

类的任何用户(通常是另一个程序,甚至是另一个类)都是类的客户。客户通过类的对象使用成员函数(消息)进行有用的操作。在后面的章节中我们将介绍,客户只能创建类的对象和使用这些已创建的对象,或者客户也可以根据现有类通过继承创建新的类。

Smalltalk

在 Smalltalk 中,调用对象的接口函数(成员函数)被视为向对象发送消息。我们向BankAccount类对象发送MakeDeposit消息,要求它接收存款。向对象发送消息将引起该对象中的方法被执行(即当我们发送消息时,对象会执行特定的方法或函数)。也就是说,对象响应了消息。消息只是客户所见的一个名称,而且该名称可能在运行期被绑定在正确的实现(方法)上,该实现在接收消息的对象内。类的每个实例(即每个对象)都包含实例变量的单独副本,如图1-2所示。

注意:

术语方法(消息)、操作和成员函数在本书中将互换使用,实质上它们的含义相同。

1.4.1 对象中包含的内容

每一个被创建(或者实例化)的对象都获得自身数据成员的副本,数据成员(静态数据成员除外)都是不共享的。稍后我们会介绍,在 C++的运行程序中,只有静态数据成员可以在类的对象之间共享。Smalltalk 也支持共享数据成员[6]。什么是成员函数?是否每个对象也能得到每个成员函数代码的副本?很显然不是这样。每个对象都能对声明在类中的所有成员函数作出响应,但是对象本身并不包含成员函数实现代码的副本。至少在运行程序(进程或者任务)中,只存在成员函数实现代码的一份副本。无论在进程中创建了多少个类对象,成员函数的代码均不会被复制,这些代码在类的所有对象之间共享。为了便于理解,可以想象类的实现代码驻留在一个库中。许多实现都可以进一步优化,它们只会为整个系统保留唯一一份实现代码的副本,这通常使用动态共享库完成。这些细节都由操作系统具体规定。例如,我们可以用Card类来表示纸牌游戏中的一张牌,如下所示:

enum Suit { Clubs, Diamond, Heart, Spade, Unknown };

enum Rank { Two, Three, Four, Five, Six, Seven, Eight,

Nine, Ten, Jack, Queen, King, Ace, Invalid };

enum Color { Red, Black };

class Card {

public:

void FaceUp();   // 牌正面朝上

void FaceDown();    // 牌正面朝下

// 其他成员函数

Card(Rank r, Suit s); // 按此格式创建一张牌

private:

Rank  cardRank

Suit  cardSuit;

Color  cardColor;

};

如果我们需要52张纸牌,那么就应该创建Card类的52个对象,每一个对象都有自己的数据成员cardRank, cardSuit和cardColor的副本。

Card myDeck[52];  // 创建一副标准牌,52张

纸牌中的每张牌都可以单独操作。我们也可以按如下方式实例化一些纸牌对象,见图1-3:

Card    spade_Ace(Ace, Spade); // 黑桃A

Card    clubs_2(Two, Clubs);  // 梅花2

Card    diamond_Jack(Jack, Diamond); // 方块J

1.4.2 实例化(或创建)对象

一旦设计并实现了一个类,程序员在需要使用该类的对象时,即可通过代码实例化它们。各种语言实例化对象的方式不同。在C++中,对象的实例化看起来是一个简单的声明,如下所示:

BankAccount myAccount;

在Smalltalk中,为初始化类的新对象,程序员必须给该类发送预定义的消息new,如下所示:

BankAccount new.

在Eiffel中,必须用预定义的操作make来创建一个新对象。

myAccount : BankAccount; - 只是声明,未创建对象。

myAccount.!!make; - 创建对象

1.5 什么可以作为类

用简单的例子详细讨论类和对象非常容易,但是难点在于如何为给定的问题找出合适的类。我们必须理解类代表什么,何时将问题中的某些部分转化为类,而非数据,反之亦然。根据我们的定义,类拥有一组对象的共同属性(或者特性)。怎样的共同才是共同?何时说这是一个类,而不是另一个类的对象?这些都是我们在学习OOP时会遇到的,和真正关心的问题。

当我们决定创建一个类时,第一个问题就是“是否确实需要这个类的多个实例?”,如果答案为“是”,那么我们的方法就可能正确——至少粗略看起来正确。如果发现类的实例之间没有绝对差别(也就是说,每个实例和其他实例相同,而且它们的行为完全一致),那么,我们可能错误地创建了这个类,应该将这个类创建为一个值。例如,如果在处理鲜花问题时将Color作为类,而每种颜色都可以用唯一的数字代表,那么,将Color设计成一个类就毫无意义。但是,如果我们在图形系统中处理颜色(涉及复杂的颜色计算),则必须将Color定义为类,因为基于颜色的组成(红色、绿色和蓝色),每种颜色都具有许多成分和深浅变化。更重要的是,可以通过控制 Color 的成分来控制颜色。这说明,在图形系统中,Color不仅仅是一个值,它还包含许多依附于它的行为。

再来看看另一个例子,家庭地址(或者Address)可能被看做是字符(或者值)数组。但是,Address 在电子邮件系统中可能是一个类,它包括域名、计算机名称等,而且地址还反映了用于消息传送的路由性质。很明显,在这样的系统中,我们不能将地址作为字符数组,它是一个真正的类。

记住,不可能一次设计就能一步到位。在第一轮设计中作为类的部分,可能在第二轮设计中改为数据,反之亦然。解决问题的方案在不断变化,最终的解决方案很可能与最初设计截然不同。

这都源于类的重要特性。类不只是容器,不能认为它仅仅用于存储被函数修改的数据。类为客户提供了复杂实体的简化版本,并允许客户通过操作类的对象完成有用的工作。类不只是将它的各个部分简单地组合起来而已。类决定如何完成任务,它清楚地陈述了该类的对象可以做什么。回顾 Color 的例子,如果在图形系统中,只有红色、绿色和蓝色,对客户而言意义不大。Color类需要完成的任务是将各种颜色成份组合起来,并为其添加值来表现这些颜色。同理,银行账户不能只包含字符和浮点数字,银行账户类应该允许客户更加方便、安全地管理他们的金钱。

1.6 什么不是类

理解何时不能将某些部分设计成类也非常重要。将函数组合到一起是把模块(或者把简单的 C 头文件)转化为类的效果,这不是真正的类。其实,只需从模块中取出函数,并让它们成为公有成员函数,你就拥有了一个类!类并不仅仅是一组函数,它的含义比这多得多。

例如,考虑一个模块,它实现一组数学函数,如开方、求幂、求倒数等。有人可能试图(错误的)将这个模块转换为MathHelper类。

class MathHelper {

public:

double Sqrt(double aNumber);

double Power(double aNumber, int raiseTo);

double Inverse(double aNumber);

private:

// 一个私有数据也没有!

};

这里的问题在于,MathHelper类的内部没有任何数据需要管理。客户调用一个成员函数,并为其提供实参,成员函数利用客户所提供的实参完成必要的计算。但是,在计算时,成员函数无需类的帮助,类中也没有任何数据供成员函数使用。函数只是简单地组合到一起(没有必要),它们没有共同点。这样的类包含的是一组函数(代码),没有数据。较好的方案是创建Number类,并为Number类提供操作。

class Number {

public:

Number Sqrt();

Number Power(Number raiseTo);

Number Inverse();

Number Absolute();   // 计算绝对值

private:

// 内部表示,用于存储数字

};

在这种情况下,Number类控制了数字的内部表示。因为客户并不了解这些内部表示,所以从逻辑上讲,是类提供了所需的操作。

进一步分析这个设计,我们可以构思一个继承层次,用于代表不同类型的数字(实数、整数、复数等)。这些类型可以成为Number的派生类(或子类),如图1-4所示。

继承将在第5章和第6章中讨论。

类似地,一个 C“结构”不能直接成为一个类。不能将结构修改成类,让所有数据成为私有数据,然后添加一组函数来获得和设置数据成员,这不是类。类不只是一组允许客户获取和设置数据成员值的函数。数据封装隐藏了类中的数据,而且通过成员函数提供更高层次的抽象。如果只是让函数读写结构中的数据,其实并未简化任何东西。只包含获值函数(getter)和设值函数(setter)的类是糟糕的设计[7]

1.7 类的目的

设计类的目的必须非常明确。一个优秀的类应该易于理解和使用,必须清楚地向客户说明其目的。我们不能创建一个 Color 类,既代表花的颜色又代表图形系统中的颜色,它们有完全不同的要求。设计人员没必要为满足一组客户而在类中添加成员函数,这样添加的成员函数总体上与类要完成的任务无关。换言之,每个类都是为特定领域的某种用途而设计的。例如,考虑下面的Person类:

// 糟糕的设计

class Person {

public:

Person(/*一些参数 */);

~Person();

char* GetName() const;

char*GetAddress() const;

unsigned long GetBankAccountNumber() const;

//...

};

GetBankAccountNumber成员函数和类的抽象无关。我们如何知道一个人一定有银行账户?为此,我们需要的人必须是一个银行客户。

有时候,设计人员加入一个成员函数是因为它不适合放在其他地方,或者他们发现某成员函数只能在某受限环境中使用。但是,这样做会破坏抽象,而且让客户很困惑。这个类的设计人员可能混淆了抽象的Person和银行客户。

通过设计人员对类的描述,可以判断该类是否是设计良好的类。这也成为判断优秀类的一个标准。对于优秀的设计,只需用一句话即可描述出类的目的。如果需要用两段文字来描述,则说明这个类中的功能太多(可能互相矛盾),应该将其分解为许多更小的类。另一个标准是类中成员函数的数量,设计优秀的类所包含的成员函数应该介于15~25个。

当客户查看类时,应该很容易明白它能完成的工作和无法完成的工作。类必须简明扼要地说明它的功能和局限性。如果在查看类之后,客户仍不解其用途,则说明类的设计很糟糕,而且很可能是错误的,需要重新设计。

类(实际上是类的对象)负责履行其功能。当设计出类后,它(实际上是类的设计人员)便对客户承诺了它的功能。程序在执行时,类会负责管理一些细节,因此客户不用担心。例如,在进行存款和取款时,BankAccount类负责计算和处理正确的余额。类(对客户)的承诺是,当客户使用BankAccount类的对象管理客户账户时,在客户只使用类已公开方法的前提下,类保证其银行账户的正确性和安全性。BankAccount类在履行它的职责时,可能会与其他类合作。客户可能不会关心这种合作,但是,作为类的设计人员必须非常清楚这些细节。类之间的合作将在后面章节中介绍,我们将根据本章的内容循序渐进地学习如何设计类。

1.8 深入了解对象

如前所述,对象是类的实例,对象赋予类生命。换言之,可以通过实例化一个对象,并对其进行操作,体验类可以完成的功能。对象很聪明,它知道可以做什么,不可以做什么。另外,对象还知道如何修改和维护它的数据成员。

因此,区别类和对象是一个逻辑问题。简单地说,对象是带有状态和行为的活的实体。所有类对象的行为都定义在类中,而状态则由对象单独维护。状态和行为这两个词非常简单,但应用于对象上却意义深远。

1.8.1 对象的状态

为了讨论对象的状态,我们再回顾一下银行账户问题。每个BankAccount类对象中都有一个balance数据成员。假设我们不允许客户的账户透支,那么,只需声明账户中的余额不允许小于0。这是任何银行账户对象的已知性质(property)[8],我们不必检查对象的状态来确认这个属性。也就是说,这是每个银行账户对象的静态性质(static property)。

然而,在BankAccout类对象生存期内的任何时候,账户中的余额都是balance数据成员中的值。该数据成员的值随着账户的存款、转账、取款不断变化。因此,账户余额是一个动态变化的值。换言之,balance 数据成员是一个动态值。对象的状态是所有静态性质以及这些静态性质的动态值的集合。性质是一个对象独有的特征(feature)或质量(quility)。例如,注册号可以作为汽车的一个性质,每辆汽车都有一个注册号(其值是不同的);与此类似,名字可以作为人的一个特征,每个人都有一个名字(尽管不是唯一的)。

对象的状态不仅仅是(通常不是)简单的数据类型。许多对象中还包含其他对象作为自身状态的一部分。例如,Car类对象会包含Engine类对象,作为自身状态的一部分;而Bank类对象会包含BankAccount类对象和Customer类对象,作为它状态的一部分(见图1-5)[9]

1.8.2 对象状态的重要性

你可能会疑惑,为何要如此关注被封装对象中的数据部分?因为对象如何响应我们的命令(操作)以及对其他对象(客户)做什么,都直接依赖于对象的状态;执行某方法所得的结果也直接依赖于对象的状态。例如,向BankAccount类对象发送WithDraw消息时,将依次发生以下步骤:

(1)检查核实账户是否属于调用操作的人。

(2)如果请求的总额大于当前余额,则打印错误消息,并返回调用程序。

(3)否则,从余额中减去提款数额,并返回。

以上的每一个步骤都需要知道对象状态的信息,每种方法都依赖于对象的状态。这些方法都假定对象的状态是正确的。如果对象的状态不正确(由于一些未知原因),对象的行为将无法预测。

洗衣机可以作为讲解状态的另一个例子。当我们按下“WASH”按钮时,机器利用对象中的某些数据成员检查门是否关闭(可能还包括检查是否装入了衣物)。如果未关门,设备将不会运转。大多数洗衣机利用传感器(一个简单的开关)来检查门的状态,用户无法直接操控这个开关(它是一个封装的数据成员)。如果一个冒失的用户不小心接触到这个开关,并操作此开关,那么洗衣机将被蒙蔽,(错误地)相信门已经关闭。如果现在用户按下“WASH”按钮,洗衣机肯定会运转,也许还会混合洗涤剂、衣物和水。之所以出现这样不可预测(或不希望出现)的行为,是由于对象的状态被非法改动。一个设计良好的类的实现不应该允许客户直接访问对象的状态。状态只能由成员函数修改,而客户只能通过操作对象来使用成员函数。同理,如果用户在未关门的情况下按下COOK按钮,微波炉会出现什么情况?微波炉不会启动。因为微波炉在启动磁电管(产生微波的设备)之前会检查门是否关闭,如果门未关闭,则不会启动设备。这与洗衣机中使用传感器确认门是否关闭非常类似。但是,如果客户可以操控这个开关并执行关闭(并未真正关门),然后按下COOK按钮,即使微波炉的门实际上未关闭,微波炉也会启动磁电管。因为它相信门已经关上了,这可能会对站在微波炉附近的人造成无法弥补的伤害。

1.8.3 谁控制对象的状态

如前所述,对象的状态通过成员函数修改。然而,并不是所有的方法(成员函数)都可以修改对象的状态——一些方法仅允许使用状态中的值(如 BankAccount 中的GetBalance)。类中的每一个方法都会对对象的状态进行一定程度的假定,这样的假定可在文档中说明,也可在代码中说明。而且,类假定无法从外部修改对象的状态——只有在成员函数内才能修改对象的状态。成员函数非常了解用对象的状态值可以做什么,也非常清楚如何改变对象的状态值,正是成员函数控制了对象的状态[10]。注意,成员函数代表客户执行操作,这点很重要。客户调用一个操作(即向对象发送请求),操作便完成一些有意义的工作。通常,方法由客户启动[11],它不会自己执行。

成员函数也了解对象状态的约束(或限制)。再次以 BankAccount 类为例,BankAccount类中的所有方法都清楚对账号的约束,即任何账号中的余额都不能小于0。该类中的每一个方法都强制执行这个约束。如果对象的状态不是从成员函数内部进行修改,那么对象的行为将无法预测,这就是洗衣机例子中未关上门就能启动的问题。再举一例,如果有人非法侵入他人的银行账户,将其balance设置为0,则该账户真正的持有者将无法从账户中提款,因为该账户的状态显示余额为零。语言无法阻止这种恶意地侵入,但是,它可以防止出现意外错误。这种保护通过将balance数据成员设置为私有来完成。换言之,所有不让普通客户访问的数据都应封装在类的private区域中。这也称为数据封装(data encapsulation)。

创建类时,一定要为其进行封装。为确保正确的行为,类的对象需要一些内部信息。没有任何数据成员的类(不是抽象基类)是糟糕的设计,它说明该类创建的对象没有任何状态。我们将在第2章详细讲解数据封装。

1.8.4 对象的行为

客户通过类的对象使用方法来进行有意义的操作。对象的行为在某种方式上是对客户调用消息的响应。行为是对象对消息采取的行动和做出的反应。消息会引起状态的变化,也会引起发送更多的消息至其他对象,或两者兼有之。当客户向对象发送消息时,为了完成操作,该对象可能向另一个对象发送其他消息。例如,BankAccount 类对象在收到Withdraw消息时,为了记录当前的交易,可能要向TransactionLogger类的对象(假设为tl)发送消息;为了保存当前交易,对象tl可能还要向数据库(也许位于其他城市)发送消息。很明显,向对象发送一个消息,可能引起向其他对象发送别的消息,还可能会出现其他对象发送某个消息至原始对象(甚至是递归地)。行为在对象对消息作出响应时,记录外部可见的动作。这就是客户从外部所感知的情况。

有些消息可能会引起状态的变化,有些可能不会。在C++(和Eiffel)中,可以清楚地识别不会引起任何状态变化的消息[12]。在类的文档中,必须为每个方法都清楚地记录该消息能完成什么任务(从客户的观点来看)。作为设计者,我们这样设计的目的是,在不暴露类实现细节的前提下,为客户尽可能多地提供类的信息。

1.9 面向对象软件开发的阶段

1.9.1 面向对象分析(OOA)

很显然,软件工程不是从研究一组类或对象开始的。我们从简单描述问题开始(大多数都不完整),这是面向对象软件开发过程的起点。在这一阶段中,我们要找到合适的类。记住,为了提供良好的解决方案,大多数复杂的问题需要的不只是一个类,而是许多类。一组类可以互相通信、合作和协作,以完成最终的目标。问题是我们如何找到(甚至创造)所需的类?这可能是面向对象软件开发过程中最困难的一步,这一过程占据了相当长的时间。由于问题的说明不完整,或陈述的问题通常是面向实现而非面向问题,导致很难找出能设计成类的部分。

面向对象分析(Object-Oriented Analysis,缩写OOA)涉及从类和对象的角度分析问题,这些类和对象都要从问题领域(problem domain)中找出。但是,这些类并不是在最终实现中能直接使用的类。整个过程基本上是一个建模练习,即尝试建立问题领域的模型。本阶段的任务主要是,彻底地分析问题和明确地指定要求。要在客户的问题领域找出类和对象,并用其完整地描述什么方案可行,什么方案不可行。换言之,我们应采用客户能够理解的类和对象来描述问题。这些类和对象都可以直接在问题领域中找到。

接下来要思考的是:问题领域这个术语是什么意思?任何待解决的问题都与一个或多个(通常未知)领域相关,我们需要寻求熟悉这些领域的人提供专业知识和技术方面的帮助,才能提出解决方案。例如,为银行管理事宜设计一个解决方案,需要寻求银行管理人员的帮助。此时我们认为,这个问题属于银行(或金融)领域。简而言之,问题领域就是问题所属的区域或部门。问题领域(或简称领域)的范围很广,可以是浴室改建、自行车制造、机械设计、收款账户、库存管理、FDA审批、生物-物理仿真、网络化、用户接口、动画、财政、办公自动化、分布式计算、数值分析、计算机通信、数据库管理等。一个人不可能精通所有的领域,因此在没有其他人的帮助下,无法解决不同领域的问题。

即使是面向对象软件开发的专家,要解决银行管理的问题,也需要银行管理人员的帮助。银行管理人员非常清楚银行系统的需求,也知道当前系统的缺点,因此他们能提供大量有用的信息,是名副其实的领域专家。但是,通常这些专家对编程一无所知,不具备编程技能。

根据领域专家的介绍,熟悉面向对象软件开发的人就能提出解决方案。如果没有专家的帮助,不可能设计出优秀的面向对象(OO)方案。要解决任何与现实相关的问题,都需要领域专家和面向对象软件专家的密切合作。

OOA 阶段的成果不可能完整。我们在该阶段中(即在开始实现之前),进行了大量的工作,仅仅提出了一个解决方案的框架。但这是个很好的开端。注意,在OOA阶段中,我们应该将注意力放在问题领域中使用的类,而非实现中使用的类。实现的细节将在面向对象设计(OOD)阶段实现。

通过以上OOA的分析可知,类与生俱来。在现实生活中,我们每天都遇到各种不同的类:邮局、邮箱、账单、管理人员、鲜花、报纸、微波炉、CD唱机、父亲、母亲和汽车等。这些都是我们在日常生活中打交道的对象。我们很清楚这些对象可以做什么,他们的目的是什么。在软件开发过程中,就涉及为问题领域中已知的对象建模。我们可以将现实生活中了解的对象映射到问题解决方案的逻辑视图中。例如,在一个公司管理的问题中,可以将公司的职员当做对象。类似地,再次使用类和对象,将公司的薪水建模为薪水管理系统。由于我们已经很好地理解了所涉及的类和对象,因此在软件解决方案中,通过对现实世界的仿真更容易解决问题。这也能帮助我们控制(和管理)问题的复杂程度。现实生活中对象有一定的局限性。例如,我们不能要求邮递员向我的叔叔送花[13],也不能要求微波炉播放CD。我们很清楚现实生活中这些对象的能力和局限,这些同样也映射在我们的解决方案中。在一些情况下,这种映射很简单(如在公司管理系统或者银行账户管理系统中)。但是,在某些情况下,特别是在针对计算机软件的问题中,我们通常无法确定对象,因此要找到这些对象相当不易。例如,很难为一个中断管理系统找到一组类,因为这样的类没有任何对象。因此,在建立解决方案的框架之前,我们需要在OOA阶段彻底地分析问题。由此可见,要解决涉及交易处理的问题绝非易事。

1.9.2 面向对象设计(OOD)

OOD(面向对象设计)阶段在OOA(面向对象分析)阶段之后,在本阶段中,我们将在OOA阶段开发的框架中加入细节,待解决的问题将被分解为类和对象用于实现中。本阶段需要完成的任务包括定义类之间的关系、描述对象如何才能适应系统的进程模型、如何跨地址空间划分对象、系统如何完成动态行为等。OOD阶段的成果将更加清楚,而且更容易理解。尽管如此,它仍然是不完整的。由于设计的实现尚未完成,因此我们对解决方案的真实行为还一无所知。

找到那些难以琢磨的对象

为实现一个解决方案,找到(实际上是创造和发现)一组正确的类并不简单。事实上,这是最困难的事情。在这个过程中,你可能不得不使用所有的相关指导原则和被证明行之有效的方法(并透彻地理解问题),来生成一组初始的类。一般情况下,可将问题中的下列实体转化为类:

人,位置和东西;

事件——鼠标输入、出生、死亡等;

交易——同意贷款、汽车销售等;

人所扮演的角色——父亲、母亲等。

对于较简单的问题,设计人员通过将名词作为类,将动词作为这些类的方法,即可获得关于类的线索。但是,在用语言描述此问题时,却很容易将名词和动词的角色完全颠倒。因此,通过这种途径获得的类的线索,在探索过程中仅作参考。

OOA和OOD都遵循一些现有的设计方法论。方法论(methodology)使用一些表示法来表示类、对象以及它们之间的关系。它支持系统不同模型(逻辑和物理)的描述,是设计过程中不可或缺的基本工具。表示法像是设计团队都明白的一门共同语言,为设计团队提供了共同的词汇,方便交流。一些正在使用的流行方法论如下:

(1)Booch方法论[14]

(2)Rumbaugh方法论[15](也称为OMT-Object建模工具)。

(3)Shlaer&Mellor方法论[16]

((1)和(2)现在合并为统一建模语言(Unified Modeling Language),将在第2章中介绍)

注意,仅学习一种设计方法论并不能成为OOD专家。方法论只是一种用于表达设计思路的工具,它能让其他人更好地理解使用者的想法和思路。方法论可以在设计过程中提供帮助,但是对发现类和实现类却无能为力。设计小组成员的知识和经验是无可取代的。

OOA和OOD都不是C++语言所特有的[17],它们是解决任何面向对象问题的基本方法。事实上,OOA 和 OOD 并不依赖于任何语言。不过,如果能预先知道在实现中将使用何种语言,会有所帮助。在设计阶段,有可能出现这样的情况:要使用多个类之间的特殊关系,但某些语言并不支持类间的这种关系。例如,设计中要用到的多重继承,但Smalltalk并不支持多重继承。此时,如何用Smalltalk来实现多重继承成了大问题,解决这样的问题需要程序员付出相当大的努力。因此,事先了解(如果有可能的话)实现中所使用的语言,对设计有很大帮助。另外,在设计环节中,不应该使用某种语言所特有的语法小细节。设计应尽可能独立于语言的特定要素。令人欣慰的是,OOA 和 OOD 几乎独立于任何语言,可以在客户选定的任何面向对象语言中实现设计的方案。

1.10 面向对象编程(OOP)

这是面向对象软件开发环节的最后一个阶段。将 OOD 阶段的成果输出,将其输入至OOP 阶段中。这个阶段,将用选定(或根据项目要求指定)的语言编写真正的代码。如前所述,面向对象编程是一种由合作对象(就是类的实例)构成程序的编程方法,可通过继承关系设计出相关联的类。

1.11 对象模型的关键要素

到目前为止,在我们讨论的问题解决方案中,建立对象模型的关键要素是:

●数据抽象

●封装

●层次

我们将在后续章节中详细介绍这些要素,在这里先做简要说明。

数据抽象(data abstraction)是为了强调对象的相似性,忽略其差异性来定义类。在表现类(抽象)的主要特性时,应避免展现那些不重要的和分散注意力的元素。实际上,类就是一个抽象。顺带一提,抽象将重点放在对象的外部视图上,并将对象必不可少的行为从内部的实现细节中分离出来。我们将在第2章中详细讨论数据抽象。

封装(encapsulation)(或信息隐藏)是为了隐藏抽象的内部实现细节。它将抽象的外部接口从内部实现细节中分离出来。封装和抽象彼此互补。一个设计良好的抽象会封装一些成员,而被封装的实体则帮助抽象保持完整性。需要注意的是:抽象先于封装。另外,只有在开始实现时,才应该将注意力放在封装上。

层次(hierarchy)是为了支持抽象的有序。抽象很强大很有用。但是,在绝大多数重大问题中,我们最终都会由于创建了太多的抽象,以至于无法统观大局。虽然封装和模块在一定程度上能缓解这一局面,但是,我们仍陷入了不计其数的抽象迷雾中。人的思维一次只能理解一组有限的抽象,提出太多抽象对读者而言简直就是一次信息保留测试,实在让人难以消化。为了避免这些不利因素,我们可以将这些抽象安排在不同的层次,这样即便尚未充分理解抽象的主要特性,也可完全明白某层次中的所有抽象。要构建这样的层次实属不易,要有效地实现它们则更加困难。

在OOP中普遍存在两种层次。继承关系支持类层次(class hierarchy)(is-a关系,见图1-6);聚集关系(aggregation relation)(has-a关系,见图1-7)支持部分-整体概念。继承用于描述一般-特殊关系,而聚集用于描述涉及包含(containment)和共享的关系。

继承(单一继承和多重继承)是OOP中最强大的范式之一。OOP的绝大多数强大功能以及代码复用(reuse),都源于继承关系。BankAccount 可作为继承的一个例子,我们将BankAcount作为父类(或基类),SavingAaccount、CheckingAccount、LoanAccount等作为子类(或派生类),在层次中安排不同类型的银行账户。客户无需担心不同类型的银行账户,只需观察BankAccount父类的接口,即可大体了解其他类型的账户。

汽车可以作为聚集的一个例子,汽车由发动机、轮胎、座椅等组成。我们说 Car 有Engine、一组Wheel和固定数量的Seat等。对于普通用户而言,虽然Car的内部由许多其他对象组成,但是Car仅作为单个对象出现。而且,客户并不关心Car类对象如何与内部对象通信,如何控制内部对象。应该由Car的成员函数来管理它所包含的对象。

再举另一种 has-a 关系的例子,假设我们在 BankAccount 类的示例中再添加一个BankAccount 类,让它们成为 Bank 对象的联合(association)。很显然,BankAccount类对象没有拥有Bank类对象,它与Bank类对象只是有关联而已。实际上,BankAccount类对象和许多其他BankAccount类对象共享相同的Bank类对象。由此看来,has-a关系并不总是意味着一个对象在物理上包含另一个对象,在该例中它只表示各类之间的某种联合。

还需注意,所有BankAccount类对象都只能使用公有接口(public interface)与Bank类对象通信,它们相当于Bank类的客户。对于Bank类对象,BankAccount类对象并无任何特权。以上讨论的两种聚集还有一些其他含义,在下一章中我们将作详细介绍。

聚集有助于简化接口和共享对象,在后续章节中将详细讨论继承和聚集。

1.12 OOP范式和语言

数据抽象、封装和继承层次都是面向对象编程范式的基本概念。它们不是个别语言的专属特性,任何声称支持OOP的语言都必须支持这些范式。此外,对学习这些概念感兴趣的人不必担心特定的语言,完全可以从总体上理解这些概念(这样做甚至更好),不要将学习的重点放在钻研支持某一特性的语言语法上。一旦设计者或程序员理解了这些概念,无论学习哪一门语言的语法都非常轻松。这类似于驾驶汽车,一旦你掌握了如何驾车以及了解其中的组件如何工作,就可以把车开到任何地方——需要学习的新内容仅仅是交通规则而已。

1.13 面向对象编程语言的要求

现在,可以从支持面向对象编程的角度来研究编程语言了。什么语言可以成为面向对象编程语言?

任何声称支持面向对象编程的语言都必须提供易于设计和实现的特性:

●数据抽象

●封装

●继承

所谓“易于”,指的是抽象和封装必须自然而然,不需要程序员在这上面花太多功夫。程序员通过语言的要素,应该很容易地实现优秀的抽象和提供数据封装。必须牢记,这些语言是设计用于支持OOP的。

对于 OOP,继承是另一项非常重要的特性。不支持继承的语言不能成为面向对象编程语言。某些语言支持数据抽象和封装,但并不支持任何形式的继承。这样的语言不是面向对象编程语言,它们被称为基于对象语言(object-based language),虽然可以实现对象,但是,却无法通过继承扩展它们(如Ada和Modula-2等都属于这个范畴的语言)。注意,在C中也可以进行数据抽象和某种程度的封装。然而,这样的抽象和封装需要程序员做大量的工作,它并不是语言本身所具有的。实际上,OOP在C中可以实现,甚至在汇编语言中也可以实现!这是一个实用性的问题。如果你打算使用OOP,直接使用支持OOP概念且检查机制较多的语言更为实际。记住,任何面向对象语言(object-oriented language)都是基于对象语言。

诸如Smalltalk,C++,Eiffel和Object Pascal等语言都是真正的面向对象语言,因为它们都很好地支持抽象、封装和继承。继承是区别基于对象语言和面向对象语言的关键特性(见表1-2)。

1.14 对象模型的优点

本章我们轻松愉快地讨论并赞扬了面向对象软件开发,最后,强调了要理解面向对象的优势到底是什么。为了有效地使用对象模型,应该充分地理解对象模型的优点。

(1)对象模型鼓励建立随时间演化的系统。这确保了系统的稳定性和可扩展性。不应该为了提供新的功能而抛弃之前的系统(或者从零开始重新设计),使用继承能更加容易地扩展系统功能[18]。换言之,在对现有系统进行扩展设计的过程中,可同时使用该系统。

(2)以对象和类来思考问题更加容易,因为我们熟悉周围现有的对象。实际上,即使不是计算机方面的专业人士,也能发现这种模型比传统模型更易于理解。

(3)对象模型通过将客户与实现分离,强制执行严格的编程,并通过数据封装来防止数据被意外破坏。

(4)简单类的复用(reuse),如复用日期、时间、分数等,很大程度上促进了代码复用和避免编写重复的代码。简单的类也可用于创建较复杂的类,达到更高程度的代码复用。

(5)对象模型鼓励软件和设计的可扩展复用。类可以通过继承扩展,在类层面达到复用。当类的整个框架用于解决大型问题时,能更大程度地利用复用。简单地说,框架是为解决特殊问题而设计的一组合作的类。客户可以方便地精化(refine)、复用、定制(customize)和扩展框架。大容量存储设备框架就是一个简单的例子,它使得编写新设备的驱动程序更容易。另一个例子是管理各种不同传输协议(如TCP/IP,X.25和XNS)的框架。此外,类层次的复用也相当普遍。我们经常使用的许多数据结构(如链表(list)、队列(queue)和散列表(hash table) )通常都来自一个类层次。在面向过程编程中,很难完成这样的复用。

警告:

虽然本章讨论了面向对象编程的诸多优点,但是不要误认为一旦我们开始使用对象模型,面向过程编程就毫无用处了。与此相反,对象模型是“站在巨人的肩膀上”建立起来的,而在数十年其他模型编程中获得的大量知识,会帮助我们将事情做得更好。对象模型为编程模型引入了新的元素,提供了其他模型没有的新特性。另一方面,你对功能分解和C编程越熟悉,在理解和掌握面向对象技术和C++时就会越困难!这是一个相当大的范式转换。

1.15 小结

对象模型的重点在类和对象。

在OOP中,重点是做什么,而不是如何做。

对象包括状态和行为。

对象互相通信以解决问题。

数据抽象、封装和继承是对象模型的强大范式,不是任何语言的特性。

支持抽象、封装和继承的语言就是面向对象编程语言。

必须通过仔细分析问题、良好的设计和高效的实现,才能发挥对象模型的优势。

第2章 什么是数据抽象

面向对象编程的一项基本任务是创建带有适当功能的类,并隐藏不必要的细节(即抽象数据)。下面,我们将用一个现实生活中的例子来解释数据抽象的概念。

绝大多数人都见过影碟播放机(laser disc player)(或LD播放机)。现在,提出一个简单的问题:设计一个影碟播放机,要求易于使用和修改,可后续添加更多有用的功能。

注意:

如果难以理解影碟播放机,可以用CD播放机代替LD播放机,其设计原理类似。实际上,影碟播放机的功能是CD播放机功能的超集。

我们需要解决的问题是:

(1)面板上应该提供哪些控件?

(2)为了连接到不同的设备(如扩音器、电视机或计算机),播放机应使用何种输入、输出装置?

(3)遥控器上应该有多少个按钮?

播放机应该能够:

●根据用户要求,播放影碟;

●向前和向后查找特定的节(如果是CD播放机,则查找特定的音轨);

●允许用户插入和弹出影碟;

●根据用户要求,启动和关闭播放机;

●当播放机播放影碟时,用户可随时要求暂停;

●根据用户要求,完全停止播放。

下面初步分析一下,在面板上应提供的用户控件(开关或按钮)(见图2-1)。

先来分析其中一个控件的功能:

PLAY:当我们按下PLAY按钮时,自然希望影碟播放机开始启动,并在它所连接的电视屏幕上显示图像。但是,在其他情况下按下PLAY按钮,结果是否会不同?

(a)只有在播放机已接通电源,且POWER开关为ON的情况下,PLAY按钮才会响应。否则,按下PLAY按钮不会响应。

(b)如果播放机内无影碟,则PLAY按钮不会响应。

(c)如果满足(a)和(b),则播放机内的激光束被激活,机械驱动系统带动马达开始转动影碟。

(d)播放机内的电子元件读取影碟上的编码信息,将其转换为视频信号(在多项信号处理操作后),并将视频信号传输给电视机显示图像。CD 播放机和 LD 播放机对碟片的音频信息处理方式类似。

(e)如果加载的影碟格式不正确(例如,如果在PAL播放机中放入了NTSC碟片)[19],播放机的显示面板(或播放机连接的电视机屏幕)上会显示错误的消息。

(f)如果满足以上所有前提条件,则给出一个可视或可听(或兼而有之)的指示,表明播放机正在播放。

从这些简化的步骤中可以看出,只是按下 PLAY 按钮就会启动播放机内的许多操作。但是,对于使用播放机的用户而言[20],她只对屏幕上出现的图像和伴随的音频信息感兴趣,甚至不会意识到播放机内有一个激光束!更不会注意到影碟的旋转或激光束的活动。当她按下PLAY按钮后,希望在电视机屏幕上看见清晰的图像并听见悦耳的声音。

作为LD播放机的设计人员,能从这些信息中获得什么?

在用户看来,LD播放机是一个魔术盒。用户插入影碟,按下PLAY按钮,它就能让与其连通的电视机屏幕上出现画面。这是LD播放机公认的性质(见图2-2)。

我们的设计必须从客户角度出发,不应让内部的细节打扰终端用户的使用。在电视出现图像之前,我们还需要考虑许多其他的事情。

我们已经设计好LD播放机,上面布满许多小按钮,其中一个就是PLAY键。装入影碟后按下PLAY按钮,屏幕上便会出现图像。无论你相信与否,我们已经抽象了数据部分(即我们已经完成了数据抽象)。

数据抽象(data abstraction)在忽略类对象间存在差异的同时,展现了对用户而言最重要的特性。的确,抽象应该对终端用户隐藏无关紧要的细节,避免暴露有可能分散用户注意力或与使用环境毫不相干的细节。

分析LD播放机的设计

(1)我们不会让激光束、马达等部件暴露出来,也不会让用户对它们一无所知。我们会提供可视或可听(或兼而有之)的指示,显示播放机的内部工作状态。

(2)用户只需按下PLAY按钮,即可欣赏影碟中记录的图像,不必亲自开启马达、接通激光束装置电源、将其对焦在影碟上等。完全没必要让普通用户这么做。有些操作相当危险,假如要求用户手动操作激光束会怎样?制造商很快就会被法律诉讼淹没。(警告:在无任何保护的情况下,暴露在激光束下非常危险。)

(3)如果用户在未装入碟片的情况下,按下PLAY按钮,播放机既不会开启激光束,也不会启动马达。我们的设计应该能智能检测此信息。实际上,播放机随时都记录下自身的状态,并根据相应的信息做出响应。

(4)如果设计人员将 LD 播放机的各个零部件拆分,放在桌上,告诉用户这就是 LD播放机,用户肯定会认为设计人员的精神有问题。设计,或者说抽象,就是把各个大小不一、功能各异的零部件组合起来,在它们之间建立适当的通信机制。进一步而言,设计为用户提供一个明晰的接口,将组成LD播放机的所有部件(即实现)隐藏,只显示用户操作播放机所需的控件。

2.1 接口和实现的分离

细心的读者可能已经注意到,在上一段中,我们使用了接口(interface)和实现(implementation)两个术语。传统的面向过程编程思想认为,接口和实现差异不大。但是在面向对象编程中,整个设计过程都围绕着接口进行。

2.1.1 接口的含义

通常,我们在现实中使用现有对象时,都会注意到眼前的事物。驾驶汽车时,看到的是不同的仪表盘(速度表、油料表、温度表)、方向盘、油门踏板等。当你踩下油门(也叫做节流阀或加速器)时,希望汽车移动(假设已挂挡,且手刹已松开),此时汽车内部发生了许多事情:

(1)从油箱中向化油器(或者喷油器)中泵油。

(2)化油器或喷油器将汽油送至发动机汽缸中。

(3)配电器为火花塞供电,点燃燃料。

(4)活塞带动曲轴,驱动车轮转动。

如果密切关注以上这些细节会让驾驶员分心。把电喷式发动机换成化油器发动机,对于驾驶员而言意义不大,他希望的是汽车的各个操作环节流畅。另外,改变汽车内部燃料供给机制,也不会影响驾驶员的驾驶技术(或习惯)。

当驾驶员查看速度表时,可以很容易地获知汽车的准确速度。至于速度表如何检测速度,与驾驶员毫不相关。顺带一提,速度表通过机械耦合机制或电子机械系统(电子和机械系统的结合)测量汽车的速度。

通过以上分析,对驾驶员而言,真正重要的是她从外部看到或感觉到的事物,而不是它们在汽车内部如何运作。类对象的接口支持外部视图,接口就是用户观察的对象视图,以及用户可以用接口做什么(也包括接口对用户做什么)。因此从用户角度看,油门踏板控制汽车的速度,速度表显示汽车当前的速度。与此类似,用户将LD播放机看做是接受影碟并将编码图像再现至电视机屏幕上的装置。

当我们设计接口时,应最大程度地满足用户的要求,这些用户也称为客户(client)。所谓类的客户,就是使用类且不知道(或不用关心)类内部运作的人。客户可创建类的对象,并通过接口对其进行操作。

2.2 对象接口的重要性

数据抽象的目的是,提供清晰和充足的接口,在方便且受控的模式下允许用户访问底层实现。接口应满足用户使用对象的基本需求。我们的唯一目标是:牢记客户,为让她们的生活更加舒适而不懈努力。因此,抽象的首要目标是,为客户简化操作。

2.2.1 什么是充足的接口

仅为讨论需要,假定我们设计的LD播放机未提供STOP按钮,会怎样?它将永远播放一张碟片!只有切断电源才能让播放机停止播放。可见,没有 STOP 按钮的接口不足以满足用户的基本要求。大多数情况下,如果现有接口无法满足用户需求,用户都倾向于绕过所有的安全和保护,直接干扰内部运作。设计良好的接口不应该出现这样的问题。当且仅当接口能满足用户需求时,该抽象才是设计良好的抽象。

同理,如果我们的设计未提供 SEARCH 按钮,这样的接口也不充足。LD 播放机的用户,有权播放碟片中任意选定部分的图像。要求用户只能按照从头到尾的顺序观看完全不合理。

2.3 实现的含义

如果能理解接口的概念,就很容易理解实现。接口告诉客户可以做什么,实现则负责如何做,所有的工作都在实现中完成。客户无需了解类如何实现接口所提供的操作。因此,实现用于支持由对象表现的接口。继续用汽车的例子来说明,化油器(或喷油嘴)是支持节流阀装置的接口。与此类似,速度传感装置用于支持速度表,速度表是显示汽车速度的接口[21]。激光束和马达都是影碟播放机实现的一部分,它们用于从影碟中提取编码信息。从以上的分析可知,使用接口并不需要了解实现。实际上,使用接口的用户不了解内部实现反而会更安全些,部分(甚至完全)了解实现可能导致黑客代码突破接口或实现的障碍。另外,单一接口可由不同实现支持,不同接口可由单一实现[22]支持。在后面的章节中,将详细介绍这方面内容。

注意:

有趣的是,我们生活在一个充满抽象的世界。顾客需要让修理工修理汽车,顾客虽然不知道如何修理,但知道如何与修理工交流;汽车修理工明白如何修理汽车,根据顾客的要求修理汽车。对顾客而言,由汽车修理工为顾客表现的接口就是他修理汽车的个人能力。与此类似,我们操作电脑时,也对其不甚了解。我们使用文字处理系统时,在键盘上敲击一个键,希望相应的字符出现在连接的屏幕上和准备好的文档中。至于键盘如何检测到按下键的动作、如何转换信息,然后将其传输给文字处理器,均与操作者无关。在我们周围,汽车修理工、电脑、键盘等,都是我们世界中的抽象,我们的生活处处充满面向对象!

2.4 保护实现

传统的面向过程编程,缺乏对实现者的保护。如果实现在生存期内无法保证自身的完整性,这样的实现则毫无用处。假如有人篡改了汽车速度表的传感装置会怎样?速度表将无法正确显示汽车的速度。假如自动柜员机(ATM)允许客户可以直接操作他人的账户会怎样?假如允许消费者不通过电源插座,直接使用电力电缆会怎样?

以上这些情况都有一个共同点,即它们只可通过特定的方式(接口)使用,没有其他方法(电脑黑客除外!)可绕过接口直接通向实现。驾驶员不能篡改速度表,至少是很难[23]。ATM允许个人对自己的账户执行某些受限操作,但不允许客户接触他人的账户。同样,直接从家门前的电线杆上(或配电箱中)接电是非法的。

这些例子中,接口(速度表、ATM、电源插座)都由实现支持,而且该实现由对应的接口来保护(即接口提供一个清晰且定义明确的方法访问实现)。换言之,实现以特定方式工作,并跟踪自身的状态。另外,实现假设它的状态仅能通过接口更改,如果违反此前提条件(即不知何故,实现的状态被直接从外部更改,并未通过提供的接口更改),则无法保证实现进行正确地操作。从接口的角度看,实现应通过公共接口运行(或由公共接口访问),以确保实现的完整性。

回顾一下影碟播放机的例子,当我们按下PLAY时,LD播放机的实现将进行检查,以确保:

(a)已载入碟片;

(b)已关闭碟片托架(或托盘)。

如果满足了以上条件,LD播放机将激活激光束装置和马达驱动器等,并开始读碟。为了检测条件(a)和(b),LD 播放机可能使用某些微动开关,而且假设任何人都无法直接操作这些开关,只有实现才能控制微动开关。[ 这与我们常见的洗衣机和烘干机的关门传感器类似。这些电器附带的说明书中会清楚地说明,只有关闭洗衣机的门,洗衣机才可运转。如果仔细查看会发现,在洗衣机门的下面或后面有一个小型传感器。洗衣机门关闭时,开关被压低,说明门已关闭。如果好奇心驱使你按下开关,可以确信洗衣机在门未关闭的情况下,将开始运转!此时你已经绕过洗衣机的接口直接操作,而这样的行为可能导致你受伤或洗衣机受损。] 如果违反了这个假设,将无法保证播放机按指定说明运行。如果我们手动操控这些开关,愚笨的播放机则相信碟片已载入,并且托架已关闭;然后我们按下PLAY按钮,播放机一定会启动激光束装置,这可能会对播放机和用户造成不可挽回的损失(万幸的是,播放机的构造不会让这样的情况发生)。

如果用户按已发布的接口(published interface)操作(即按照说明使用),则不会出现这种情况。

数据抽象引出了相关的概念:数据封装(data encapsulation)。只要存在由实现支持的带接口的对象,就一定存在实现隐藏(implementation hiding)(也称为信息隐藏)。有些信息对实现而言相当重要,但是对使用接口的用户而言却毫无价值,这些信息将被隐藏在实现中。实现由接口封装,而且接口是访问该实现的唯一合法途径(见图2-3)。

这就相当于说:通过门是进入房间的唯一的途径。LD播放机把激光束装置、马达和电子元件封装在内。被封装的项很可能是另一个对象(或一组对象),而马达和激光束的确是其他类的对象。注意,被封装的数据对于对象的实现极其重要。进一步而言,实现必须维护被封装信息的完整(或正确的状态)。

2.5 数据封装的优点

数据被封装后,客户无法直接访问,更不能修改,只有接口函数才可访问和修改封装的信息。进一步而言,使用接口的用户完全不知道描述该接口的函数如何使用封装信息,而且对象(或类)的用户对此也毫无兴趣。因为只有保证 OOP(或用于实现对象的语言)的假定,即封装的信息只能通过已发布的接口访问,才能确保对象的完整。我们之所以能正常操作LD播放机,是因为激光束装置(或其他电子元件)被封装,而且只有实现才有权访问(即实现对封装的信息有独占访问权)。在用户操作LD播放机时,接口代表用户操控封装的信息。

数据封装的另一个优点是实现独立(implementation independence)。由于类的用户无法查看封装的数据(或信息),他们甚至不会注意到封装数据的存在(从某种程度上看,这取决于语言,将在后续章节中介绍)。因此,改动封装内的数据不会(也不该)影响用户所见的接口。为说明这点,回顾一下LD播放机。

假设在第一次实现中,我们用一套16位数模(D/A)转换器,将碟片中的数字信息转换成模拟格式,且运行良好。但是通过实验发现,20位D/A转换器能提供更高质量的视频和音频解析。因此,我们改变了LD播放机电子元件的实现,将其中的16位D/A转换器更换成20位D/A转换器。进行这样的改动后,用户操作播放机的方式是否会发生变化?完全不会。用户甚至不会觉察到我们在内部使用的D/A转换器类型。即使他知道(也许通过阅读用户手册中的技术规范),也无法直接访问 D/A 转换器。只有实现才知道 D/A 转换器从16位变成了20位。接口十分了解实现和被封装的数据,它(接口)明白如何利用新的实现工作。因此,改变封装信息只会影响接口对封装数据的操作(实现),不会影响客户所见的接口。也就是说,实现中的改动不应该影响该对象的客户所见的接口。

互联网地址也可作为抽象和封装的例子,TInternetAddress类代表互联网地址。该类提供必要的操作,用于获取域名、主机名等,客户无需知道内部如何储存互联网地址。首次引入互联网地址时,用32位表示。对于当时来说,完全可以表示所有的域和域内的节点。但是,随着互联网的普及,每月都有成千上万的新主机加入互联网。照此速度发展,我们很快就会用尽互联网地址。为突破这个限制,将很快使用 128 位表示互联网地址。不过不用担心,这只是内部实现的变化,通过TInternetAddress类即可轻松完成,客户无需学习使用新的互联网。互联网地址的表示方式被封装并转换至 128 位实现,只会引起实现中的变化,不会影响接口。这就是抽象和封装的优点。

2.6 接口、实现和数据封装之间的关系

接口是任何类(和它的对象)客户的视图;

接口由封装的实现支持;

改变类的实现(支持接口)不应该影响该类客户所见的接口;

封装的实现能让实现者修改实现但不影响接口。即客户使用的接口与支持接口的实现彼此独立;

数据抽象和数据封装原则并不是语言的属性,它们是面向对象编程范式的基本概念,任何支持OOP的语言都必须支持数据抽象(以及第1章中讨论的其他概念)。

注意:

本章使用现实世界中简单易懂的示例(影碟播放机)进行讲解,目的在于让读者清晰地理解概念。在本章和后续章节中还将列举其他数据封装的示例。

2.7 数据封装注意事项

尽管封装的信息对于用户而言无关紧要,然而,为了高效地使用对象,用户可能也需要访问封装的信息。实现者封装某些元素后,必须在接口提供访问或操控封装信息的工具。尽管这些访问是受限或受控的,但仍然要给接口提供适当的工具用于访问和修改封装的实体。如果抽象封装了一部分对用户很重要的信息,却未提供合适的工具来访问被封装的信息,这样的抽象是不正确的(即接口不足)。

回到LD播放机的例子。启动和停止马达的装置确实被封装在LD播放机中,播放机只有在确定装入碟片并关闭碟片托盘后才会启动马达。但是,如果用户想更换碟片,也可随时要求播放机停止马达运转。当用户按下OPEN/CLOSE按钮时,影碟播放机将关闭马达,并弹出碟片托盘。由此可见,即使马达控制装置被封装,接口也提供了必需的控件来操控马达,尽管是间接地操控。

2.8 确定封装的内容

如果某项对于用户理解类毫无帮助,封装该项,即从接口中移除该项根本不会减少类的效用;

如果某项包含敏感数据(商业秘密、专利信息、个人信息等),为了不让用户直接访问,封装该项;

如果某些项有潜在的危险(激光束、X射线、微波等),并且要求用户掌握特殊技能(普通用户不具备)才能操作,则封装该项;

如果类为了自我管理而使用某些元素,且对接口意义不大,应封装这些元素;

●如果某些项倾向于在将来进行改动(为了使用更新的技术或者让其更快或更安全),必须从类的接口中移除,封装这些项。

我们将在后续章节中用大量例子来讨论以上情况。

2.9 抽象数据类型

有时,人们谈论的是抽象数据类型(abstract data type),而不是数据抽象(data abstraction),这可能让学习OOP的人感到困惑。其实,它们的关系非常密切。

抽象数据类型是由程序员定义的新类型,附带一组操控新类型的操作。定义新抽象类型将直接应用数据抽象的概念。抽象数据类型也称为程序员定义类型(programmer defined type)。

任何语言基本上都支持内置数据类型(如整数、浮点数、字符等),而且通常会提供一组操控这些内置类型[24]的操作。当我们使用这些内置(或语言定义)类型时,并不知道也不关心(使用了数据抽象和封装)如何实现这些数据类型。但是,语言(更精确地说是语言编译器的实现者)非常了解实现,知道如何实现这些内置类型。进一步而言,语言定义类型的用户不能直接访问这些类型的内部表示,而且必须使用语言提供的操作来操控它们。

例如,我们在使用语言提供的浮点数时,并不知道浮点类型的内部表示[25]。但是,我们肯定知道类似+和-的操作可用于浮点数计算。当我们使用浮点类型时,便间接地使用了语言编译器的实现者所提供的内部表示,而且只能使用语言已定义的操作。这也让特定编译器和特定机器彼此独立,因此,我们可以在任何机器上运行任何语言编译器(即我们获得了实现独立)。

支持结构化编程的语言(Pascal、Algol和C等)也允许用户定义新类型,它们被称为用户定义类型(user defined type)或程序员定义类型(programmer defined type)。这些用户定义类型在它们的实现中使用语言定义类型。另外,支持结构化编程的语言也允许程序员为用户定义类型定义一组操作(不要与类似+和-这样的操作混淆)。但是,主要的问题是,用户定义类型的实现并未封装。要使用程序员定义类型必须完全了解该类型的实现,但是语言并不会保护新类型的实现者,也就是说,缺少数据封装(在C中使用static数据,可实现一些封装)。新类型通常定义为一个组合,由数据结构和一组用于操控该数据结构的函数组成,后者通过特定的方式访问数据结构。原则上,任何新类型的用户都必须使用这些函数来操作新类型,换言之,用户不应该直接访问或修改数据结构。但是,由于语言无法强制执行这条规则,因此无需了解数据结构如何使用,任何人都可以编写新函数操作数据结构。也就是说,这样做并未防止非法访问实现。在Pascal、Algol、C等语言中,只有在用户遵守隐性规则的前提下,才能确保数据完整,而语言不能强制执行这条规则。从本质上而言,新类型只不过是由一组函数(或操作)支持的数据结构。

举个简单的例子,带有Push()和Pop()这样操作的Stack(栈)类,其数据结构可以是一个数组或链表(栈通过数组或链表实现),在需要时通过Push()和Pop()操作修改数据结构。此外,我们还要考虑多个栈的创建和销毁。为简单起见,我们仅以整数栈为例,储存在栈中的元素类型相同。在后续的章节中,我们将介绍如何用模板类实现泛型栈(generic stack)。

2.10 抽象数据类型——栈的实现

下面的示例用于说明,在C中一个简单栈的实现。

Stack.h文件——让所有的抽象数据类型用户都可以使用Stack。

typedef Stack* Stackld;

typedef int bool;

struct Stack {

int* data;      /* 在栈上存储元素 */

unsigned count;   /* 栈上元素的数量 */

int* top;      /* 栈顶部的指针 */

/* 略去其他细节 ...*/

};

Stack.c文件——用于操控Stack的一些函数的实现。

StackId CreateStack()

{

/* 此处代码用于创建一个新的Stack

返回新的栈名(StackId) */

}

bool DestroyStack(StackId whichStack)

{

/* 此处代码用于销毁whichStack */

}

void Push(StackId whichStack, int thisElement)

{

/* 此处代码用于将thisElement 压入whichStack */

}

int Pop(StackId whichStack)

{

/* 此处代码用于从whichStack中弹出顶部元素 */

}

Stack.c文件与Stack.h文件一同编译后,把产生的目标码和Stack.h文件一起提供给用户。Stack的用户不能访问用于操控栈的函数源代码。

Stack的数据结构只是一段哑数据(dumb data),正确使用Stack所需要的信息都嵌入在Push、Pop等用于支持数据结构的函数中。对用户而言,重要的信息全都在Stack的数据结构中,函数只提供了一个操控数据的工具。但是,用户不使用提供的函数也可访问和修改Stack 的数据结构,编译器无法制止用户这样做。换而言之,数据结构和函数之间缺乏紧密耦合。然而,在面向对象编程(OOP)语言中,函数与数据结构形成一个完整的单元(Stack类),必须使用该类提供的函数才能访问对象中的数据。对象拥有数据结构,且只允许接口访问它。由此可见,在支持OOP的语言中更容易实现抽象数据类型。

在面向过程语言中,程序员定义类型与语言定义的新类型非常相似,但前者没有太多保护,任何人都可以直接修改它们。这样的程序员定义类型称为抽象数据类型(abstract data type)。之前我们设计的LD播放机就是一个抽象数据类型。OOP语言的优势在于,它提供数据封装,保护了实现(所以也保护了实现者)。因此,在OOP中,程序员定义类型可被当做语言定义类型。换句话说,OOP语言有特权,也有责任。

举个例子,考虑一个整数数据类型,用于表示非常大的数字(常用于天文学中)。32位整数(4字节整数)无法满足我们的要求,因此需要创建一个新类型,即一个抽象数据类型,TInt。

抽象数据类型——TInt

操作(接口):

加(操作符+)

减(操作符-)

乘(操作符×)

除(操作符/)

求模(操作符%)

增加(操作符+=)

绝对值

打印

在该例中,肯定要隐藏TInt类型的实现细节。我们可能使用两个4字节整数(或一个8字节的数组)来实现这个新类型,但是TInt的客户并不知道(也不应该知道)这些细节。如果将来使用支持64位整数(这很有可能)的处理器,就可以直接在实现中使用,不用我们目前的这种表示方法,这些都由实现者决定。

利用数据抽象,我们创建了一个新类型,并且为这个新类型提供了一组有用的操作。因为语言没有直接支持这个类型,所以程序员只好利用抽象实现它,因此它是一个抽象数据类型。鉴于此,数据抽象有时也被定义为:定义抽象数据类型以及一组操作并隐藏实现的过程。

注意:

我们希望让抽象数据类型也拥有和语言定义类型相同的特权和责任(也就是说,不应该让新类型的客户发现语言定义类型和抽象数据类型之间有任何区别)。要达到这个目标,必须让语言支持操作符重载,但是,并不是所有的面向对象语言都允许这样做。在后续章节中将作详细讲解。

2.11 C++中的数据抽象

C++中数据抽象的基本单元是类(class)。

注意:

为理解本节的内容,你必须对C和C++语法有基本的了解,必须熟悉函数名重载、C++参数样式、const参数和const成员函数、引用等。

C++中类的概念是C中结构(或者Pascal中的record)概念的延伸。接下来,我们为TInt类定义接口:

class TInt {

public:

// 构造函数:用int创建一个TInt

TInt(int value);

TInt(long mostSignificant, unsigned long leastSignificant);

TInt(long value);    // 用long整数创建一个TInt

TInt(short value);   // 用short整数创建一个TInt

TInt();         // 默认构造函数

TInt(const TInt& copy); // 复制构造函数

TInt& operator=(const TInt& assign);   // 赋值操作符

~TInt();   // 析构函数

// TInt类型的操作符

TInt  operator+(const TInt& operand) const;    // 加法

TInt  operator-(const TInt& operand)const;    // 减法

TInt  operator*(const TInt& multiplicand) const; // 乘法

TInt  operator/(const TInt& divideBy) const;   // 除法

TInt  operator%(const TInt& divideBy) const;   // 求模

TInt  operator+=(const TInt& operand);  // 加法赋值操作

// 简化起见略去其他操作符

// 计算TInt数的‘to’次幂

TInt  Power(const TInt& to) const;

void  Print() const;

TInt  Absolute() const;  // 返回TInt的绝对值

void  FlipSign();   // 改变数字的符号

// 辅助函数(helperfunction)

long GetMostSignificantPart() const;

unsigned long GetLeastSignificantPart() const;

private:

// TInt类型的内部表示

unsigned long  _leastSignificantPart;

long       _mostSignificantPart;

// 也许还包含其他私有成员函数,此处略去

};

2.12 类中的访问区域

每个类都有3个不同的访问区域。在我使用过的所有OOP语言中,只有C++精心设计了这3个区域。

public 区域是最重要的区域,为类的用户指定了类的接口。任何客户都可以访问public区域。

本书的源代码样式

(1)所有的类名都以T开始。类的行为与在语言中添加的新类型类似,因此使用前缀T[26]。但是,在表示实现这些类的文件名时,去掉T。例如,包含TInt接口的文件名是Int.h。

(2)所有的成员函数名,首字母大写(如Add()、FlipSlip()等)。

(3)所有的枚举(enum)都以E开始,枚举中的成员名以e开始。

(4)函数中的所有局部(自动)变量名,首字母小写。

(5)所有的常量都用大写字母和数字表示。例如:

const unsigned DAYS_IN_WEEK = 7

(6)下划线(_)用于命名常量(如上DAYS_IN_WEEK所示)。

(7)头文件中的注释置于声明之前,简短注释可与代码位于同一行。

(8)所有的数据成员(实例变量)都以下划线(_)开始。

(9)全局变量和全局常量通常以g开始。

例如:

// 改变数字的符号

void FlipSign();

TInt operator+(...) const; // 加法

TDate gEpoch(“1-1-1970”);

作为public区域的对立面,private区域是任何客户都不能直接访问的区域,只供实现使用。换言之,只有类的实现才能访问private区域。

第3个区域是protected区域,用于代码的扩展和复用(继承)。后面的章节中将介绍相关内容。

在一个类中,可以声明多个这些区域(public,protected和private),编译器将负责合并。

理解代码

(1)构造函数(constructor):所有与类名(本例为 TInt)相同的该类成员函数都称为构造函数,它们用于创建和初始化新对象。为什么我们需要构造函数?

声明语言定义类型(如int)时,该语言(或编译器)会创建相应类型的变量(本例为int),初始值(如果指定)将储存于int变量中。例如:

int i; // 无初始值

int j = 10; // 创建初始值为10的变量j

该例中,编译器负责为i和j分配(或预留)内存,并初始化j为10。与此类似,如果是创建对象,则需要一个创建对象的工具,并且能在数据成员中储存适当的值。如在TInt类的例子中,用户希望为TInt类对象提供某些初始值。这些都可以利用构造函数来完成,一个类可以提供若干重载构造函数。在C++中,创建(或初始化)对象的唯一途径是调用类提供的构造函数。在TInt类中,

TInt(int value);

TInt(long mostSignificant, unsigned long leastSignificant);

TInt(long value);

TInt(short value);

TInt();

TInt(const TInt& arg);

以上都是构造函数,它们的行为与重载函数(编译器将它们作为函数名不同的重载函数来实现)类似。欲了解更多构造函数相关内容,请参阅第3章。

(2)析构函数(destructor):名称与类名相同,且带前缀~的成员函数称为析构函数。在C++中,其他类型(或声明)名称都不会有~。当某个类对象不再处于程序段的作用域内时,该函数负责清理工作。与构造函数不同,一个类只能有一个析构函数。析构函数在无用单元收集(garbage collection)中非常有用。

从一个函数(或块)中退出时,编译器将自动销毁在该函数(或块)中创建的对象。但是,对象可能已经聚集了资源(动态内存、磁盘块、网络连接等),这些资源储存在对象的数据成员中,由成员函数操控。由于对象被销毁(退出函数)后不可再用,因此,必须释放该对象储存的资源。但是,编译器并不知道这些资源(它们可能由动态分配),因此,对象必须负责释放它们。为了帮助对象完成这些工作,在退出函数(或块)时,所有在该函数(或块)中静态创建(即不使用 new()操作符创建)的对象都将调用析构函数。析构函数将释放对象储存的所有资源。换言之,析构函数提供了一种机制,即对象在被销毁前可自行处理掉自身储存的资源。参见下面复制构造函数的例子。析构函数的相关内容将在第3章中详细讨论。

构造函数和析构函数都是特殊的成员函数。在声明中,它们无任何返回值类型,这表明它们不能返回任何值[27]

(3)复制构造函数(copy constructor):这是一个特殊的构造函数,用于通过现有对象创建新对象,因而称为复制构造函数。复制构造函数有独特的函数原型(或签名)[28],很容易识别。当内置数据类型变量(如int和char)从一个函数按值传递至另一个函数时,由编译器负责复制该变量,并将其副本传递给被调函数(called function)。当对象按值传递给一个函数时,该对象的副本必须像内置类型那样被复制。然而,对象不是简单变量,它们是由程序员实现的复杂实体。因此,编译器在复制对象时需要帮助。逻辑上,应该由对象的实现者负责复制对象(内置类型的实现者是编译器)。复制构造函数就提供了这样的帮助。无论何时需要对象的副本,编译器都会调用复制构造函数来复制对象。特别是,当类在它的实现中使用动态内存时,复制构造函数必不可少。如果类的实现者不提供复制构造函数,编译器将会自动生成一个复制构造函数。至于这个生成的复制构造函数是否满足类的要求,那完全是另一个问题,在此暂不作赘述。注意,复制构造函数是一个特殊语义的构造函数。我们在创建和初始化一个新对象(从无到有地创建)时调用普通构造函数,在通过现有对象创建一个新对象时才调用复制构造函数。这是复制构造函数与其他构造函数的主要区别。出现下列情况时,将调用复制构造函数:

●对象从一个函数按值传递至另一个函数时;

●对象从函数按值返回时;

●通过现有对象初始化一个新对象时。

void f()

{

TInt x(100); // 创建一个TInt类型的对象x

// g() 是一个接受TInt值,且无返回值的函数

void g(TInt); // g()函数的原型

// ...

g(x);

}

void g(TInt y)

{

// ...代码...

}

当f()函数调用带参数x的g()函数时,复制构造函数构造了一个x的副本,该副本作为实参提供给g()的形参y。从g()退出时,对象y(该函数的形参)被销毁,即对象y调用了析构函数。不用担心,在g()内操作对象y不会影响f()内的对象x(这正是按值传递语义所期望的)。

相关内容详见第3章和第4章。

(4)赋值操作符(assignment operator):复制构造函数用于通过现有对象创建新对象,而赋值操作符用于将现有对象显式赋值给另一现有对象。赋值是用户显式完成的操作。例如:

TInt a;

TInt b(100);

// ...

a = b;  // 将b赋值给a,a和b都已存在。

注意,源对象是b,目的对象[29]是a。两个对象都是程序员使用的现存对象。如果类的实现未提供赋值操作符,在需要时编译器会为类生成一个赋值操作符。与复制构造函数的情况相同,编译器生成的赋值操作符是否满足类对象的要求是另一个问题,在此暂不作赘述。编译器生成的赋值操作符相当简单,通常用处不大。详见第3章。

(5)其他函数都是类的普通成员函数,它们通过TInt类的对象来操作。只有类的现有对象才能使用该类的成员函数(更精确地说是非静态成员函数)。

(6)操作符函数(operator function):TInt 是我们定义的新类型,我们希望提供一些操作符(如同+和-)让该类型更有用。也就是说,应该可以像整数(int)那样使用TInt类对象。例如:

TInt a(10);

TInt b(20);

TInt c;

int i = 100;

int j = 200;

int k;

c = a + b; // a与b相加,并将结果储存至c中。

k = i + j; // 整数i和j相加,并将结果储存至k中。

使用+操作符将i与j相加,是相当简单的操作,+操作符由语言提供。换言之,语言知道如何相加两个整数,因为语言提供了int的实现。如果语言知道如何实现int,它当然明白如何将它们相加。

回到 TInt 例中,该抽象数据类型的实现由实现者提供。怎样才能让语言明白如何相加两个TInt类对象?如果我们为TInt类型提供实现,就必须负责提供+操作符的实现(如果需要的话)。但是,并不是所有的语言都允许自由实现操作符。万幸的是,C++允许抽象数据类型的实现者为该类型提供合理的操作符。我们需要做的是,将+操作符的含义扩展至TInt类型。这就是操作符重载(operator overloading)的概念。操作符函数是用于实现特定操作符的函数,其定义方式与定义其他成员函数类似。但是,操作符函数的名称前必须保留关键字operator,后面紧跟重载的操作符。相关内容详见第8章。

注意,操作符重载是非常强大的机制,很容易被滥用。类的实现者必须为实现的类谨慎选择合适的操作符。类只能使用它所支持(即实现)的操作符(赋值操作符,即=操作符除外,如果类的实现者未提供该操作符的实现,需要时编译器将自动生成一个)。

注意:

严格意义上说,类似+、-、*、/等操作符应作为非成员函数(或友元函数)实现。本例中的类接口如下所示:

// TInt类型的操作符

TInt operator+(const TInt& operand1, const TInt& operand2);

TInt operator-(const TInt& operand1, const TInt& operand2);

// 诸如此类... 已略去

为尽可能简化示例,略去若干细节。欲了解友元函数的概念,详见第7章。

(7)成员函数声明中const的意义:在以上示例的头文件中,许多函数后都缀有const关键字。例如:

void Print() const;

这个const应用于函数,而非任何参数。这样的函数称为const成员函数,只有成员函数(非普通函数)可以声明为 const。类的数据成员、函数的参数、对象、普通变量等也都可以声明为const。const成员函数保证在被调用期间,不会改变调用对象的状态。该例中,Print()函数确保在被调用期间,不会修改调用对象的数据成员。这样的函数称为选择器(selector),它只能从对象中读取数据,不能在对象的数据成员中写入(修改)数据。因此,

TInt aInt;

aInt.Print();

无论如何都无法修改 aInt。在这些 const 成员函数中,编译器通过禁止给对象的数据成员赋值来确保对象的这种常量性(constantness)。详见第3章。了解抽象数据类型的知识后,我们的影碟播放机抽象可以这样声明(在C++中):

typedef short ErrorCode;

class TLaserDiscPlayer {

public:

// 操作

ErrorCode  Play(unsigned atChapter=0);

ErrorCode  Stop(void);

ErrorCode  SearchFor(unsigned chapter);

ErrorCode  OpenTray();

ErrorCode  CloseTray();

void    PowerOn();

void    PowerOff();

ErrorCode  Pause();

// 构造函数

TLaserDiscPlayer();

// 析构函数

~TLaserDiscPlayer();

// 略去其他函数

private:

enum ETrayStatus { eClosed, eOpen };

enum EPowerStatus { eOff, eOn };

enum EplayerMode { ePlay, eSearch, ePause, eStop };

ETrayStatus  _trayStatus;  // 打开或关闭

EPowerStatus _powerStatus; // 开机或关机

EPlayerMode  _playerMode;  // 播放、查找等

// 略去其他成员

};

以上的类声明未揭示任何关于如何实现接口函数(或简称接口)的细节。在 C++中,这样的类声明称为接口文件(interface file)或类头文件(class header file),常后缀.h扩展名。因此,以上示例的文件表示为Int.h。

2.13 和类一起使用的术语

在C++中,类的接口作为函数在该类中列出,这些函数称为成员函数(member function);在Smalltalk中,称为方法(method);在Ada中,称为操作(operation)(不要与C++的操作符(operator)混淆)。这些函数提供类的接口,因此也称为接口函数(interface function)。在本书中,以上术语将交替使用。在C++中,不是函数的元素称为数据成员(data member)。良好的抽象(即设计良好的接口)绝不会把任何数据成员[30]置于public区域。

表2-1总结了各语言中使用的不同术语。

SMALLTALK:

在Smalltalk中,调用对象的接口函数(成员函数)被视为向对象发送消息。这似乎很恰当。我们向 LD 播放器发送一条消息,要求它播放影碟。类似地,Throttle_down 消息由抽象的汽车解释为加速汽车的请求。向对象发送一条消息将引起该对象中一个方法被执行(即当我们发送消息时,对象将执行特定的方法(函数))。也就是说,对象对消息做出了响应。消息只是用户所见的名称,该名称可能在运行期与消息(方法)的正确实现绑定。Smalltalk把数据成员称为实例变量(instance variable)。

2.14 类的实现者

编写实现所有成员函数源代码的程序员,是类的实现者。实现者有实现成员函数的所有代码,而且非常熟悉数据成员,也非常熟悉成员函数如何使用数据成员。实现者在需要时有权修改实现(但不是接口),修改类接口的情况很少见。在后续章节中会介绍这样做的原因。

实现者编译实现所有成员函数的源代码(这将生成目标代码(object code)),并且把它和接口文件(头文件)一同提供给客户。实现的源代码通常保存在与类名相同的文件中,但名称后缀.C、.cc、.cpp、.cp等扩展名(具体选择取决于编译器和约定)。本书涉及实现文件时统一使用.cc扩展名。实现者编译Int.h和Int.cc文件,生成目标文件(称为Int.obj)。TInt类的客户绝不会看见源代码(成员函数的实现),客户收到的是Int.h文件和Int.obj文件。要使用TInt类,就需要Int.h头文件和Int.obj文件。注意,实现者和客户都使用相同的接口文件(头文件):Int.h。

2.15 实现成员函数

下面是文件Int.cc的一部分:

// Print 成员函数的实现

#include <iostream.h>

#include “Int.h”

void TInt::Print() const

{

//cout是ostream的一个实例,ostream是一个标准的i/o流库类(stream library class)。

cout << "0x" << _mostSignificantPart << ", 0x" << _leastSignificantPart;

// 在此函数中对数据成员的任何赋值行为都视为非法

}

理解代码

如果忽略类名TInt、::、后缀的const关键字,该声明看起来像是一个普通的函数:

void Print()

{

cout << "0x" << _mostSignificantPart << ",0x" << _leastSignificantPart;

}

这是一个普通的函数。那么,如何识别(或者区分)类的成员函数?只需在函数名前添加类名和::。本例中,::是作用域解析操作符。TInt::Print说明Print()函数是TInt类的一个成员函数(该函数已在头文件中声明)。如果 Print()函数未在 Int.h 头文件中声明,试图定义它将导致一个编译期错误。注意,在函数中,const 限定符只能用于成员函数。根据以上的分析,在定义类的成员函数时,我们使用“类名::函数名”的语法,就这么简单。由于成员函数属于类,经过这样的处理后,即使在两个或多个类中使用同名成员函数也不会彼此发生冲突。在成员函数内部,我们可以访问类的任何元素(数据成员或者成员函数)。

2.16 识别成员函数的目标对象

在编写成员函数(构造函数、析构函数、操作符等)的代码时,如何显式表示调用该成员函数的对象?或者,如果需要,如何显式返回目标对象(target object)的值?在成员函数内部,如何访问调用该成员函数的对象中的数据成员?

这就是this指针发挥作用的地方。类的每个成员函数都有一个特殊的指针——this。这个this指针内含调用成员函数的对象的地址(即this指针总是指向目标对象)。this指针只在成员函数内部有效,this是C++中的关键字。

this指针的类型是“指向成员函数所属类的指针”,也可以说“this的类型是类名”。在成员函数内部,this指针指向调用该成员函数的类实例。

编译器对待成员函数并没有什么特别。实际上,编译器就像实现普通函数那样实现成员函数,但是,它会专门对成员函数进行名称重整(name mangling)以确保其唯一性。每个成员函数接受的第一个参数就是this指针。尽管程序员从未显式声明this指针,但是它一定存在。this指针通常是每个(非静态)成员函数隐含的第一个参数,编译器在每个成员函数的声明中都会插入这个隐含的参数。为了说明这个概念,显式声明this指针如下,Print()成员函数应是:

void TInt::Print(const TInt* this)

{

cout << "0x" << _mostSignificantPart << ",0x" << _leastSignificantPart;

}

实际上,this指针的声明在已重整函数名(mangled function name)中可见。因此,TInt::Print应该是:

void Print_3TIntFv(const TInt* this)

{

cout << "0x" << this->_mostSignificantPart << ", 0x" <<

this->_leastSignificantPart;

}

一旦离开成员函数,this名称将不再有效。

是否一定要使用this指针来引用目标对象中的成员?

不是所有情况都需要这样做。只有在成员函数使用该类成员(数据成员或成员函数)的非限定(unqualified name)名时,才意味着使用this指针。如果在成员函数内部引用类的成员,编译器会在每条表达式中均插入this指针(如果用户没有这样做)。回顾Print()函数,可以这样改写:

void TInt::Print()

{

cout << "0x" << this->_mostSignificantPart << ", 0x" <<

_leastSignificantPart;

}

this->_mostSignificantPart 表达式使用 this 指针显式访问数据成员_mostSignificantPart。this->_mostSignificantPart 表达式的意思是:this 指针指向该对象中的 _mostSignificantPart数据成员。this指针只是成员函数的一个参数(但存在一些限制,将在其他地方讨论),可以像使用成员函数的其他参数那样使用this指针。甚至在 2.15 节的 Print()实现中(没有显式使用 this 指针引用成员),编译器也会将_mostSignificantPart表达式自动展开为this->_mostSignificantPart表达式。

在如下代码段中,

TInt aInt;

aInt.Print();

对象aInt调用Print()(即向对象aInt发送Print()消息)。在Print()函数中this指针将指向aInt。

由于this是指向对象的指针,因此,如果要使用this指针获得整个对象,我们必须使用*操作符对this指针解引用(de-reference)为*this。正如其他指针那样,this内部存放的是对象的地址,*this则是该对象的值。

this指针的概念非C++独享。OOP语言在涉及接收消息的对象时,使用不同的名称。如Smalltalk称为Self,Eiffel称之为Current。

C++:

现在,把我们的注意力转到TInt类的一些操作符函数上。

// +操作符的实现

TInt TInt::operator+(const TInt& operand) const

// TInt 是该操作符函数的返回类型

{

/*

用于计算操作数和TInt数之和的代码,TInt数调用+操作符函数,this指针指向TInt 数。

该函数计算*this和操作数之和,并将计算结果以新的TInt数返回,未修改*this或操作数

(因此用const限定符)。算法如下:

1.  加上 _leastSignificantPart部分并保存进位位元(carry bit)

2.  使用进位位元加上 _mostSignificantPart部分

3.  把(1)和(2)储存在临时TInt数中

4.  按值方式返回临时TInt数

*/

TInt result = *this; // ①调用复制构造函数

unsigned char carry = 0;

// 加上 _leastSignificant部分并检查进位

result._leastSignificantPart += operand.GetLeastSignificantPart();

if ( result._leastSignificantPart < operand.GetLeastSignificantPart() )

carry = 1;

// 带进位加上 _mostSignificant

result._mostSignificantPart += carry + operand.GetMostSignificantPart();

return (result);

}

// 构造函数的框架

TInt::TInt(long msp, unsigned long lsp)

{

// 将传递给构造函数的值复制至相应的数据成员中

_leastSignificantPart = lsp;

_mostSignificantPart = msp;

}

①与先创建对象(该例中使用默认构造函数)然后再赋值的方法相比,在一个步骤中创建并初始化对象效率更高。在后续章节中,将详细讲解这些内容。

2.17 程序示例

以下示例为TInt类的测试程序。代码仅为了程序的完整,并无实际意义。

#include "Int.h"

main()

{

TInt hugeNumber(100, 2000); // 创建一个TInt ①

TInt normalNumber(1000); // 另一个TInt ②

TInt sum;

// 发送消息打印hugeNumber

// 设置this指针指向hugeNubmer

hugeNumber.Print(); // ③

// 改变hugeNumber 的符号

hugeNumber.FlipSign(); // ④

// 再次打印

hugeNumber.Print();

// hugeNumber与normalNumber相加,计算结果储存至sum中

// sum = hugeNumber + normalNumber语句解释为:

// (1)通过hugeNumber调用+操作符函数,将normalNumber作为参数传递

// (2)通过sum调用赋值操作符(=操作符),将(1)计算的结果作为参数传递

// sum.operator=( hugeNumber.operator+(normalNumber) );

sum = hugeNumber + normalNumber; // ⑤

// 打印结果,即通过sum调用print()

sum.Print();

}

①该步骤创建一个TInt类对象,并规定了有效数字的范围。在这里,将调用TInt类提供的配匹构造函数。

②该步骤只利用一个整数参数创建了另一个TInt对象。在这里,将调用TInt类中接受一个整数参数的构造函数。

③该步骤通过对象hugeNumber调用成员函数,使用常用的“对象.函数”的句法。

④该步骤改变hugeNumber的符号,通过FlipSign成员函数完成。

⑤该步骤计算hugeNumber和normalNumber两个TInt类对象之和,并将结果赋值给另一个TInt类对象sum。注意,此处的求和操作不会影响hugeNumber和normalNumber。第一个对象(hugeNumber)调用成员函数操作符,第2个操作数(normalNumber)作为参数传递给+操作符。

2.18 对象是重点

在面向对象编程中,我们总是使用对象。要牢记:无论何时我们讨论调用函数或发送消息时,都必须涉及一个对象。我们通过对象调用函数(或发送消息给对象),在没有对象接收消息的情况下,不能发送消息。必须有的放矢,对象即是目标。因此,我们必须以“通过对象调用成员函数”来讨论,绝不会简单地说“调用函数”。此外还需注意,不能随心所欲地向对象发送消息[31],对象只会对它实现的消息(即对象支持并理解的消息)做出响应。对象实现的方法在类的接口文件中说明。牢记这些简单的概念,OOP 会非常有趣,简单易学。

好奇的读者可能对上面 TInt 类声明中的 private 区域提出质疑,那似乎违反了数据封装的原则。如果不能访问(更不用说修改)某些成员,应将其设置为不可见,而不是把它们作为类接口文件的一部分。这在其他OOP语言中并不明显。不只你一个人提出了这样的疑问,初次接触C++的人都会对此很感兴趣。这个问题与C++编译器和(或)语言的实现有关,将在后续章节中讨论。

样式:

既然private区域在类的接口中没有意义,那么将其置于类声明的底部,便不会引人注目,然后将 public 区域置于顶部,并列出成员函数。要使用有意义的类名和成员函数名,避免使用只有自己才理解的缩略词。

2.19 对接口的再认识

我们已经学习了接口在OOP中的重要性,也理解了接口和实现之间的关系。每个对象都支持一组操作(消息或成员函数)。在声明操作时,需要指定操作的名称、被当做参数的对象,以及操作的返回值。这就是之前提到的操作的签名(或操作/函数的原型)。由对象操作定义的所有签名的集合,称为该对象的接口。

类型(type)是用于表示特殊接口的名称,它使得接口易于管理。否则,接口之间就无法区分。如果一个对象支持定义在TInt接口中的所有操作,则该对象的类型就是TInt。一个对象可能有许多类型,换言之,一个对象可以支持(或响应)多个接口。如果两个对象都支持相同的接口,则它们的类型相同,但两者的实现可能完全不同。

因此,现在你可能认为,类只不过是一个接口而已。在某些语言中,的确如此。但是,它们还是略有不同。有必要理解对象的类(class)和类型(type)之间的区别。对象的类定义如何实现对象,它定义对象的内部表示以及操作的实现。然而,对象的类型与实现无关——它只涉及对象可响应的操作集合。一个对象可以有多种类型,不同的类可以有相同的类型。但是另一方面,类和类型的关系又十分密切。类清晰地定义了该类对象可执行的操作,因此,类也定义了对象的类型。类的任何对象都支持该类定义的接口。

在C++(和Eiffel)中,类同时指定了对象的类型和它的实现。C++和Eiffel都不能在类的外部定义接口。也就是说,接口只能通过类指定[32]。类似Java的语言,允许程序员无需依附任何类就能定义接口,类可以稍后实现一个接口(即类对象支持的接口)。在Smalltalk中,程序员无需指定变量的类型。在运行期,将消息发送给对象时,必须经过检查,以确保接收对象可实现该消息,但并不要求对象必须是某个特定类的实例。

经过以上的讨论,我们还要学习许多重要的课程。在实际应用中,最好是编写仅依赖接口的软件,而不是依赖特定实现的软件。如果软件不与任何特定实现绑定,一个接口可以由许多不同实现支持,那么,在使用支持接口的新对象时,也不必时常更改软件。这正是我们在软件设计中力争达到的目标,软件越独立于实现越好。

有时,就算我们知道一些实现细节,也应装作一无所知。即使实现做了改动(甚至是很大的改动),我们也不必为此担心,因为接口仍保持不变。但是,在许多情况下,程序员会在程序中(有意或无意地)涉及一些实现的细节。当处理对象时,这样的程序就非常依赖于特定的实现,迟早都会导致设计和实现的重大变化。

从另一个方面看(即接口设计方面),接口不应该暴露实现细节。精心设计的接口不应该要求客户了解实现的任何细节。如果在接口中暴露了某些实现细节(甚至是非常微小的),一些客户就会期望获得所有的实现细节,从而使得软件非常脆弱,毫无稳定性可言。

注意:

接口和实现的相关讨论,请参阅第5章。

2.20 什么是多线程安全类

传统上,操作系统(OS)只支持进程(也称为任务)。每个进程都有自己的地址空间,且有一个单独的执行线程,进程执行一个包含一系列指令的程序。但是,现在大多数操作系统都支持单进程中的多线程。一个任务可根据需要包含多个线程,单进程中的所有线程共享进程的地址空间,线程可以访问进程中的所有全局数据。

使用线程有很多优点。首先,创建线程的成本更低(且更效率)。创建一个新进程需要涉及操作系统中的大量工作(设置内存页面、注册、进程上下文等),而线程需要的大多数资源都可以从进程中获得。其次,线程间的通信更加容易。不同的进程位于不同的地址空间中,因此进程之间的通信(IPC)并不容易。但是,在单个进程中,不同的线程共享相同的地址空间,因此线程之间的通信非常容易。另外,在多线程中,可以阻止(block)操作,也可以并行处理操作。操作系统单独调度(schedule)每一个线程。例如,有一个应用程序,允许从一个设备上复制文件到另一个设备上,用户可能要求在中途停止复制操作。如果创建一个仅用于等待用户输入的单独进程,显然很不合理。在这种情况下,创建一个等待用户输入的单独线程更加合适。主应用程序执行复制操作,而辅助线程等待用户输入。如果用户决定终止复制操作,则运行等待用户输入的线程,并通知主应用程序,用户要求中止操作;然后,主应用程序线程中止复制操作。在单个应用程序中使用多线程非常普遍。再举另外一例,文档处理应用程序可以用一个线程打印文档,而另一个线程执行生成索引,同时还有另一个线程用于接收用户提供的文档摘要信息。

任何使用多线程的应用程序都可称为多线程应用程序。涉及多个线程时,同步(synchronization)和互斥(mutual exclusion)尤为重要。当一个线程正在访问某段数据时(例如,打印文档的线程),必须防止其他线程试图访问相同的数据(为了写入)。根据操作系统,实现可以使用互斥体(mutex)、信号量(semaphore)、临界区(crical section)、自旋锁定(spin-lock)、消息、事件等来达到这个目的。例如,文档中的每一页都由一个互斥体来保护,只允许一个线程访问该页。无论用何种访问控制的方案,实现必须确保不会发生死锁(deadlock)情况。

应用程序(或系统)在多线程运行的环境中正常运行称为多线程安全(multi-thread safe)。确保多线程安全并不容易,实现必须使用之前提及的某种同步方案来实现互斥。

我们在这里讨论的并不是新内容,只有在涉及多进程时,同步才是个问题。不管怎样,一个进程不能访问另一个进程地址空间内的内容,这使得同步稍微容易一些。但是,进程中的线程共享进程所拥有的资源。因此,确保适当的同步非常重要。例如,在文档处理的应用程序中,如果打印线程已锁定页面,索引线程在访问相同页面之前必须等待,直到打印线程解锁页面。

在使用引用计数(reference counting)(也称为使用计数(use count))方案的情况下,多线程安全非常关键。引用计数方案将在后续章节中讨论。修改引用计数必须是一个线程安全的操作。

在多线程环境中使用对象时,多线程安全更加重要。如果不能确保多线程安全,可能会导致灾难。一个进程内的两个线程可以使用相同的对象。记住,所有对象都共享成员函数代码。当一个线程调用一个成员函数,在成员函数内部完成执行之前,如果(操作系统)调度(schedule)另一个线程运行,且该线程也通过相同的对象调用相同的成员函数,则对象必须保证自身完整和运行良好。如果对象不能做到这一点,这样的类就不是线程安全(thread-safe)的。当然,如果一个类(成员函数和数据成员)没有任何线程安全的特殊要求,维持线程安全就完全不成问题。

在设计新的类时,注意多线程安全非常重要。如果类的对象即使在多线程环境下都能保持完整,必须在类的文档中予以说明。另一方面,如果类的对象不保证多线程安全,也要在类的头文件和文档中清楚地说明其局限性。不要误认为设计的每个类都必须保证多线程安全,事实并非如此。是否需要线程安全取决于类和客户的要求。还需记住,X类如果使用其他类作为它实现的一部分,为保证 X 类为线程安全,有必要保证它使用的其他类都为线程安全。或者,即使它所依赖的其他类非线程安全,至少必须保证 X 类线程安全(这更加困难)。为达到线程安全,下面列举了一些指导原则:

(1)如果类声明为线程安全,确保每个成员函数实现也是线程安全的。

(2)如果类在实现中使用其他的类(对象),确保仍然能保证线程安全。

(3)如果使用一些类库来实现类,确保正在使用的库函数是线程安全的。

(4)如果正在使用操作系统调用,检查以确保这些调用都是线程安全的。

(5)当使用编译器提供的库时,检查它们是否都是线程安全的。

许多库的供应商提供辅助类,用于帮助达到线程安全。例如,查看提供线程安全引用计数的类十分常见。如果你的项目需要线程安全,它可能会帮助实现一组确保线程安全的低级类。这样的类可以提供引用计数、线程安全指针、线程安全打印实用程序等。如果整个项目小组都在各自的实现中使用这些类,就能保证整个项目的线程安全。

线程安全

在本书中,需要重点注意线程安全的地方,将特别标出“线程安全”,如本段所示,方便读者查找定位。

2.21 确保抽象的可靠性——类不变式和断言

任何抽象都必须与客户履行它的契约(contract)。当客户使用类时,他希望类的对象像其发布描述的那样运行正常。另一方面,类的实现者必须千方百计地确保对象运行正常。但是,类只有在客户履行自己那部分契约后,才能正确行使它的职责。例如,类的成员函数可能要求传入的参数为非零指针(non-zero pointer)。只有满足此前提条件,成员函数才能保证它的行为。因此,客户必须履行一些义务。换言之,如果客户履行了她那部分契约,对象的实现者必须尊重客户那部分契约,并确保正当的行为。

在类和成员函数的文档中说明契约,这个主意不错。但是,把这些契约条件作为类的一部分实现代码,在执行类实现时检测契约条件,效果更好。这体现了断言(assertion)的巨大价值所在。

通常,断言是一个用于评估真假的表达式。如果表达式评估为假,则断言失败。例如,在TlaserDiscPlayer类中,Play成员函数会包含一个托盘关闭的断言。稍后即将介绍它的语法。

2.21.1 类不变式

进一步研究发现,在每个成员函数中(或甚至在一个成员函数的内部的多处)都包含一个断言可能并不方便。每个类都会在对象中包含一些恒为真的条件,无论对象调用任何成员函数,这些条件都必须为真。这样的条件称为类不变式(class invariant)。顾名思义,在对象中这些条件恒为真。如果我们以某种方式给类添加这些条件,并保证每个成员函数的代码都检查这些条件,将会非常方便。

2.21.2 前置条件和后置条件

除这些类不变式之外,成员函数可能会包含其他条件,在执行代码前必须保证这些条件为真。这些在操作开始被调用之前必须为真的条件,称为前置条件(precondition)。

C++:

在C和C++中,断言已经使用很长一段时间。所有的C和C++编译器都支持assert宏。该宏接受一个表达式,而且必须判断表达式的真假。倘若表达式判断为真,则继续执行;倘若表达式为假,则程序停止,并显示错误消息表明断言失败。消息中包含文件名,违规的语句源代码行号。这是最简单的(且唯一可用的)断言形式。Play成员函数的断言代码如下:

TLaserDiscPlayer::Play(unsigned atChapter)

{

// 这是断言代码

assert(this->_trayStatus == eClosed);

// 略去其余代码

}

Play成员函数在被调用之前,要求影碟播放机托盘关闭,这个条件表示为断言。

一旦成员函数完成它的操作,将会执行某些条件必须为真的断言。换言之,如果成员函数成功地执行完毕,它将生成一个满足某些条件的结果,这样的条件被称为后置条件(postcondition)。

Eiffel:

在Eiffel中,前置条件和后置条件都是非常流行的概念,而且得到了很好的支持。Eiffel运行期系统检查这些条件,并确保它们为真,否则,停止正在运行的程序。在require子句中,前置条件置于操作的开始。操作的后置条件将在操作末尾的ensure子句中说明。按照这样的方式,每个操作不管在何处被调用,都可以自由地使用任何前提和后置条件。

在进入和退出每个操作(成员函数)时,都必须检查类不变式。为了让实现者便于使用它,我们最好能将所有的不变式都置于类内部的已知区域。类不变式就是在对象的生命期内,必须保证对象状态的语句。例如,Person抽象中可能包含某个不变式,用于保证人的出生日期必须为有效数据,且姓名正确。与此类似,不允许透支的 BankAccount 抽象中可能包含余额不能小于零的不变式。这些不变式功能强大,它们允许实现者清楚地规定类的某些特性。客户在使用类的对象时,可以认为这些条件为真。

C++和Smalltalk都没有内置支持类不变式,但是Eiffel内置支持类不变式。

Eiffel:

在 Eiffel 中,类不变式定义在类内部的 invariant 区,每个操作内部都会检查它们。特别是,在进入和退出每个操作时,会检查类不变式是否为真。这确保可以随时预测类对象的行为。实际上,操作的每个前置条件和后置条件都包含对类不变式的判断。

2.21.3 使用断言实现不变式和条件

既然 C++未直接支持类不变式,我们就必须设法构造一些策略来达到类似的效果。assert宏可以在这派上用场,以下就是一个简单的策略。

简单地定义一对宏:PRE_CONDITION和POST_CONDITION,它们使用assert宏。

#define PRE_CONDITION(condition) assert(condition)

#define POST_CONDITION(condition) assert(condition)

如果需要,可以在这些宏中添加消息,当断言失败时打印消息。

#define PRE_CONDITION(message, condition) assert( (message, (condition)))

#define POST_CONDITION(message, condition) assert( (message, (condition)))

当断言失败,希望打印失败消息时,这会有所帮助。例如,你可以编写以下代码:

PRE_CONDITION(“Laser disc tray is not closed”, (_trayStatus==eClosed));

虽然这样的前置条件和后置条件只是简单的断言,但是,它们让程序更可靠、更易读,而且更加易于理解。

然而,添加对类不变式的支持并不容易。记住,在每次进入和退出每个成员函数时,类不变式都必须为真。要保证这一点,需要在一个函数(名为 InvariantChecker)中定义一组条件,且在每次进入和退出每个成员函数时,都调用该函数。这样的方法冗长且易出错,因为每个成员函数必须要调用这个函数。如果在类中添加新函数,这样的方法甚至更易出错。但是,却没有其他可行的办法,有总比没有好。如果没有其他办法,至少要在文档中清楚地说明,用户才能明确地知道实现者所保证的契约是什么。很多类的设计者提供非常清楚的类不变式文档,并遵循一些策略确保不变式被强制执行。

注意:

当使用任何一种策略时,都要根据需要添加可关闭或开启策略的支持(一对#ifdef)。

2.21.4 高效使用断言

高效使用断言可实现更可靠的程序。当条件不成立时,断言至少保证程序不会继续执行。但是发生断言失败,就不可能再复原。要解决这个问题,需要用到 C++支持的真正的异常管理工具(参见第10章)。但是,理解和使用异常并不容易,它需要适当的架构和高效的设计。断言简单且易于实现,众多程序员已经使用多年。

即使你并不打算精心设计程序,但至少要练习使用简单的断言。

不要认为一旦开始使用断言,就不能转用其他更好的处理方式。如果将来决定使用真正的异常管理,只需在代码中找到所有调用assert的地方,然后用适当的throw语句替换即可。

与真正的C++异常类似,使用断言不允许忽略错误条件。在第10章中将讨论这个问题,我们可以捕获异常,然后处理它们,而不是像断言失败那样,总是导致程序中止。换而言之,断言失败是无法捕获的异常。在许多情况下,未注意到的隐藏错误会对程序的其他部分造成严重的破坏。例如,如果忘记检查某个指针是否为零(NULL),函数的其他部分(或程序)企图使用这个指针时,将会导致应用程序崩溃。调试这样的错误相当费时费力。简单的断言可以防止出现令人不快的结果,而且还能告知程序崩溃的确切原因。在一些平台和编译器中,使用零指针访问成员函数不会出现任何问题,应用程序正常工作。但是,如果将该应用程序移植至另一个使用零指针访问的平台,很可能会导致程序立即崩溃,这时,你的恶梦就开始了(断言可以轻而易举地避免这种问题发生)。

在第10章中,我们将完整地讨论异常的架构和管理问题。

2.22 面向对象设计的表示法

正如我们需要语言来表达我们的思想,面向对象设计也需要词汇(或表示法)来传达问题解决方案的思路。我们需要一种表示法可以代表类、对象、类关系、状态图、进程图、对象关系等。

最初,在我开始写这本书时,有两种流行的对象表示法:Booch表示法(由Grady Booch of Rationsl制订)和OMT(对象建模技术,James Rumbaugh和他的团队在通用电气公司制订)。我喜欢用Booch表示法。然而,1994年Rumbaugh与Booch合作,开始从事统一表示法的工作。经过他们的努力,合并了 Booch 和 OMT 中使用的概念,提出了统一建模语言UML[33](Universal Modeling Language);1995年,Ivar Jacobson(Objectory公司)和用例方法(use-case approach)的拥护者又加入其中(在Rational公司收购Objectory公司之后),因此在UML中也能找到用例方法的一些要素。在编写本书时,UML的最新版本是1.1(1999年7月)。最初,我在书中全部使用Booch表示法,随着UML开始逐渐流行,它很快将成为标准面向对象表示法,因此,除Booch表示法外,我也在书中加入了UML图。如果你已经熟悉OMT表示法,会发现UML和OMT之间有几分相似。下面将重点介绍Booch表示法和UML。

2.23 Booch表示法

类用不定形(变形虫)来表示,如图2-5所示。图中只显示重要的属性(C++数据成员)和重要的操作(C++成员函数)。抽象类将在第5章中讨论,如图2-5所示。

对象用实线不定形表示(见图 2-6)。我在本书中很少使用对象图,但是,对象场景图(object scenario diagram)在描述解决方案的状态时非常有用。

2.24 Booch中类的关系

发现类并建立类之间的关系,是分析和设计过程中最重要的阶段。

Booch方法论在类之间使用两种主要关系——is-a关系和has-a关系。关系的完整列表如下:

●关联(association)

●继承(inheritance)(is-a)

●聚集(aggregation)(has-a)

●使用(using)

●实例(instantiation)(模板)

●元类(metaclass)

2.24.1 关联

关联(association)用于分析的早期阶段,最终会成为has-a、is-a或者“使用”关系。关联是一个双向关系,A的客户可以达到B,而B的客户也可以达到A(见图2-7)。

关系两端的数字代表基数(cardinality),每家银行可以有 0..N个客户,而客户可以在0..N家银行中有账号(见图2-8)。

2.24.2 聚集(has-a)

在这种关系中,A类的实例包含B类的实例(A“has-a” B)。这并不意味着一定是物理上的包含(physical containment)(通过值),也可以通过指针或引用包含。这种关系通常称为“has-a”关系,也称为聚集(aggregation),用于表示整体/部分关系(见图2-9)。

如图2-9所示,关系线上的标注表明了聚集的性质,线两端的数字表示基数。

人可以有多个驾驶执照(不同国家、不同类型的驾驶执照等),也可能一个驾驶执照都没有,这种情况用基数(0..N)表示。换而言之,人可以有 0个或者多个驾驶执照。符号●说明“has-a”关系的源头。该例中,一个 Person 有一个(或多个)DrivingLicense。在给定Person对象的情况下,可以查找(或枚举(enumerate))Person所拥有的DrivingLicense。但是,在给定DrivingLicense对象的情况下,不可能找到它所属的Person。这是因为“has-a”是单向关系,符号●表示关系的源头。Person 类对象旁的数字 1 表明任何 DrivingLicense对象都只属于一个Person。关系的名称“持有”也标在图2-9中。

“has-a”关系可以通过指针、引用或者甚至通过包含的对象来实现(见表2-2)。也就是说,“has-a”并不意味着每个Person类对象在物理上都包含许多DrivingLicense类对象(尽管也可能这样实现)。

“has-a”关系和“is-a”关系可以说是类之间最常用的关系。本书很多地方都使用has-a关系来表示类之间某些类型的包含关系。注意,“has-a”是一个通用术语,它的含义很多。例如,多人共同拥有度假屋,就可以表示为Person和VacationHome之间的“has-a”关系。

类似地,公司雇佣职员也是这样,仍然可以表示为Employee和Company之间的has-a关系(见图2-10)。关系的名称可以帮助我们更易理解关系的性质。

在职员——公司关系中,“为其工作”是从雇员的角度说明两者的关系。如果给定Employee类对象,我们可以找到她所工作的Company(一个或多个);如果给定Company类对象,为了找到Employee,还需要另一种关系,如上图的“聘用/雇佣”关系所示。这允许我们为给定的 Company 类对象找到 Employee,这种双向关系很常见。按照这样的分析思路,请注意,不可能在任何时候都为特定的 VacationHome 找到所有与之关联的Person,因为在 VacationHome 到 Person 之间不存在任何关系。从 Person 到VacationHome之间存在“has-a”关系,而从VacationHome到Person之间没有这种关系。这就是“has-a”语句是单向关系的含义。在“共同拥有”关系中,Person旁有一个基数(0..N)。这只表明VacationHome可以同时被Person(一人或多人)拥有。很明显,Person类对象在物理上并未包含VacationHome类对象在内。

再来看另一个“has-a”关系的例子。汽车有一个引擎,也就是说,Car 在物理上包含一个Engine,这种情况可以用线末尾的实心方块■表示,如图2-11所示。另外,空心方块□代表通过引用包含。

该例中,每个 Car 类对象都有自己的 Engine 类对象,这是真实的物理包含关系。而且,每个Car都有1个Engine类对象,如基数所示。每个Engine有4个或更多的Cylinder(汽缸),如图2-11所示。

“has-a”关系表示一种生存期——控制的关系。被包含的 Engine 类对象的生存期,由包含它的Car类对象所控制。当创建Car类对象时,也创建了Engine类对象;当销毁Car类对象时,也销毁了Engine类对象(它是Car的一部分)。

2.24.3 “使用”关系

“使用”关系用于表示大多数的客户——供应商关系。如果A类将B类作为A类成员函数的参数接收(或者从A类的成员函数返回B类对象),并且和B类没有任何其他类型的关联,此时A类使用B类。如果B类用于A类的某个方法的实现中,也存在“使用”关系。空心圆圈○表示“使用”关系。

在图2-12中,MailGateway(邮件网关)正在使用MessagePacket(消息包)。记住,“使用”关系并不意味着“has-a”关系。如果MailGateway储存MessagePacket类对象(以某种形式),那么MailGateway就有一个MessagePacket,这就没必要用“使用”关系。“has-a”关系是“使用”关系的超集,而且“has-a”比“使用”关系的功能更强大。

在问题的解决方案中,“使用”关系并不常见。

2.24.4 继承关系(is-a)

“is-a”关系用于表示继承,意味着从基类到派生类的一般-特殊关系(见图2-13)。

Manage(经理)是一个 Employee(雇员)。箭头从派生类(Manage)指向基类(Employee)。在后面的章节中,将详细介绍继承。

大多数问题的解决方案都使用“has-a”和“is-a”关系的组合。“has-a”和“is-a”都是非常强大的关系,它们各有优缺点。设计者必须清楚地理解何时使用“is-a”关系,何时使用“has-a”关系。本书中有许多示例都阐述了两者之间的差别。

2.24.5 类范畴

大多数项目都不可能在一张纸上显示出该项目涉及的所有类。为帮助分组(和建模)类,我们使用了类范畴(class category)。类范畴通过包含在其中的类提供服务。类范畴有自己唯一的名称(类似于类)。

要对整个系统进行高级描述,只能用类范畴(见图2-14)。类范畴的分解图必须显示其中包含的所有类,以及这些类之间的关系。

2.25 统一建模语言(UML)

本节将概述UML的一些特点。许多示例出自UML1.0版本的说明文档。

类用矩形表示,如图2-15所示。类名通常用粗体表示,如图中的Person所示。属性(可选类型和初始值)在类名下的第二栏(或框)中列出。操作(可选参数列表和返回类型)在类名下的第三栏中列出列表。在类的高级概述图(overview diagram)中,第二栏和第三栏可以省略,只在矩形中显示类名即可。

在类名的上方可以规定类的衍型(stereotype)。衍型表明它是何种类型的类,如异常类、控制类、接口类等。衍型包含在一对双尖括号(« »)符号中,该符号通常可以在大多数符号集中找到。为了方便起见,也可以使用一对(<< >>)表示(见图2-16)。

如图2-16所示,bad_cast类是一个衍型为exception的类,表明该类将用于异常管理(详见第10章)。类似地,PrintQueManager是一个单例类(该类只能创建一个对象,详见第9章)。衍型在这里的目的是指明类的性质。

抽象类(见第5章)的名称用斜体表示(见图2-17),抽象操作也用斜体表示。

对象用矩形表示,矩形中的对象名和类名带下划线(见图2-18)。

顶格中以对象名:类名的形式显示。匿名对象可省略对象名。如果不显示类名,也不要显示:。

可依个人喜好绘制表示类和对象的图形大小。

2.26 UML中类的关系

我们感兴趣的是一组类以及这些类之间的关系,而非单个类。

统一建模语言在类之间主要使用两种关系:关联(association)和泛化(generalization) (继承inheritance)。

2.27 关联

关联表示对象与不同类之间的结构关系(structual relationship),大多数关联都是二元关系(binary relation)。类之间的多重关联(multiple association)和类本身的自关联(self association)都是合法的(见图2-19)。

关联可以有一个名称,表明阅读方向的箭头为可选。注意,方向箭头为可选,但关联名必须显示。关联在不同的方向可以有不同的名称,但是,大多数情况下,没必要注明(特别是在已标出角色名(role name)的情况下)。

“为其工作”是从Person到Company的关联名,“雇佣”是从Company到Person的关联名,箭头指明关联的方向。如此详细的命名并不常见。

每个关联的末端就是角色。每个角色都有一个名称,说明其他的类如何看待这个类。Company将Person看做成“雇员”。类似地,Person将Company看成“雇主”。角色名必须唯一,它比关联名更重要。

每个角色都说明了类的多重性(multiplicity)。例如,Person可以为许多公司工作(即人与许多公司相关联)。这说明角色的多重性(有多少个 Company 类对象可以与一个Person 类对象相关联?)。符号*表明“许多”(对象的无限数目,其表示为 0..*)。一个Person 可以为许多 Company 工作,一个 Company 可以雇佣许多 Person。因此,Company对于Person的多重性就是1..*(许多)。多重性也可以是一个数字(1或5等等),或者是一个范围(1..5)。

关联末端的箭头表明关联的导航性。例如,如果可以找到(遍历)Person所拥有的所有DrivingLicense(驾驶执照)类对象列表,可以表示为图2-20。

Person可以拥有许多驾驶执照(不同国家、不同类别等),但是DrivingLicense一定只属于一个Person。

一个类还可以与本身形成关联,即成为关联类(association class)。在下面的示例中(见图2-21),ATMTransaction有自己的属性和操作。关联类也可以与自身形成关联。在关联线用虚线引出的类,即是关联类。

在该例中(见图2-21),ATM类包含诸如owner(谁操作ATM,通常是银行)、address (物理位置)等的细节信息。

当然,Person在Company中所占据的Job本身也是一个类(见图2-22)。

关联类可能只包含关联的属性,而无任何操作。在这种情况下,关联类的名称可以不显示。

2.27.1 作为聚集的关联

在许多情况下,我们更倾向于显示整体-部分关系。聚集[34](aggregation)是一种特殊形式的关联。这种情况下,部分(即整体所包含的部分)的生存期不再取决于整体的生存期(见图2-24)。

通过一个空菱形连接的类为聚集。不能在线的两端都绘制菱形(见图2-23)。当类之间没有生存期依赖时,该表示法用于表示常见的按引用聚集。Orchestra(管弦乐队)是Performer(演奏者)的全体演出者。如果将表示聚集的空菱形填充,则表示组合(composition)——聚集的一种加强的形式。稍候将讨论这个问题。

2.27.2 OR关联

在某些情况下,一个类可以参与两个关联,但是每个对象一次只能参与一个关联。BankAccount可以由一人或多人所持有(共同账户(joint account)),或由Company(公司)持有。这种情况可以用约束条件{or}表示。因此,Person的多重性是*,而Company的多重性就是 1。Automobile(汽车)也可作为类似的例子,Company 或 Person 都可以拥有Automobile。

2.28 组合

这是一种聚集形式,有很强的生存期,且部分和整体之间的所有权依赖关系也很强。聚集(容器)的多重性不能超过1个(无共享)。组合可以用三种不同的方式表示,其中一种熟悉的符号就是实心菱形。例如,AirPlane(飞机)对象有一个CockPit(驾驶舱)、 Engine (引擎)、Seats(座椅)等。CockPit 对象与 AirPlane 对象一起被创建(见图 2-25),一起被销毁(聚集是不可改变的)。

当多重性(基数)大于 1 时,可以在创建聚集本身后再创建部分(part),除非在聚集被销毁前,显式移除部分,否则部分会和聚集一起被销毁。一架 AirPlane 有多个 Engine和多个Seat等,而且在AirPlane类对象的生存期内,可以添加或移除Seat。当AirPlane类对象被销毁后,它所包含的所有对象都会被销毁,除非它们已经从AirPlane类对象中移除(例如,座椅可能被移除,复用于另一架飞机中)。在图2-26或图2-27中都表示了组合关系。

2.29 泛化关系(is-a)

泛化关系用于表示继承,意味着从基类到派生类的一般——特殊关系(见图2-28),图中的箭头必须为空心,另一种表示法如图2-29所示。多重继承如图2-30所示。继承将在第5章和第6章中讨论。

2.30 has-a关系的重要性

“has-a”关系(也称为关联、聚集、包含、组合)是在OOD(面向对象设计)中频繁使用的重要关系。但是,许多设计者和程序员都没有很好地理解其相关性,从而导致复杂僵化的设计和不必要的继承。

在OOD阶段,软件的复用主要通过两种方式完成:继承和包含。它们各有优缺点。优秀的设计者了解它们的局限性、优点和代价,可以灵活自如地应用它们。继承将在第5章、第6章以及第二部分的第12章中详细讨论。

包含是一项强大的设计技术,它比继承更容易实现和管理。包含是对接口编程的真正应用。再回到Car包含Engine的例子,Car类对象对于它所包含的Engine类对象没有特权。Car类对象只通过它的接口操作(或使用)Engine类对象,绝对不会违反封装原则。进一步而言,Car中包含Engine类对象只是一个实现细节,Car的客户无需知道这些细节。Car抽象提供了一个定义良好的接口,Car的客户只需理解Car的接口和行为。Car的实现者也可以在运行期自由替换Engine类型(支持Engine的接口)的不同对象。例如,如果Car类对象持有一个Engine类对象的指针(或引用),在运行期,可能会用某些支持Engine接口的其他对象替换现有对象。这种改变不会影响 Car 的客户,因为改变的是内部的实现细节(详见第二部分第11章)。

利用聚集,可以用不同的对象组合出新的行为。我们可以通过改变内部实现对象,来为现有对象添加新的性能。这使得软件更加灵活,能适应不同的修改需要。

但是,“has-a”关系也有它们共同的缺点。当使用带有包含关系的对象时,由于额外的间接层次太多,导致运行期的效率太低。带有聚集关系的对象,其行为更加难以理解和描述,因为它们的行为在运行期很容易改变。要实现可靠的包含关系还要对资源管理特别留心(避免资源泄漏)。

总而言之,在设计抽象时,要特别注意类与类之间的关系。如果决定使用继承和包含关系,要额外小心。在学习完继承后,我们将在第6章末尾重新讨论这个问题。

阅读:

了解Smalltalk中的消息(方法)关系。

研究Eiffel中的接口与实现分离。

学习Modula-2中的模块(module)概念,并与对象作比较。

学习不同语言中的动态绑定(dynamic binding)和静态绑定(static binding)。

了解Ada中的程序包(package)概念。

理解Java中的类和接口模型。

2.31 小结

数据抽象提供了隐藏无关紧要和不相关细节的重要特性。

抽象数据类型在实现中的变化不会影响接口。

封装实现的所有细节,类的客户无法了解已封装的实现。

数据抽象不是某种语言的特性,它是面向对象编程中的基本概念。

第3章 C++与数据抽象

在C++中,抽象的基本单元是类。类只不过是功能增强的C结构。本章将介绍C++如何支持数据抽象。

3.1 类概念的基础

在C++中,类和结构几乎相同(除了一些细微差别,稍后介绍)。对类概念的讨论,同样可适用于结构。

类的一些重要特性如下:

(1)类的访问区域有private、protected和public区域。

(2)类包含静态和非静态成员函数的原型(或签名)以及数据成员的声明。

(3)可以在类中包含另一个类(嵌套类)的声明。

(4)可以在类中包含静态数据成员和静态成员函数的声明(稍后介绍)。

如下所示为整数栈TIngStack类的示例:

/* TIntStack 是一个储存整数的栈,它提供普通的Push和Pop操作。

* 虽然实现通用栈也非常容易(将在第9章中介绍),但是,为了讨论方便,我们使用整数栈。

* 以下代码在IntStack.h文件中

* /

Class TIntStack {

public:

// 成员函数

// 默认构造函数

TIntStack(unsigned int stackSize = DEFAULT_SIZE);

TIntStack(const TIntStack& that); // 复制构造函数

// 赋值操作

TIntStack& operator=(const TinStack& assign);

~TIntStack(); // 析构函数

void Push(int thisValue);

int Pop();

unsigned int HowMany() const;

// 更多成员函数

private:

// 数据成员

int*   _sp;

unsigned _count;

unsigned _size;

};

3.2 类要素的细节

3.2.1 访问区域

客户可以访问在类的 public 区域中声明的任何成员。我们可以把该区域看做是通用公共(general public)的接口,它没有任何保护,是类限制最少的区域。一个设计良好的类绝不会将数据成员包含在public区域,该区域只能包含成员函数。如果在public区域包含数据成员,那么无需类的实现者,仅通过编译器即可访问这些数据成员。这违反了数据抽象和封装原则。这也是我们为什么总将数据成员放在private或protected区域的原因。

SMALLTALK:

在Smalltalk中,类绝不能包含公有实例变量,只有方法才能设置为公有。这样规定的目的是,只有类实现才有权访问实例变量。客户需要调用方法,才能获得和设置实例变量的值。

注意:

允许客户设置对象中的数据成员值的方法,通常称为设值方法(setter)。用于返回数据成员值的方法称为获值方法(getter)。

Eiffel:

在Eiffel中,类没有任何限制,可以导出(export)任何成员函数和数据成员。但是,客户只能访问却不能修改导出的数据成员。换言之,客户对导出的数据成员只有只读访问权限。另外,通过导出的成员名无法识别是数据成员还是成员函数。这和Pacsal类似,调用无参数的函数看上去像是对某变量的引用。

回到C++中,相对于public区域的另一个极端区域是private区域。成员函数的实现可以访问在类中声明的所有成员(也就是说,类的成员函数可以访问类作用域内的任何成员)。因此,编写类成员函数的程序员就是类的实现者。类的普通用户无法操控private区域,用户对private区域知道得越少越好。不言自明,如果某程序能访问private区域的成员,它也能访问类中的其他成员(包括public和protected区域)。实际上,C++在设计访问控制时很奇怪,一方面允许“看见”私有数据成员的声明;另一方面又不允许公共客户访问它们。

protected区域的限制比private区域宽松,但比public区域严格。protected区域用于给派生类(通过继承)使用,后面的章节将作详细介绍。如果客户能访问protected区域,也能访问public区域。

类可以包含多个public,private和protected区域,C++对这些区域的数量没有限制。任何区域都可以包含成员函数和数据成员。在类的不同区域中声明的任何成员(数据或函数)将获得相应声明区域的访问规则。

构造函数:构造函数是特殊的成员函数。它无返回值,而且不能是const或static成员函数。类可以包含任意数量的重载构造函数。

在创建对象时,会调用构造函数。可以通过多种方式创建类的对象,如下所列:

TIntStack myStack;           // ①

TIntStack s1(100);           // ②

TIntStack s2 = s1;           // ③

TIntStack *dsp = new TIntStack(200);  // ④

TIntStack s3 = TIntStack(250);     // ⑤

3.2.2 分析

①TIntStack myStack;

这里,我们想创建一个TIntStack类的对象myStack。这与以下声明类似:

int j; // 创建一个int的对象j

因为myStack无任何参数,所以该声明将为myStack调用默认构造函数。在第2章中介绍过,不带任何参数调用的构造函数即是默认构造函数。在 TIntStack 类中,确实有一个默认构造函数。它接受一个 unsigned int 参数(stackSize),而且该参数有一个默认值DEFAULT_SIZE。记住,对象只能用构造函数创建。

警告:

如果在 TIntStack 类中未声明任何构造函数,编译器会为其生成一个默认构造函数(default constructor)。这个生成的默认构造函数不接受任何参数,且只允许我们创建对象,但该对象并未初始化。一定要记住这条规则:只要在类中声明了一个构造函数,编译器就不会生成默认构造函数。可以认为编译器生成的默认构造函数的实现如下:

TIntStack::TIntStack() {/ * 空 */ }

而且,它是一个内联函数。

②TIntStack s1(100); // 创建一个 TIntStack类对象 s1

这里,我们试图创建一个TIntStack类的对象s1,其栈的大小为100个元素。这样的语法看起来像是函数调用,它的确调用了 TIntStack 类的构造函数,该函数接受一个整数(或unsigned int)参数。因此,将调用TIntStack类中匹配的构造函数。

在 TIntStack 类的构造函数的实现中,我们将用户要求的栈大小保存在数据成员_size中,将栈中当前元素的数目保存在数据成员_count中,并且使用一个指向int数组的指针_sp为栈的元素分配内存。

#include “IntStack.h”

TIntStack::TIntStack(unsigned int stackSize /* = DEFAULT_SIZE */)

{

// 只有stackSize 大于0时,才分配内存。

if (stackSize > 0) {

_size = stackSize;

_sp = new int[_size]; // 为stack的元素分配内存

// 将所有元素都初始化为0

for (int i = 0; i < _size; i++)

_sp[i] = 0;

}

else {

_sp = 0; // 为指针设置独特的值(unique value)

_size = 0;

}

_count = 0; // 栈中无元素

}

调用构造函数时,只创建了一个空对象,其中的数据成员包含无用单元(garbage)。这些数据成员就像是未初始化的自动变量,我们必须正确地初始化它们。通过检查以确认客户请求的栈的大小为正整数,然后使用 new()操作符分配内存,用合适的值初始化所有的数据成员。这些就是需要在构造函数中完成的工作。

警告:

如果将X类声明为:

// X.h

class X {

public:

X(int size = 256); // ⑧构造函数

X(); // ⑨其他构造函数

// 其余函数...

};

我们马上会注意到,X类中有两个相互矛盾的构造函数。例如:

#include “X.h” // 包含X类的声明

main()

{

X alpha; // 创建一个X类的对象alpha

X b(10); // 创建另一个对象 b

}

为对象alpha调用哪一个构造函数?我们可以认为调用了上面的⑨,因为它不需要传递任何参数。但是,也可以在函数没有参数的情况下调用⑧,这里存在冲突。[ 编译器一旦遇到诸如 X 类这样的构造函数声明,会立即标记出来。] 因此,当 X.h 进行编译时,将会报错,迫使X的实现者解决这个问题。这称为声明时错误检测(error detection at the point of declaration),错误一出现它就马上检测出来(即在X.h自身进行编译时)。然而,某些编译器直到使用这些构造函数时才报错(即在编译客户代码时才会检测出来)。这称为使用时错误检测(error detection at the point of use)。但是客户无法修复这个错误,因为他没有(或无权操控)X 类的代码。我们只能希望随着 C++编译器日趋成熟,这样的问题在所有的编译器中都能得到改善。

3.3 复制构造函数

③TIntStack s2 = s1;

这是一个小小的技巧。我们试图创建TIntStack类的另一个对象s2。但是,我们希望用s1初始化这个对象。换句话说,必须通过s1创建s2。当然,前提是s1已经存在。这类似于以下声明:

int j;

int k = j; // 创建一个k并初始化为j

在这种情况下,编译器知道如何用j初始化k,因为int是语言定义(内置)类型。然而,③中的TIntStack是程序员定义类型,这表明由程序员负责将s1初始化s2(在语言的一些帮助下)。这里要用到复制构造函数(一种特殊的构造函数)。无论何时我们需要通过现有对象创建一个新的对象,都要使用类提供的复制构造函数。该例中,我们会用到TIntStack类中的复制构造函数,类中已经声明了一个。复制构造函数的声明如下:

TIntStack(TIntStack& source)

或者

TIntStack(const TIntStack& source)

该复制构造函数有一个它所属类的参数(引用)。

警告:

如果我们在类中未提供复制构造函数,编译器会为类生成一个[35]。但是,生成的复制构造函数可能并不满足要求。我们将在第4章中进一步学习复制构造函数。下面先针对以下示例,了解如何实现自己的复制构造函数。

TIntStack::TIntStack(const TIntStack& source)

{

// 由于复制构造函数是该类的成员函数,

// 写于此处的代码可访问TIntStack类中的所有区域。

// 源实参(argument source)是TIntStack的一个对象。

_size = source._size;

if (_size > 0) { // 只有size大于0,才分配内存。

_sp = new int[_size]; // 为栈的元素分配内存

_count = source._count; // 栈中元素的数目

// 从“源”中复制所有的元素至栈内

for (int i =0; i < _count; ++i)

_sp[i] = source._sp[i];

}

else {

_sp = 0; // 为指针设置独特的值

_count = 0; // 栈内无元素

}

}

和其他构造函数一样,在调用复制构造函数时,新栈中的数据成员不存在有意义的值(它们都包含无用单元)。我们必须检查传递给复制构造函数的对象(复制操作的源,对象源)中数据成员的值,并正确地复制这些值。首先,检查源中是否有元素。如果有,就为其分配内存。但不要就此止步,因为我们正在把一个栈复制至另一个栈,真正的深复制(deep copy)操作要求复制所有的元素(浅复制和深复制的概念将在后续章节中介绍)。我们必须遍历栈上的所有元素,并将它们复制到目标栈上。

在复制操作中,如何将问题中的对象映射至复制构造函数的参数上?一图胜千言,下面我们用图来进行说明(见图3-1)。

成员函数只能通过现有对象调用。编译器初始化 s2(根据 TIntStack s2 = s1),然后通过s2调用复制构造函数,并把现有对象s1作为复制操作的源,复制给s2。此时,s2的数据成员中包含的是无用单元。在复制操作完成之后,栈s2和栈s1完全一样,如图3-2所示。

现在,用户可以独立地操作s1和s2。即操作s1并不会影响s2,操作s2也不会影响s1。在C++中,也可以这样写:

int j;

int k(j); // 相当于int k = j;

char *p(“ABCD”); // 相当于char *p = “ABCD”;

以上代码是将对象的初始化语法扩展至基本类型。

思考:

当栈很大时,这样的复制操作代价很大(从内存和 CPU 时间方面来说)。是否可以优化复制操作,不用再重复地复制存储区和元素?要记住,可以独立操作s1和s2,我们将在第4章中介绍解决方案。

④TIntStack *dsp = new TIntStack(200)

在①中,编译器负责为栈对象 myStack 分配内存(不是为对象中的元素分配内存,构造函数为元素动态分配内存,并将其地址储存在_sp 中)。为 TIntStack 类对象分配的内存包括其数据成员(_size、_count和_sp)的内存,以及编译器需要的其他内部信息。编译器控制这种对象的分配和生存期。在④的声明中,使用了new()操作符,这表明dsp所指向的对象应该通过动态分配内存,在堆(heap)上创建。这种动态分配对象的生存期应该由程序员控制,而非编译器控制。

创建对象时发生了以下3个步骤(无论怎样创建):

(1)编译器需要获得对象所要求的内存数量。

(2)获得的原始内存被转换成一个对象。这涉及将对象的数据成员放置在正确的位置,还有可能建立成员函数指针表(涉及虚函数时)等。这些都在编译器内部进行,程序员完全不用担心(详见第13章)[36]

(3)最后,在(1)和(2)都完成后,编译器通过新创建的对象调用构造函数(由类的实现者提供或编译器生成的默认构造函数)。

在静态创建对象的情况下,如果内存已在进程数据区(process data area)或运行时栈(run-time stack)中预留,则只需完成步骤(2)和(3)。如果有动态分配的对象,编译器会请求new()操作符为该对象分配内存[37]。然后,在新分配的内存上完成步骤(2)和(3)。最后,返回指向该对象(地址储存在dsp中)的指针。为了通过该对象调用成员函数,其语法与C结构中使用的语法非常相似。例如,将数字10压入dsp所指向的栈中,代码如下:

dsp->Push(10); // 将10压入dsp所指向的栈

对栈s2进行相同操作,代码如下:

s2.Push(10);

这两种方法非常相似。相比较而言,s2.Push(10)更为直接,对象直接被引用。但是dsp->Push(10)也间接引用对象。我们通过指针定位对象,并通过该对象调用Push。->是成员访问操作符(在C中也广泛应用)。->操作符与指向对象的指针一起使用。

3.3.1 访问对象的数据成员——C++模型

细心的读者(特别是熟悉Eiffel或Smalltalk的读者)会发现,上面的复制构造函数代码有些奇怪。在Eiffel和Smalltalk中,如果对象调用某成员函数,那么这个成员函数便可以访问该对象中的数据成员,这和 C++相同。换言之,成员函数可以访问当前对象的private(在C++中,还包括protected)成员(当前对象正调用该方法)[38]。这在C++中也完全正确。但是,在上面的复制构造函数示例中,代码还试图访问参数源(一个完全不同的对象)的私有成员_count。在C++对象模型中,保护应用于类层次,而非对象层次。这意味着,只要类的成员函数访问某个对象,它便可以访问同类其他对象的数据成员(和成员函数)。也就是说,X类的对象beta(有一个bar成员函数),可以访问X类的另一个对象alpha内部的任何成员,不受任何限制。这样的访问必须使用圆点语法(或->语法)。然而,Eiffel和Smalltalk强制执行对象层次的保护。也就是说,在Smalltalk和Eiffel中,即使对象都属于相同的类,成员函数也只能访问自己对象(也就是当前实例,即调用成员函数的对象)的私有数据,不能访问其他对象的私有数据。在这些语言中,要访问某个对象(同类或不同类)的私有数据,必须通过该对象的成员函数才行。当前对象没有任何特权可以访问其他对象的私有数据,只能使用提供给普通客户的方法。这是Eiffel和Smalltalk的程序员在学习C++时最容易混淆的地方。

注意:

在C++、Smalltalk和Eiffel中,每个对象都会获得它在类中声明的非静态(即关键字static未出现在声明中)数据成员的副本。然而,Smalltalk和C++支持共享成员的概念。在类中有这样一种成员,无论该类创建了多少对象,在程序运行过程中,只产生一个副本。这样的成员在Smalltalk中称为类变量(class variable)。在C++中,通过使用静态数据成员,也能达到类似的效果。这个概念将在第5章中详细讨论。

⑤TIntStack s3 = TIntStack(250);

实际上,⑤和下面的代码一样:

TIntStack S2 = S1;

但是,编译器优化可能会改变这个声明的实现。⑤这个声明表明我们正在请求创建一个TIntStack对象s3,而且为了初始化s3,指定必须创建一个包含250个元素的临时TIntStack类对象。临时对象将在s3创建之后消失。其实,我们不必创建一个临时对象,然后将它复制给s3,这样很浪费时间。为何不直接创建一个大小为250的对象s3?这就是大多数编译器所完成的工作。它们优化语句,并直接创建一个大小为250的TIntStack类对象。

接下来,我们举例说明其余部分。编写一个PrintStack函数,它按顺序从栈中弹出元素,并打印它们。

/*

* 该函数用于打印栈中的所有元素

* 按顺序Pop()元素,并按相同的顺序打印。

* /

void PrintStack(TIntStack thisOne)

{

// 找出栈中元素的个数

unsigned i = thisOne.HowMany();

for (unsigned j = 0; j < i; j++)

cout << “[” << thisOne.Pop() << “]” << endl; // 打印弹出的元素

}

下面是使用TIntStack类的main()程序。

#include  “IntStack.h”

#include  <iostream.h>

main()

{

// 写在这里的代码可访问TIntStack类 public区域中的任意成员,

// 但不能访问该类的其他区域。

TIntStack a(5);   // 自动对象,退出main函数即销毁。

TIntStack b(20);  // 自动对象,退出main函数即销毁。

TIntStack *ip = new TIntStack; // 动态对象

for (int i = 1; i <= 5; i++) { // 连续压入整数

a.Push(i);

b.Push(-i);   // 压入负值

}

// 继续通过b执行压入操作

for (; i <= 10; i++) { // 连续压入整数

b.Push(-i);  // 压入负值

}

PrintStack(a);   // 打印a中包含的信息

a = b;   // 将一个栈赋值给另一个栈

PrintStack(*ip);

delete ip; // 为什么要删除?稍后解释

return 0;

}

现在来分析一下。我们在运行时栈(run-time stack)上静态创建了两个TIntStack类对象,a和b,并且在运行时堆(run-time heap)上创建另一个由指针ip所指向的TIntStack类对象。我们在这些TIntStack中压入了一些数字,然后通过对象a调用PrintStack。

但是,PrintStack函数按值接受参数(即,a按值进行传递)。这说明不得不制作一个a的副本,并将该副本传递给PrintStack。那么,谁负责复制并稍后删除它?下面将详细介绍整个过程。

如果按值传递语言定义类型(如int和char),则由编译器负责复制和传递它们,并在函数返回后删除它们。但是在以上示例中,我们要按值传递一个程序员定义对象(TIntStack)。编译器如何对其进行复制?很明显,它并不知道关于对象的任何信息,也不知道复制对象需要什么。在这里要注意,我们通过现有对象创建了一个新对象。

正如C++中的定义(前面解释过),无论何时需要对象的副本,编译器都会调用对象所属类(在该例中是TIntStack)的复制构造函数。复制构造函数负责对对象进行有意义且安全的复制。因此,当调用PrintStack时,编译器将调用TIntStack类的复制构造函数制作一个a的副本。

在这种情况下,复制操作的源对象(source object)是调用的实参——a,目的对象(destination object)是PrintStack函数的形参thisOne。从main()中调用PrintStack时,将从实参a初始化形参thisOne。该操作将调用复制构造函数。此时,编译器先通过目的对象调用复制构造函数,并为该复制构造函数提供对象a作为源实参。我们已经知道了复制如何工作(参见前面复制构造函数的代码)。现在,PrintStack函数获得了原始对象的副本,而且,对thisOne的任何改动都不会影响main()中的原始对象。因此,保证了原始数据的安全。

如果离开PrintStack函数,对象thisOne会发生什么情况?在运行时栈分配的一切(自动变量)都将被清除,而且编译器将回收它们所占用的内存。所有的语言定义类型都是这样。因此,从PrintStack退出时,局部变量i和j不在作用域内(即它们在程序控制返回的main作用域内不可用),编译器将回收之前被它们所占用的空间。与此类似,局部对象thisOne不在作用域内,因此也应该回收它所占用的内存。编译器清楚地知道对象本身的大小,但是,它并不知道对象中的由指针_sp所指向的某些动态分配的内存。这时,类的析构函数会派上用场。

无论何时对象离开作用域,在编译器真正回收该对象所占用的内存之前,都会通过对象调用析构函数(类中仅有一个)。析构函数应该释放对象获得的任何资源。在该例中,需要回收TIntStack类对象中指针_sp所指向的内存。注意,虽然析构函数与其他成员函数类似,但是,只有当对象在作用域中不再可见时,编译器才会调用析构函数。[ 需要程序员直接调用析构函数的情况非常少见。] 一旦析构函数执行完毕,在此作用域内的对象就不能再被访问。注意,析构函数只在函数返回之前(或离开代码块之前)被调用。在退出某作用域之前,编译器通过该作用域内(函数或块)已创建的对象调用析构函数。但是,程序员无法使用这些对象,因为对象名在作用域退出时不可见。由于将要被销毁的对象在此时仍然存在,且运行良好,因此,析构函数可以通过它调用该类的其他成员函数。

以下是析构函数的实现:

TIntStack::~TIntStack()

{

cout << “Executing the destructor for TIntStack\n”;

delete [] _sp;

}

该析构函数的代码非常简单。它只用于释放对象在生存期内获得的所有资源(储存在_sp 中的是 TIntStack 类对象所分配的内存)。注意,我们无需担心其他数据成员(_count和_size)所占用的空间,因为编译器知道它们是对象的一部分,会负责处理。当创建TIntStack类对象时,对象的大小包含所有数据成员的大小。编译器不知道指针_sp所指向的内容,但它知道_sp本身的大小。

在析构函数执行完毕后,编译器将进行一些内部清理操作,以释放对象所占用的内存。

注意:

每个类只有一个析构函数。读者可能会奇怪,为什么 C++不允许有多个析构函数?为什么不是每个构造函数都有一个析构函数(构造函数-析构函数对)?编译器应该记住调用了哪一个构造函数,稍后再调用与之匹配的析构函数。为了提供这样的功能,需要在对象中储存额外的信息,而且也大大增加了实现者的负担,他们不得不为每个构造函数都编写一个析构函数。如果真有这样的要求,类的实现者会保留一些数据成员,用于跟踪被使用的构造函数,然后在析构函数中使用这些信息,确保操作正确。基本上,不同析构函数(如果语言允许)的代码都会被转移到语言允许的唯一一个析构函数中,类的实现者根据在创建对象时所保存的信息执行正确的代码。

注意到在main程序示例的末尾,通过指针ip调用了delete。为什么要这样做?原因非常简单。无论何时,只要我们使用动态分配,就明确地表示我们将负责控制它的生存期。编译器不会通过动态分配的元素调用析构函数。ip所指向的对象由new操作符动态分配,编译器不会自动释放它(即编译器不会通过该对象自动地调用析构函数)。当我们不再使用动态分配的对象时,要自行负责处理和销毁。正确的方法是,通过指向对象的指针调用delete 操作符。当我们希望处理动态分配的存储区时,只需通过指向该处的指针调用delete操作符即可,正如我们对数据成员_sp所进行的操作。通过指针调用delete操作符时,将发生以下两步骤:

(1)如果指针是一个指向类的指针(如该例中的ip),则通过该指针指向的对象调用类(该例中为TIntStack)的析构函数。此步骤由程序员执行清理工作。

(2)回收指针所引用的内存。

在main中为ip指针调用new()的步骤,基本与以上顺序相反。

调用析构函数可释放已获得的所有资源,然后,通过delete操作符释放new()所分配的内存。这就是在main()程序中调用

delete ip;

背后的原因。

3.4 赋值操作符

现在,让我们分析main程序中的语句:

a = b ;   // 将一个栈赋值给另一个栈

在该语句中,我们将对象b赋值给对象a,使用赋值操作符完成赋值操作。如果a和b都是简单的整数,无论a中的值是什么,编译器都会用b中的值将其擦写(overwrite)。就是这么简单。但是在该例中,a和b都是我们创建的对象,这意味着由我们负责赋值操作。我们知道TIntStack类对象之间如何进行赋值,并且能够实现赋值操作符。对于任何赋值操作符,都应注意以下几点:

(1)确保对象没有自我赋值(如a = a)。

(2)复用被赋值对象中的资源或销毁它。

(3)从源对象中将待复制内容复制到目的对象。

(4)最后,返回对目的对象的引用。

赋值操作符已在类中声明。TIntStack类的赋值操作符签名如下:

TIntStack& operator=(TIntStack& source) // ①

TIntStack& operator=(const TIntStack& source) // ②

下面是赋值操作符的实现:

TIntStack& TIntStack::operator=(const TIntStack& source)

{

// 检查自我复制(即a = a)。

// source是被赋值的对象,&source给出对象source的地址,

// ‘this’是被赋值对象的地址。

if (this == &source) {

cout << “Warning: Assignment to self.\n”;

return *this;

}

/*

如果源对象栈中的元素数目小于或等于目的对象的大小,则没有任何问题。

我们要做的就是复制栈中相应的元素和_count变量。

但是,如果源对象栈中元素的数目大于目的对象的大小,则必须先删除目的对象_sp

中内存,再为其分配新的内存(与源对象栈中元素的数目相等),然后复制所有元素。

*/

if (source._count > this->_size) {

// 源对象中元素的数目大于目的对象的大小,

// 删除_sp中的内存并重新分配内存。

// 见下文解释

delete [] _sp;

this->_size = source._size;

_sp = new int [ this->_size ];

}

// 无论是小于等于还是大于的情况,都会用到以下代码遍历栈,

// 并复制元素

for (int i = 0; i < source._count; i++)

this->_sp[i] = source._sp[i]; // 复制元素

this->_count = source._count;

return *this; // 见下文解释

}

分析:赋值操作由程序员显式完成,系统(编译器)绝不会直接调用它。赋值是在两个现有对象之间执行的操作。如下语句:

a = b;

当a和b都为对象时,上句可解析为:

a.operator=(b);

换言之,左侧(left hand side,缩写为LHS)的对象调用成员函数operator=(),右侧(right hand side,缩写为RHS)操作数作为operator=()的参数。赋值操作右侧的对象(该例中为b)不能被修改,该操作的副作用(side effect)是:返回对左侧对象(该例中为 a)的引用。因为不能修改右侧的对象(我们只能读取右侧对象并写入左侧对象),所以该对象应作为 const 实参传递给 operator=()函数(如②所示)。然而,如果使用没有const参数的赋值操作符(如①所示),就可以被修改右侧的对象。我们并不推荐使用①这样的赋值操作符,因为类的用户并不希望在赋值操作中改变右侧的对象。由于 a 和 b 都是真正的现有对象,因此,将b赋值给a时,a中的值将被b中的值擦写(overwrite)。

记住:

如果我们在类中未提供赋值操作符,编译器会为类生成一个默认赋值操作符(编译器绝不会自动调用它,但确实会生成一个)。但是,这样的默认赋值操作符可能无法满足我们的要求,下一章将对此作详细介绍。

在任何赋值操作中,我们要做的就是将数据从源对象赋值到目的对象。一般而言,这很容易,但在某些情况下会有困难。在 TIntStack 的示例中,我们需要从源栈赋值到目的栈。如果源栈中的元素数目少于或等于目的栈中的可用空间,赋值操作便非常简单,只需复制相应的栈元素。但是,如果源栈中的元素数目多于目的栈所能持有的数目会怎样?我们不允许这样的操作发生,在打印出错误消息后将从赋值操作返回。不过,这样限制太大。或者,我们可以改变目的栈的大小,使其能容纳所有元素。如果我们接受后者的方案,就需要在目的栈上为源栈的元素分配与源栈大小相等的新内存。这样做之前,我们还要删除目的栈中_sp 所指向的现有内存(无法扩展这个内存),这就是上面的赋值操作符中调用delete所完成的任务。一旦完成内存分配,我们就只需要复制相应的元素进栈即可(见图3-3和图3-4)。

(摘自前面的main程序)

TIntStack a(5);  // 自动对象(auto object),在退出main时被销毁

TIntStack b(20); // 另一个自动对象

最后,我们返回对目的对象(也就是栈a)的引用。你可能会质疑,为什么要这样做?假设我们编写了一个级联赋值操作(cascaded assignment operation)如下:

c = a = b; // 将b赋值给a,然后将结果再赋值给c

赋值操作按从右到左的顺序执行[39],此表达式即转换为:

c = (a = b); // 首先将b赋值给a,然后将结果赋值给c

警告:

既然可以写“a = b;”,那么也可以写

a = a; // 语法上正确,但是逻辑上错误!

这样的表达式可能会通过指针和引用(别名)直接或间接地发生,我们的实现必须检查是否出现这种情况。因此,必须核实源地址和目的地址是否相同。如果赋值发生在相同的对象之间,则不会进行任何操作,只返回对目的对象的引用。这就是上面示例的实现中,进行自我赋值检查所完成的工作。

如果没有进行自我赋值检查,可能会导致严重问题。考虑以下包含指针数据成员的TString类:

class TString {

public:

TString (const char* sp) {

_data = new char[strlen(sp) + 1];

strcpy(_data, sp);

}

TString & operator=(const TString & assign);

private:

char* _data; // 字符串指针

};

TString& TString::operator=(const TString& assign)

{

// 错误代码,未检查自我赋值

delete [] this->_data; // ①

_data = new char[strlen(assign._data) + 1]; // ②

strcpy(this->_data, assign._data);

return *this;

}

TString  x1(“Text string1”);

x1 = x1;

以上operator=的实现并不安全,其中的①,删除的是x1._data。接着在②中,我们又试图使用 assign._data。因为左侧的对象(*this)与右侧的对象(assign)是同一个对象,即assign._data与x1._data相同。所以,我们搬起石头砸自己的脚。当执行②时,该程序可能崩溃,或者在别的地方出现问题。因此,在赋值操作的实现中,必须进行自我赋值检查。

将b赋值给a非常简单,之前的示例中已介绍过赋值如何工作。我们希望将赋值(b赋值给a)的结果再赋值给c。为使其正常工作,第1个赋值操作应返回a的值(或对a的引用)。否则,从a到c的第2次赋值就会出问题。通常,在C和C++中,我们只知道使用赋值操作的结果,却并不了解内部的实现。例如,正是因为赋值操作的副作用,才使得以下代码可以正常工作:

int i = 10, j = 20; // 此处无赋值操作

int k;

cout << (i = j) << endl; // 将j赋值给i,并打印赋值结果①。

if (k = j) { // 将j赋值给k,如果结果不为0,执行if体内部的语句②。

// 省略此处代码

}

在①中,完成赋值后返回i的值,并打印该值。与此类似,在②中,将j赋值给k,完成赋值后,返回k的值并与0进行比较。

为了让以上赋值都能正常工作,必须确保在赋值操作完成后,该赋值操作返回对 a 对象的引用。

Pascal:

在类似Pacal的语言中,以下的赋值操作没有副作用:

a := b;

在赋值后无法返回a的值。因此,以下语句

c := a := b;

无法在Pascal(以及类似的语言,如Modula-2)中使用。

思考:

对比复制构造函数和赋值操作符中的代码,会发现很多相似的地方。它们都将现有对象复制到另一个对象中。但是,你能否发现两处代码的主要差别?换言之,复制构造函数和赋值操作符的代码有何区别?能否让它们共享实现?

为完成TIngStack的示例,其余的成员函数实现如下:

void TIntStack::Push(int what)

{

if (_count < _size) {  // 如有更多空间储存元素

_sp[_count] = what;   // 储存元素

_count++;  // 递增count

}

else {

cout << “Stack is FULL. Cannot Push value” << what << endl;

}

}

int TIntStack::Pop()

{

if (_count <= 0) { // 栈为空

cout << “Stack is EMPTY\n”;

exit (1);  // 如果失败如何报错?可在此处抛出异常。

}

_count--;

return _sp[_count];

}

unsigned TIntStack::HowMany() const

{

return _count;

}

3.5 this指针和名称重整的进一步说明

在前面介绍的实现中,我们有时使用this指针访问对象的数据成员。如第2章所述,成员函数中的this指针指向调用该成员函数的对象。在前述的main()程序中,如下代码

a.Push(i);

通过a对象调用Push成员函数。在Push成员函数内部,this指针持有a对象的地址。以这样的方式,成员函数可以访问对象内的任何元素(数据成员和成员函数)。如第2章所述,编译器像实现其他函数那样,实现每个成员函数,但是,每个成员函数应该可以通过某种方法访问调用它的对象。为达到这个目的,this指针将作为隐藏的参数传递给每个成员函数,且 this 指针通常是函数接收的第1个参数(其后是已声明的其他参数)。假定 Push成员函数的实现如下:

void Push(TIntStack* this, int what)

{

// 实现代码如前所述

// 通过a调用Push时,即a.Push(10),

// “this”指针指向a对象。

}

然而,Push可能是一个已经使用的函数名。或者说,在其他类中可能也包含了Push函数。编译器(或链接器)如何区别它们?我们如何表示重载的构造函数和析构函数?要回答这些问题,必须使用函数名重整(function name mangling)的概念。

注意:

ANSI C++ 语言标准对名称重整的样式未作要求(稍后讨论)。编译器的实现者可以自行规定名称重整的方案(甚至包括所有权)。了解一下名称重整有好处,但并不需要理解详细的重整方案。以下示例仅用于介绍概念,其重整方案依不同的编译器而已。

类的每个成员函数都包含类名(实际上是this指针的类型)和一些其他的信息。Push成员函数可能变成:

void Push__9TIntStackFi()

在数字 9 前面有两条下划线(ASCII _,十进制为 95)。数字代表类名的字符个数(TIntStack为9),这对于解析名称很有帮助。如果程序员不得不查找类(某函数所属)的名称,那么只需先查找__和其后的数字(假设为 N),然后提取数字后的 N个字符,即可获得该函数所属的类名。剩余的字符代表参数类型和返回值类型。注意,只有参数类型(不是参数名)才用于名称重整。__后的字符序列按照该函数已声明的所有参数顺序编码。在上面的重整名称中,Push为原始名,9TIntStack表明该函数的第1个参数(即this指针)类型为TIntStack,F[40]表明该函数为全局函数,i表明该函数接受1个整数参数。返回值类型不是重整名称的一部分。

构造函数并没有特别的名称!它的名称与类名相同。为表示构造函数,在重整过程中,会在类的重整名称前加上

__ct__

因此,重整后的默认构造函数应该是:

TIntStack(); // 普通的未重整名称

__ct__9TIntStackFv(); // 重整后的名称

同样地,此处的9TIntStack仍代表this指针的类型,F表明该函数为全局函数,v表明该函数不接受任何参数(void)。

其他的构造函数(如果有的话)也会包含参数,因此,它们确切的名称应该不同。例如,复制构造函数应该是:

TIntStack(const TIntStack&); //未重整名称

__ct__9TIntStackF9TIntStackRC(); // 重整名称

// R 代表引用,C 代表const。

第二个9TIntStack代表参数,RC表明该参数类型为const的引用。欲了解名称重整的细节,详见ARM[41]

根据以上的分析,析构函数(每个类只有一个)应该为:

~TIntStack(); // 未重整名称

__dt__9TIntStackFv(); // 重整名称

类似地,操作符函数也有经过特定编码的重整名。例如,赋值操作符被编码成:

__as__9TIntStack();

全局new()操作符以__nw开始,全局delete操作符以__dl开始。

语言中所有的操作符都有预定义的(pre-defined)编码。

你可能会问,为什么要知道关于名称重整的这些细节?

想象一下,你实现了一个类,但忘记实现一些成员函数。编译过程可能没什么问题,但链接器会对未定义的函数报错。这些显示在报错信息中的函数,看上去与你在类头文件[42]中的函数不一样。现在,你彻底糊涂了!

这是因为名称重整的缘故。链接器在报错时绝不会打印原始的函数名,它只会给出由编译器提供的等价重整名。链接器对重整一无所知(至少到目前为止,我还未见过链接器了解此事),它只会抱怨那些未定义的名称。此时,如果了解一些名称重整会对你有帮助。如果链接器打印出的名称以__ct__开始,则说明出问题的是某个构造函数,这缩小了查找未定义成员函数的范围。随着你编写的C++代码越来越多,重整名称会成为你破译报错信息的秘密武器!相信我。

警告:

记住,this指针是个非常神圣的指针。不能修改this指针,绝不能给它赋值!需要在成员函数内修改this指针的情况非常少见,稍后将会介绍这样的示例。

你可能也注意到,在每个成员函数内,我们并未用 this 指针访问数据成员。例如,在Pop函数中,无需任何限定符,即可直接访问数据成员_count。无论何时在成员函数中使用数据成员的名称,编译器都会为其预先添加“this”限定符。因此,_count 成为this->_count。除非我们明确需要指向对象的指针,否则不必显式使用它。

样式:

在本书中,只要有可能出现混淆的地方,都会显式使用 this 限定符。例如,在之前的赋值操作符中,有如下语句:

this->_count = source._count;

也可以写成:

_count = source._count;   // 这样写也正确!

但是,同一语句中,通过不同的对象多次用相同的名称_count 会令人困惑。因此,要显式使用this限定符。

什么时候必须使用 this 指针?当我们希望返回对调用某函数的对象的引用时,必须使用*this。否则,如何显式表示“我的对象”这个概念?另一种情况是(如前所述的赋值操作符中),我们希望获得对象的地址,也必须显式使用this名称。到目前为止,这是显式使用this名称最常见的两种情况。

3.6 const成员函数的概念

TIntStack类中的HowMany成员函数有特别的签名:

unsigned HowMany() const;

如第2章所述,const后缀表明HowMany是const成员函数。这意味着,HowMany不会修改它的调用对象。换言之,HowMany不会对该对象(即调用HowMany函数的对象)的任何数据成员赋值。这样的限制由编译器强制执行。以下为该函数的实现:

unsigned TIntStack::HowMany() const

{

// 如果添加_sp = 0; 语句会怎样(仅举个例子)

// _sp = 0;

return _count;

}

注意,要同时在成员函数的声明和定义中指明const限定符。

上面的代码不会修改对象内的任何数据。它只是读取数据成员_count 的值。我们可以将const成员函数看成只读函数。如果在调用const成员函数之前先查看对象中的数据,然后在const成员函数返回后再次检查相同对象中的数据,会发现两组值无任何差别。因为调用的函数是const成员函数,所以对象中的数据都不会被更改[43]

如果上面带注释的代码行取消注释,编译器就会发现给成员函数_sp赋值的行为,并立即将其作为错误进行标记,因为我们违反了成员函数的常量性(constantness)(即在const成员函数内改变数据成员)。

const成员函数到底有什么优点?回顾一下第2章中,关于接口和客户的讨论。const成员函数向它的客户保证它运行良好,并告诉客户调用const成员函数没有危险。这将逐渐建立客户的自信,他们对正在使用的软件会更加有信心。

在以上的讨论中,我们所见的就是逐位常量性(bitwise constantness)或位模式[44]常量性(bit-pattern constantness)的概念。const成员函数不会修改对象中的单一位。对象中的位模式在调用const成员函数前后都一样。编译器只是强制执行这种位模式常量性。

思考:

然而有些实例,当成员函数修改对象内部的数据时,从客户的角度看,并不会影响对象。换言之,尽管对象内部的成员函数发生了变化,在客户看来,对象并未改变。这样的成员函数称为强制执行概念上的常量性(或虚常量性(virtual constantness))。即使对象内部的某些位已发生变化,但该对象仍然保留概念上的常量性。我们将在第4章中介绍相关示例。编译器不会强制执行概念上的常量性——这必须由实现者来完成。我们想给客户传达const 成员函数的视图,与此同时,改变对象内部的一些数据。要让客户信服此函数为const成员函数很容易,但如何在const成员函数内部改变对象中的数据?编译器会捕捉到这个错误!如果必须在const成员函数内部修改某些数据,正确的处理方法是:声明这些数据成员时添加前缀mutable限定符(例如,mutable bool _cacheValid)。这样,即使在const成员函数中,也可以修改_cacheValid。

3.7 编译器如何实现const成员函数

了解编译器如何强制执行逐位常量性(bitwise constantness)非常有趣。记住,成员函数没什么特别,它只是一个带有奇怪名称和this指针的函数。那么,编译器如何能检测到成员的赋值?

这非常简单。数据成员和函数之间唯一的连接就是this指针。const成员函数必须把调用它的对象当做const对象,这可以通过将this指针声明为指向const的指针轻松地做到,很简单。现在来看看带有显式声明this指针的HowMany原型:

unsigned HowMany(const TIntStack* this);

根据这个声明,任何通过指针给对象内部的数据成员赋值都是非法的,因为该this指针是一个指向常量的指针。

在同一个类中,可以包含两个相同的函数,一个是 const 函数,另一个是非 const函数。这完全可行,而且在某些环境中非常有用。

注意:

const成员函数不能在它的实现中调用另一个非const成员函数。因为相同的对象(相同的this指针)也可以调用非const成员函数,而此非const成员函数可以随意修改对象。这违反了调用const成员函数的常量性(constantness),编译器将会检测出来。

Eiffel:

Eiffel 支持与 const 成员函数非常类似的概念。Eiffel 有两种类型的成员函数:过程(procedure)和函数。过程与普通的成员函数类似,但它们无返回值。函数是一个操作,根据对象中包含的值进行计算,并返回计算的值,这样的函数不会改变对象的状态。因此,过程和函数都与const成员函数类似。Smalltalk并没有类似的概念。

3.8 C++中类和结构的区别

类和结构之间只有一个微小的差别:如果不予以指定,类中的元素都为private,而结构中的元素都为public。例如:

class X {

int  a;  // private数据成员

void f(); // private成员函数

};

struct Y {

int  a;  // public数据成员

void f(); // public成员函数

};

这是C++中,类和结构的唯一区别。

到目前为止,我们了解了C++中一些关于类和对象的细节,以及一些语言特定的细节。可以利用这些知识编写小型的C++程序来锻炼自己的C++技巧,我们鼓励你多做这样的练习。本章的余下部分将涉及OOP的概念,包括接口和类的客户角度。我们还将介绍类设计的关键要素。我相信,这些内容比之前讨论过的内容更为精彩,更加有趣。

3.9 类可以包含什么

在C++中,类可以包含:

●基本类型的数据成员(如int和char)

●另一个类的对象

●指向另一个类(或相同类)对象的指针和对另一个类(或相同类)对象的引用

●指向基本类型的指针和对基本类型的引用

●静态数据成员

●成员函数(静态和非静态)

●指向另一个类的成员函数的指针

●友元类和(或)友元函数声明

●另一个类的声明(嵌套类,极少使用的特性)

在接下来的内容中,我们将介绍以上所有的声明(以及它们的优缺点)示例。下面的TPerson 类中包含了所列的各种声明。然而,这些声明都不完整——仅是代码片段。读者可以先对比它们之间的不同。

// TPerson类

class TPerson {

public:

TPerson(const char theName[], const char theAddress[] = 0);

const char* GetName() const;

private:

char* _name; // 指向字符的指针

char* _address;

};

class TListNode; // 前置声明 – 稍后再补充完整

class TListIterator;

class TList {

public:

TList(); // 创建一个简单的链表

unsigned HowMany() const;

private:

TListNode* _head; // 链表的首节点

TListNode* _tail; // 链表的末节点

friend class TListIterator;

}

class TListNode {

// 其他声明

private:

TListNode* _next; // 指向下一节点的指针

TListNode* _previous; // 指向前一节点的指针

};

class TStopWatch {

public:

TStopWatch();

private:

static long& _systemClock; // 对系统指针的引用

};

3.10 设计期间的重点——类的接口

在第2章中讨论了接口和实现的概念,现在来进一步学习它们。当客户(任何使用类创建对象或通过继承使用类创建另一个类的人)查看类时,最关心的是类中声明的内容。更具体地说,如果客户只准备创建类的对象(大多数客户都会这样做),他会注意public区域;如果客户试图通过类的继承创建(设计)一个新类,他会注意public和protected区域,这种情况下,无需过多关注private区域。

通过观察类的公有成员函数,客户可获知能对类对象进行的绝大多数操作。这些成员函数只是客户通过对象进行操作的工具。例如,回顾第2章中的影碟播放机,用户(客户)通过查看影碟机面板上的按钮,即可获知能对影碟机进行哪些操作。这些按钮类似于影碟播放机抽象的成员函数,它们应该能让客户明白播放机可以做些什么。但同时我们也不要提供过多的按钮(或控件),那会让用户很困惑。再者,每个按钮应该清楚地表达各自的用途。为了让客户对播放机有统一的印象,一个按钮应该有且仅有一个用途。但是,有时仅根据面板上的按钮名可能难以理解(和使用)某些特殊的按钮,这时就要查阅用户手册。用户手册中会详细完整地描述每个按钮和控件的功能。同样,设计良好的类也需要一个文档,用于描述每个成员函数的用法。这些都是设计良好接口的关键。与此类似,我们与人初次见面,会对他/她有第一印象(友好、敌对、肤浅等),接下来的交谈将直接受第一印象的影响。由此可见,设计良好的类应该对它的客户友好。下一节,我们将初步介绍一些设计良好接口的关键要素。

3.11 类名、成员函数名、参数类型和文档

类通常被另一个程序员用来创建对象(或者通过继承创建其他类),而方法则被这些对象(可能提供参数)调用。我们不仅要为类和其中所包含的方法提供有意义的名称,还应该为成员函数的参数使用合适的名称,这样,客户可以清楚地知道某个特别参数的用途。许多程序员只指明函数接受的参数类型,而不给出名称,必须改掉这个坏习惯。当然,编译器不会在意你选择什么名称,它只负责匹配类型。但是,参数名可以向客户传达许多信息。除此之外,我们还要注意使用正确的参数传递模式(值、引用或指针)。

在大多数情况下,仅通过查看名称,无法清楚地了解类及其成员函数的用途。我们必须提供详尽的文档(documentation),其内容包括:

●类的用途

●预定用户(打算给谁使用)

●它所依赖的类(如果有)

●类的限制是什么

●它期望从客户方面获得什么[45]

在多线程系统中,还要进一步说明在多线程执行的环境中,类是否可用,这非常重要。大多数公司、项目和架构都要求类的设计者和实现者提供更多详细的文档。在设计和为类编写文档时,遵循所有的指导原则非常重要。

还有一点也至关重要,类的设计者(和实现者)必须非常清楚地说明创建对象的限制条件。例如,某些类要求只能在运行时栈中创建类的对象(以确保自动销毁);而另一个类可能限制只能在动态堆上创建对象(即对象必须只能用new操作符创建)。这些对创建对象的限制不是很常见,但是,如果遇到也不必惊讶。用文档说明创建对象的限制是个不错的主意,但并不是最佳方案,最好是能通过语言强制执行这些限制。在 C++中,控制对象在何处创建非常容易。实现它们的技巧将在第11章中介绍。

类中包含的每种方法,都需要一个类似的说明文档。类文档(class documentation)将客户的注意力集中在一个方向,而且每种方法的文档(或说明)进一步阐明了该方法在类中完成的工作。这样的文档不应该是一本厚厚的书,它可以是头文件中的简单注释(对于简单方法),或是和类一起提供的辅助文档。大文档会让客户感到害怕,他们担心类太复杂,难以理解(客户认为正是这些原因导致需要大的文档),因此不愿意使用它。一个设计优秀并带有适宜说明的类将吸引客户的注意力,并鼓励她们使用。这类似于一个维护良好的公园,它引诱你入园漫步。在头文件中作简明扼要的注释非常有用,因为大多数程序员会首先在头文件中查找信息。此外,每种方法也应指明它所接受的每个参数的用途。在使用引用和指针(大多数是指针)的情况下,必须清楚地定义存储区所有权的职责(ownership responsibility),否则,会导致内存泄漏或运行时崩溃,扰乱系统程序的正常运行。事实上,大部分与资源相关的问题都是由于类的实现者和用户未明确各自的职责引起的。当方法返回值(引用,指针或值)给调用者时,也需要明确地定义存储区所有权职责。要养成尽可能使用const参数和const成员函数的习惯,因为编译器能识别const元素,而且const可防止意外的修改。语言不能阻止恶意用户的所作所为,它只能防止用户在使用时出现的意外错误。

你可能会问,为什么要对文档和参数传递如此小题大做?为什么不能让编译器来处理这一切?问题是,很多程序员可以完成的事情,编译器根本检测不到。编译器无法读取你的想法,也不知道函数声明中某个参数的用途。编译器所能见的只是类型名,它根本不关心函数的参数名。但是,对我们而言,这些参数名有实际意义。作为程序的设计者,我们在设计中将特定议题作为目标,并尝试解决一些问题。编译器完全不明白这些议题,对它而言,任何程序都是一系列的指令而已,它无法知晓“设计蓝图”。这就是文档、约定(convention)和指导原则发挥作用的地方。

3.12 参数传递模式——客户的角度

在设计类的接口时,要声明类的成员函数,并指定它们的参数。类的客户调用成员函数时,提供实参(如果有的话)。

每种方法都应清楚地指明参数的传递模式,参数可以按值、按引用或按指针传递。与const联合使用,参数会更加安全可靠。函数的原型用于向客户传达这些信息。

每个参数的传递模式都给客户传达特定的含义。再者,有时还需遵循一些经长时间验证确实行之有效的规则。因此,为参数选择合适的类型非常重要。在接下来的内容中,我们将介绍参数传递的不同样式和它们的含义。

注意:

在接下来的示例中,术语主调函数(caller)指的是g()函数(或者main程序),它调用另一个函数f()。在这种情况下,f()就是被调函数(callee),即g()所调用的函数。换言之,主调函数是发起转移控制权的函数,被调函数是接受控制权的函数。

在以下所列例子中,将使用T类和X类,以及X类的成员函数f()。无需考虑T类和X类中所具体包含的内容。

(1)void X::f(T arg)  // 第一例,按值传递(pass by value)。

被调函数可以对arg(原始对象的副本)进行读取和写入。在f()内改动arg不会影响f()的主调函数,因为主调函数已提供原始对象的副本。这也许是参数传递最佳和最安全的模式,主调函数和被调函数互相保护。但是,这种模式也存在缺点:要调用复制构造函数复制原始对象,再将原始对象的副本传递给f(),而且在退出f()时,通常还必须通过arg调用析构函数。必须记住,每次调用构造函数后,迟早都要调用析构函数销毁对象。构造函数和析构函数的开销很大。再者,有时复制对象操作仅限于特权客户或被完全禁止。在这种情况下,就不能使用按值传递,用按引用传递参数会更好。另外,复制大型对象非常耗时,此时,按值传递参数通常不是首选的方案。f()不应该在对象(该对象调用 f())的数据成员中储存arg的地址(使用this指针),因为一旦退出函数,arg即被销毁。

(2)void X::f(const T arg) // 第二例,按值传递。

该例和上例非常相似,仍然是按值传递,且它有普通按值传递所有的优缺点。但是,在该例中,被调函数只能对arg进行读取,不能写入,因为arg被声明为const对象。通常,主调函数对这样的参数传递样式都视而不见。实际上,主调函数并不关心被调函数如何操作它的副本。因为那只是个副本,并不是真正的对象。const 仅是被调函数对原始对象副本施加的额外限制。

(3)void X::f(T& arg) // 第一例,按引用传递(pass by reference)。

除非有其他的说明,否则,该例意味着被调函数可对arg进行读出和写入。换言之,arg是一个输入输出形参(in-out parameter)。被调函数可以修改真正的对象,也就是说,f()可以在需要时从arg中读取输入形参,然后再将结果写回arg中。如果确实打算这样操作,要在注释中清楚地说明。注意,arg属于主调函数,f()不会销毁它。

另一方面,我们可能打算把arg作为只输出形参(out-only parameter)使用(即被调函数可将结果值写入arg中,但不能从中读取值)。编译器无法强制执行这个规则。通常,在这种情况下,arg 是一个未初始化的对象,仅用于返回值(只是一个输出形参)。主调函数创建一个空对象(可能使用默认构造函数),并将其传递给f(),f()把返回值写入arg中。需要更详细的文档才能清楚地说明该意图。如果打算把 arg 作为输出形参使用,那么,最好让这样的函数都遵循一种不同的命名约定(如函数名前缀 Copy)。在主调函数已选定储存格式,被调函数只负责填充原始对象的情况下,使用引用作为输出形参是不错的方案。通常这种情况要用到继承层次,我们将在后续章节中介绍。记住,arg属于主调函数。按引用传递参数保证了参数是活的对象,它不像空指针那样。这也保证了引用的对象在f()调用的生存期内一直存在。不要在真正需要使用对象的地方,使用指向T的指针。

警告:

通常认为,无论何时传递引用参数,被调函数都不应该保存 arg 的地址。因为在退出函数后,无法保证arg还存在。(3)说明,f()应假设arg的生存期受限于f()的作用域内。想象一下,如果f()将arg的地址保存在它的对象(该对象调用f())的一个数据成员中,稍后试图通过已保存的地址使用arg。在此之前,arg可能已经在主调函数的作用域内被销毁了。主调函数不会保证 arg 的生存期!进行任何类似的操作一定会导致程序崩溃(或引起无法预料的行为和难以追踪的潜在程序错误)。以上的分析并不是说f()不应该获取arg的地址,获取地址没错,我们在赋值操作符中就要获取地址。但是,不要在任何数据成员中保存该地址。

线程安全

在多线程环境中,主调函数必须保证arg在f()函数的整个生存期内都存在。如果某线程调用一个带引用形参的函数,在f()调用完成之前,如果其他的线程被调度执行,且该进程销毁了传递给f()的对象,情况会变得非常糟糕。调试这样的代码简直是一场噩梦。

绝不要对只输入形参(in-only parameter)使用按引用传递(无const限定符)模式。

(4)void X::f(const T& arg)  // 第二例,按引用传递。

此例优于(3),被调函数对arg只能读取不能写入。因为arg是对const对象的引用,它是一个只输入形参(in-only parameter)。在传递大型对象时,此传递样式为高效之道,强烈推荐使用。在按值传递不可用时,尽可能地使用该样式。和(3)一样,该例也意味着被调函数不能储存arg的地址,因为无法保证arg在函数返回后仍存在。在(3)和(4)中,f()函数可能也想制作一份arg的副本,但主调函数不允许这样做。要在文档中清楚地说明主调函数的意图。

(5)void X::f(T* argp)   // 第一例,按指针传递。

C程序员钟爱指针。大多数时候,他们在C中使用指针合情合理,因为C并没有引用的概念。然而,在 C++中,如果不能清楚地理解指针的意图,会让情况变得很糟。无论何时,使用指针的好处是:可以用一个特别的值——0(也称为NULL),区别合法指针和非法指针。引用无此特点,无法区别合法引用和非法引用。实际上,正确使用引用时,绝不会出现对不存在对象的引用。

这种情况有些含糊不清,而且有潜在的不安全隐患。如果被调函数仅对argp所指向的对象写入,那么,它只是一个输出形参,argp的名称前应缀有out。甚至,如果此函数也能遵循不同的命名约定会更好(例如,函数名前缀Copy)。主调函数必须把可修改对象的地址传递给f()。传递NULL指针非常容易(有意或无意地)。这意味着,被调函数不能假定argp所指向对象存在。指针argp本身按值传递,这表明f()既不能创建新对象也不能储存argp中的地址,这样,主调函数才能检索到该地址。如果确实希望让f()改变argp所持的地址,应传递对argp指针的引用[46]。argp也可以被当做输入输出形参,在这种情况下,被调函数能读取argp所指向的对象,还能为其填入返回值。如果传入的是一个空指针就不能这样做。

如果确实想使用这种模式,最好是让argp带默认值0,如果将接口改变为:

void X::f(T* argp = 0)

就相当于明确地告知客户,即使将零指针(zero pointer)传递给这个函数,也不会引起任何无法预料的行为。

3.13 采用语义

在以上给出的这些限制中,(5)这种参数传递模式到底在何处使用?这种模式在所谓的采用语义(adopt semantics)中很有用。它真正的含义是:主调函数将argp所指向的存储区(实际上是资源的生存期)的所有权职责传递给被调函数(即,属于f()的对象)。主调函数创建了一个T类型的动态对象(可能使用new()),但是主调函数并不知道何时delete该动态对象(这种情况经常出现)。这是因为,被调函数可能仍然在使用它(或主调函数无法删除它),也可能是被调函数希望使用主调函数提供的存储区。在这种情况下,主调函数将argp所指向的对象的所有权职责移交给被调函数。换言之,被调函数采用argp指向的存储区。当被调函数不再需要argp所指向的对象时,要负责删除该对象。在现实生活中也有类似的情况,生母生下孩子(创造了它),然后将其转交给养父母。另外,在电子邮件(e-mail)系统中也有类似的情况,用户创建一条消息,然后将其转交给邮件发送系统。邮件发送系统即采用了用户创建的消息。

警告:

采用主调函数存储区的函数(或对象)应该知道如何销毁所采用的实体。例如,如果主调函数使用malloc()(C库函数,用于分配动态内存)创建对象,而被调函数使用delete销毁相同的对象,那将是一场灾难。再者,主调函数和被调函数必须处于相同的地址空间中[47]。如果跨地址空间边界转移所有权,要控制相同的进程则绝非易事。

在使用采用语义时,最好对这样的函数使用不同的命名约定(如以Accetp、Embrace、Own或Adopt为前缀的名称)。在这种情况下,参数名也应该以这些单词为前缀。例如,在电子邮件系统中,负责接管用户消息的成员函数(在TEnvelope类中)称为EmbraceMesage()、Adoptmessage()、OwnMesage()或者AcceptMessage()。然而,这种命名约定不可用于构造函数和操作符。

(6)void X::f(const T* argp)  // 第二例,按指针传递。

该模式不能用于采用语义(至少不能用于类型转换),因为指向 const 的指针无法被删除。当然,我们可以通过转换指针类型移除const限制。但这样做很危险,并不推荐这样做,应当避免使用这种不安全的操作。然而,如果协议未涉及调用delete,就仍然可以这样做。该例中,被调函数只能从argp 中读取,参数argp 为只读(输入形参)。被调函数应检查传入的参数以确保 argp 指针为非 0。如果传递零指针是安全的,应该在文档中清楚地说明,或者如(5)所述使用argp(0) 默认值。

显然,如果主调函数选择传递真正的对象或0,那么(5)和(6)都可用。也可用于实现采用语义。但是,如果被调函数需要的是一个对象(且不是采用语义),则使用(3)或(4)中的引用参数。

指针还可用于递增或递减。如果被调函数需要使用传入的参数(argp)来定位它所指向的内容,那么只能使用指针,引用在这里没用。但是,以上介绍的模式均未涉及指针的算法[48]。除非另有说明,否则客户应假设被调函数不会递增或递减主调函数传递的指针参数。如果被调函数需要对指针进行运算,那么其函数签名应为:

void X::f(T argp[])

注意是argp[],而不是简单的指针*argp。argp[]明确地指出函数需要一个数组。另外,还要在该函数的文档中清楚地说明这样的意图(指针算法)。如果被调函数并不打算对指针参数执行任何运算,被调函数也可通过以下声明向主调函数作出保证:

void X::f(T* const argp)

这明确指出,argp是一个const指针(而不是指向const的指针)。实际上这也表明,编译器可以检测出对argp进行的任何递增或递减操作。

(7)void X::f(T* const argp)

此例与(5)类似。被调函数可以对指针argp所指向的对象进行读取和写入,但不能移动指针(即不允许对argp 进行运算)。这意味着,被调函数不能访问argp 指向区域的前后地址。换言之,被调函数向主调函数保证了它的意图。注意,你也可以删除 argp。虽然无法删除(编译时错误)指向const的指针,但const指针没有这样的限制。

(8)void X::f(const T* const argp)

此例为(6)和(7)的组合。被调函数宣称它既不会修改argp所指向的内容,也不会对argp进行任何运算。这意味着,argp是一个只输入形参(in-only parameter)。此方案不支持如(6)所述的采用语义。

3.14 为参数选择正确的模式

尽可能避免按值传递大型对象。对于基本类型和小型对象,可按值传递。复制对象的开销很大,但是,如果主要考虑安全问题,则应坚持按值传递。

需要传递对象时,不要传递指针。

void f (T* object);

(1)指针无法保证它确实指向某对象,它很可能是一个空指针。

(2)如果被调函数需要将真正的对象作为参数,则传递引用,而不是指针。这可确保不出现问题,被调函数无需检查空引用(因为不可能出现这种情况)。

(3)如果希望被调函数在对象中写入(输出形参),则传递引用,但不是对const的引用。

(4)传递引用并不意味着将对象的所有权移交给被调函数。主调函数仍然拥有对象(且对其负责)以及与该对象相关的内存。如果被调函数希望主调函数放弃对象的所有权,应在文档中清楚地说明。否则,双重删除(double delete)会导致程序无法正常运行。[多次释放(在C++中为delete,C中为free)一个指针所指向的动态内存地址时,会发生双重删除。使用new()分配的内存只能使用delete操作符删除一次。但是有时,由于程序出错,相同的指针会被多次删除。]

(5)被调函数需要检查指针是否为空,才能使用它。这样的接口非常不好用,而且难以追踪。如果确实需要,应在文档中清楚地说明,以免混淆。

当传递基本类型参数时,要坚持使用值参数(value argument)。传递指针和传递整数的开销一样,但是,按值传递更安全。

在可以使用默认值参数的地方,尽可能使用默认值参数。它们不仅包含更多信息,还有助于理解参数的用途和减少了客户的负担。客户只需传递较少的参数。

尽量对参数和函数使用const限定符。前面介绍过,编译器能识别const限定符,这样做让编译器也参与其中,使得程序更加强健稳定。

如果希望在参数中使用多态(后续章节将介绍),则必须使用引用或指针参数,不能使用按值传递。此规则也适用于返回值(在下面说明)。

注意:

无论何种规则,都有一些例外,特别是将指向基本类型的指针作为参数时。即使不涉及转移所有权,也应按照惯例,传递指向基本类型(特别是字符指针,char*)的指针。这是因为指针允许导航(使用++和--操作符),而引用并没有这种灵活性。在大多数情况下,这样的指针都用来作输出和输入/输出形参(参见 strcpy、strcat 等库函数)。如果在接口中发现使用未转移所有权的普通指针时,不要感到惊讶。不过,这样的接口必须在文档中详细地说明。由于指针的灵活性,甚至当传递未转移所有权的对象时,在某些情况下也要使用指向这些对象的指针,而不是引用。方法可以要求指向对象的指针参数,但要带有默认值NULL,这允许主调函数在调用方法时可省略参数。在这种情况下,指针将用于指出哪个参数是可选的,而引用参数却很难做到这一点,因为它没有独特的值来区分非法引用(每个引用都是合法的引用)。因此,要养成遵循一些规则,但同时也记住例外的好习惯。无论如何,都要在文档中清楚说明这样的接口,以避免出现混淆。

3.15 函数返回值

许多函数向主调函数返回值、引用或指针。要正确和高效地使用它们,必须先理解它们的含义。可能有以下几种模式返回:

T X::f();      // 按值返回T

T* X::f();     // 返回T类对象的指针/地址

const T* X::f();  // 返回指向const T类对象的指针

T& X::f();     // 返回对T对象的引用

const T& X::f();  // 返回对const T类对象的引用

(1)绝不返回对局部变量的引用(或指向局部变量的指针)。一旦离开函数,局部变量将被销毁,但在此之后,引用(或指针)仍然存在,它依旧引用(或指向)某些已不存在的对象。

(2)如果在函数内部创建新对象,并且希望将该对象的所有权移交给主调函数,那么该函数必须返回一个指针。主调函数拥有返回的指针所指向的内存。当被调函数创建了一个新对象(或指向某对象的指针),但却不能控制该对象的生存期时,通常会出现这种情况。为这样的函数使用一种命名约定是个不错的想法(如CreateXXX())。而且,如果存在这样的函数,也必须在文档中清楚地说明。这与之前讨论的采用语义相反。例如,在TPerson类中,有一个成员函数:

class TPerson {

public:

// ...

char* CreateNameOfPerson()const; // Person类的成员

};

假设,CreateNameOfPerson()用于为名称中的字符分配内存,且返回指向该字符的指针(由主调函数所拥有)。当不再需要它时,主调函数应释放内存。只有在指针返回值时,才能转移动态对象的所有权。若返回引用或值则无法转移所有权(至少很不容易)。如果不想返回指针,可以将指针包含在对象中(像C++库中的string类对象),然后按值返回对象。另外,我们也可以使用某些回收程序对象(C++中的 auto_prt),自动地删除从这种函数返回的指针,将在下一章中将介绍。

(3)如果不允许主调函数修改返回的指针所指向的字符(或者对象),则返回指向const的指针。在TPerson类中:

class TPerson {

public:

// ...

const char* GetNameOfPerson() const;

};

这并不意味着转移了内存的所有权。主调函数只能读取返回的指针所指向的内容,不能删除它(若删除它,在编译时将检测出错误)。如果从const函数返回指针(无论何种原因),应该返回指向数据成员的 const 指针。若返回指向数据成员的非 const 指针,将抵销 const 函数的优点,编译器会检测出这样的错误。当按值返回的开销很大时(或者语义上不正确时),推荐使用返回指向 const 的指针(指向一个数据成员),但是,主调函数不应该修改指针所指向的内容。客户必须尊重指针的 const 性质,并且在使用它时不转换它的类型。

警告:

记住:绝不返回指向某个数据成员的非 const 指针。否则,不仅会为你带来不必要的麻烦,而且,还会把实现数据暴露给客户,从而削弱了抽象,破坏了数据封装。

如果从函数返回值可能失败,那么,从函数返回指针是唯一的选择。通过返回空指针可以轻松完成。空指针能指出函数本应返回的值出现问题或不存在(如上面示例中的GetNameOfPerson)。这就是为什么从函数返回指针很常见的原因之一(除了上面提到的其他原因之外)。

(4)如果要返回一个基本类型(char、int、long等),那么,按值返回和按引用或指针返回效率相同。但是,按值返回较为安全和容易,而且易于理解。

(5)在某些情况下(如 operator+),无法返回引用,因为函数的结果未知(而且无法提前计算),正确的实现将要求按值返回(在函数内部创建一个临时变量)。这是实现这种函数最佳和最安全的方法,将在第8章中介绍。

3.16 从函数中返回引用

要尽可能避免从函数返回引用。原因如下:

(1)能从函数安全返回对某对象的引用(假定为foo)时,该函数不能创建对象foo。否则,谁对新创建对象的存储区负责?因为它不能是局部对象,这意味着在调用foo()之前,对象foo必须存在,甚至还应保证从函数返回后它仍然存在。

左值(L-value)语义的含义

左值可用在赋值操作符的左则(LHS)。例如,a = b表示a将被修改,而且它是一个左值。许多C++(和C)的操作符都要求正确地操作左值。所有其他操作符与赋值号结合的操作符,例如,+=、/= 等都是左值操作符。写a *= b和a = a * b一样,对象a都将被修改。因此,a必须为左值。如果a是const,那么a *= b将不会通过编译,因为在这种情况下,a不能被修改(它不是一个左值)。在该例中,b是右值,可以从右值中读取数据。任何能作为左值使用的对象(或基本类型)都不能是const。操作符(=、+=、-=、*=、/= 等)都有左值语义。也就是说,左侧的对象将被修改,因此它必须为左值。

(2)函数(该函数返回对某对象的引用)如何表明被引用的对象创建失败?没有诸如空引用这样的东西,唯一可替代的方案就是抛出(throw)异常[49]

有一些非常特殊的情况,需要从函数返回引用(而且要安全返回)。

通过现有对象调用某些函数,必须保证对象(调用函数的对象)至少在函数生存期内一直存在。在这种情况下,就可安全地返回对某对象的引用(实际上是*this)。这主要用于赋值操作符(operator=)和一些具有左值语义的操作符中(如+=、*=等),详见第8章。

如果希望从函数多态返回,唯一的选择就是返回引用或者指针(后面章节将会介绍)。在这种情况下,按值返回不可用。

3.17 编写内存安全类

良好实现的类应该负责管理正确分配内存,无论创建(无论以何种方式创建)和使用了多少对象,都不会引发任何内存(资源)泄漏。设计和实现这样的类并不容易,要理解内存安全类的指导原则,必须先理解无用单元回收(garbage collection)、悬挂引用(dangling reference)和初始化问题。第4章将涵盖这些内容。现在,先来了解一下内存安全类。

3.17.1 改善性能

通过以上对值、指针和引用的讨论,你可能会担心性能问题。如果需要改善类(或一组类)的性能,以下列出的一些指导原则会有所帮助。必须反复强调一点,在编写类的第1个版本的代码时,首要的目标是正确地实现类,不要把注意力集中在性能上。等其他部分都完成妥当后,实际性能测试的结果会指导我们如何改善性能。

记住:

(1)避免制作对象的副本。复制对象的开销很大(在内存和CPU时间方面)。

避免创建新对象,设法复用现有对象。创建(和销毁)对象开销很大。

在适当的时候使用const引用形参。

使用const成员函数。

尽可能地使用初始化语义(而非赋值)。

优先使用指针而不是引用作为数据成员。指针允许惰性求值(lazy evaluation),而引用不允许。在第4章和第二部分的第11章中将做详细介绍。

避免在默认构造函数中分配存储区。要将分配延迟到访问成员时,通过指针数据成员(pointer data member)可轻松完成(见第11章)。

用指针数据成员而不是引用和值成员。

尽可能地使用引用计数(在其他章节深入讨论)。

通过重新安排表达式和复用对象减少临时对象。

(2)在编写代码的最初阶段中避免使用技巧。

坚持安全第一,确保不会出现内存泄漏。

在软件开发的早期阶段,不用担心优化的问题。基于性能评定,再关注这个问题。

在现实世界中,通常认为任何软件都是以速度作为最终评定的标准。许多时候,客户并不关心我们是使用 OO 技术还是其他什么技术。请不要误认为我是个因循守旧、不切实际的理论派拥护者。实际上,任何软件在快速运行之前,都必须保证该软件足够稳定和可靠。我们可以通过对实现的改善和性能工具提供的帮助,来提高软件的性能。在尚未完成类的实现时,无需过分担心性能问题。

3.18 客户对类和函数的责任

设计优秀且文档完备的类只有在客户使用时才有用。以上所有的讨论旨在满足客户的要求和需求,另一方面,客户也有自己的责任。客户必须记住以下所列的几点。再者,还需记住,在软件世界中,我们中的大多数人同时扮演着客户和实现者的角色。

(1)理解类的用途。即使类的名称可以表明它的用途,但文档中可能还会有其他的建议。类的名称所传达的信息非常有限。

(2)清楚地理解类的实现者希望从客户方面获得什么。在客户和实现者之间有一个契约。

(3)注意每个成员函数,特别是const成员函数——它们比较安全。

(4)理解传递的参数。当类采用参数时要小心,绝不传递局部对象(栈对象)的地址给采用对象的函数。

(5)当函数返回指针和引用时,理解你的责任是什么。特别要理解对存储区的责任。

(6)如果类的文档和头文件中的信息不同,在使用类之前,要区分哪一个是正确的。

(7)优先使用以指向const的指针和对const的引用作为参数的函数,这些函数比较安全。

(8)不管类的实现者是谁,不要依赖他告诉你的任何非文档说明的类细节。要坚持使用类接口和说明文档。

(9)要提防那些连最小成员函数集合(构造函数、复制构造函数、赋值操作符和析构函数)都尚未实现的类。

我们将在后续章节介绍更多关于客户责任的内容。

3.19 小结

清楚地理解构造函数、析构函数、复制构造函数和赋值操作符的责任和限制。

理解何时编译器会在未提供某项的前提下,自动生成的某项。

在需要时尽可能地使用 const 成员函数。对于需要实现概念常量性(conceptual constantness)的地方,要在文档中清楚地说明。

选择的类名和函数名应该表明各自的用途。避免使用缩写(除非问题领域中已熟知)。较长的类名和函数名可传达更多的信息,且不会导致任何额外的编译时或运行时的开销。

不要在函数声明中省略参数名称。每个参数名称都应该向客户清楚地表明它的用途。

理解函数参数模式和返回值的客户视图。使用合适的参数传递模式,并保持统一的代码风格。尽量避免从函数返回非const的指针。

在文档中说明类的用途和适用客户。为每个成员函数都提供有意义的文档说明。

第4章 OOP中的初始化和无用单元收集

在前几章中,我们重点介绍了对象创建和接口设计方面的内容。然而,从宏观上来观察系统(或工程)会发现,随时都有大量的对象被创建、复制和销毁。在真正的面向对象系统中,对象是计算的基本实体。几乎所有的操作都与对象息息相关,每个对象都必须先创建才能使用。一旦创建了对象,就必须保证稍后将其销毁。

在对象的生存期内,它可以被复制到另一个对象中(即,对象有多个副本),也可以赋值给另一个对象;或者,另一个对象也可赋值给它。程序员和设计者充分理解创建、复制和销毁对象的含义非常重要。本章将重点介绍相关议题。

4.1 什么是初始化

在函数内部创建一个基本数据类型,或在类中创建此类型的数据成员时(如 C++中的char),该基本类型中包含的值是什么?

其中包含的是预定义的内容,还是未定义的无用单元?

这些都是在学习新语言或新范式(如 OOP)时,应该真正关心的问题。举例说明,TPerson类如下所示,该类用于机动车辆注册系统、员工数据库等。

class TPerson {

public:

TPerson() { /* 无代码 */ }  // ①默认构造函数

TPerson(const char name[], const char theAddress[],

unsigned long theSSN, unsigned long theBirthDate);

TPerson&  operator=(const TPerson& source);

TPerson(const TPerson& source);

~TPerson();

void SetName(const char theNewName[]);

void Print() const;

// 为简化起见,省略一些细节

private:

char*      _name;

unsigned long  _ssn; // 美国社会安全号码

unsigned long  _birthDate;

char*      _address;

};

以下是TPerson类的一种典型用法:

main()

{

int i;     // 局部变量

int j = 10;

i = 20;

TPerson alien; // TPerson类的对象

alien.Print();

}

这就引出了许多问题:

(1)定义局部变量i时,i中的值是什么?

(2)TPerson类的对象alien中的数据成员_ssn、_name和_birthDate中的值是什么?

根据C++(和C)中的定义,i中的值是未定义的。该值就是在创建i的内存区域中所包含的值(在运行时栈上),没人知道是什么。换言之,变量i未初始化。另一方面,我们用10创建了j,变量j中的值即为10。在该程序中,j中包含的值是已定义的,变量j即代表初始值10。

初始化是在创建变量(或常量)时,向变量储存已知值的过程。这意味着该变量在被创建(无论以何种方式)时即获得一个值。进一步而言,在初始化期间,为变量储存值(即初始值,如示例中的10)时,我们并未覆盖该变量中的任何值。换言之,初始化并不用于擦除变量中的任何现有值。初始化只是在变量被创建的同时,为其储存一个已知值。

以上代码保证了在使用j时,j中就已储存了值10。当然,变量i也可用,但在为其赋值20之前,包含在i中的值是未知的。而且,当我们将20赋值给i的同时,正在擦除其中包含的任何值(即使是未知的)。这就是赋值与初始化的不同。

赋值一定会擦除变量中的现有值,变量中的原始值在该步骤中丢失。初始化是在创建变量的同时便为其储存一个值。由于被初始化的变量,在初始化步骤开始前并不存在,因此该步骤并未丢失任何值。任何变量只可初始化一次,但可以赋值任意多次。这是初始化和赋值的根本区别。

如果在给i赋值前使用i(如数组下标),将无法预知结果。而使用j则很安全,因为我们知道它确切的值。使用已初始化的变量更加安全,因为它的行为可预知。在后面的章节中,我们将把该原则扩展到对象上。

4.1.1 使用构造函数初始化

之前主要讨论的是main()中的局部变量。不过,我们更感兴趣的是对象。对象alien的数据成员中所包含的值是什么?我们并不知道,因为并未用合适的值初始化它们。

C++:

除非类的构造函数显式初始化对象的数据成员中的值,否则该值是未定义的。这与上面的变量i类似。i和_ssn数据成员(或其他数据成员)之间的唯一区别是:前者是main()内部的一个局部变量,而后者是类内部的数据成员。默认情况下,C++编译器不会初始化对象的任何数据成员。这项工作由实现者负责让构造函数完成。即使是类的默认构造函数,也不会为对象的数据成员储存任何预定义的值。

为什么初始化数据成员如此重要?

假设我们实现了TPerson类的Print()成员函数的代码,如下所示:

TPerson::Print() const

{

cout << “Name is” << _name << “ SSN: ” << _ssn << endl;

// ... 更多

}

在用对象alien调用Print()时,你能否猜到Print()会输出什么?绝对不能。_name和_ssn字符指针都并未初始化,我们不知道它们包含的内存地址是什么。这样通过cout调用插入操作符(insertion operator)(<<)可能会导致各种无法预知的输出。如果我们能肯定已正确初始化指针_name,那么,调用插入操作符输出的内容才可预知。不仅如此,除非正确地初始化对象的所有数据成员,否则对象的行为都无法准确地预知,而且使用这样的对象也不安全。类的设计者和实现者必须保证类对象的行为正确,无论以何种方式创建对象,该对象的行为都应该是已知的。如果无法履行这样的承诺,就违反了实现者与客户之间的契约。记住,一旦创建了类的对象,客户便可通过该对象调用它所属类的任何成员函数。实现者不能强制执行规则来限制访问成员函数,而且实现者也不能假定客户通过对象调用成员函数的顺序。因此,黄金法则如下:

一定要记住,用合适的值初始化对象的所有数据成员。

所谓合适的值,指的是类的每个成员函数都能清楚解析的值。类的任何成员函数都必须能理解数据成员中的值,并且根据该值作出判断。

这看起来容易,但实际上并非如此。初始化数据成员不是简单的问题。在构造函数内部执行初始化时,可能会调用其他成员函数,如果这些成员函数使用尚未初始化的数据成员,后果将不堪设想。构造函数必须确保,在它内部被调用的任何成员函数都能运行正常。这意味着构造函数在调用其他成员函数之前,必须在所有数据成员中都储存了适当的值。在某些情况下,为了完成对象的初始化,该构造函数必须依赖于其他成员函数的结果。但是,如果这些成员函数试图使用尚未初始化的数据成员,程序会无法控制。实现者必须特别注意,一旦出现这种棘手的情形,可能不得不重新组织代码。解决方案之一是:使用非成员函数(静态或全局函数),避免访问数据成员。

警告:

在某些情况下,当为对象调用构造函数时,构造函数可以判断新对象不会使用哪些数据成员(基于传递给构造函数的参数)。鉴于此,实现者可能会选择不初始化某些数据成员(因为它们不会在对象中使用)。这样做可以接受,但实现者作出这样的假设时必须十分谨慎。如果将来需要修改类的实现,还需额外注意对未初始化数据成员的假设。无论数据成员在对象中使用与否,初始化对象的所有数据成员也许会更加容易些。否则,应使用类的相关知识,并提供详细的文档和关于假设的断言。

Smalltalk:

就初始化而言,Smalltalk和C++迥然不同。在Smalltalk中,一旦创建了对象,就保证已正确地初始化所有的实例成员。如果通过默认创建方法创建对象的所有实例成员,这些实例成员均包含一个预定义值nil。注意,nil是已知值。这是一种特殊的情况,意味着实例成员未初始化。在 C++中,没有这样的初始化,实现者必须要显式地进行初始化。在Smalltalk中,类对象的创建将由它的元类(metaclass)控制。

Eiffel:

在Eiffel中,用make操作创建对象,这与C++中的构造函数非常类似。该方法用已知值初始化所有的基本类型(如整数和字符),所有的整数数据成员都设置为 0;布尔类型设置为false;所有类引用设置为void[50]。既然Eiffel不支持基本类型和引用之外的其他类型,那么这种初始化方案就要考虑到对象的所有类型。如果默认的make不合适,为了处理初始化,实现者可以为类特别定义 make 操作(带访问保护)。还需注意,仅有对象的声明并不能创建一个新对象,必须通过对象名调用make方法,才能在运行时创建该对象。用!!前缀表明创建新对象(!!objectname.make)。如果调用make的语句前未加!!前缀,make将重置现有对象中的值。

了解以上知识后,现在我们来完成 TPerson 类的构造函数。应该用此构造函数替换116页的内联实现(①)。

TPerson::TPerson()

{

// 赋值样式的构造函数,不推荐使用,

// 但仍比没有代码好。

_name = 0;

_address = 0;

_ssn = 0;

_birthData = 0;

}

这非常容易,我们只需为数据成员赋特殊的值(distinct value)。注意,我们选择的值在普通的TPerson类对象中并不存在,真实的人名、社会安全号码等不可能是0。这告诉成员函数,在客户试图使用TPerson类时,TPerson类对象并不代表真实的人。对于指针,0是一个用于区别指针是否合法的值,因为合法的指针不会是0地址。特殊的值,即在一般情况下绝不会出现的值,必须谨慎选择。在某些情况下,−1便可作为整数的特殊值。例如,用整数代表数组的下标,此时将 −1作为特殊的值就特别合适。数组的下标不可能是负数(除非是用户定义的类),因此 −1表明无效下标。

但是,如果某个数据成员是常量会怎样?我们无法给const数据成员赋值,只能在创建对象时初始化该数据成员。举例说明,假设我们将_birthDate数据成员改为const。换言之,必须保证人的出生日期无论何时都不变。一旦创建了TPerson类对象,该对象中除了出生日期,其他的数据成员都可以修改。当然,以上所示的构造函数并不正确,我们稍后将修正它。新的TPerson类声明如下:

class TPerson {

public:

TPerson(unsigned long theBirthDate);

TPerson(const char name[], const char theAddress[],

unsigned long theSSN, unsigned long theBirthDate);

TPerson&  operator=(const TPerson& source);

TPerson(const TPerson& source);

~TPerson();

void SetName(const char NewName[]);

void Print() const;

// 为简化起见,省略一些细节

private:

char*          _name;   // 作为字符数组

unsigned long      _ssn;

const unsigned long   _birthDate;

char*          _address;  // 作为字符数组

};

_birthDate成为一个const,我们再也无法给_birthDate字段赋值,只能初始化它。为满足这样的要求,C++提供了如下初始化语法(为构造函数),如下所示:

TPerson::TPerson(unsigned long theBirthDate) : _birthDate(theBirthDate)

// 初始化阶段从这里开始

{ // 构造函数的赋值阶段从这里开始

_name = 0;

_ssn = 0;

_address = 0;

}

构造函数右括号后的:表明初始化序列的开始。在:之后、构造函数的左花括号{(执行构造函数——构造函数体的开始)之前的操作就是初始化。实际上,构造函数的这个阶段称为初始化阶段。左花括号{表明构造函数的赋值阶段开始。需要初始化的元素(该例中为_birthDate)后紧跟圆括号(),括号中的值即是用于初始化的值。这看起来像是函数调用。仅为了理解这样的语法,我们可以假设()中的元素赋值给括号左边的元素。该例中,我们用参数theBirthDate的值初始化const数据成员_birthDate。如果类包含更多的const元素,可以在逗号分离列表中按顺序初始化它们。如果在构造函数中忘记初始化类的const数据成员,编译器在无法正确初始化成员时会直接报错。

这种初始化语法还有更深层的含义。如果对象包含另一个类的对象作为数据成员(即内嵌对象),如何为这样的内嵌对象[51]调用成员函数?这也由初始化语法来完成。例如,如果TCar是一个类(用于汽车经销):

class TCar {

private:

unsigned _weight;   // 汽车的重量,英镑。

short  _driveType;  // 四轮驱动或两轮驱动

TPerson _owner;    // 谁拥有这辆车?

// 省略不重要的细节

public:

TCar(const char name[], const char address[],

unsigned long ssn, unsigned long ownerBirthDate,

unsigned weight = 900, short driveType = 2);

};

TCar的构造函数可以写成:

TCar::TCar(const char name[], const char address[], unsigned long ssn,

unsigned long ownerBirthDate, unsigned weight /* =

900 */, short driveType /* = 2 */)

// 提供汽车户主名、地址、社会安全号码和出生日期

// 通过调用合适的构造函数,初始化TPerson类的_owner对象

: _owner(name, address, ssn, ownerBirthDate)

// 初始化_owner数据成员

{

// 为简洁起见,此处省略构造函数的代码

}

在该例中(如图4-1所示),每个TCar类对象都有一个TPerson类的内嵌对象_owner (它有自己的数据成员_name、_ssn、_birthDate和_address)。参见图4-1。

警告:

在本书中,为了让读者易于理解对象的结构,对于所涉及的对象将以图的形式提供概念上(或逻辑上)的布局。但是,这并不意味着编译器实际上遵循所示的布局,编译器实现方面的细节将在第13章中介绍。这些图用于给读者显示实现下的整体效果。绝大多数程序员都无需知道对象实际的字节布局。

注意:

汽车可以由个人、公司或银行所拥有。当设计真正的应用程序时,就必须考虑类似的问题。然而,在该例中,重点在于理解初始化的细节,而不是TCar类。因此,我们假设汽车只属于个人。

以上的初始化代码表明,用name、ssn、ownerBirthDate等参数初始化_owner数据成员。我们之前介绍过,对象的初始化由构造函数来完成。实质上,我们正在为_owner 数据成员调用构造函数。因为_owner的类型是TPerson,所以调用的是TPerson类的构造函数。

这种初始化语法是初始化const数据成员的唯一选择,而且,这也是初始化内嵌对象的唯一办法。在后续章节中将介绍,初始化语法也是初始化基类的唯一语法。但是该语法并不局限于此,它可用于初始化任何数据成员。例如,我们可重写TPerson类的构造函数:

TPerson::TPerson (unsigned long theBirthDate) :

_ssn(0) ,

_name(0),

_birthDate(theBirthDate),

_address(0)

{ /* 构造函数体中无代码 */ }

以上代码在初始化阶段便完成了所有工作,赋值阶段无需做任何事情。当处理基本类型时,选择这样的初始化方式还是其他方式,只是样式的选择或偏好问题。但是,在某些情况下,如后续章节所述,采用初始化样式效率更高(带内嵌对象时)。

样式:

无论何时,如果可以在初始化语法和赋值两种样式之间作选择,应选择初始化样式。假设在最初的实现中,被初始化的某成员是基本数据类型,而在后来的实现中将该成员改为类对象(参见下一页TDate类的用法),如果选择初始化样式,则无需修改代码。如果我们为对象使用赋值语法,要记住,在开始赋值前,可能已经通过该对象调用了构造函数,而且我们可能已经使用构造函数初始化了该对象(因此,无需再进行赋值操作)。

但是,在初始化阶段不一定就能完成所有的工作。在很多类中,许多操作都必须在所有数据成员被完全初始化之前执行。这些操作涉及计算不同的值或调用不同的函数(成员函数和非成员函数)。在构造函数中,可能要按照预定义的顺序执行一些步骤,这些步骤只能在赋值阶段完成。当某数据成员依赖于另一个被初始化的值时,甚至更加复杂。因此,尽可能地使用初始化语法,但也不能过分依赖它。无论如何,最终的目标是构造出完整且正确的对象。

警告:

在面向过程编程中,依赖于某个特殊函数来初始化很常见。该函数通常称为 Initialize(或 Init),用于在程序启动后完成应用程序(或模块)中的初始化工作。在面向过程编程中,以这样的方式初始化很合适。但是,在面向对象编程(OOP)中不要用这种方式初始化。完全初始化对象的正确(且唯一)方法是,在构造函数中进行。类的设计者不应该要求客户调用 initialize()方法来初始化对象,这很可能导致错误,因为很容易忘记调用Initialize()。因此,对这种方式的初始化应避而远之。只有当对象依赖于另一个对象进行初始化,且另一个对象尚未创建时才需要采取这种方式初始化。在包含虚基类的复杂继承层次中会出现这种情况。

回到TPerson类,用数字表示日期非常不方便。当然,你可以使用儒略日(julian date),但那更适用于机器,而我们倾向于用更简单的格式来表示日期(如6/11/95)。如果用这样的格式比较日期是否相等,会非常方便。为了让日期对用户更加友好,我们用另一个 TDate类来表示日期,这是另一个简单的抽象。这种情况下,我们对TDate类的接口更感兴趣,实现的问题反而不太重要。使用诸如TDate这样的类,使得接口更易于理解,而且简化了实现。对于这样的类需要考虑诸多设计因素,详见第11章。

class TDate {

public:

enum EMonth { eJan = 1, eFeb, eMar, eApr, eMay, eJun, eJul,

eAug, eSep, eOct, eNov, eDec };

// 简单的构造函数

TDate(unsigned day, EMonth mon, unsigned year);

TDate(const char date[]);    // 数据作为字符串传入

TDate();  // 用操作系统日期设置日期

unsigned GetYear() const;

EMonth GetMonth() const;

unsigned GetDay() const;    // 月份的天数

// 便捷函数(conveniencefunction)

void AddToYear(int increment); // increment可为负

void AddToMonth(int increment);// increment可为负

void AddToDay(int increment);  // increment可为负

// 比较操作符

bool operator==(const TDate& second) const;

bool operator!=(const TDate& second) const;

TString GetDate() const;    // 返回字符串表示

private:

short  _year;   // 一些实现数据

short  _day;

EMonth _month;

};

无需过多关注类的细节,我们将在后续章节中讨论所有的相关问题。如下所示,虽然我们对TPerson类作了改进,但无需对TPerson类的构造函数实现作任何改动(除非将参

数_brithDate的类型改为const char[]):

class TPerson {

public:

TPerson(const char birthDate[]);

TPerson(const char name[], const char theAddress[],

unsigned long theSSN, const char birthDate[]);

TPerson&  operator=(const TPerson& source);

TPerson(const TPerson& source);

~TPerson();

void SetName(const char theNewName[]);

void Print() const;

// 为简化起见,省略细节

private:

char*       _name;

unsigned long   _ssn;

const TDate    _birthDate;

char*       _address;

};

新的TPerson类对象概念上的布局,如图4-2所示。从现在开始,该TPerson类将用于所有的示例中。

4.1.2 使用内嵌对象必须遵守的规则

(1)如果类的构造函数中包含其他类的对象,那么必须在构造函数的初始化阶段,为它所使用的所有内嵌对象调用合适的构造函数。

(2)如果实现者在调用内嵌对象的构造函数时失败,编译器将设法为内嵌对象调用默认构造函数(如果有可用且可访问的默认构造函数)。

(3)如果(1)和(2)都不成功,则构造函数的实现是错误的(导致编译时错误)。

(4)每个内嵌对象的析构函数,将由包含该对象的类的析构函数自动调用,无需程序员干预。

4.2 无用单元收集问题

在我们讨论无用单元收集[52](garbage collection)之前,先了解一下何为无用单元(garbage),何为悬挂引用(dangling reference)。

4.2.1 无用单元

所谓无用单元(garbage),是一块存储区(或资源),该存储区虽然是程序(或进程)的一部分,但是在程序中却不可再对其引用。按照 C++的规定,我们可以说,无用单元是程序中没有指针指向的某些资源。以下是一个示例:

main()

{

char* p = new char[1000]; // 分配一个包含1000个字符的动态数组

char* q = new char[1000]; // 另一块动态内存

// 使用p和q进行一些操作的代码

p = q;   // 将q赋值给p,覆盖p中的地址

/* p所指向的1000个字符的存储区会发生什么?此时,p和q指向相同的区域,

没有指针指向之前p指向的旧存储区!该储存区还在,仍然占用着空间,

但程序却不可访问(使用)该区域。这样的区域则称为无用单元。*/

}

现在,在main()中为p分配的内存便是无用单元,因为它仍然是正在运行程序的一部分,但是,所有对它的引用都被销毁了。

无用单元不会立即对程序造成损害,但它将逐渐消耗内存,最终耗尽内存导致系统中止运行。在某些情况下,由于若干原因[53],还可能导致无法停止程序。随着越来越多的无用单元被创建,系统运行得越来越慢。定期进行无用单元收集是资源回收的有效途径。当然,无用单元收集并不是毫无代价的,因为必须定期地运行(自动或手动地)一个收集所有无用单元、并将其返回自由池(free pool)中的程序。而且,不一定能收集完所有的无用单元。

4.2.2 悬挂引用

当指针所指向的内存被删除,但程序员认为被删除内存的地址仍有效时,就会产生悬挂引用(dangling reference)。例如:

main()

{

char *p;

char *q;

p = new char[1024]; // 分配1k字符的动态数组

// ... 使用它

q = p; // 指针别名(pointer aliasing)

delete [] p;

p = 0;

// 现在q是一个悬挂引用,如果试图 *q = ‘A’,将导致程序崩溃。

}

如果试图访问q所指向的内存,将引发严重的问题。在该例中,指针q称为悬挂引用。指针别名(即多个指针持有相同的地址)通常会导致悬挂引用。与无用单元相比,悬挂引用对于程序而言是致命的,因为它必定导致严重破坏(大多数可能是运行时崩溃)。

4.2.3 无用单元收集和悬挂引用的补救

这两个问题(无用单元和悬挂引用)都是操纵指针和指针别名直接导致的结果。由于程序员复制了地址,但尚未理解复制地址的语义(和后果),才引发了这些问题。这不是OOP才有的新问题,但OOP让这些问题的影响更加严重。

SMALLTALK:

一些语言提供自动的无用单元收集。在 Smalltalk 环境下工作的程序员根本无需担心无用单元,因为无用单元收集在Smalltalk中是自动进行的。语言会跟踪对内存的引用,当不再引用某块内存时,语言便自动释放它们。

EIFFEL:

Eiffel以辅助程序的形式提供自动的无用单元收集,该程序定期在后台运行,用于收集所有不可再访问的单元。

C++:

C++不提供自动的无用单元收集机制。它支持所有类型的指针变量。这就把无用单元收集的重任留给了程序员。一般而言,这是存储区管理的问题。在 C++中,无用单元收集是一个研究课题。也许在不久的将来,C++也会有自动的无用单元收集。

由此可见,只要不让程序员创建持有内存区域地址的指针类型,几乎就可以避免悬挂引用的问题。在Eiffel、Smalltalk和Java中就是这种的情况。

你可能觉得奇怪,无用单元收集和悬挂引用在其他类型的编程中也会出现,为何要将这两个问题作为OOP中的特殊问题?请继续往下读。在面向过程编程系统中,没有对象的概念,也不会频繁地进行内存分配(和释放)。然而,在 OOP 中,一切皆为对象,而且绝大多数大型对象都要分配资源。在我们感兴趣的面向对象系统中,时刻都有成百上千的对象,对象不断地被创建、复制和销毁。而且,可以按不同的方式,甚至动态地创建对象。因此,作为类的实现者,不仅要充分理解无用单元收集问题,还要额外注意存储区的管理。

4.2.4 无用单元收集和语言设计

语言(自动或程序员实现)支持的无用单元收集类型,和语言本身的设计原理有较大的关系。提供自动无用单元收集的语言(如Eiffel和Smalltalk),实际上是基于引用的语言。在基于引用的语言中,每个对象只是一个引用。当创建对象时,事实上是创建了一个引用,该引用持有真正对象的地址,此地址被保存在别处。这使得复制和共享对象非常容易和迅速。但是,另一方面,这也导致安全性较低。因为通过使用对象的引用,可能会意外地修改该对象。

然而,C++是一种基于值的语言(C 也是)。在该语言中,一切(对象和基本类型)皆为值。每个对象都是一个真正的对象,不是一个指向储存在别处的对象的指针。C++对待类和基本类型一样,这是该语言中的统一模型。

Eiffel:

Eiffel使用双重方案。在Eiffel中,所有对象都基于引用,但所有基本类型都基于值。新对象获得自己所有基本实例变量的副本,但是,在新对象中只能包含对对象的引用。在其他地方也提到,引用要么是void,要么是一个对有效对象的引用。

Smalltalk:

另外,Smalltalk对待对象和基本类型一致。在该语言中,一切皆为对象,所有的基本类型也是对象。这使得语言易于理解,无需区分对象和基本类型的不同。

以下的示例说明了多种语言间的不同。回顾TCar类的例子:

class TCar {

private:

unsigned    _weight;  // 汽车的重量,英镑

short     _driveType; // 四轮驱动或两轮驱动

TPerson    _owner;   // 谁拥有这辆车?

// 略去其他不相关的细节

public:

TCar(const char name[], const char address[],

unsigned long ssn, unsigned long ownerBirthDate,

unsigned weight = 900, short driveType = 2 );

};

为什么无用单元控制非常重要

无用单元控制在软件设计中是一个非常重要的问题,因为它会影响应用程序的整体性能。即使是不太重要的应用程序,在持续运行很长一段时间(数周甚至数月)未停止,内存泄漏也会导致严重的问题。随着越来越多的内存成为无用单元,应用程序(和整个系统)的性能逐渐降低,导致越来越多的虚拟内存分页(paging)活动。最终,由于分页文件被占满,整个系统只能暂停。

并非所有的程序都能随意暂停。暂停某些重要的应用程序任务(例如,核电站控制程序),将导致灾难性的后果。如果监视和控制反应堆的软件持续地泄漏内存,该系统最终将停止运行,这会给周围的居民带来毁灭性的灾难。

在嵌入式系统中,虽然不像大型的操作系统需要进行虚拟内存管理,但内存泄漏也会引发事故(如控制汽车燃料供给系统的电脑)。如果用于驱动燃料喷嘴的软件,由于发生内存泄漏,在汽车行驶数百公里后,停止运行会怎样?汽车会突然停在路中间,但是之前不会发出任何警告。

自动的无用单元收集方案,看上去像是解决大多数类似问题的良方,但事实并非如此。在无用单元收集程序运行时,应用程序必须暂停运行。暂停的时间可能短至几微秒,或长达数百毫秒。想象一下,照相机已对焦,准备捕捉精彩的瞬间。正当按下快门时,无用单元收集程序运行,用于拍照的软件暂停。在无用单元收集完成后,精彩动作的瞬间已经过去了,未拍到合适的图像。

编写绝不泄漏任何资源(内存、文件句柄(file handle)、网络套接字(network socket)等)的软件,应成为每个软件设计者和开发者的共同目标。

虽然我们在C++中声明了TCar类,但了解一下Eiffel、Smalltalk和C++中的对象结构也非常有趣。假设有一个TCar类的对象,对象名为Jeep,driveType = 4(即四轮驱动),重量为800磅(见图4-3)。

如图4-3所示,3种语言的模型大不相同。在C++中创建的对象,是真正的对象。因此,以下声明:

TCar jeep(“Einstein”, “Princeton, NJ”, 618552272, “12-11-1879”, 800,4);

将创建一个真正的对象,如图4-3所示。

Eiffel中的声明:

jeep : TCar;  - Eiffel声明

不会创建任何新对象,它只创建一个引用。下面的语句:

jeep.!!make;  - 仍然是Eiffel代码

将创建一个对象,并在运行时初始化它。现在,jeep必定是对内存中某个对象的引用。

Eiffel:

在Eiffel中,通常由make操作来创建对象。一个类可以在creation关键字下声明很多这样的创建例程。和任何其他例程一样,访问控制也可应用于该例程上。!!前缀意味着创建一个新对象。如果在无!!前缀的情况下调用make,它会重置现有对象中的值。

在Smalltalk中,以下声明:

jeep new TCar “Smalltalk code”

将创建一个TCar类的新对象,且所有实例变量都设置为nil。

从以上这些示例中不难发现,每种语言创建对象的方式都不同。而且,对象的表示也有所不同。

4.2.5 在C++中何时产生无用单元

本节将详细介绍哪些操作会产生无用单元。当对象所分配的资源未释放,但该资源不可再用、不可再访问时,便产生了无用单元。很多情况下都会导致资源不可访问(或不能使用),即资源在程序的作用域内不可再用。例如:

(1)从函数退出时,在函数内部创建的所有局部变量(包括对象)以及按值传递的所有参数都不可访问。

(2)从块退出时,在块内部声明的所有局部变量(包括对象)都不可访问[54]

(3)任何复杂表达式包含的临时变量,在不需要时必须全部予以销毁,否则它们将成为无用单元。

(4)任何动态分配的对象,在不需要时必须由程序员显式地销毁。

我们已经在面向过程编程中熟悉了这些情况。但是,在面向对象编程中,情况会更加复杂。因为对象不是简单的变量,其中甚至还包含其他的对象。被分配的资源作为对象的一部分,当该对象不可再用时,释放已分配的资源(包括其他对象)非常重要。这是一个递归过程,因为对象可能包含其他对象,而其他对象也可能包含另外的对象,如此以至无限多的对象。对象内部的所有其他对象所分配的资源,在不需要时必须及时予以释放。

还有一种情况也会产生无用单元,即在复制对象和给对象赋值时。我们稍后将详细讨论这个问题。

4.2.6 对象何时获得资源

对象被创建或被客户传递时,可通过动态分配获得资源。进一步而言,在对象的生存期内,可通过该对象调用不同的方法获得资源。例如,在 TPerson 类中,为了储存_name中的字符,要在构造函数内分配内存。类似地,第3章中提到的 TIntStack 类对象,要为储存元素而分配内存。特别是提供储存机制的类(如List或Queue),在对象的生存期内,有可能根据需要随时分配资源。

对象还可能因为采用语义(第3章中讨论过)而获得资源。在这种情况下,某对象分配了资源,但是将这些资源的所有权转移给另外的对象,后者负责在不需要这些资源(或资源不可再访问)时释放它们。

4.3 C++中的无用单元收集

C++提供类的析构函数专门处理无用单元收集,但是,这并不意味着无用单元收集只发生在析构函数中。实际上,某些其他成员函数也必须考虑无用单元收集。

类的析构函数给予对象最后一次机会释放它所获得的所有资源。在退出某作用域之前,由语言自动为在该作用域中创建的自动(基于栈)对象调用析构函数。此时,对象即将被销毁(也就是说,被对象占用的内存即将被系统回收)。一旦析构函数完成,对象将彻底地消失。

删除(使用 delete 操作符)指向某对象的指针时,将通过该对象调用对象所属类的析构函数。

TPerson *p;

p = new TPerson(“12-25-95”); // 在堆上创建一个TPerson类对象

// ...

delete p; // 这将通过p所指向的对象,调用TPerson类的析构函数。

在析构函数执行完毕后,p所指向的内存被释放。TPerson类的构造函数和析构函数的实现,如下所示:

// 第一个构造函数

TPerson::TPerson(const char birthDate[])

: _ssn(0), _name(0), _birthDate(birthDate), _address(0)

{ /* 构造函数体无代码 */ }

char* Strdup (const char* src)   // 辅助函数

{

char* ptr = new char[strlen(src)+1];

strcpy(ptr, src);

return ptr;

}

// 第二个构造函数

TPerson::TPerson(const char theName[], const char theAddress[],

unsigned long theSSN, const char theBirthDate[])

: _ssn(theSSN), _birthDate(theBirthDate)

{  // 初始化_name、_address等

_name = (theName ? Strdup(theName) : 0);

_address = (theAddress ? Strdup(theAddress) : 0);

}

我们已在堆(heap)上为待储存人名中的字符分配内存。析构函数负责释放这些内存。

// 析构函数

TPerson::~TPerson()

{

delete [] _name;

delete [] _address;

}

现在,考虑下面一段代码:

main()

{

TPerson john(“11-23-45”);

// ...

john.SetName(“John Wayne”);

}

对象john只有出生日期,没有姓名。接下来,我们使用SetName成员函数设置john的姓名。SetName成员函数用来做什么?以下是它的实现代码:

void TPerson::SetName(const char newName[])

{

unsigned oldLength = _name ? strlen(_name) : 0;

unsigned newLength = newName ? strlen(newName) : 0;

if (oldLength < newLength) { // _name中没有足够的空间

delete [] _name;  // 无用单元收集

// 使用已定义的Strdup函数

_name = (newName ? Strdup(newName) : 0);

}

else {

if (newName) strcpy(_name, newName);

else {delete [] _name; _name = 0;}

}

}

我们检查_name中的字符个数是否足够容纳newName,如果不够就删除_name并分配一块新内存。在分配新内存之前,一定要记得释放_name中的存储区,这非常重要。这就是我们所说的生存期内获得资源。对象john的析构函数仍将正常工作,因为_name确保指向有效内存或者为0。

回顾TCar类的例子,当销毁TCar类的对象时,该对象中的对象_owner怎么办?幸运地是,语言提供了帮助。当TCar类对象即将被销毁时,语言将调用包含在TCar类对象中的对象_owner(TPerson 类型)的析构函数,确保不会发生资源泄漏。一般而言,在对象即将被销毁时,包含在该对象中的所有对象的析构函数将被递归地调用,直至所有被包含的对象都被销毁。这只适用于按值包含在其他对象中的对象。如果某对象包含指向其他对象的指针,则由析构函数负责显式销毁它们。

4.4 对象的标识

本节将介绍命名对象的概念。特别地,我们要明辨对象的名称、对象的标识和对象间共享的语义。

对于这个例子(见图4-4),回顾TPerson类。以下一段代码创建了一些TPerson类对象。

main()

{

TPerson person0(“Albert Einstein”, 0, 0, “12-11-1879”);

TPerson person1(“12-11-1879”);

TPerson* person2 = new TPerson(“10-11-95”); // 动态对象

TPerson* person3 = new TPerson(“6-27-87”);

TPerson* person4 = 0; // 未指向person

TPerson* person5 = 0; // 未指向person

person1.SetName(“Albert Einstein”);

person2->SetName(“Foo Bar”);

person4 = person3; // 参见图4-4

}

显然,对象person1是一个独立的对象,它的名称为person1。但是,person2不是对象真正的名称,它表示内存中另外创建的一个无名称的对象。类似地,person3也表示内存中无名称的另一个对象。在涉及 person2 所表示的对象名时,我们可以通过 *person2 间接地表示的该对象名。在该例中,识别对象很容易,但并不是通过它们的名称来识别。严格来说,只有person0和person1是对象的名称。而person2、person3是指向内存中匿名对象的指针,person4和person3都表示相同的对象。该例中的person2和person3通过不同的状态识别不同的对象。按照这样的思路,person1是具有不同状态且名称不同的对象。

对象的名称在它的整个生存期内可以不唯一,但该对象的标识必须唯一。对象的标识不能随着对象状态的改变而改变。根据[Booch94],对象的标识是该对象区别于其他对象的性质。如上所述的程序和图中,在程序的执行期内,在内存中创建的匿名对象获得了person3和person4名称。我们可以把person4从与之相关的对象中分离出来,但是却不能移除对象的标识——每个对象都拥有一个独一无二的标识,在其生存期内绝不会改变。

通过对象person1执行SetName方法,将改变它的状态,但不会改变它的标识。与此类似,通过person2所表示的对象执行SetName也不会改变它的标识。在对象的整个生存期内,该对象可以获得不同的名称和经历许多状态的改变,但它只有一个独一无二的标识。这与火车站非常类似,每个火车站都拥有唯一的标识,许多火车可以在火车站来来往往(状态改变),但火车站的标识不会改变。换言之,在对象的生存期内,可以通过多个名称引用该对象,但该对象的标识是独一无二的。

看看执行如下语句将发生什么情况:

person5 = person3;

记住,person3指定了一个唯一的对象。此时,该对象获得了一个名为person5的别名(见图4-5[55])。

现在,如果我们操作person3或person5所表示的对象,实际上是在操作同一个对象。虽然名称不同(person4、person5和person3),但对象的标识相同。实际上,我们现在已经在三个名称之间共享了一个对象,即共享了对象的结构(因此也共享了状态)。从图4-5中不难发现,person4和person5都是最初由person3所表示对象的别名。

注意,两个对象的状态可能完全一样(如person0和person1),但是它们并不是相同的对象,因为它们的标识不同。它们占用不同的内存,因而位于不同的内存地址。

你可能想知道,为什么区别标识和名称如此重要?为了妥善管理对象无内存泄漏,必须清楚地知道正在被访问对象的标识是什么。悬挂引用是一个对象有多个名称(别名)直接导致的结果,这也有可能引起内存泄漏。

如果运行以下代码(见图4-6):

delete person4;

person4 = 0;

显然,此时person3和person5成为悬挂引用,而且我们并未删除person4。另一方面,如果执行下一页的几条语句(如图4-7所示)。

person4 = &person1;

person3 = person2;

person5 = 0;

我们已经创建了无用单元,因为最初由person3、person4和person5所表示的对象已经无法找到。该对象仍然是正在运行程序的一部分,但是,无论怎样都无法再访问它。这是由于未充分理解对象名和对象标识之间的区别所导致的后果。

如此看来,共享对象有错?当然不是。在很多情况下,共享对象是首选的方案。但是,在共享对象时,必须充分理解并正确管理对象的别名。如果未能正确遵循共享对象的原则,便会产生悬挂引用、内存泄漏(无用单元)和无法预料的对象状态改变。悬挂引用在运行时就会暴露出严重的问题,而内存泄漏则需要时间的积累才会引发问题。在本章中还能见到共享对象的不同样式。更多共享对象的技术将在本书的其他章节中讲解。

4.5 对象复制的语义

复制对象是OOP中的一个很普通的操作。既然在我们的世界中,一切皆是对象,我们肯定会遇到需要某个对象的多个副本的情况。

如第3章所述,在许多不同的情况中都需要复制对象。例如,当按值传递(和按值返回)参数给函数时,就需要制作对象的副本。当函数被调用时,复制操作由语言(编译器)发起,这是一个隐式进行的操作。

当然,对象的副本也可由程序员通过声明显式创建。我们可以编写如下代码:

TPerson p(“John Doe”, “Anytown”, 618552567, “12-22-78”);

TPerson q = p;

此时,q是p的副本。换言之,q由p创建而来。

复制对象不是一项简单的操作。因为对象不是基本类型,它们是带有关键状态信息的复杂实体。进一步而言,对象中可能包含其他对象,而这些其他对象又可能包含另外的对象,诸如此类。如何复制这些对象?当然,语言可以执行复制操作,但可能并不满足实现者的要求。实现者可能希望在复制期间共享对象内部的某些对象,而编译器无法进行这样的逻辑判断,这就是为什么需要程序员干预的原因。

复制对象不是OOP领域的新问题。不同的语言遵循不同的复制对象方案。接下来,我们介绍C++中的情况,并和其他语言进行对比。

4.5.1 简单复制操作的语义

复制对象的一种简单的方法是,不管数据成员的类型,只复制数据成员的值。只需将源对象(source object)数据成员中包含的值复制到目的对象(destination object)相应的数据成员中[56]。我们再次用TPerson类举例说明。

void foo(TPerson thePerson)

// foo函数按值接受一个TPerson参数

{

// 此处代码不重要,已略去。

}

main()

{

TPerson bar(“Foo Bar”, “Unknown”, 414235056, “6-6-99”);

// ...

foo(bar); // 调用以对象“bar”为参数的foo函数

}

调用foo()时,将制作对象bar的副本。在C++中,用复制构造函数完成这样的复制操作,即通过现有对象制作一个该对象的副本。我们在TPerson类中尚未实现复制构造函数,所以,编译器会生成默认复制构造函数(default copy constructor):

TPerson::TPerson(const TPerson& source)

该复制构造函数将执行数据成员的逐个成员复制(memberwise copy)。复制构造函数中的代码类似图4-8。

TPerson::TPerson(const TPerson& source)

: _birthDate(source._birthDate), // 调用TDate的复制构造函数

_name(source._name), _ssn(source._ssn), _address(source._address)

{ }

源对象中的每个成员只是被盲目地复制到目的对象中,这称为浅复制(shallow copy)。如图4-8所示,相应的元素被复制。就像复制整数那样,复制_name数据成员(包含地址234876)中的地址。除了_name和_address数据成员外,这种复制不会有问题。对于TDate类对象,将调用TDate的复制构造函数来制作_birthDate对象的副本。当要离开foo()函数时,局部对象thePerson即将离开作用域,因此语言将通过该对象调用它的析构函数。该析构函数将释放_name和_address所指向的内存。但是,当返回至main()时,源对象bar仍在使用,而且它的数据成员_name仍持有已被析构函数删除的内存地址。现在,bar中的_name(以及_address)便成为悬挂引用。这是使用默认构造函数(或浅复制)存在的问题。

注意:

术语浅复制和深复制(稍后讨论)源自Smalltalk,这些术语通常用于描述复制语义。一般而言,深复制操作意味着递归地复制整个对象,而浅复制则意味着在复制对象的过程中,源对象和副本之间只共享状态。

如果对象不包含任何指针和引用,浅复制完全满足需要。如下所示,TPoint2D类代表2维图形系统中的1个点:

class TPoint2D {

public:

TPoint2D(double x = 0.0, double y = 0.0);

DistanceTo(const TPoint2D& otherPoint);

// 复制构造函数和赋值操作符省略 – 由编译器提供

// ... 其他细节省略

private:

double _xcoordinate;

double _ycoordinate;

};

编译器生成的简单复制构造函数可以完全满足需要。该类的对象包含两个双精度数,只需复制它们的值即可。

回到TPerson类,我们并不希望复制_name和_address数据成员的值,因为它们是指针。我们希望为它们所指向的内容分配足够的内存,然后复制那些内容。在复制操作完成后,源对象和目的对象之间不会共享任何东西。这就是深复制。

Smalltalk:

Smalltalk 为所有类都提供了两种复制对象的方法——shallowCopy 和 deepCopy。在Smalltalk 系统中,所有对象都可以使用这些方法。shallowCopy 方法创建一个新对象,但该对象与原始对象共享状态;deepCopy 方法复制对象及其状态,而且这种复制将为对象内所包含的所有对象进行递归复制。因此,deepCopy 的结果是生成一个与源对象完全一样,但互相独立的对象,生成的对象与源对象不共享任何东西。在Smalltalk中,每个类都获得copy方法,而且copy的默认实现就是shallowCopy。需要组合使用shallowCopy和deepCopy的类(很多情况都需要这样)应该实现自己的 copy 方法。Smalltalk 按值传递对象的语义和 C++中传递引用几乎等价。然而需要注意的是,一些最新的 Smalltalk 实现(如VisualWorks)使用了略为不同的复制方案。

Eiffel:

Eiffel在复制对象时,遵循组合的引用——值(reference-value)语义。复制对象的方法称为Clone(),在默认情况下,所有类都可以使用(与Create类似)。该成员函数对基本类型(如整数和字符)进行真正地复制,即复制它们的值。然而,如果原始对象包含对另一个对象的引用,则只复制引用,而不是复制被引用的对象,这与浅复制十分类似。在本书其他章节提到过,在Eiffel中,对象只能包含基本类型或对其他对象的引用。对象不能按值包含另一个对象——它只能包含对其他对象的引用[57](为了方便共享)。如果类的实现者需要一个不同的复制语义,必须在类中为其他成员函数也提供相应的复制语义。Eiffel并不真正支持按值调用。在 Eiffel 中,调用函数(或过程)将把形参与实参相关联,这和 C++中的引用类似。但不同的是,被调函数不能直接修改实参。换言之,对于任何arg参数,被调函数都不能修改它的值。如果arg是一个基本类型参数,则被调函数不能修改arg的值(它所引用的内容);如果arg是类类型,则被调函数不能将arg与新对象相关联,或将其与void引用。但是,Eiffel允许函数通过arg调用方法来修改arg。(不能把这种策略和C++的const成员函数混淆。在任何情况下,无论是直接还是间接,都不能修改const成员函数的对象。)鉴于Eiffel的这个特性,函数只可通过预定义的保留名称Result,才能向主调函数返回值,Result可以在函数内部(而不是在过程中)使用。

理解复制对象的另一种方法是将一个对象想象为树的根节点。该对象(节点)可以包含任意数量的对其他对象(节点)的引用(在C++中,也包括任意数量的指向其他对象(节点)的指针)。当我们以树的形式描绘对象图时,它是一棵非常大(深)的树。深复制从树的根节点开始,然后递归遍历整棵树,复制每一个节点。复制操作结束后,我们获得一棵新树,它和原树完全一样。而浅复制只能复制根节点,其他所有节点在原树和副本树之间共享,复制的结果只是一棵带共享节点的树(见图4-9)。

现在,我们来看看具有深复制语义的复制构造函数的实现:

TPerson::TPerson(const TPerson& source)

// 初始化适当的数据成员

: _ssn(source._ssn),  // 社会安全号码

_birthDate(source._birthDate) // ①参见图4-10

{

// 如果不是NULL,复制_name和_address。

// 需要检查源是否包含非零指针,然后为其分配内存和复制数据。

// 我们已为此编写好Strdup()(参见p.131),现在可以直接使用它。

this->_name = source._name ? Strdup(source._name) : 0;

// 以类似方式再次复制_address

this->_address = source._address ? Strdup(source._address) : 0;

}

复制的语义和语言设计

在语言中,复制对象的语义和语言的设计原则密切相关。C++是一种基于值的语言,它采用统一的方式处理对象和基本类型。进行复制时,它把一切都当作值,仅复制值。另外,语言本身无法决定指针和引用用法的语义。这些决定权掌握在实现者手中,他们应该提供复制所需要的所有额外功能。除此之外,实现者还可以提供浅复制和深复制的组合语义。而语言则通过允许实现者提供复制构造函数,来实现各种复制的语义。

Smalltalk 是一种基于引用的语言,因此复制对象非常容易。该语言同样以统一的方式处理所有的对象。在Smalltalk中,一切都是对象,因此非常容易遵循统一原则。与 C++相比,Smalltalk 在复制方面提供更多的功能,它为每个类都提供浅复制和深复制。以上提到的这些功能都很容易完成,因为Smalltalk是基于引用的语言。而且,无用单元收集也内置在Smalltalk系统中。

Eiffel 以不同的方式对待基本类型和对象。在该语言中,对象只能包含对其他对象的引用。与 Smalltalk 类似,无用单元收集是语言本身的一部分。这就是为什么 Eiffel只支持浅复制的原因。唯一的缺点是,需要为定义深复制语义的方法另外命名,Clone本身不能修改或覆盖。

由于目的对象的_name和_address数据成员都未指向任何资源(因为它们刚由编译器创建),因此我们要为其分配动态内存,并复制所有字符。注意,这里将调用TDate的复制构造函数用于复制_birthDate对象。TPerson类并不知道如何复制TDate类对象,这应该由TDate的复制构造函数负责。在p.140的代码中,①通过_birthDate对象调用了复制构造函数(见图4-10)。

记住:

总而言之,当类包含任何指针、引用或其他对象时,使用默认复制操作都很不安全。不要依赖编译器生成的复制构造函数,应当编写自己的复制构造函数,以提供正确的复制操作。

4.6 对象赋值的语义

赋值与复制的操作非常类似。在 C++中,绝大多数的复制操作都由语言隐式调用(当对象按值传递或按值返回时)。当通过现有对象创建新对象时,也进行了复制操作(但不是很频繁)。与复制相反的是,赋值是必须由程序员显式调用的操作。然而,在 Eiffel 和Smalltalk中,赋值和复制操作都由程序员显式调用。这也是基于值的语言与基于引用的语言之间的区别。

在 C++中,对于对象和基本类型赋值都具有相同的含义。把基本类型变量赋值给另一个(兼容的)基本类型变量时,将复制变量中的值。例如:

int x = 10;

int y;

y = x;

将x中的值复制给y。同样,对于对象:

TPoint2D p1;

TPoint2D p2(100, 200);

p1 = p2;

将 p2 数据成员中的值复制给 p1 数据成员。这里不会特别对待指针和引用,复制它们的方式和复制基本数据类型相同。这就是默认赋值操作(default assignment operation)。赋值相应成员的方法称为逐个成员赋值(memberwise assignment),它由赋值操作符实现。

TPoint2D::operator=(const TPoint2D& source);

注意,operator在C++中是保留字(reserved word)[58]。如果类并未声明和实现该赋值操作符,编译器将自动生成一个。而且该生成的赋值操作符(称为默认赋值操作符)执行逐个成员赋值。由编译器提供的默认赋值操作符实现,类似这样:

TPoint2D::operator=(const TPoint2D& source)

{

this->_xcoordinate = source._xcoordinate; // 复制x区域

this->_ycoordinate = source._ycoordinate; // 复制y区域

return *this;

}

这看起来和前面介绍的浅复制操作非常相似。我们并不希望在TPerson类中使用这样的默认赋值操作符,因为该类包含指针,这样复制指针不安全。因此,我们将为 TPerson类实现自己的赋值操作符,如下所示:

TPerson& TPerson::operator=(const TPerson& source)

{

if (this == &source) // 自我赋值检查

return *this;

// 首先,复制(赋值)所有基本数据;

this->_ssn = source._ssn;

// 接下来,需要复制name中的字符。

// 如果name中的空间充足,则只需复制字符即可,

// 否则,删除name所指向的现有内存,然后分配新的内存块。

// 最后,复制字符。

if (source._name != 0) {    // 是否有任何复制?

int nameLength = strlen(source._name);

int thisNameLength = (this->_name) ?

strlen(this->_name) : 0;

if (nameLength <= thisNameLength)  // 简单的情况

strcpy(this->_name, source._name);

else {   // 复杂的情况

delete [] this->_name;

name = new char[nameLength + 1];

// +1,为放置\0

strcpy(this->_name, source._name);

}

}

else {

delete [] this->_name; this->_name = 0;

}

// 为address重复以上步骤

if (source._address != 0) {

int addressLength = strlen(source._address);

int thisAddrLength = (this->_address) ?

strlen(this->_address) : 0;

if (addressLength <= thisAddrLength) {

// 简单的情况

strcpy(this->_address, source._address);

}

else {   // 复杂的情况

delete [] this->_address;

_address = new char[addressLength + 1];

// +1,为放置\0。

strcpy ( this->_address, source._address);

}

}

else {

delete [] this->_address; this->_address = 0;

}

return *this;

}

我们刚才实现的赋值操作符,就是将TPerson类对象显式赋值给另一个TPerson类对象时,所使用的赋值操作符。但是,某些情况下需要将其他类型的数据赋值给TPerson类对象,可以通过在类中实现重载赋值操作符,以接受不同类型的参数。在后续章节中将介绍相关的示例。还需注意的是,_birthDate 数据成员不能被复制,因为它是 const 成员,不允许为其赋值。这里再次假设,一旦创建一个TPerson类对象,这个人的出生日期在其生存期内便不能改变。这个限制作用于_birthDate 上似乎有些严格,但是它用于阐明带有const 数据成员限制的复制和赋值的目的。如果这个限制对于一些应用程序而言过于严格死板,也可将_birthDate改为非const成员。

需要遵循的规则是:

一定要为每个类实现赋值操作符,不要依赖语言所生成的默认赋值操作符。

感兴趣的读者可能注意到,生成的默认复制构造函数和赋值操作符都是内联函数(inline function)。欲了解C++中复制构造函数的内部细节,请参阅第13章内容。

注意:

鉴于这种情况,在 C++中,很容易控制对象的赋值和复制。如果我们希望禁止公有客户和派生类复制对象,只需将复制构造函数设置成private。对于赋值操作符也一样。还需注意,可以限制(而不是完全禁止)制作的副本数目。本章稍后将会解释为什么需要这种副本控制。

Smalltalk:

了解Smalltalk中的赋值语义很有趣。该语言用<-操作符表示赋值。例如,a <- b意味着将b赋值给a。对于简单(或者基本)类型,赋值所涉及的复制值和C++中一样。但是,赋值应用于对象时,行为则完全不同。在Smalltalk中,每个对象都是对内存中其他对象的引用。鉴于此,对象赋值实际上就是引用赋值。如果a和b都是对象,且通过操作a <- b将b赋值给a,则b所引用的对象通过名称a获得另一个引用。赋值后,a和b都引用相同的对象。无需多说,无论a在赋值之前所引用的是什么,在赋值后都不能通过a再访问它。注意,Smalltalk不允许在函数内部对形参赋值,这是该语言的一项限制。

Eiffel:

Eiffel在赋值限制方面和Smalltalk完全一样。该语言用操作符:=表示赋值(和Pascal一样)。同样,简单类型间的赋值也指复制值,而对象(实际上是对象引用(object reference))间的赋值则意味着复制引用。和Smalltalk一样,Eiffel也不允许对形参赋值。

4.6.1 左值操作赋值

左值就是可被修改的值(通常在赋值操作符左侧的名称)。详见第3章中对左值和右值的介绍。在C和C++中,默认赋值语义会产生一个左值,这意味着可以进行级联赋值操作(即,a = b = c)。在Smlltalk中也可以这样。实际上,Smalltalk中的每种方法都保证有返回值,这是赋值的有用副作用,利用它可以写出清楚简练的表达式。但是,Eiffel却不允许级联赋值,这和Pascal一样。

记住:

●C++允许实现者定义复制对象的语义。

●C++允许实现者定义赋值的语义。

●实现者可以在每个类的基础上控制复制和赋值语义。

4.7 对象相等的语义

显然,赋值和复制是类的设计者和实现者必须考虑的重要问题。另一个相关问题也同等重要,即对象相等(object equality)的概念。

当我们说两个对象相等时,即一个对象和另一个对象相等到底意味着什么?要牢记,两个不同对象相等和两个名称代表相同对象(也就是对象之间等价的概念),这两个概念是有区别的。对象相等要比较对象的结构和状态,而对象等价(object equivalence)则要比较对象的地址。两个不同的对象可能相等,但是不允许它们是同一个对象。

以下是一个示例:

main()

{

TPerson person1(“Bugs Bunny”, “Toon Town”, 414235056, “12-30-56”);

TPerson* person2 = new TPerson (“Daffy”, “TV Land”, 418325156,

“6-6-55”);   // 动态对象

TPerson person3(“Goofy”, “Toon Town”, 418235057, “11-30-60”);

TPerson* person4 = 0;  // 尚未指向对象

TPerson person5(“Bugs Bunny”, “Toon Town”, 414235056, “12-30-56”);

int i1 = 100  // ①

int i2 = 200;  // ②

person4 = &person3;

}

根据图4-11,我们可以提出一些简单的问题:

●person1和person5是否相等?

●person4所指定的对象和person3所指定的对象是否相同?

当然,person1 和 person5 并不是相同的对象,但它们都具有相同的状态。另一方面,person4 和 person3 并不是相互独立的对象,它们指定了相同的对象。很明显,person3 和person4是等价的。参考上面程序中的①和②,如果我们提出这样一个问题:i1和i2是否相等,则答案非常简单——它们不相等。在这种情况下,我们只需比较i1和i2中的值,便可立即推断,因为它们没有相同的值,所以不相等。但是,对于对象,我们不能作如此简单的判断,因为很难将对象看成一个简单的值。显而易见,我们还需要了解更多细节。

比较对象是非常重要的操作,特别是包含复杂数据结构的对象,例如链表(list)、队列(queue)和树(tree)[59],相关操作包括查找储存在内的特定对象、基于key排序对象等。所有这些操作都需要比较对象来判断对象是否相等。进一步而言,判断对象是否相等避免了意外删除对象,同时也避免了重复操作。比较两个链表之间或两个队列之间是否相等,涉及定义包含其他聚集对象的对象之间是否相等,更为复杂。

4.7.1 对象相等和对象等价

在处理对象时,不同的语言定义对象相等的方式不同。例如,C++对于对象等价并未定义任何默认的含义,而Eiffel和Smalltalk则定义了默认含义。再者,不同程序员对对象间相等的解释也不同。接下来,先介绍基于引用的语言如何表示对象等价和对象相等。

Smalltalk:

Smalltalk为等价判断提供了==方法,所有对象都可以使用该方法。如果==方法返回值为 true,则待比较的两个对象是相同的对象(它们等价)。换言之,这两个对象是对相同对象的不同引用。为了判断对象是否相等,Smalltalk 提供了=方法。该方法通过比较两个对象中相应实例变量的值来实现。任何加入新实例变量的类都需要重新实现这个方法。例如,比较链表对象要涉及比较链表的长度,以及比较链表中的每个元素是否相等。这与递归导航整个对象树的深复制操作非常类似。与==对应的是~~,它用于判断两个对象是否不等价。与此类似,=对应的是~=,即不相等操作符。

Eiffel:

在Eiffel中,等价的语义与Smalltalk类似。简单类型的比较操作符是=,基于简单类型变量中所包含的值作比较。对于对象引用(object reference),比较操作符使用引用本身的值作比较。当两个对象引用都引自相同的对象时,则比较它们是否相等,以此判断两者是否等价。Equal方法用于比较当前对象和另一个对象是否相等,可用此方法比较对象(不是引用)。但是,Equal 是一个浅比较操作,它不会递归地遍历对象引用,以判断两对象是否相等。在需要用到深比较语义时,程序员必须编写自定义的方法。另外,该语言中不相等操作符是/=。

C++:

C++与Smalltalk和Eiffel完全不同,这可以理解。C中定义的比较操作符是==,它用于比较值(C 中不使用==操作符来比较结构)。C++中并未定义默认的比较机制。在需要使用类的比较语义时,由设计者负责实现操作符== 和在重载操作符==函数中提供正确的比较语义。比较指针与比较整数类似,而且也是语言的一部分。例如,p.146 示例中,person3和person4两个指针的比较的结果显示它们包含相同的地址。比较引用和比较变量(或对象,如果它们是对象引用)相同。例如:

int i = 10;

int &ir = i;

int j = 100;

int &jr = j;

int k = 100;

int &kr = k;

if (kr == ir) { }  // 该判断为false – 比较k和i的值

if (kr == jr) { }  // 该判断为 true – 比较k和j的值

比较引用kr和引用jr就是比较j和k的值(因为引用就是现有实体的别名)。

为了比较TPerson类对象,我们需要实现比较操作符。

class TPerson {

public:

// 其他细节如前所述,已省略

bool operator==(const TPerson& other) const;

bool operator!=(const TPerson& other) const;

void SetName(const char theNewName []);

void Print() const;

// 为简化起见,省略细节

private:

// 如其他细节如前所述,已省略

};

bool TPerson::operator==(const TPerson& other) const

{

if (this == &other) { return true; }  // 比较别名

if (this->ssn == other._ssn && this->_birthDate ==

other._birthDate) {

// 现在比较人名

if (strcmp(this->_name, other._name) == 0)

// strcmp是一个库函数

return true;

}

return false;  // 它们不相等

}

这里的假设是,如果社会安全号码、出生日期和人名都相等,则必定是同一个人。事实上,社会安全号码本身就是唯一的,而且可作为比较的唯一根据。

另一个需要实现的操作符是不相等操作符!=。如果类实现了==,则最好也实现!=。成对实现操作符可以保证在比较对象时,两操作符中只有其中之一(==或!=)为真。如果缺少其中一个,类的接口则看起来就不完整,而且即使使用另一个操作符[60]更加切合实际,客户也只能被迫使用类所提供的不成对的操作符。

bool TPerson::operator!=(const TPerson& other) const

{

return ! (*this == other); // 还可写成:

// return ! (this->operator==(other));

}

记住:

●如果对象需要比较语义,要实现==操作符。

●如果实现==操作符,记住要实现!=操作符。

●还需注意,==操作符可以是虚函数(将在第5章中讨论),而!=操作符通常是非虚函数,如上代码所示。

回顾TCar类,如果需要比较TCar类对象,将涉及比较诸如车牌号、车辆识别码等细节。从这个角度来说,真正的比较操作和深复制操作非常类似。对于任何复杂对象,都不得不通过该对象内部所有的对象、指针和引用递归地调用比较操作符来判断是否相等。然而,C++中的等价就相对简单得多,它只涉及比较地址。

注意:

Smalltalk有一个与对象散列值(hash value)相关的概念。每个类都支持hash方法,作为类本身基本运算的一部分。该方法为每个对象都返回一个唯一的整数。任何相等的两个对象都会返回相同的散列值。但是,不相等的对象也可以(或不可以)返回相同的散列值。通常,该方法用于链表、队列等中的快速查找对象。即使 C++和 Eiffel 都未提供这样的方法作为语言的一部分,但许多商业软件产品仍提供hash成员函数。而且,在许多实现中,系统中的每个类都要求提供hash方法的实现。语言结构被用于强制执行这样的限制。有时,某些方法也要求强制执行这样的限制,如 isEqual()和 Clone()方法。决定哪些固有方法需要所有类的支持是设计的难点,任何设计团队都应在早期设计阶段处理这些问题。

4.8 为什么需要副本控制

在讨论了对象的复制和赋值后,现在来学习为什么需要副本控制。你可能形成这样的一种观点,即每个类都应该提供public复制构造函数和赋值操作符函数。

但是,实际并非如此。很多情况都存在禁止复制对象的语义;另外某些情况下,复制可能仅对一组选定的客户有意义;甚至还有些情况,只允许限定数量的对象副本。所有这些情况都要求有正确且高效的副本控制。在接下来的内容中,我们将举例说明副本控制的必要性。一旦了解这些示例,你将体会到,C++基于每个类提供的副本控制机制如此地灵活。控制创建对象和复制对象的一般技巧将在后面章节中介绍。

4.8.1 信号量示例

假设有一个 TSemaphore 类。信号量(semaphore)用于过程(或线程)间的同步,以确保安全共享资源。当一个过程需要使用一个共享资源时,该过程需要靠获得守护资源的信号量来确保互斥。这可以通过TSemaphore类提供的Acquire方法完成。如果所需资源已被其他任务获得,则Acquire调用发生阻塞,而且调用的任务将等待,直到其他任务放弃(relinquish)资源。有可能出现多个任务同时等待相同资源的情况。

一旦任务获得资源,它便完全拥有该资源的所有权,直至通过调用Release成员函数放弃资源。鉴于此,复制信号量对象是否正确?更确切地说,复制信号量对象的语义是什么?如果允许复制,那么是否意味着有两个都已获得相同资源的独立信号量对象?这在逻辑上不正确,因为任何时候一个进程只能获得一个资源(或在已统计信号量的情况下有限数量的进程)。或者,这意味着两个信号量对象共享相同的资源?共享状态可能是一个较好的解决方案,但是,这使得信号量的实现和使用复杂化。信号量被看做是经常使用的“轻量级”对象,使其实现复杂化并不合理。在支持任何复制操作之前,还需要澄清一个问题:也许更好的解决方案应该是禁止任何复制。这意味着一旦创建信号量,任何人都不能复制它。

以下是TSemaphore类的接口。

class TSemaphore {

public:

// 默认构造函数

TSemaphore();

// 由客户调用,以获得信号量。

bool Acquire();

// 不再需要独占访问资源时,调用此函数。

void Release();

// 有多少资源正在等待使用该资源?

unsigned GetWaiters() const;

private:

// TSemaphore对象不能被复制或赋值

TSemaphore(const TSemaphore& other);

TSemaphore& operator=(const TSemaphore& other);

// 细节省略

};

信号量(资源)的自动获得和释放

程序员在使用TSemaphore这样的类时,必须记住使用Acquire成员函数来获得信号量。更重要的是,在离开函数前必须释放信号量(使用Release)。典型代码如下:

class X {

public:

// 成员函数

void f();

private:

TSemaphore _sem;

};

void X::f() // X的成员函数

{

// 获得已锁定的信号量

_sem.Acquire();

// 希望完成的任务

if (/* 某些条件 */) {/* 一些代码 */ _sem.Release();

return; }

else { /* 其他代码 */ _sem.Release(); }

}

必须记住,在每退出f()函数时都要释放信号量。这很容易出错,为避免这样的麻烦,我们可以使用辅助类来自动获得和释放信号量,如下TAutoSemaphore类所示。

class TAutoSemaphore {

public:

TAutoSemaphore(TSemaphore& sem)

: _semaphore(sem)

{ _semaphore.Acquire(); }

~TAutoSemaphore() { _semaphore.Release(); }

private:

TSemaphore& _semaphore;

};

利用这个类,f()中的代码可以简化为:

void X::f() // X的成员函数

{

// 创建TAutoSemaphore类对象,同时也获得信号量。

TAutoSemaphore autosem(_sem);

// 希望完成的任务

if (/* 某些条件 /*) { /* 一些代码 */ return; }

else { /* 其他代码 */ }

// autosem的析构函数在退出f()时,自动释放_sem信号量

}

TAutoSemaphore 类的构造函数期望传入一个信号量对象,并将信号量作为构造函数的一部分。TAutoSemaphore 类的析构函数负责释放所获得的信号量。因此,一旦在某作用域内创建了TAutoSemaphore类的对象,它的析构函数将会确保释放已获得的信号量,程序员无需为此担心。至少现在看来,需要我们管理的事务又少了一件。

这样的类在C++程序中非常普遍。另一个类似的类是TTracer,它用于跟踪进入函数和从函数退出。

class TTracer {

public:

#ifdef DEBUG

TTracer(const char message [])

: _msg(message)

{ cout << “>> Enter ” << _msg << endl; }

~TTracer() { cout << “<<Exit “ << _msg << endl; }

private:

const char* _msg;

#else

TTracer(const char message []) { }

~TTracer() { }

#endif

};

在后面的章节中,可以找到更多这样的例子。

这种类的实现是操作系统(和处理器)特定的,它甚至需要使用汇编语言代码。

4.8.2 许可证服务器示例

另举一例,假设有一个允许站点注册的软件包。公司可以为固定数量的用户购买站点许可证,而不是购买同一个应用程序的多个独立副本。现在,虽然只有一份软件的副本(因此需要较少存储区),但公司里的每个人(受限于许可证授予的数量)都可以使用。可以在服务器(server machine)上运行许可证服务器(license server)[61],为任何想使用此软件的人授权许可证令牌(license token)。只有当未归还许可证令牌(outstanding token)数量少于需要授权的站点数量时,才会发出许可证令牌。TLicenseServer类如下所示。

class TLicenseToken; // 前置声明

class TLicenseServer {

public:

// 构造函数 – 创建一个有maxUsers个许可证的新许可证服务

TLicenseServer(unsigned maxUsers);

~TLicenseServer();

// 授予新许可证或返回0。主调函数采用已返回的对象。

// 不再使用令牌时,应将其销毁 – 见下文

TLicenseToken* CreateNewLicense();

private:

// 对象不能被复制或赋值

TLicenseServer(const TLicenseServer& other);

TLicenseServer& operator=(const TLicenseServer& other);

unsigned _numIssued;

unsigned _maxTokens;

// 省略若干细节

};

class TLicenseToken {

public:

TLicenseToken();

~TLicenseToken();

private:

TLicenseToken(const TLicenseToken& other);

TLicenseToken& operator=(const TLicenseToken& other);

// 省略若干细节

};

既然TLicenseToken是由TLicenseServer以用户为单位而发出的,那么确保用户无法复制返回的令牌非常重要。否则,许可证服务器将无法控制用户的数量。每当新用户希望使用由许可证服务器控制的应用程序时,他请求 TLicenseServer 生成一个新的TLicenseToken类对象。如果可以生成新令牌,则返回一个指向新TLicenseToken的指针。该令牌由调用者所拥有,用户不再需要使用应用程序时,必须销毁它。当许可证令牌被销毁时,它将与许可证服务器通信,以减少未归还许可证令牌数目。注意,许可证服务器和令牌都不能被复制,用户不可以复制令牌。许可证令牌可包含许多信息,如任务标识号、机器名、用户名、产生令牌的日期等。因为许可证服务器和令牌的复制构造函数和赋值操作符都为私有,所以不可能复制令牌,这便消除了使用欺骗手段的可能性。

要求用户销毁令牌是件麻烦事。我们可以完成这样的实现,即在令牌追踪软件使用的同时,如果软件在预定时间内未被使用,该实现保证能自动地销毁许可证令牌。实际上,这样的实现十分常见。

账单管理是该实现的一个应用,可根据客户所使用的服务来收费。这广泛应用于有线电视的按次计费的程序中[62]

你可能觉得不允许复制令牌的限制过于严格。但是,如果允许这样做应该考虑创建一个新令牌,并通知许可证服务器进行复制。可以完成这样的实现,这仍然需要副本控制。

4.8.3 字符串类示例

各种语言的程序员都使用字符串来显示错误消息、用户提示等,我们也经常使用和操控这样的字符串数组。字符串数组的主要问题是存储区管理和缺少可以操控它们的操作。在C和C++中,字符串数组不能按值传递,只能传递指向数组中第1个字符的指针。这很难实现安全数组。为克服这个障碍,我们应该实现一个 TString 类提供所有必须的功能。TString类对象管理自己的内存,而且它会在需要时分配更多的内存,我们无需为此担心。

注意:

C++标准库包含一个功能强大的string类,也用于处理多字节字符。由于string类易于理解,同时能清楚地说明概念,因此在下面的示例中将用到它。

以下是类TString的声明:

/*

* 一个字符串类的实现,基于ASCII字符集。

* TString类对象可以被复制和赋值。该类实现了深复制。

* 用这个类代替 “ ”字符串。

*/

#include <iostream.h>

#include <string.h>

#include <stdlib.h>

#include <ctype.h>

class TString {

public:

// 构造函数,创建一个空字符串对象。

TString();

// 创建一个字符串对象,该对象包含指向字符的s指针。

// s必须以NULL结尾,从s中复制字符。

TString(const char* s);

// 创建一个包含单个字符aChar的字符串

TString(char aChar);

TString(const TString& arg);  // 复制构造函数

~TString();   // 析构函数

// 赋值操作符

TString& operator=(const TString& arg);

TString& operator=(const char* s);

TString& operator=(char s);

// 返回对象当前储存的字符个数

int Size() const;

// 返回posn中len长度的子字符串

TString operator()(unsigned posn, unsigned len) const;

// 返回下标为n的字符

char operator()(unsigned n) const;

// 返回对下标为n的字符的引用

const char& operator[](unsigned n) const;

// 返回指向内部数据的指针,当心。

const char* c_str() const { return _str; }

// 以下方法将修改原始对象。

// 把其他对象中的字符附加在 *this后

TString& operator+=(const TString& other);

// 在字符串中改动字符的情况

TString& ToLower();   // 将大写字符转换成小写

TString& ToUpper();   // 将小写字符转换成大写

private:

// length是储存在对象中的字符个数,但是str所指向的内存至少要length+1长度。

unsigned _length;

char*   _str;   // 指向字符的指针

};

// 支持TString类的非成员函数。

// 返回一个新的TString类对象,该对象为one和two的级联。

TString operator+(const TString& one, const TString& two);

// 输入/输出操作符,详见第7章。

ostream& operator<<(ostream& o, const TString& s);

istream& operator>>(istream& stream, TString& s);

// 关系操作符,基于ASCII字符集比较。

// 如果两字符串对象包含相同的字符,则两对象相等。

bool operator==(const TString& first, const TString& second);

bool operator!=(const TString& first, const TString& second);

bool operator<(const TString& first, const TString& second);

bool operator>(const TString& first, const TString& second);

bool operator>=(const TString& first, const TString& second);

bool operator<=(const TString& first, const TString& second);

如下所示,简单的实现将为字符分配内存,而且在需要时为对象进行深复制。这些实现都易于理解和执行。

TString::TString()

{

_str = 0;

_length = 0;

}

TString::TString(const char* arg)

{

if (arg && *arg) { // 指针不为0,且指向有效字符。

_length = strlen(arg);

_str = new char[_length + 1];

strcpy(_str, arg);

}

else {

_str = 0;

_length = 0;

}

}

TString::TString(char aChar)

{

if (aChar) {

_str = new char[2];

_str[0] = aChar;

_str[1] = ‘\0’;

_length = 1;

}

else {

_str = 0; _length = 0;

}

}

TString::~TString() { if (_str != 0) delete [] _str; }

// 复制构造函数,执行深复制。为字符分配内存,然后将其复制给this。

TString::TString(const TString& arg)

{

if (arg._str!= 0) {

this->_str = new char[strlen(arg._str) + 1];

strcpy(this->_str, arg._str);

_length = arg._length;

}

else {

_str = 0; _length = 0;

}

}

TString& TString::operator=(const TString& arg)

{

if (this == &arg)

return *this;

if (this->_length >= arg._length) {// *this足够大

if (arg._str != 0)

strcpy(this->_str, arg._str);

else

this->_str = 0;

this->_length = arg._length;

return *this;

}

// *this没有足够的空间,_arg更大.

delete [] _str; // 安全

this->_length = arg.Size();

if (_length) {

_str = new char[_length + 1];

strcpy(_str, arg._str);

}

else _str = 0;

return *this; // 总是这样做

}

TString& TString::operator=(const char* s)

{

if (s == 0 || *s == 0) { // 源数组为空,让“this”也为空。

delete [] _str;

_length = 0; _str = 0;

_str = 0;

return *this;

}

int slength = strlen(s);

if (this->_length >= slength) { //*this足够大

strcpy(this->_str, s);

this->_length = slength;

return *this;

}

// *this没有足够的空间,_arg更大。

delete [] _str; // 安全

this->_length = slength;

_str = new char[_length + 1];

strcpy(_str, s);

return *this;

}

TString& TString::operator=(char charToAssign)

{

char s[2];

s[0] = charToAssign;

s[1] = ‘\0’;

// 使用其他赋值操作符

return (*this = s);

}

int TString::Size() const { return _length; }

TString& TString::operator+=(const TString& arg)

{

if (arg.Size()) { // 成员函数可调用其他成员函数

_length = arg.Size() + this->Size();

char *newstr = new char[_length + 1];

if (this->Size())  // 如果原始值不是NULL字符串

strcpy(newstr, _str);

else

*newstr = ‘\0’;

strcat(newstr, arg._str); // 附上参数字符串

delete [] _str;  // 丢弃原始的内存

_str = newstr;   // 这是创建的新字符串

}

return *this;

}

TString operator+(const TString& first, const TString& second)

{

TString result = first;

result += second; // 调用operator+=成员函数

return result;

}

bool operator==(const TString& first, const TString& second)

{

const char* fp = first.c_str(); // 调用成员函数

const char* sp = second.c_str();

if (fp == 0 && sp == 0) return 1;

if (fp == 0 && sp) return -1;

if (fp && sp == 0) return 1;

return ( strcmp(fp, sp) == 0); // strcmp是一个库函数

}

bool operator!=(const TString& first, const TString& second)

{ return !(first == second); } // 复用operator==

// 其他比较操作符的实现类似operator== ,

// 为了简洁代码,未在此处显示它们。

char TString::operator()(unsigned n) const

{

if (n < this->Size())

return this->_str[n]; // 返回下标为n的字符

return 0;

}

const char& TString::operator[](unsigned n)const

{

if (n < this->Size())

return this->_str[n]; // 返回下标为n的字符

cout << “Invalid subscript: ” << n << endl;

exit(-1);  // 应该在此处抛出异常

return _str[0];  // 为编译器减轻负担(从不执行此行代码)

}

// 将每个字符变成小写

TString& TString::ToLower()

{

// 使用tolower库函数

if (_str && *_str) {

char *p = _str;

while (*p) {

*p = tolower(*p);

++p;

}

}

return *this;

}

TString& TString::ToUpper() // 留给读者作为练习

{

return *this;

}

TString TString::operator()(unsigned posn, unsigned len) const

{

int sz = Size(); // 源的大小

if (posn > sz) return “ ”; // 空字符串

if (posn + len > sz) len = sz – posn;

TString result;

if (len) {

result._str = new char[len+1];

strncpy(result._str, _str + posn, len);

result._length = len;

result._str[len] = ‘\0’;

}

return result;

}

ostream& operator<<(ostream& o, const TString& s)

{

if (s.c_str())

o << s.c_str();

return o;

}

istream& operator>>(istream& stream, TString& s)

{

char c;

s = “ ”;

while (stream.get(c) && isspace(c))

;// 什么也不做

if (stream) {  // stream正常的话,

// 读取字符直至遇到空白

do {

s += c;

} while (stream.get(c) && !isspace(c));

if (stream)   // 未读取额外字符

stream.putback(c);

}

return stream;

}

4.9 分析

TString 类对象内部用一个指针和一个_length 数据成员表示。默认构造函数将_length和_str指针都设置为0。_length数据成员是获取保存在字符串中的字符数目的一种方法。无论何时修改TString类对象中的字符数目,都会即时更新_length。

TString::TString(const char* arg)在分配内存后从arg中复制字符到字符串对象中,然后正确设置_length。

TString::TString(char c)用单个字符建立一个字符串对象。

TString::TString(cost TString& arg)是复制构造函数,它执行深复制。首先,为arg指定的字符分配内存,然后复制所有字符。

TString::operator=(const TString& arg)是一个赋值操作符,它进行了一些优化。如果this的_length大于arg的,那么只需进行复制操作。否则,将删除_str所指向的内存(无用单元收集),然后分配一段新内存,再复制字符。

TString:operator=(const char* s)是另一个赋值操作符,它允许客户将字符数组赋给一个现有TString类对象,自动进行内存管理。

TString::operator=(char charToAssign)还是一个赋值操作符,它允许客户将单个字符赋给一个现有的TString类对象,自动进行内存管理。

TString::Size()返回字符串的大小(即储存的字符数目)。

TString::operator+=(const TString& arg)是一个级联操作符。该操作将arg中的字符附在this中的字符后面。它为组合的字符串分配内存,然后复制字符。注意,不能修改arg。这个实现可以让诸如a += a这样的表达式正常运行。

operator+(const TString& first, const TString& second)以非成员函数的方式实现了加法操作符,原因将在第8章的操作符重载中讨论。在本章的讨论中,它是否为成员函数并不重要。该操作符在实现中使用了operator+=。

其实,加法操作符也可以这样写:

TString operator+(const TString& first, const TString& second)[63]

{

TString result;

result = first;

result += second; // 调用operator+=成员函数

return result;

}

但是,这样效率太低。我们用默认构造函数创建了一个新字符串对象result,接下来给它赋值,然后在它后面加上second。注意,赋值操作将覆盖result中的值。为何不直接用复制构造函数创建对象 result?这就是实现中(p.158 中的加法操作符)所完成的工作。原始代码调用了复制构造函数和 operator+=,而上面的代码调用了默认构造函数、赋值操作符和operator+=。很明显,原始实现的效率更高。许多成员函数中都会出现类似的情况。要记住,无论何时创建新对象又需要为其赋值时,都应检查是否可以用复制构造函数直接创建对象,这样的实现效率更高。

operator==(const TString& first, const TString& second)是一个比较操作符,以字符串中的字符词汇为基础进行比较。我们将该操作符作为非成员函数来实现,所有其他关系操作符的实现都与此类似。

TString::operator()(unsigned n)是一个子字符(sub-char)操作符,它返回下标为n的字符。这没什么特别。

TString::operator[](unsigned n)是一个下标操作符。它允许将TString类对象作为字符数组使用。注意,该操作符返回对const字符的引用。这确保字符串中的字符不能通过返回的引用来修改。

TString::ToLower()是一个辅助方法。它允许用户将字符串中的字符从大写形式改为小写形式。ToUpper与这个方法相反。

TString::operator()(unsigned posn, unsigned len)是一个子串(substring)操作符。它返回从posn的第一个字符开始往后len个字符的子串。注意,新字符串对象按值返回。

其他成员函数不言自明。输入/输出操作符、operator<<和operator>>将在第7章中详细讨论。

如此详细地讨论TString类,目的不是学习C++语法,而是为了思考下面解释的内容。

上面的代码中存在一个小问题(或者说是bug)。_length数据成员追踪_str中字符的数目,但不是已分配内存的原始大小。因此,如果创建一个可容纳 100个字符的 TString 类对象,然后将包含10个字符的字符串赋给它,则_length数据成员为10。即使_str指向可容纳100个字符的内存块,其实只使用了前10个字符,剩下的全浪费了。如果我们知道要分配多少空间和使用有多少空间就好了。你能否在保证无内存泄漏的前提下解决这个问题?

4.10 “写时复制”的概念

{

通过以上的讨论可知,TString类相当易懂和易实现。如果经常使用该类的对象作为函数参数和按值返回的值,会出现什么情况?因为TString类使用了深复制语义,如果TString类对象中的字符数目很多,将花费很长的时间来复制字符和删除动态分配内存。这也意味着,创建对象和销毁对象的开销很大。我们设计 TString 类的初衷,就是希望客户在使用字符串的地方,都能使用 TString 类对象。但是,如果创建、复制、赋值和销毁这些对象的开销太大,难免客户避而远之。是否有办法可以优化实现,加快对象的复制速度?

的确,复制 TString 类对象时,也要复制对象中的所有字符。但是,这样做太浪费时间。我们可以尝试修改实现,使其在建立多个 TString 类对象副本时,让这些副本都共享原始字符串中的字符,并不真正复制它们。我们了解过如何实现这样的共享。重要的是,当某个副本企图修改(或甚至销毁)对象中的字符时,共享机制必须确保该副本(TString类对象)获得一份自己的字符副本,而不会影响其他仍然共享字符的对象。例如(为理解以下代码,见图4-12)。

TString one(“ABCDEFG”)

TString two(one); // 进行复制

TString three;

three = one;

现在,如果three对象企图通过如下代码修改它的字符:

three.ToLower();  // 将字符改为小写

而其他的对象(one和two)不会受到影响。最终,我们应该得到图4-13所示的结果。

如果我们可以确保如上所述的条件,便可达到加速复制的目标。在企图修改时才进行真正的复制,这样方案就是写时复制(copy-on-write)。这项原则已经在软件工程中使用了很长时间,特别是在系统软件中[64]。它的基本含义是:在对资源进行写入之前,资源(在该例中就是字符)是共享的。当某共享资源的对象试图在资源中写入时,就制作一个副本。

这个概念要求具备三个基本条件:

(1)共享资源不应该导致太多额外的成本(内存和CPU)。

(2)应该能清楚地识别并控制所有可以修改资源的途径。

(3)设计的实现必须在任何情况下都能追踪共享资源的对象数目。

第一个条件意味着,为了共享字符串中的字符,我们不能过度地增加每个对象的大小。而且,为此实现的代码也不能太复杂。换言之,应该以最小的代价完成共享。否则,共享的开销便抵销了它的优势。

第二个条件意味着,共享资源的对象只能通过定义良好的途径(或函数)来修改共享资源。我们的实现必须能识别所有的这些途径,并完成正确的工作。

第三个条件建议,为进行正确的操作,在任何时候,设计的实现都必须确切地知道有多少个对象正在共享资源。如果无法满足此条件,会出现无用单元或悬挂引用的问题。毫无疑问,在多线程环境中,也必须满足以上所有条件。

正确执行“写时复制”语义的关键就是从实现中分离接口。当复制TString类对象时,它必须共享原始对象中已存在的实现,而不应该创建一个新的字符集。再者,实现必须记住共享实现(或资源)的对象数目,这称为引用计数(reference count)。它是对资源引用的数目,而且在任何时候都必须保持正确。鉴于此,这种方案有时也称为引用计数机制(reference counting mechanism)。但是,引用计数并不意味着“写时复制”。实际上,在后面的章节中我们将介绍在使用引用计数时,并未进行“写时复制”的情况。此外,引用计数也被称为使用计数(use count)。

接下来,我们将字符的实现(和存储区)移动至TString类内的StringRep嵌套结构中。在C++中,嵌套类并不意味着它的对象就是嵌套对象,只是在类的声明处反映其嵌套性质。进一步而言,StringRep的名称只能在TString类内部可见。以下是新的TString类声明:

#include <iostream.h>

#include <string.h>

#include <stdlib.h>

#include <ctype.n>

class TString {

public:

// 构造函数

TString(); // 创建一个空字符串对象

// 创建一个字符串对象,该对象包含指向字符的s指针。

// s所指向的字符串必须以NULL结尾,通过s复制字符。

TString(const char* s);

TString(char aChar);  // 创建一个包含单个字符aChar的字符串

TString(const TString& arg);  // 复制构造函数

~TString();   // 析构函数

// 赋值操作符

TString& operator=(const TString& arg);

// 返回指向内部数据的指针,小心。

const char* c_str() const { return _rp->_str; }

// 这些方法将修改原始对象,将其他对象的字符附在 *this后。

// 在字符串中改变字符的情况

TString& ToLower();   // 将大写字符转换成小写

TString& ToUpper();   // 将小写字符转换成大写

// 其他成员函数未显示

private:

struct StringRep {

char* _str;   // 实际的字符

unsigned _refCount;   // 对它引用的数目

unsigned _length;    // 字符串中的字符数目

};

StringRep* _rp;   // 在TString中唯一的数据成员

};

// 其他非成员函数未作改动--此处未显示

每个TString类对象都包含指向StringRep对象的指针。在复制TString类对象时,只需复制_rp 指针,就这么简单。实际上,也可以将 StrginRep 设计成一个带有构造函数和析构函数的真正独立的类。但是在该例中,不用这样做。我们需要的只是一个字符指针和引用计数的占位符。参见图4-14理解以下代码:

TString one(“ABCDEFG”);

TString two(one); // 进行复制

TString three;

three = one;

现在,我们来看看它的实现有何不同:

TString::TString()

{

_rp = new StringRep;

_rp->_refCount = 1;

_rp->_length = 0;

_rp->_str = 0;

}

TString::TString(const char* s)

{

_rp = new StringRep;

_rp->_refCount = 1;   // 这是使用StringRep的唯一对象

_rp->_length = strlen(s);

_rp->_str = new char[_rp->_length + 1];

strcpy (_rp->_str, s);

}

TString::TString(char aChar)

{

_rp = new StringRep;

_rp->_length = 1;

_rp->_str = new char[_rp->_length + 1];

_rp->_str[0] = aChar;

_rp->_str[1] = 0;

_rp->_refCount = 1;   // 这是使用StringRep的唯一对象

}

TString::TString(const TString& other)

// 这是最重要的操作之一。

// 我们需要在other中,通过_rp所指向的对象递增引用计数。它又获得一个引用。

other._rp->_refCount++;

// 让它们共享资源

this->_rp = other._rp;

}

TString& TString::operator=(const TString& other)

{

if (this == &other)

return * this; // 自我赋值

/* 这是另一个重要的操作。我们需要在other中,通过_rp所指向的对象递增引用计数。

同时,需要通过“this”指向的对象递减引用计数。*/

other._rp->_refCount++;   // 它又获得一个引用

// 递减和测试,是否仍然在使用它?

if (--this->_rp->_refCount == 0) {

delete [] this->_rp->_str;

delete this->_rp;

}

this->_rp = other._rp; // 让它们共享资源

return * this;

}

// 这是一个重要的成员函数,需要应用“写时复制”方案

TString& TString::ToLower()

{

char* p;

if (_rp->_refCount > 1) {

// 这是最困难的部分。分离TString 对象并提供它的StringRep对象。

// 这是“写时复制”操作。

unsigned len = this->_rp->_length; // 保存它

p = new char[len + 1];

strcpy(p, this->_rp->_str);

this->_rp->_refCount--; // 因为 *this即将离开内存池

this->_rp = new StringRep;

this->_rp->_refCount = 1;

this->_rp->_length = len;

this->_rp->_str = p;  // p在前面已创建

}

// 继续,并改变字符

p = this->_rp->_str;

if (p != 0) {

while (*p) {

*p = tolower(*p); ++p;

}

}

return * this;

}

TString& TString::ToUpper() // 留给读者作为练习

{

return *this;

}

TString::~TString()

{

if (--_rp->_refCount == 0) {

delete [] _rp->_str;

delete _rp;

}

}

// 已省略其他成员函数的实现

下面的代码用于说明赋值操作符如何工作(见图4-15):

TString x(“1234ABCDXYZ”);

TString y(x); // 通过x复制构造y

TString a (“PQRS”);

TString b(a);

a = x;

到目前为止,这些操作是最重要的,因为它们控制着对象的复制。现在,为了完善代码,来看看ToLower成员函数的实现效果(见图4-16)。

假设有如下的代码:

TString x(“1234ABCXYZ”);

TString y(x);

TString z = x;

z.ToLower();

现在,分析一下TString类的析构函数。当TString类对象离开作用域后,如果不再使用_rp所指向的内存,必须将其删除。否则,我们只是减少了引用计数的值,并未清理内存就匆忙前进。

线程安全可移植性:

必须记住,在以上讨论的示例中,所有修改_refCount数据成员的地方,都不是多线程安全的操作。在需要多线程安全的情况中,必须保证这样的递增和递减操作是多线程安全的。方法是:使用操作系统特定的同步工具(甚至是在汇编语言例程中);或者,由一个不同的类(将在下一章中介绍)来处理这种针对处理器的操作,而且客户必须使用这个类。重要的是识别线程安全,如何实现它只是细节问题。

思考:

在上面的代码中,很多地方都需要创建、删除和操控StringRep对象。很明显,这并不是最好的方法。尝试修改实现,以便StringRep有自己的构造函数、析构函数以及其他函数。这样,StringRep便可自我管理。另外,完成TString类的实现。

4.10.1 何时使用引用计数

共享资源是大多数应用程序中十分常见的功能,在需要共享资源(无论是否有“写时复制”)时,使用引用计数是一种整洁的方案。引用计数促使实现更高效、更简洁,而且使应用程序运行得更快。引用计数为客户分担了资源管理的负担,并让其成为实现的一部分(这是正确的处理方法)。

4.10.2 “写时复制”小结

上面使用的引用计数方案有一些与众不同的特点。

TSring 类对象负责处理 StringRep 对象。可以把 StringRep 对象看成主对象(master object),它拥有存储区和引用计数。实际上,客户并不知道内部如何完成所有的工作,因为“写时复制”方案保证了她不会受到任何影响。客户总会认为自己拥有了 TString 类对象副本,其实真正的实现远比这复杂得多。“写时复制”这个概念,在禁止高开销复制、需要更高效复制操作的地方非常有用。

在不适合使用“写时复制”方案(因为主对象并不允许复制),但却需要共享的地方,我们将使用无“写时复制”的引用计数语义。在所有情况中,都必须考虑是否允许客户修改主对象。我们将在后面的章节中介绍更多相关的示例。

4.11 类和类型

到目前为止,我们一直将类作为OOP中的基本实体。但是,从语言类型机制的角度来看,类几乎可以看做是添加至语言中的一个新类型[65]。在C++和Eiffel中更是如此。类代表一个新类型,而对象就是该新类型的实例。与基本类型(或语言定义类型)一样,类有一些限制和责任。而且,语言对于类对象如何与其他类对象混合也存在一些规则。这与各语言定义类型的兼容性问题非常类似。例如,可以将整数赋值给浮点变量,但却不能将double类型变量赋值给char类型变量。这样的限制也适用于类和对象,只有相关类的对象才能互相兼容。例如,如果TCar是TVehicle的子类(即TCar从TVehicel派生),那么TCar类的对象便可与TVehicle类的对象兼容。通过下一章讨论的继承机制即可实现。

既然类像是程序员定义的新类型,那么程序员必须十分了解类的属性、特权和责任。正如语言设计者必须了解语言定义类型一样,类的设计者必须将重点放在类上。一般而言,一个类的对象不能与其他类的对象混合(进行赋值),除非类之间是通过继承相关的。类层次的设计者可以实现这种继承。换言之,两个对象只有在设计者希望它们兼容时才能兼容。除此之外的其他情况,类是有差异的、不兼容的类型。

使用这种将类作为类型(class-as-a-type)的方案可以编写出更好的软件,而且在编译时即可验证其正确与否。因为每个类都是一个新类型,所以可以强制执行非常严格的类型检查。这使得程序更加强健和可靠。

4.12 小结

一定要完全初始化对象。所有构造函数都应确保用合适的值初始化所有数据成员。

一定要为所有的类都实现复制构造函数、赋值操作符和析构函数。由编译器生成的默认版本在实际的商业级程序中几乎没用。

充分理解无用单元收集和悬挂引用的概念,确保设计的类不会发生内存泄漏。

正确理解对象的标识,不要混淆指向对象的指针和真正的对象。

为类提供复制和赋值(如果有意义的话)。在类不允许复制和赋值语义的地方,关闭(或控制)复制和赋值。

如果设计的实现将用于多线程系统中,应确保引用计数是多线程安全的。

为了让实现更加高效,使用“写时复制”的方案。

用复制构造函数操作代替使用默认构造函数后立即使用赋值的操作。

本书赞誉

对于使用C++进行面向对象编程的开发人员来讲,本书是他们的必备读物。本书包含了丰富的面向对象编程知识,可以让他们进一步提升其编程技能。除了讲解C++编程技巧之外,本书还向读者展示了使用C++进行面向对象设计的技术。更难能可贵的是,开发人员在开发高效编程架构背后的思维过程也在本书中得以淋漓尽致地体现。

Venkat Narayanan

Cisco公司项目经理

加州大学圣克鲁兹分校讲师

Kayshav 的这本著作不仅仅会讲解 C++的高级功能特性,还会讲解如何使用这些功能特性来设计大型的面向对象软件系统。由于Kayshav是从软件工程师的角度编写了本书,因此对于有志于成为C++开发高手的读者来说,本书的实用性更强。

本书之所以宝贵,一方面是因为本书内容易于理解,另一方面是本书囊括了所有的C++主题知识。更为重要的是,读者还可以学到如何避免C++程序中的“阿喀琉斯之踵”(Achilles heel,可以引申为“致命要害”)——内存泄漏。如果读者仅仅掌握了“内存泄漏”这一个主题,也可以单凭这“一招鲜”在日后的C++开发生涯中驰骋纵横。

如果读者足够聪明勤奋,则可以全盘吸收掌握本书无所不包的C++对象编程知识。而且,掌握了本书内容的读者,对任何C++开发团队来讲,都是奋力争夺的宝贵人才。

Michael Hennessy

俄勒冈大学计算机科学系资深讲师

即使在学完C++编程的工作机制之后,读者也需要明白C++编程机制之后的原理。本书完美地将这两者结合起来,读者在学完C++和面向对象编程知识之后,不但可以知道实现软件功能的多种方式,而且还可以确定哪种方式是最佳的。这本书之所以能在众多C++图书中脱颖而出,就是因为它以一种良好的写作风格,外加大量优秀且实用的案例代码,清晰地表达了C++编程的本质。

Kenneth Fogle

加拿大魁北克蒙特利尔道森学院计算机系教授

加拿大魁北克蒙特利尔肯高迪亚大学继续教育讲师

本书编排结构清晰,内容引人入胜。Kayshav 通过本书向读者展示了 C++设计和编程中会遇到的各种陷阱,同时阐明了C++编程语言的力与美。单凭这一点,本书就可以在我的书架中占据一席之地。

Lyle Thompson

HelioSoft公司CEO

相关图书

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

相关文章

相关课程