首页资源分类编程语言C/C++ > c++ primer

c++ primer

已有 445791个资源

下载专区

上传者其他资源

文档信息举报收藏

标    签:cc++

分    享:

文档简介

c c++入门书籍 适合初学者

文档预览

1 前言 本书全面介绍了 C++ 语言。作为一本入门书(Primer),它以教程的形式 对 C++ 语言进行清晰的讲解,并辅以丰富的示例和各种学习辅助手段。与大多 数入门教程不同,本书对 C++ 语言本身进行了详尽的描述,并特别着重介绍了 目前通行的、行之有效的程序设计技巧。 无数程序员曾使用本书的前几个版本学习 C++,在此期间 C++ 也逐渐发展 成熟。这些年来,C++ 语言的发展方向以及 C++ 程序员的关注点,已经从以往 注重运行时的效率,转到千方百计地提高程序员的编程效率上。随着标准库的广 泛可用,我们现在能够比以往任何时候更高效地学习和使用 C++。本书这一版本 充分体现了这一点。 第四版的改动 为了体现现代 C++ 编程风格,我们重新组织并重写了本书。书中不再强调 低层编程技术,而把中心转向标准库的使用。书中很早就开始介绍标准库,示例 也已经重新改写,充分利用了标准库设施。我们也对语言主题叙述的先后次序进 行了重新编排,使讲解更加流畅。 除重新组织内容外,为了便于读者理解,我们还增加了几个新的环节。每一 章都新增了“小结”和“术语”,概括本章要点。读者可以利用这些部分进行自 我检查;如果发现还有不理解的概念,可以重新学习该章中的相关部分。 书中还加入了下述几种学习辅助手段: • 重要术语用黑体表示,我们认为读者已经熟悉的重要术语则用楷体表示。 这些术语都会出现在的“术语”部分。 • 书中用特殊版式突出标注的文字,是为了向读者提醒语言的重要特征,警 示常见的错误,标明良好的编程实践,列出通用的使用技巧。希望这些标 注可以帮助读者更快地消化重要概念,避免犯常见错误。 • 为了更易于理解各种特征或概念间的关系,书中大量使用了前后交叉引 用。 • 对于某些重要概念和 C++ 新手最头疼的问题,我们进行了额外的讨论和 解释。这部分也以特殊版式标出。 • 学习任何程序设计语言都需要编写程序。因此,本提供了大量的示例。所 有示例的源代码可以从下列网址获得: • http://www.awprofessional.com/cpp_primer 万变不离其宗,本书保持了前几版的特色,仍然是一部全面介绍 C++ 的教程。 我们的目标是提供一本清晰、全面、准确的指南性读物。我们通过讲解一系列示 例来教授 C++ 语言,示例除了解释语言特征外,还展示了如何善用这门语言。 2 虽然读者不需要事先学过 C 语言(C++ 最初的基础)的知识,但我们假定读者 已经掌握了一种现代结构化语言。 本书结构 本介绍了 C++ 国际标准,既涵盖语言的特征,又讲述了也是标准组成部分 的丰富标准库。C++ 的强大很大程度上来自它支持抽象程序设计。要学会用 C++ 高效地编程,只是掌握句法和语义是远远不够的。我们的重点在于,教会读者怎 样利用 C++ 的特性,快速地写出安全的而且性能可与 C 语言低层程序相媲美的 程序。 C++ 是一种大型的编程语言,这可能会吓倒一些新手。现代 C++ 可以看成由 以下三部分组成: • 低级语言,多半继承自 C。 • 更高级的语言特征,用户可以借此定义自己的数据类型,组织大规模的程 序和系统。 • 标准库,使用上述高级特征提供一整套有用的数据结构和算法。 多数 C++ 教材按照下面的顺序展开:先讲低级细节,再介绍更高级的语言特 征;在讲完整个语言后才开始解释标准库。结果往往使读者纠缠于低级的程序设 计问题和复杂类型定义的编写等细节,而不能真正领会抽象编程的强大,更不用 说学到足够的知识去创建自己的抽象了。 本版中我们独辟蹊径。一开始就讲述语言的基础知识和标准库,这样读者就 可以写出比较大的有实际意义的程序来。透彻阐释了使用标准库(并且用标准库 编写了各种抽象程序)的基础知识之后,我们才进入下一步,学习用 C++ 的其 他高级特征来编写自己的抽象。 第一和第二部分讨论语言的基础知识和标准库设施。其重点在于学会如何编 写 C++ 程序,如何使用标准库提供的抽象设施。大部分 C++ 程序员需要了解本 书这两部分的内容。 除了讲解基础知识以外,这两部分还有另外一个重要的意图。标准库设施本 身是用 C++ 编写的抽象数据类型,定义标准库使用的是任何 C++ 程序员都能使 用的构造类的语言特征。我们教授 C++ 的经验说明,一开始就使用设计良好的 抽象类型,读者会更容易理解如何建立自己的类型。 第三到第五部分着重讨论如何编写自己的类型。第三部分介绍 C++ 的核心, 即对类的支持。类机制提供了编写自定义抽象的基础。类也是第四部分中讨论的 面向对象编程和泛型编程的基础。全书正文的最后是第五部分,这一部分讨论了 一些高级特征,它们在构建大型复杂系统时最为常用。 3 致谢 与前几版一新,我们要感谢 Bjarne Stroustrup,他不知疲倦地从事着 C++ 方面的工作,他与我们的深厚友情由来已久。我们还要感谢 Alex Stepanov,正 是他最初凭借敏锐的洞察力创造了容器和算法的概念,这些概念最终形成了标准 库的核心。此外,我们要感谢 C++ 标准委员会的所有成员,他们多年来为 C++ 澄 清概念、细化标准和改进功能付出了艰苦的努力。 我们要衷心地感谢本书审稿人,他们审阅了我们的多份书稿,帮助我们对本 书进行了无数大大小小的修改。他们是 Paul Abrahams,Michael Ball,Mary Dageforde,Paul DuBois,Matt Greenwood,Matthew P. Johnson,Andrew Koenig, Nevin Liber,Bill Locke,Robert Murray,Phil Romanik,Justin Shaw,Victor Shtern,Clovis Tondo,Daveed Vandevoorde 和 Steve Vinoski。 书中所有示例都已通过 GNU 和微软编译器的编译。感谢他们的开发者和所 有开发其他 C++ 编译器的人,是他们使 C++ 变成现实。 最后,感谢 的工作人员,他们引领了这一版的整个出版过程:——我们最 初的编辑,是他提出出版本书的新版,他从本书最初版本起就一直致力于本书; ——我们的新编辑,他坚持更新和精简本书内容,极大地改进了这一版本;—— 他保证了我们所有人能按进度工作;还有 、、和,他们和我们一起经历了整个 设计和制作过程。 4 目录 前言 ..................................................................................................................................................1 第四版的改动...........................................................................................................................2 本书结构...................................................................................................................................3 致谢...........................................................................................................................................4 第一章 快速入门...........................................................................................................................17 1.1. 编写简单的 C++ 程序..........................................................................................17 调用 GNU 或微软编译器 ....................................................................................................20 Exercises Section 1.1.1 ...........................................................................................................21 1.2. 初窥输入/输出........................................................................................................21 关键概念:已初始化变量和未初始化变量 ......................................................................... 26 Exercises Section 1.2.2 ...........................................................................................................27 1.3. 关于注释.................................................................................................................27 Exercises Section 1.3 ......................................................................................................29 1.4. 控制结构.................................................................................................................29 关键概念:C++ 程序的缩排和格式....................................................................................31 再谈编译.................................................................................................................................34 Exercises Section 1.4.2 ...........................................................................................................35 Exercises Section 1.4.3 ...........................................................................................................37 从键盘输入文件结束符.........................................................................................................39 Exercises Section 1.4.4 ...........................................................................................................39 1.5. 类的简介.................................................................................................................39 关键概念:类定义行为.........................................................................................................42 Exercises Section 1.5.1 ...........................................................................................................43 Exercises Section 1.5.2 ...........................................................................................................45 1.6. C++ 程序 ................................................................................................................45 Exercises Section 1.6 ..............................................................................................................47 小结.................................................................................................................................47 术语.................................................................................................................................47 第一部分 基本语言.......................................................................................................................54 第二章 变量和基本类型.......................................................................................................55 2.1. 基本内置类型.........................................................................................................55 内置类型的机器级表示.........................................................................................................57 建议:使用内置算术类型.....................................................................................................60 Exercises Section 2.1.2 ...........................................................................................................61 2.2. 字面值常量.............................................................................................................61 建议:不要依赖未定义行为.................................................................................................66 Exercises Section 2.2 ..............................................................................................................67 2.3. 变量.........................................................................................................................67 Exercises Section 2.3 ..............................................................................................................68 关键概念:强静态类型.........................................................................................................69 Exercises Section 2.3.1 ...........................................................................................................70 5 术语:什么是对象?.............................................................................................................71 Exercises Section 2.3.2 ...........................................................................................................73 Exercises Section 2.3.3 ...........................................................................................................77 警告:未初始化的变量引起运行问题 ................................................................................. 78 Exercises Section 2.3.4 ...........................................................................................................79 Exercises Section 2.3.5 ...........................................................................................................81 Exercises Section 2.3.6 ...........................................................................................................84 2.4. const 限定符 ...........................................................................................................84 Exercises Section 2.4 ..............................................................................................................87 2.5. 引用.........................................................................................................................87 术语:const 引用是指向 const 的引用...........................................................................89 Exercises Section 2.5 ..............................................................................................................90 2.6. typedef 名字............................................................................................................90 2.7. 枚举.........................................................................................................................91 2.8. 类类型.....................................................................................................................93 Exercises Section 2.8 ..............................................................................................................97 2.9. 编写自己的头文件.................................................................................................97 编译和链接多个源文件.........................................................................................................99 Exercises Section 2.9.1 .........................................................................................................102 小结...............................................................................................................................104 术语...............................................................................................................................105 第三章 标准库类型.............................................................................................................112 3.1. 命名空间的 using 声明 ........................................................................................112 Exercises Section 3.1 ............................................................................................................115 3.2. 标准库 string 类型................................................................................................115 警告:标准库 string 类型和字符串字面值.....................................................................116 Exercises Section 3.2.1 .........................................................................................................116 Exercises Section 3.2.2 .........................................................................................................119 建议:采用 C 标准库头文件的 C++ 版本 .........................................................................127 Exercises Section 3.2.4 .........................................................................................................127 3.3. 标准库 vector 类型.............................................................................................127 关键概念:vector 对象动态增长.....................................................................................130 Exercises Section 3.3.1 .........................................................................................................131 关键概念:安全的泛型编程...............................................................................................134 警告:仅能对确知已存在的元素进行下标操作 ...............................................................135 Exercises Section 3.3.2 .........................................................................................................136 3.4. 迭代器简介...........................................................................................................136 术语:迭代器和迭代器类型...............................................................................................137 Exercises Section 3.4 ............................................................................................................142 Exercises Section 3.4.1 .........................................................................................................143 3.5. 标准库 bitset .........................................................................................................143 Exercises Section 3.5.2 .........................................................................................................150 小结...............................................................................................................................150 术语...............................................................................................................................150 6 第四章 数组和指针.............................................................................................................155 4.1. 数组.......................................................................................................................155 警告:数组的长度是固定的...............................................................................................159 Exercises Section 4.1.1 .........................................................................................................160 Exercises Section 4.1.2 .........................................................................................................162 4.2. 指针的引入...........................................................................................................162 建议:尽量避免使用指针和数组.......................................................................................163 Exercises Section 4.2.2 .........................................................................................................169 关键概念:给指针赋值或通过指针进行赋值 ................................................................... 170 Exercises Section 4.2.3 .........................................................................................................172 Exercises Section 4.2.4 .........................................................................................................177 建议:理解复杂的 const 类型的声明.............................................................................180 Exercises Section 4.3 ............................................................................................................182 4.3. C 风格字符串 .......................................................................................................182 Exercises Section 4.3 ............................................................................................................188 C 风格字符串与 C++ 的标准库类型 string 的比较 ...................................................192 Exercises Section 4.3.1 .........................................................................................................194 Exercises Section 4.3.2 .........................................................................................................196 4.4. 多维数组...............................................................................................................196 Exercises Section 4.4.1 .........................................................................................................200 小结...............................................................................................................................200 术语...............................................................................................................................200 第五章 表达式.....................................................................................................................204 5.1. 算术操作符...........................................................................................................205 警告:溢出和其他算术异常...............................................................................................207 Exercises Section 5.1 ............................................................................................................209 5.2. 关系操作符和逻辑操作符...................................................................................209 Exercises Section 5.2 ............................................................................................................213 5.3. 位操作符...............................................................................................................213 Exercises Section 5.3.1 .........................................................................................................217 5.4. 赋值操作符...........................................................................................................218 Exercises Section 5.4.2 .........................................................................................................221 Exercises Section 5.4.3 .........................................................................................................222 5.5. 自增和自减操作符...............................................................................................222 建议:只有在必要时才使用后置操作符 ........................................................................... 223 建议:简洁即是美...............................................................................................................224 Exercises Section 5.5 ............................................................................................................225 5.6. 箭头操作符...........................................................................................................225 Exercises Section 5.6 ............................................................................................................226 5.7. 条件操作符...........................................................................................................226 Exercises Section 5.7 ............................................................................................................228 5.8. sizeof 操作符 ........................................................................................................228 Exercises Section 5.8 ............................................................................................................229 5.9. 逗号操作符...........................................................................................................229 7 Exercises Section 5.9 ............................................................................................................230 5.10. 复合表达式的求值.............................................................................................230 Exercises Section 5.10.2 .......................................................................................................236 建议:复合表达式的处理...................................................................................................238 Exercises Section 5.10.3 .......................................................................................................239 5.11. new 和 delete 表达式........................................................................................239 警告:动态内存的管理容易出错.......................................................................................243 Exercises Section 5.11 ..........................................................................................................244 5.12. 类型转换.............................................................................................................244 Exercises Section 5.12.3 .......................................................................................................250 建议:避免使用强制类型转换...........................................................................................253 Exercises Section 5.12.7 .......................................................................................................255 小结...............................................................................................................................255 术语...............................................................................................................................256 第六章 语句.........................................................................................................................261 6.1. 简单语句...............................................................................................................261 6.2. 声明语句...............................................................................................................262 6.3. 复合语句(块)...................................................................................................263 Exercises Section 6.3 ............................................................................................................264 6.4. 语句作用域.........................................................................................................264 6.5. if 语句 ...................................................................................................................265 Exercises Section 6.5.1 .........................................................................................................270 6.6. switch 语句 ...........................................................................................................270 Exercises Section 6.6.5 .........................................................................................................278 Code for Exercises in Section 6.6.5 ......................................................................................278 6.7. while 语句.............................................................................................................279 Exercises Section 6.7 ............................................................................................................282 6.8. for 循环语句 .........................................................................................................282 Exercises Section 6.8.2 .........................................................................................................286 6.9. do while 语句........................................................................................................286 Exercises Section 6.9 ............................................................................................................289 6.10. break 语句...........................................................................................................289 Exercises Section 6.10 ..........................................................................................................291 6.11. continue 语句 ......................................................................................................291 Exercises Section 6.11 ..........................................................................................................292 6.12. goto 语句.............................................................................................................292 Exercises Section 6.12 ..........................................................................................................293 6.13. try 块和异常处理 ...............................................................................................293 Exercises Section 6.13.2 .......................................................................................................297 6.14. 使用预处理器进行调试.....................................................................................299 Exercises Section 6.14 ..........................................................................................................301 小结...............................................................................................................................301 术语...............................................................................................................................302 第七章 函数.........................................................................................................................306 8 7.1. 函数的定义...........................................................................................................306 Exercises Section 7.1.2 .........................................................................................................311 7.2. 参数传递...............................................................................................................311 Exercises Section 7.2.1 .........................................................................................................314 Exercises Section 7.2.2 .........................................................................................................321 Exercises Section 7.2.5 .........................................................................................................328 Exercises Section 7.2.6 .........................................................................................................329 7.3. return 语句 ............................................................................................................330 Exercises Section 7.3.2 .........................................................................................................337 Exercises Section 7.3.3 .........................................................................................................339 7.4. 函数声明...............................................................................................................339 Exercises Section 7.4 ............................................................................................................341 Exercises Section 7.4.1 .........................................................................................................344 7.5. 局部对象...............................................................................................................344 Exercises Section 7.5.2 .........................................................................................................346 7.6. 内联函数...............................................................................................................346 Exercises Section 7.6 ............................................................................................................348 7.7. 类的成员函数.......................................................................................................348 Exercises Section 7.7.4 .........................................................................................................356 7.8. 重载函数...............................................................................................................356 建议:何时不重载函数名...................................................................................................359 Exercises Section 7.8.1 .........................................................................................................362 Exercises Section 7.8.3 .........................................................................................................366 Exercises Section 7.8.4 .........................................................................................................370 7.9. 指向函数的指针...................................................................................................370 小结...............................................................................................................................374 术语...............................................................................................................................375 第八章 标准 IO 库 ............................................................................................................379 8.1. 面向对象的标准库...............................................................................................379 Exercises Section 8.1 ............................................................................................................383 8.2. 条件状态...............................................................................................................383 Exercises Section 8.2 ............................................................................................................387 8.3. 输出缓冲区的管理...............................................................................................387 警告:如果程序崩溃了,则不会刷新缓冲区 ................................................................... 388 8.4. 文件的输入和输出...............................................................................................389 警告:C++ 中的文件名......................................................................................................391 Exercises Section 8.4.1 .........................................................................................................394 Exercises Section 8.4.3 .........................................................................................................398 8.5. 字符串流...............................................................................................................398 Exercises Section 8.5 ............................................................................................................401 小结...............................................................................................................................401 术语...............................................................................................................................401 第二部分:容器和算法...............................................................................................................403 第九章. 顺序容器................................................................................................................404 9 9.1. 顺序容器的定义...................................................................................................405 Exercises Section 9.1.1 .........................................................................................................409 Exercises Section 9.1.2 .........................................................................................................411 9.2. 迭代器和迭代器范围...........................................................................................411 Exercises Section 9.2 ............................................................................................................414 对形成迭代器范围的迭代器的要求 ................................................................................... 415 Exercises Section 9.2.1 .........................................................................................................417 9.3. 每种顺序容器都提供了一组有用的类型定义以及以下操作: .......................418 Exercises Section 9.3.1 .........................................................................................................419 关键概念:容器元素都是副本...........................................................................................421 Exercises Section 9.3.3 .........................................................................................................425 Exercises Section 9.3.4 .........................................................................................................427 Exercises Section 9.3.5 .........................................................................................................429 Exercises Section 9.3.6 .........................................................................................................431 Exercises Section 9.3.7 .........................................................................................................434 Exercises Section 9.3.8 .........................................................................................................437 9.4. vector 容器的自增长............................................................................................437 Exercises Section 9.4.1 .........................................................................................................441 9.5. 容器的选用...........................................................................................................441 Exercises Section 9.5 ............................................................................................................444 9.6. 再谈 string 类型.............................................................................................444 Exercises Section 9.6 ....................................................................................................446 Exercises Section 9.6.4 ................................................................................................456 9.7. 容器适配器.........................................................................................................459 Exercises Section 9.7.2 ................................................................................................463 小结...............................................................................................................................463 术语...............................................................................................................................463 第十章 关联容器.................................................................................................................466 10.1. 引言:pair 类型.............................................................................................466 Exercises Section 10.1 ..................................................................................................469 10.2. 关联容器...........................................................................................................469 Exercises Section 10.2 ..................................................................................................470 10.3. map 类型...........................................................................................................470 Exercises Section 10.3.1 ..............................................................................................472 Exercises Section 10.3.2 ..............................................................................................473 Exercises Section 10.3.4 ..............................................................................................475 Exercises Section 10.3.5 ..............................................................................................479 Exercises Section 10.3.6 ..............................................................................................480 Exercises Section 10.3.9 ..............................................................................................485 10.4. set 类型...........................................................................................................485 Exercises Section 10.4 ..................................................................................................486 Exercises Section 10.4.2 ..............................................................................................489 10.5. multimap 和 multiset 类型 ........................................................................490 Exercises Section 10.5.2 ..............................................................................................494 10 10.6. 容器的综合应用:文本查询程序...................................................................495 Exercises Section 10.6.2 ..............................................................................................498 Exercises Section 10.6.3 ..............................................................................................501 Exercises Section 10.6.4 ..............................................................................................503 小结...............................................................................................................................503 术语...............................................................................................................................504 第十一章 泛型算法.............................................................................................................506 11.1. 概述...................................................................................................................506 Exercises Section 11.1 ..................................................................................................509 关键概念:算法永不执行容器提供的操作.......................................................................509 11.2. 初窥算法...........................................................................................................510 关键概念:迭代器实参类型...............................................................................................512 Exercises Section 11.2.1 ..............................................................................................512 Exercises Section 11.2.2 ..............................................................................................515 Exercises Section 11.2.3 ..............................................................................................521 11.3. 再谈迭代器.......................................................................................................521 Exercises Section 11.3.1 ..............................................................................................523 Exercises Section 11.3.2 ..............................................................................................529 Exercises Section 11.3.3 ..............................................................................................532 关键概念:关联容器与算法...............................................................................................534 Exercises Section 11.3.5 ..............................................................................................535 11.4. 泛型算法的结构...............................................................................................536 Exercises Section 11.4.2 ..............................................................................................539 11.5. 容器特有的算法...............................................................................................539 Exercises Section 11.5 ..................................................................................................541 小结...............................................................................................................................541 术语...............................................................................................................................542 第三部分:类和数据抽象...........................................................................................................545 第十二章 类.........................................................................................................................546 12.1. 类的定义和声明...............................................................................................546 Exercises Section 12.1.1 ..............................................................................................548 建议:具体类型和抽象类型...............................................................................................549 关键概念:数据抽象和封装的好处...................................................................................551 Exercises Section 12.1.2 ..............................................................................................551 Exercises Section 12.1.3 ..............................................................................................555 Exercises Section 12.1.4 ..............................................................................................557 12.2. 隐含的 this 指针...........................................................................................558 建议:用于公共代码的私有实用函数...............................................................................562 Exercises Section 12.2 ..................................................................................................563 12.3. 类作用域...........................................................................................................563 Exercises Section 12.3 ..................................................................................................566 Exercises Section 12.3.1 ..............................................................................................571 12.4. 构造函数...........................................................................................................571 Exercises Section 12.4 ..................................................................................................573 11 建议:使用构造函数初始化列表.......................................................................................577 Exercises Section 12.4.1 ..............................................................................................579 Exercises Section 12.4.2 ..............................................................................................580 Exercises Section 12.4.3 ..............................................................................................583 Exercises Section 12.4.4 ..............................................................................................586 Exercises Section 12.4.5 ..............................................................................................588 12.5. 友元...................................................................................................................588 Exercises Section 12.5 ..................................................................................................591 12.6. static 类成员 ................................................................................................591 Exercises Section 12.6 ..................................................................................................593 Exercises Section 12.6.1 ..............................................................................................594 Exercises Section 12.6.2 ..............................................................................................597 小结...............................................................................................................................597 术语...............................................................................................................................598 第十三章 复制控制.............................................................................................................601 13.1. 复制构造函数...................................................................................................602 Exercises Section 13.1 ..................................................................................................604 Exercises Section 13.1.2 ..............................................................................................607 13.2. 赋值操作符.......................................................................................................608 Exercises Section 13.2 ..................................................................................................610 13.3. 析构函数...........................................................................................................611 Exercises Section 13.3 ..................................................................................................613 13.4. 消息处理示例...................................................................................................614 Exercises Section 13.4 ..................................................................................................620 13.5. 管理指针成员...................................................................................................620 Exercises Section 13.5 ..................................................................................................623 建议:管理指针成员...........................................................................................................628 Exercises Section 13.5.1 ..............................................................................................629 Exercises Section 13.5.2 ..............................................................................................631 小结...............................................................................................................................632 术语...............................................................................................................................632 第十四章 重载操作符与转换.............................................................................................635 14.1. 重载操作符的定义.............................................................................................635 习题 14.1..............................................................................................................................640 警告:审慎使用操作符重载...............................................................................................642 Exercises Section 14.1.1 .......................................................................................................643 14.2. 输入和输出操作符.............................................................................................643 Exercises Section 14.2.1 .......................................................................................................646 Exercises Section 14.2.2 ..............................................................................................649 14.3. 算术操作符和关系操作符.................................................................................649 Exercises Section 14.3 ..........................................................................................................651 14.4. 赋值操作符.........................................................................................................652 Exercises Section 14.4 ..........................................................................................................654 14.5. 下标操作符.........................................................................................................654 12 Exercises Section 14.5 ..........................................................................................................656 14.6. 成员访问操作符.................................................................................................656 Exercises Section 14.6 ..........................................................................................................660 14.7. 自增操作符和自减操作符.................................................................................660 Exercises Section 14.7 ..........................................................................................................665 14.8. 调用操作符和函数对象.....................................................................................665 Exercises Section 14.8 ..........................................................................................................666 Exercises Section 14.8.1 .......................................................................................................669 Exercises Section 14.8.3 ..............................................................................................672 14.9. 转换与类类型.....................................................................................................673 Exercises Section 14.9.2 .......................................................................................................679 警告:避免转换函数的过度使用.......................................................................................684 Exercises Section 14.9.4 .......................................................................................................688 警告:转换和操作符...........................................................................................................690 Exercises Section 14.9.5 .......................................................................................................692 小结...............................................................................................................................692 术语...............................................................................................................................693 第四部分:面向对象编程与泛型编程.......................................................................................695 第十五章. 面向对象编程...................................................................................................696 15.1. 面向对象编程:概述.........................................................................................696 15.2. 定义基类和派生类.............................................................................................698 Exercises Section 15.2.1 .......................................................................................................701 关键概念:类设计与受保护成员.......................................................................................702 Exercises Section 15.2.3 .......................................................................................................707 关键概念:C++ 中的多态性..............................................................................................710 Exercises Section 15.2.4 .......................................................................................................713 关键概念:继承与组合.......................................................................................................716 Exercises Section 15.2.5 .......................................................................................................719 Exercises Section 15.2.7 .......................................................................................................721 15.3. 转换与继承.........................................................................................................721 15.4. 构造函数和复制控制.........................................................................................725 关键概念:重构...................................................................................................................729 关键概念:尊重基类接口...................................................................................................730 Exercises Section 15.4.2 .......................................................................................................731 Exercises Section 15.4.4 .......................................................................................................736 15.5. 继承情况下的类作用域.....................................................................................737 Exercises Section 15.5.1 .......................................................................................................738 Exercises Section 15.5.2 .......................................................................................................740 关键概念:名字查找与继承...............................................................................................743 Exercises Section 15.5.4 ..............................................................................................744 15.6. 纯虚函数.............................................................................................................745 Exercises Section 15.6 ..........................................................................................................746 15.7. 容器与继承.........................................................................................................746 Exercises Section 15.7 ..........................................................................................................747 13 15.8. 句柄类与继承.....................................................................................................747 Exercises Section 15.8.2 .......................................................................................................753 Exercises Section 15.8.3 .......................................................................................................758 15.9. 再谈文本查询示例.............................................................................................758 Exercises Section 15.9.2 .......................................................................................................764 Exercises Section 15.9.5 .......................................................................................................772 Exercises Section 15.9.6 .......................................................................................................774 小结...............................................................................................................................774 术语...............................................................................................................................775 第十六章 模板和泛型编程.................................................................................................778 16.1. 模板定义.............................................................................................................778 Exercises Section 16.1.1 .......................................................................................................781 Exercises Section 16.1.2 .......................................................................................................783 Exercises Section 16.1.3 .......................................................................................................786 Exercises Section 16.1.4 .......................................................................................................789 Exercises Section 16.1.5 .......................................................................................................790 Exercises Section 16.1.6 .......................................................................................................792 警告:链接时的编译时错误...............................................................................................793 16.2. 实例化.................................................................................................................793 Exercises Section 16.2.1 .......................................................................................................801 Exercises Section 16.2.2 ..............................................................................................804 16.3. 模板编译模型.....................................................................................................804 Exercises Section 16.3 ..........................................................................................................807 警告:类模板中的名字查找...............................................................................................808 16.4. 类模板成员.........................................................................................................808 Exercises Section 16.4 ..........................................................................................................813 Exercises Section 16.4.1 .......................................................................................................818 Exercises Section 16.4.2 .......................................................................................................820 Exercises Section 16.4.4 .......................................................................................................826 Exercises Section 16.4.6 .......................................................................................................832 16.5. 一个泛型句柄类.................................................................................................833 Exercises Section 16.5.1 .......................................................................................................836 Exercises Section 16.5.2 .......................................................................................................839 16.6. 模板特化.............................................................................................................839 Exercises Section 16.6.1 .......................................................................................................843 Exercises Section 16.6.2 .......................................................................................................846 Exercises Section 16.6.3 .......................................................................................................848 16.7. 重载与函数模板.................................................................................................849 Exercises Section 16.7 ..........................................................................................................853 小结...............................................................................................................................853 术语...............................................................................................................................854 第五部分 高级主题...................................................................................................................857 第十七章 用于大型程序的工具.......................................................................................858 17.1. 异常处理.............................................................................................................858 14 Exercises Section 17.1.1 .......................................................................................................862 Exercises Section 17.1.3 .......................................................................................................866 Exercises Section 17.1.5 .......................................................................................................869 Exercises Section 17.1.8 .......................................................................................................874 Exercises Section 17.1.9 .......................................................................................................881 警告:Auto_ptr 缺陷.........................................................................................................882 Exercises Section 17.1.11......................................................................................................888 17.2. 命名空间...........................................................................................................888 Exercises Section 17.2.1 ..............................................................................................894 Exercises Section 17.2.2 ..............................................................................................895 未命名的命名空间取代文件中的静态声明.......................................................................897 Exercises Section 17.2.3 ..............................................................................................897 Exercises Section 17.2.4 ..............................................................................................902 警告:避免 Using 指示.....................................................................................................903 Exercises Section 17.2.6 ..............................................................................................910 17.3. 多重继承与虚继承...........................................................................................911 Exercises Section 17.3.1 ..............................................................................................913 Exercises Section 17.3.2 ..............................................................................................915 Exercises Section 17.3.2 ..............................................................................................917 本节习题的代码...................................................................................................................920 Exercises Section 17.3.4 ..............................................................................................921 Exercises Section 17.3.6 ..............................................................................................926 Exercises Section 17.3.7 ..............................................................................................929 小结...............................................................................................................................930 术语...............................................................................................................................931 第十八章 特殊工具与技术.................................................................................................936 18.1. 优化内存分配...................................................................................................936 Exercises Section 18.1.2 ..............................................................................................942 术语对比:new 表达式与 operator new 函数 ..............................................................942 Exercises Section 18.1.4 ..............................................................................................945 Exercises Section 18.1.5 ..............................................................................................946 Exercises Section 18.1.6 ..............................................................................................949 Exercises Section 18.1.7 ..............................................................................................955 18.2. 运行时类型识别...............................................................................................956 Exercises Section 18.2.1 ..............................................................................................959 Exercises Section 18.2.2 ..............................................................................................961 Exercises Section 18.2.4 ..............................................................................................966 18.3. 类成员的指针...................................................................................................966 Exercises Section 18.3.1 ..............................................................................................969 Exercises Section 18.3.2 ..............................................................................................973 18.4. 嵌套类...............................................................................................................973 Exercises Section 18.4.2 ..............................................................................................979 18.5. 联合:节省空间的类.......................................................................................980 18.6. 局部类...............................................................................................................983 15 18.7. 固有的不可移植的特征...................................................................................985 对链接到 C 的预处理器支持.............................................................................................992 Exercises Section 18.7.3 ..............................................................................................994 小结...............................................................................................................................994 术语...............................................................................................................................995 附录 A. 标准库 ..........................................................................................................................999 A.1. 标准库名字和头文件................................................................................................1000 A.2. 算法简介 ..................................................................................................................1003 A.2.1. 查找对象的算法...........................................................................................1003 A.2.2. 其他只读算法...............................................................................................1005 A.2.3. 二分查找算法...............................................................................................1006 A.2.4. 写容器元素的算法.......................................................................................1006 A.2.5. 划分与排序算法...........................................................................................1008 A.2.6. 通用重新排序操作.......................................................................................1010 A.2.7. 排列算法.......................................................................................................1012 A.2.8. 有序序列的集合算法...................................................................................1013 A.2.9. 最大值和最小值...........................................................................................1014 A.2.10. 算术算法.....................................................................................................1015 A.3. 再谈 IO 库 ..............................................................................................................1018 A.3.1. 格式状态.......................................................................................................1018 A.3.2. 许多操纵符改变格式状态...........................................................................1019 A.3.3. 控制输出格式...............................................................................................1020 A.3.4. 控制输入格式化...........................................................................................1027 A.3.5. 未格式化的输入/输出操作.......................................................................1027 A.3.6. 单字节操作...................................................................................................1028 A.3.7. 多字节操作...................................................................................................1029 警告:低级例程容易出错.................................................................................................1030 A.3.8. 流的随机访问...............................................................................................1031 A.3.9. 读写同一文件...............................................................................................1034 16 第一章 快速入门 本章介绍 C++ 的大部分基本要素:内置类型、库类型、类类型、变量、表 达式、语句和函数。在这一过程中还会简要说明如何编译和运行程序。 读者读完本章内容并做完练习,就应该可以编写、编译和执行简单的程序。 后面的章节会进一步阐明本章所介绍的主题。 要学会一门新的程序语言,必须实际动手编写程序。在这一章,我们将缩写 程序解决一个简单的数据处理问题:某书店以文件形式保存其每一笔交易。每一 笔交易记录某本书的销售情况,含有 ISBN(国际标准书号,世界上每种图书的 唯一标识符)、销售册数和销售单价。每一笔交易形如: 0-201-70353-X 4 24.99 第一个元素是 ISBN,第二个元素是销售的册数,最后是销售单价。店主定 期地查看这个文件,统计每本书的销售册数、总销售收入以及平均售价。我们要 编写程序来进行这些计算。 在编写这个程序之前,必须知道 C++ 的一些基本特征。至少我们要知道怎么 样编写、编译和执行简单的程序。这个程序要做什么呢?虽然还没有设计解决方 案,但是我们知道程序必须: • 定义变量。 • 实现输入和输出。 • 定义数据结构来保存要处理的数据。 • 测试是否两条记录具有相同的 ISBN。 • 编写循环。处理交易文件中的每一条记录。 我们将首先考察 C++ 的这些部分,然后编写书店问题的解决方案。 1.1. 编写简单的 C++ 程序 每个 C++ 程序都包含一个或多个函数,而且必须有一个命名为 main。函数 由执行函数功能的语句序列组成。操作系统通过调用 main 函数来执行程序, main 函数则执行组成自己的语句并返回一个值给操作系统。 下面是一个简单的 main 函数,它不执行任何功能,只是返回一个值: int main() { return 0; 17 } 操作系统通过 main 函数返回的值来确定程序是否成功执行完毕。返回 0 值表明程序程序成功执行完毕。 main 函数在很多方面都比较特别,其中最重要的是每个 C++ 程序必须含有 main 函数,且 main 函数是(唯一)被操作系统显式调用的函数。 定义 main 函数和定义其他函数一样。定义函数必须指定 4 个元素:返回 类型、函数名、圆括号内的形参表(可能为空)和函数体。main 函数的形参个 数是有限的。本例中定义的 main 函数形参表为空。第 7.2.6 节将介绍 main 函 数中可以定义的其他形参。 main 函数的返回值必须是 int 型,该类型表示整数。int 类型是内置类型, 即该类型是由 C++ 语言定义的。 函数体函数定义的最后部分,是以花括号开始并以花括号结束的语句块: { return 0; } 例中唯一的语句就是 return,该语句终止函数。 注意 return 语句后面的分号。在 C++ 中多数语句以分号作为 结束标记。分号很容易被忽略,而漏写分号将会导致莫名其妙 的编译错误信息。 当 return 带上一个值(如 0)时,这个值就是函数的返回值。返回值类型 必须和函数的返回类型相同,或者可以转换成函数的返回类型。对于 main 函数, 返回类型必须是 int 型,0 是 int 型的。 在大多数系统中,main 函数的返回值是一个状态指示器。返回值 0 往往表 示 main 函数成功执行完毕。任何其他非零的返回值都有操作系统定义的含义。 通常非零返回值表明有错误出现。每一种操作系统都有自己的方式告诉用户 main 函数返回什么内容。 18 1.1.1. 编译与执行程序 程序编写完后需要进行编译。如何进行编译,与具体操作系统和编译器有关。 你需要查看有关参考手册或者询问有经验的同事,以了解所用的编译器的工作细 节。 许多基于 PC 的编译器都在集成开发环境(IDE)中运行,IDE 将编译器与 相关的构建和分析工具绑定在一起。这些环境在开发复杂程序时非常有用,但掌 握起来需要花费一点时间。通常这些环境包含点击式界面,程序员在此界面下可 以编写程序,并使用各种菜单来编译与执行程序本书不介绍怎样使用这些环境。 大多数编译器,包括那些来自 IDE 的,都提供了命令行界面。除非你已经 很熟悉你的 IDE,否则从使用简单的命令行界面开始可能更容易些。这样可以避 免在学习语言之前得先去学习 IDE。 程序源文件命名规范 不管我们使用命令行界面还是 IDE,大多数编译器希望待编译的程序保存在 文件中。程序文件称作源文件。大多数系统中,源文件的名字由文件名(如 prog1) 和文件后缀两部分组成。依据惯例,文件后缀表明该文件是程序。文件后缀通常 也表明程序是用什么语言编写的,以及选择哪一种编译器运行。我们用来编译本 书实例的系统将带有后缀 .cc 的文件视为 C++ 程序,因此我们将该程序保存 为: prog1.cc C++ 程序文件的后缀与运行的具体编译器有关。其他的形式还包括。 prog1.cxx prog1.cpp prog1.cp prog1.C 19 调用 GNU 或微软编译器 调用 C++ 编译器的命令因编译器和操作系统的不同而不同,常用的编译 器是 GNU 编译器和微软 Visual Studio 编译器。调用 GNU 编译器的默 认命令是 g++: $ g++ prog1.cc -o prog1 这里的 $ 是系统提示符。这个命令产生一个为 prog1 或 prog1.exe 的 可执行文件。在 UNIX 系统下,可执行文件没有后缀;而在 Windows 下, 后缀为 .exe。-o prog1 是编译器参数以及用来存放可执行文件的文件 名。如果省略 -o prog1,那么编译器在 UNIX 系统下产生名为 a.out 而 在 Windows 下产生名为 a.exe 的可执行文件。 微软编译器采用命令 cl 来调用: C:\directory> cl -GX prog1.cpp 这里的 C:directory> 是系统提示符,directory 是当前目录名。cl 是 调用编译器的命令。-GX 是一个选项,该选项在使用命令行界面编译器 程序时是必需的。微软编译器自动产生与源文件同名的可执行文件,这 个可执行文件具有 .exe 后缀且与源文件同名。本例中,可执行文件命 名为 prog1.exe。 更多的信息请参考你的编译器用户指南。 从命令行编译器 如果使用命令行界面,一般在控制台窗口(例如 UNIX 的 shell 窗口或 Windows 的命令提示窗口)编译程序。假设 main 程序在名为 prog1.cc 的文件 中,可以使用如下命令来编译: $ CC prog1.cc 这里 CC 是编译器命令名,$ 表示系统提示符。编译器输出一个可执行文件, 我们可以按名调用这个可执行文件。在我们的系统中,编译器产生一个名为 a.exe 的可执行文件。UNIX 编译器则会将可执行文件放到一个名为 a.out 的文 件中。要运行可执行文件,可在命令提示符处给出该文件名: $ a.exe 20 执行编译过的程序。在 UNIX 系统中,即使在当前目录,有时还必须指定文 件所在的目录。这种情况下,键入: $ ./a.exe “.”后面的斜杠表明文件处于当前目录下。 访问 main 函数的返回值的方式和系统有关。不论 UNIX 还是 Windows 系 统,执行程序后,必须发出一个适当的 echo 命令。UNIX 系统中,通过键入如 下命令获取状态: $ echo $? 要在 Windows 系统下查看状态,键入 C:\directory> echo %ERRORLEVEL% Exercises Section 1.1.1 Exercise 查看所用的编译器文档,了解它所用的文件命名规范。编 1.1: 译并运行本节的 main 程序。 Exercise 1.2: 修改程序使其返回 -1。返回值 -1 通常作为程序运行失 败的指示器。然而,系统不同,如何(甚至是否)报告 main 函数运行失败也不同。重新编译并再次运行程序,看看你 的系统如何处理 main 函数的运行失败指示器。 1.2. 初窥输入/输出 C++ 并没有直接定义进行输入或输出(IO)的任何语句,这种功能是由标准 库提供的。IO 库提供了大量的设施。然而,对许多应用,包括本书的例子而言, 编程者只需要了解一些基本概念和操作。 本书的大多数例子都使用了处理格式化输入和输出的 iostream 库。 iostream 库的基础是两种命名为 istream 和 ostream 的类型,分别表示输入 流和输出流。流是指要从某种 IO 设备上读入或写出的字符序列。术语“流”试 图说明字符是随着时间顺序生成或消耗的。 21 1.2.1. 标准输入与输出对象 标准库定义了 4 个 IO 对象。处理输入时使用命名为 cin(读作 see-in) 的 istream 类型对象。这个对象也称为标准输入。处理输出时使用命名为 cout (读作 see-out)的 ostream 类型对象,这个对象也称为标准输出。标准库还 定义了另外两个 ostream 对象,分别命名为 cerr 和 clog(分别读作 “see-err”和“see-log”)。cerr 对象又叫作标准错误,通常用来输出警告 和错误信息给程序的使用者。而 clog 对象用于产生程序执行的一般信息。 一般情况下,系统将这些对象与执行程序的窗口联系起来。这样,当我们从 cin 读入时,数据从执行程序的窗口读入,当写到 cin、cerr 或 clog 时,输 出写至同一窗口。运行程序时,大部分操作系统都提供了重定向输入或输出流的 方法。利用重定向可以将这些流与所选择的文件联系起来。 1.2.2. 一个使用 IO 库的程序 到目前为止,我们已经明白如何编译与执行简单的程序,虽然那个程序什么 也不做。在开篇的书店问题中,有一些记录含有相同的 ISBN,需要将这些记录 进行汇总,也就是说需要弄清楚如何累加已售出书籍的数量。 为了弄清楚如何解决这个问题,我们先来看应如何把两数相加。我们可以使 用 IO 库来扩充 main 程序,要求用户给出两个数,然后输出它们的和: #include int main() { std::cout << "Enter two numbers:" << std::endl; int v1, v2; std::cin >> v1 >> v2; std::cout << "The sum of " << v1 << " and " << v2 << " is " << v1 + v2 << std::endl; return 0; } 程序首先在用户屏幕上显示提示语: Enter two numbers: 然后程序等待用户输入。如果用户输入 37 跟着一个换行符,则程序产生下面的输出: 22 The sum of 3 and 7 is 10 程序的第一行是一个预处理指示: #include 告诉编译器要使用 iostream 库。尖括号里的名字是一个。头文件。程序使 用库工具时必须包含相关的头文件。#include 指示必须单独写成一行——头文 件名和 #include 必须在同一行。通常,#include 指示应出现在任何函数的外 部。而且习惯上,程序的所有 #include 指示都在文件开头部分出现。 写入到流 main 函数体中第一条语句执行了一个表达式。C++ 中,一个表达式由一个 或几个操作数和通常是一个操作符组成。该语句的表达式使用输出操作符(<< 操 作符),在标准输出上输出提示语: std::cout << "Enter two numbers:" << std::endl; 这个语句用了两次输出操作符。每个输出操作符实例都接受两个操作数:左 操作数必须是 ostream 对象;右操作数是要输出的值。操作符将其右操作数写 到作为其左操作数的 ostream 对象。 C++ 中,每个表达式都会产生一个结果,通常是将操作符作用到其操作数所 产生的值。当操作符是输出操作符时,结果是左操作数的值。也就是说,输出操 作返回的值是输出流本身。 既然输出操作符返回的是其左操作数,那么我们就可以将输出请求链接在一 起。输出提示语的那条语句等价于 (std::cout << "Enter two numbers:") << std::endl; 因为((std::cout << "Enter two numbers:"))返回其左操作数 std::cout, 这条语句等价于 std::cout << "Enter two numbers:"; std::cout << std::endl; endl 是一个特殊值,称为操纵符,将它写入输出流时,具有输出换行的效 果,并刷新与设备相关联的 缓冲区。通过刷新缓冲区,用户可立即看到写入到 流中的输出。 23 程序员经常在调试过程中插入输出语句,这些语句都应该刷新 输出流。忘记刷新输出流可能会造成输出停留在缓冲区中,如 果程序崩溃,将会导致程序错误推断崩溃位置。 使用标准库中的名字 细心的读者会注意到这个程序中使用的是 std::cout 和 std::endl,而不 是 cout 和 endl。前缀 std:: 表明 cout 和 endl 是定义在命名空间 std 中 的。使用命名空间程序员可以避免与库中定义的名字相同而引起无意冲突。因为 标准库定义的名字是定义在命名空间中,所以我们可以按自己的意图使用相同的 名字。 标准库使用命名空间的副作用是,当我们使用标准库中的名字时,必须显式 地表达出使用的是命名空间 std 下的名字。std::cout 的写法使用了作用域操 作符(scope operator,:: 操作符),表示使用的是定义在命名空间 std 中的 cout。我们将在 第 3.1 节学习到程序中经常使用的避免这种冗长句法的方法。 读入流 在输出提示语后,将读入用户输入的数据。先定义两个名为 v1 和 v2 的 变 量来保存输入: int v1, v2; 将这些变量定义为 int 类型,int 类型是一种代表整数值的内置类型。这 些变量 未初始化,表示没有赋给它们初始值。这些变量在首次使用时会读入一 个值,因此可以没有初始值。 下一条语句读取输入: std::cin >> v1 >> v2; 输入操作符(>> 操作符)行为与输出操作符相似。它接受一个 istream 对 象作为其左操作数,接受一个对象作为其右操作数,它从 istream 操作数读取 数据并保存到右操作数中。像输出操作符一样,输入操作符返回其左操作数作为 结果。由于输入操作符返回其左操作数,我们可以将输入请求序列合并成单个语 句。换句话说,这个输入操作等价于: std::cin >> v1; std::cin >> v2; 24 输入操作的效果是从标准输入读取两个值,将第一个存放在 v1 中,第二个 存放在 v2 中。 完成程序 剩下的就是要输出结果: std::cout << "The sum of " << v1 << " and " << v2 << " is " << v1 + v2 << std::endl; 这条语句虽然比输出提示语的语句长,但概念上没什么区别。它将每个操作 数输出到标准输出。有趣的是操作数并不都是同一类型的值,有些操作数是字符 串字面值。例如 "The sum of " 其他是各种 int 值,如 v1、v2 以及对算术表达式 v1 + v2 求值的结果。iostream 库定义了接受全部内置类型的输入输出操作符版本。 在写 C++ 程序时,大部分出现空格符的地方可用换行符代 替。这条规则的一个例外是字符串字面值中的空格符不能用 换行符代替。另一个例外是空格符不允许出现在预处理指示 中。 25 关键概念:已初始化变量和未初始化变量 在 C++ 中,初始化是一个非常重要的概念,对它的讨论将贯穿本书始 终。 已初始化变量是指变量在定义时就给定一个值。未初始化变量则未给定 初始值: int val1 = 0; int val2; // initialized // uninitialized 给变量一个初始值几乎总是正确的,但不要求必须这样做。当我们确定 变量在第一次使用时会赋一个新值,那就不需要创建初始值。例如,在 本节开始我们的第一个有意义的程序中,定义了未初始化变量,并立即 读取值给它们。 定义变量时,应该给变量赋初始值,除非确定将变量用于其他意图之前 会覆盖这个初值。如果不能保证读取变量之前重置变量,就应该初始化 变量。 26 Exercises Section 1.2.2 Exercise 编一个程序,在标准输出上打印“Hello, World”。 1.3: Exercise 我们的程序利用内置的加法操作符“+”来产生两个数的 1.4: 和。编写程序,使用乘法操作符“*”产生两个数的积。 Exercise 我们的程序使用了一条较长的输出语句。重写程序,使用 1.5: 单独的语句打印每一个操作数。 Exercise 1.6: 解释下面的程序段: std::cout << "The sum of " << v1; << " and " << v2; << " is " << v1 + v2 << std::endl; 这段代码合法吗?如果合法,为什么?如果不合法,又为 什么? 1.3. 关于注释 在程序变得更复杂之前,我们应该明白 C++如何处理注释。注释可以帮助其 他人阅读程序,通常用于概括算法、确认变量的用途或者阐明难以理解的代码段。 注释并不会增加可执行程序的大小,编译器会忽略所有注释。 本书中,注释排成斜体以区别于一般程序文本。实际程序中, 注释文本是否区别于程序代码文本取决于编程环境是否完善。 C++ 中有单行注释和成对注释两种类型的注释。单行注释以双斜线(//)开 头,行中处于双斜线右边的内容是注释,被编译器忽略。 另一种定界符,注释对(/* */),是从 C 语言继承过来的。这种注释以“/*” 开头,以“*/”结尾。编译器把落入注释对“/**/”之间的内容作为注释: #include /* Simple main function: Read two numbers and write their sum */ int main() 27 { // prompt user to enter two numbers std::cout << "Enter two numbers:" << std::endl; int v1, v2; // uninitialized std::cin >> v1 >> v2; // read input return 0; } 任何允许有制表符、空格或换行符的地方都允许放注释对。注释对可跨越程 序的多行,但不是一定要如此。当注释跨越多行时,最好能直观地指明每一行都 是注释。我们的风格是在注释的每一行以星号开始,指明整个范围是多行注释的 一部分。 程序通常混用两种注释形式。注释对一般用于多行解释,而双斜线注释则常 用于半行或单行的标记。 太多的注释混入程序代码可能会使代码难以理解,通常最好是将一个注释块 放在所解释代码的上方。 代码改变时,注释应与代码保持一致。程序员即使知道系统其他形式的文档 已经过期,还是会信任注释,认为它会是正确的。错误的注释比没有注释更糟, 因为它会误导后来者。 注释对不可嵌套 注释总是以 /* 开始并以 */ 结束。这意味着,一个注释对不能出现在另一 个注释对中。由注释对嵌套导致的编译器错误信息容易使人迷惑。例如,在你的 系统上编译下面的程序: #include /* * comment pairs /* */ cannot nest. * "cannot nest" is considered source code, * as is the rest of the program */ int main() { return 0; } 当注释掉程序的一大部分时,似乎最简单的办法就是在要临时忽略的区域前 后放一个注释对。问题是如果那段代码已经有了注释对,那么新插入的注释对将 提前终止。临时忽略一段代码更好的方法,是用编辑器在要忽略的每一行代码前 面插入单行注释。这样,你就无需担心要注释的代码是否已包含注释对。 28 Exercises Section 1.3 Exercise 编译有不正确嵌套注释的程序。 1.7: Exercise 1.8: 指出下列输出语句哪些(如果有)是合法的。 std::cout << "/*"; std::cout << "*/"; std::cout << /* "*/" */; 预测结果,然后编译包含上述三条语句的程序,检查你 的答案。纠正所遇到的错误。 1.4. 控制结构 语句总是顺序执行的:函数的第一条语句首先执行,接着是第二条,依次类 推。当然,少数程序——包括我们将要编写的解决书店问题的程序——可以仅用 顺序执行语句编写。事实上,程序设计语言提供了多种控制结构支持更为复杂的 执行路径。本节将简要地介绍 C++ 提供的控制结构,第六章再详细介绍各种语 句。 1.4.1. while 语句 while 语句提供了迭代执行功能。可以用 while 语句编写一个如下所示的 从 1 到 10(包括 10)的求和程序: #include int main() { int sum = 0, val = 1; // keep executing the while until val is greater than 10 while (val <= 10) { sum += val; // assigns sum + val to sum ++val; // add 1 to val } std::cout << "Sum of 1 to 10 inclusive is " << sum << std::endl; 29 return 0; } 编译并执行后,将输出: Sum of 1 to 10 inclusive is 55 与前面一样,程序首先包含 iostream 头文件并定义 main 函数。在 main 函数中定义两个 int 型变量:sum 保存总和,val 表示从 1 到 10 之间的每一 个值。我们给 sum 赋初值 0,而 val 则从 1 开始。 重要的部分是 while 语句。while 结构有这样的形式: while (condition) while_body_statement; while 通过测试 condition (条件)和执行相关 while_body_statement 来 重复执行,直到 condition 为假。 条件是一个可求值的表达式,所以可以测试其结果。如果结果值非零,那么 条件为真;如果值为零,则条件为假。 如果 condition 为真(表达式求值不为零),则执行 while_body_statement。执行完后,再次测试 condition 。如果 condition 仍 为真,则再次执行 while_body_statement。while 语句一直交替测试 condition 和执行 while_body_statement,直到 condition 为假为止。 在这个程序中,while 语句是 // keep executing the while until val is greater than 10 while (val <= 10) { sum += val; // assigns sum + val to sum ++val; // add 1 to val } while 语句的条件用了小于或等于操作符(<= 操作符),将 val 的当前值 和 10 比较,只要 val 小于或等于 10,就执行 while 循环体。这种情况下, while 循环体是一个包含两个语句的块: { sum += val; // assigns sum + val to sum ++val; // add 1 to val } 块是被花括号括起来的语句序列。C++ 中,块可用于任何可以用一条语句的 地方。块中第一条语句使用了复合赋值操作符(+= 操作符),这个操作符把它 的右操作数加至左操作数,这等效于编写含一个加法和一个赋值的语句: 30 sum = sum + val; // assign sum + val to sum 因此第一条语句是把 val 的值加到 sum 的当前值,并把结果存入 sum。 第二条语句 ++val; // add 1 to val 使用了前自增操作符(++ 操作符),自增操作符就是在它的操作数上加 1, ++val 和 val = val + 1 是一样的。 执行 while 的循环体后,再次执行 while 的条件。如果 val 的值(自增 后)仍小于或等于 10,那么再次执行 while 的循环体。循环继续,测试条件并 执行循环体,直到 val 的值不再小于或等于 10 为止。 一旦 val 的值大于 10,程序就跳出 while 循环并执行 while 后面的语 句,此例中该语句打印输出,其后的 return 语句结束 main 程序。 关键概念:C++ 程序的缩排和格式 C++ 程序的格式非常自由,花括号、缩排、注释和换行的位置通常对程 序的语义没有影响。例如,表示 main 函数体开始的花括号可以放在与 main 同一行,也可以像我们那样,放在下一行的开始,或放在你喜欢的 任何地方。唯一的要求是,它是编译器所看到在 main 的参数列表的右 括号之后的第一个非空格、非注释字符。 虽然说我们可以很自由地编排程序的格式,但如果编排不当,会影响程 序的可读性。例如,我们可以将 main 写成单独的一长行。这样的定义 尽管合法,但很难阅读。 关于什么是 C 或 C++ 程序的正确格式存在无休止的争论,我们相信没 有唯一正确的风格,但一致性是有价值的。我们倾向于把确定函数边界 的花括号自成一行,且缩进复合的输入或输出表达式从而使操作符排列 整齐,正如第 1.2.2 节的 main 函数中的输出语句那样。随着程序的复 杂化,其他缩排规范也会变得清晰。 可能存在其他格式化程序的方式,记住这一点很重要。在选择格式化风 格时,要考虑提高程序的可读性,使其更易于理解。一旦选择了某种风 格,就要始终如一地使用。 31 1.4.2. for 语句 在 while 循环中,我们使用变量 val 来控制循环执行次数。每次执行 while 语句,都要测试 val 的值,然后在循环体中增加 val 的值。 由于需要频频使用像 val 这样的变量控制循环,因而 C++ 语言定义了第二 种控制结构,称为 for 语句,简化管理循环变量的代码。使用 for 循环重新编 写求 1 到 10 的和的程序,如下: #include int main() { int sum = 0; // sum values from 1 up to 10 inclusive for (int val = 1; val <= 10; ++val) sum += val; // equivalent to sum = sum + val std::cout << "Sum of 1 to 10 inclusive is " << sum << std::endl; return 0; } 在 for 循环之前,我们定义 sum 并赋 0 值。用于迭代的变量 val 被定义 为 for 语句自身的一部分。for 语句 for (int val = 1; val <= 10; ++val) sum += val; // equivalent to sum = sum + val 包含 for 语句头和 for 语句体两部分。for 语句头控制 for 语句体的执 行次数。for 语句头由三部分组成:一个初始化语句,一个条件,一个表达式。 在这个例子中,初始化语句 int val = 1; 定义一个名为 val 的 int 对象并给定初始值 1。初始化语句仅在进入 for 语句时执行一次。条件 val <= 10 将 val 的当前值和 10 比较,每次经过循环都要测试。只要 val 小于或等 于 10,就执行 for 语句体。仅当 for 语句体执行后才执行表达式。在这个 for 循环中,表达式使用前自增操作符,val 的值加 1,执行完表达式后,for 语句 重新测试条件,如果 val 的新值仍小于或等于 10,则执行 for 语句体,val 再 次自增,继续执行直到条件不成立。 32 在这个循环中,for 语句体执行求和 sum += val; // equivalent to sum = sum + val for 语句体使用复合赋值操作符,把 val 的当前值加到 sum,并将结果保 存到 sum 中。 扼要重述一下,for 循环总的执行流程为: 1. 创建 val 并初始化为 1。 2. 测试 val 是否小于或等于 10。 3. 如果 val 小于或等于 10,则执行 for 循环体,把 val 加到 sum 中。 如果 val 大于 10,就退出循环,接着执行 for 语句体后的第一条语句。 4. val 递增。 5. 重复第 2 步的测试,只要条件为真,就继续执行其余步骤。 退出 for 循环后,变量 val 不再可访问,循环终止后使用 val 是不可能的。然而,不是所有的编译器都有这一要求。 在标准化之前的 C++ 中,定义在 for 语句头的名字在 for 循环外是可访 问的。语言定义中的这一改变,可能会使习惯于使用老式编译器的人,在使用遵 循标准的新编译器时感到惊讶。 33 再谈编译 编译器的部分工作是寻找程序代码中的错误。编译器不能查出程序的意 义是否正确, 但它可以查出程序形式上的错误。下面是编译器能查出的 最普遍的一些错误。 1. 语法错误。程序员犯了 C++ 语言中的语法错误。下面代码段说明 常见的语法错误;每个注释描述下一行的错误。 // error: missing ')' in parameter list for main int main ( { // error: used colon, not a semicolon after endl std::cout << "Read each file." << std::endl: // error: missing quotes around string literal std::cout << Update master. << std::endl; // ok: no errors on this line std::cout << "Write new master." < int main() { int v1, v2; std::cin >> v >> v2; // error: uses " v "not" v1" // cout not defined, should be std::cout 34 cout << v1 + v2 << std::endl; return 0; } 错误信息包含行号和编译器对我们所犯错误的简要描述。按错误报告的 顺序改正错误是个好习惯,通常一个错误可能会产生一连串的影响,并 导致编译器报告比实际多得多的错误。最好在每次修改后或最多改正了 一些显而易见的错误后,就重新编译代码。这个循环就是众所周知的编 辑—编译—调试。 Exercises Section 1.4.2 Exercise 1.9: 下列循环做什么?sum 的最终值是多少? int sum = 0; for (int i = -100; i <= 100; ++i) sum += i; Exercise 用 for 循环编程,求从 50 到 100 的所有自然数的和。 1.10: 然后用 while 循环重写该程序。 Exercise 用 while 循环编程,输出 10 到 0 递减的自然数。然后 1.11: 用 for 循环重写该程序。 Exercise 对比前面两个习题中所写的循环。两种形式各有何优缺 1.12: 点? Exercise 1.13: 编译器不同,理解其诊断内容的难易程度也不同。编写一 些程序,包含本小节“再谈编译”部分讨论的那些常见错 误。研究编译器产生的信息,这样你在编译更复杂的程序 遇到这些信息时就不会陌生。 1.4.3. if 语句 求 1 到 10 之间数的和,其逻辑延伸是求用户提供的两个数之间的数的和。 可以直接在 for 循环中使用这两个数,使用第一个输入值作为下界而第二个输 入值作为上界。然而, 如果用户首先给定的数较大,这种策略将会失败:程序会 立即退出 for 循环。因此,我们应该调整范围以便较大的数作上界而较小的数 作下界。这样做,我们需要一种方式来判定哪个数更大一些。 35 像大多数语言一样,C++ 提供支持条件执行的 if 语句。使用 if 语句来编 写修订的求和程序如下: #include int main() { std::cout << "Enter two numbers:" << std::endl; int v1, v2; std::cin >> v1 >> v2; // read input // use smaller number as lower bound for summation // and larger number as upper bound int lower, upper; if (v1 <= v2) { lower = v1; upper = v2; } else { lower = v2; upper = v1; } int sum = 0; // sum values from lower up to and including upper for (int val = lower; val <= upper; ++val) sum += val; // sum = sum + val std::cout << "Sum of " << lower << " to " << upper << " inclusive is " << sum << std::endl; return 0; } 如果我们编译并执行这个程序给定输入数为 7 和 3,程序的输出结果将为: Sum of 3 to 7 inclusive is 25 这个程序中大部分代码我们在之前的举例中已经熟知了。程序首先向用户输 出提示并定义 4 个 int 变量,然后从标准输入读入值到 v1 和 v2 中。仅有 if 条件语句是新增加的代码: // use smaller number as lower bound for summation // and larger number as upper bound int lower, upper; if (v1 <= v2) { lower = v1; upper = v2; 36 } else { lower = v2; upper = v1; } 这段代码的效果是恰当地设置 upper 和 lower 。if 的条件测试 v1 是否 小于或等于 v2。如果是,则执行条件后面紧接着的语句块。这个语句块包含两 条语句,每条语句都完成一次赋值,第一条语句将 v1 赋值给 lower ,而第二 条语句将 v2 赋值给 upper。 如果这个条件为假(也就是说,如果 v1 大于 v2)那么执行 else 后面的 语句。这个语句同样是一个由两个赋值语句组成的块,把 v2 赋值给 lower 而 把 v1 赋值给 upper 。 Exercises Section 1.4.3 Exercise 如果输入值相等,本节展示的程序将产生什么问题? 1.14: Exercise 用两个相等的值作为输入编译并运行本节中的程序。将实 1.15: 际输出与你在上一习题中所做的预测相比较,解释实际结 果和你预计的结果间的不相符之处。 Exercise 编写程序,输出用户输入的两个数中的较大者。 1.16: Exercise 编写程序,要求用户输入一组数。输出信息说明其中有多 1.17: 少个负数。 1.4.4. 读入未知数目的输入 对第 1.4.1 节的求和程序稍作改变,还可以允许用户指定一组数求和。这 种情况下,我们不知道要对多少个数求和,而是要一直读数直到程序输入结束。 输入结束时,程序将总和写到标准输出: #include int main() { int sum = 0, value; // read till end-of-file, calculating a running total of all values read 37 while (std::cin >> value) sum += value; // equivalent to sum = sum + value std::cout << "Sum is: " << sum << std::endl; return 0; } 如果我们给出本程序的输入: 3456 那么输出是: Sum is: 18 与平常一样,程序首先包含必要的头文件。main 中第一行定义了两个 int 变量,命名为 sum 和 value。在 while 条件中,用 value 保存读入的每一个 数: while (std::cin >> value) 这里所产生的是,为判断条件,先执行输入操作 std::cin >> value 它具有从标准输入读取下一个数并且将读入的值保存在 value 中的效果。 输入操作符(第 1.2.2 节)返回其左操作数。while 条件测试输入操作符的返 回结果,意味着测试 std::cin。 当我们使用 istream 对象作为条件,结果是测试流的状态。如果流是有效 的(也就是说,如果读入下一个输入是可能的)那么测试成功。遇到文件结束符 或遇到无效输入时,如读取了一个不是整数的值,则 istream 对象是无效的。 处于无效状态的 istream 对象将导致条件失败。 在遇到文件结束符(或一些其他输入错误)之前,测试会成功并且执行 while 循环体。循环体是一条使用复合赋值操作符的语句,这个操作符将它的右操作数 加到左操作数上。 38 从键盘输入文件结束符 操作系统使用不同的值作为文件结束符。Windows 系统下我们通过键入 control—z——同时键入“ctrl”键和“z”键,来输入文件结束符。 Unix 系统中,包括 Mac OS—X 机器,通常用 control—d。 一旦测试失败,while 终止并退出循环体,执行 while 之后的语句。该语 句在输出 sum 后输出 endl,endl 输出换行并刷新与 cout 相关联的缓冲区。 最后,执行 return,通常返回零表示程序成功运行完毕。 Exercises Section 1.4.4 Exercise 编写程序,提示用户输入两个数并将这两个数范围内的每 1.18: 个数写到标准输出。 Exercise 如果上题给定数 1000 和 2000,程序将产生什么结果? 1.19: 修改程序,使每一行输出不超过 10 个数。 Exercise 1.20: 编写程序,求用户指定范围内的数的和,省略设置上界和 下界的 if 测试。假定输入数是 7 和 3,按照这个顺序, 预测程序运行结果。然后按照给定的数是 7 和 3 运行程 序,看结果是否与你预测的相符。如果不相符,反复研究 关于 for 和 while 循环的讨论直到弄清楚其中的原因。 1.5. 类的简介 解决书店问题之前,还需要弄明白如何编写数据结构来表示交易数据。C++ 中我们通过定义类来定义自己的数据结构。类机制是 C++ 中最重要的特征之一。 事实上,C++ 设计的主要焦点就是使所定义的类类型的行为可以像内置类型一样 自然。我们前面已看到的像 istream 和 ostream 这样的库类型,都是定义为类 的,也就是说,它们严格说来不是语言的一部分。 完全理解类机制需要掌握很多内容。所幸我们可以使用他人写的类而无需掌 握如何定义自己的类。在这一节,我们将描述一个用于解决书店问题的简单类。 当我们学习了更多关于类型、表达式、语句和函数的知识(所有这些在类定义中 都将用到)后,将会在后面的章节实现这个类。 使用类时我们需要回答三个问题: 39 1. 类的名字是什么? 2. 它在哪里定义? 3. 它支持什么操作? 对于书店问题,我们假定类命名为 Sales_item 且类定义在命名为 Sales_item.h 的头文件中。 1.5.1. Sales_item 类 Sales_item 类的目的是存储 ISBN 并保存该书的销售册数、销售收入和平 均售价。我们不关心如何存储或计算这些数据。使用类时我们不需要知道这个类 是怎样实现的,相反,我们需要知道的是该类提供什么操作。 正如我们所看到的,使用像 IO 一样的库工具,必须包含相关的头文件。类 似地,对于自定义的类,必须使得编译器可以访问和类相关的定义。这几乎可以 采用同样的方式。一般来说,我们将类定义放入一个文件中,要使用该类的任何 程序都必须包含这个文件。 依据惯例,类类型存储在一个文件中,其文件名如同程序的源文件名一样, 由文件名和文件后缀两部分组成。通常文件名和定义在头文件中的类名是一样 的。通常后缀是 .h,但也有一些程序员用 .H、.hpp 或 .hxx。编译器通常并不 挑剔头文件名,但 IDE 有时会。假设我们的类定义在名为 Sale_item.h 的文件 中。 Sales_item 对象上的操作 每个类定义一种类型,类型名与类名相同。因此,我们的 Sales_item 类定 义了一种命名为 Sales_item 的类型。像使用内置类型一样,可以定义类类型的 变量。当写下 Sales_item item; 就表示 item 是类型 Sales_item 的一个对象。通常将“类型 Sales_item 的一个对象”简称为“一个 Sales_item 对象”,或者更简单地简称为“一个 Sales_item”。 除了可以定义 Sales_item 类型的变量,我们还可以执行 Sales_item 对象 的以下操作: • 使用加法操作符,+,将两个 Sales_item 相加。 • 使用输入操作符,<<,来读取一个 Sales_item 对象。 • 使用输出操作符,>>,来输出一个 Sales_item 对象。 40 • 使用赋值操作符,=,将一个 Sales_item 对象赋值给另一个 Sales_item 对象。 • 调用 same_isbn 函数确定两个 Sales_item 是否指同一本书。 读入和写出 Sales_item 对象 知道了类提供的操作,就可以编写一些简单的程序使用这个类。例如,下面 的程序从标准输入读取数据,使用该数据建立一个 Sales_item 对象,并将该 Sales_item 对象写到标准输出: #include #include "Sales_item.h" int main() { Sales_item book; // read ISBN, number of copies sold, and sales price std::cin >> book; // write ISBN, number of copies sold, total revenue, and average price std::cout << book << std::endl; return 0; } 如果输入到程序的是 0-201-70353-X 4 24.99 则输出将是 0-201-70353-X 4 99.96 24.99 输入表明销售了 4 本书,每本价格是 24.99 美元。输出表明卖出书的总数 是 4 本,总收入是 99.96 美元,每本书的平均价格是 24.99 美元。 这个程序以两个 #include 指示开始,其中之一使用了一种新格式。 iostream 头文件由标准库定义,而 Sales_item 头文件则不是。Sales_item 是 一种自定义类型。当使用自定义头文件时,我们采用双引号(" ")把头文件名 括起来。 标准库的头文件用尖括号 < > 括起来,非标准库的头文件用双 引号 " " 括起来。 41 在 main 函数中,首先定义一个对象,命名为 book,用它保存从标准输入 读取的数据。下一条语句读入数据到此对象,第三条语句将它打印到标准输出, 像平常一样紧接着打印 endl 来刷新缓冲区。 关键概念:类定义行为 在编写使用 Sales_item 的程序时,重要的是记住类 Sales_item 的创 建者定义该类对象可以执行的所有操作。也就是说, Sales_item 数据 结构的创建者定义创建 Sales_item 对象时会发生什么,以及加操作符 或输入输出操作符应用到 Sales_item 对象时又会发生什么,等等。 通常,只有由类定义的操作可被用于该类类型的对象。此时,我们知道 的可以在 Sales_item 对象上执行的操作只是前面列出的那些。 我们将在第 7.7.3 节和第 14.2 节看到如何定义这些操作。 将 Sales_item 对象相加 更有趣的例子是将两个 Sales_item 对象相加: #include #include "Sales_item.h" int main() { Sales_item item1, item2; std::cin >> item1 >> item2; // read a pair of transactions std::cout << item1 + item2 << std::endl; // print their sum return 0; } 如果我们给这个程序下面的输入: 0-201-78345-X 3 20.00 0-201-78345-X 2 25.00 则输出为 0-201-78345-X 5 110 22 程序首先包含两个头文件 Sales_item 和 iostream。接下来定义两个 Sales_item 对象来存放要求和的两笔交易。输出表达式做加法运算并输出结果。 42 从前面列出的操作,可以得知将两个 Sales_item 相加将创建一个新对象, 新对象的 ISBN 是其操作数的 ISBN,销售的数量和收入反映其操作数中相应值 的和。我们也知道相加的项必须具有同样的 ISBN。 值得注意的是这个程序是如何类似于第 1.2.2 节中的程序:读入两个输入 并输出它们的和。令人感兴趣的是,本例并不是读入两个整数并输出两个整数的 和,而是读入两个 Sales_item 对象并输出两个 Sales_item 对象的和。此外, “和”的意义也不同。在整数的实例中我们产生的是传统求和——两个数值相加 后的结果。在 Sales_item 对象的实例上我们使用了在概念上有新意义的求和 ——两个 Sales_item 对象的成分相加后的结果。 Exercises Section 1.5.1 Exercise 1.21: 本书配套网站的第一章的代码目录下有 Sales_item.h 源文件。复制该文件到你的工作目录。编写程序,循环遍 历一组书的销售交易,读入每笔交易并将交易写至标准输 出。 Exercise 编写程序,读入两个具有相同 ISBN 的 Sales_item 对象 1.22: 并产生它们的和。 Exercise 编写程序,读入几个具有相同 ISBN 的交易,输出所有读 1.23: 入交易的和。 1.5.2. 初窥成员函数 不幸的是,将 Sales_item 相加的程序有一个问题。如果输入指向了两个不 同的 ISBN 将发生什么?将两个不同 ISBN 的数据相加没有意义。为解决这个问 题,首先检查 Sales_item 操作数是否都具有相同的 ISBN。 #include #include "Sales_item.h" int main() { Sales_item item1, item2; std::cin >> item1 >> item2; // first check that item1 and item2 represent the same book if (item1.same_isbn(item2)) { std::cout << item1 + item2 << std::endl; 43 return 0; // indicate success } else { std::cerr << "Data must refer to same ISBN" << std::endl; return -1; // indicate failure } } 这个程序和前一个程序不同之处在于 if 测试语句以及与它相关联的 else 分支。在解释 if 语句的条件之前,我们明白程序的行为取决于 if 语句中的条 件。如果测试成功,那么产生与前一程序相同的输出,并返回 0 表示程序成功 运行完毕。如果测试失败,执行 else 后面的语句块,输出信息并返回错误提示。 什么是成员函数 上述 if 语句的条件 // first check that item1 and item2 represent the same book if (item1.same_isbn(item2)) { 调用命名为 item1 的 Sales_item 对象的成员函数。成员函数是由类定义 的函数,有时称为类方法。 成员函数只定义一次,但被视为每个对象的成员。我们将这些操作称为成员 函数,是因为它们(通常)在特定对象上操作。在这个意义上,它们是对象的成 员,即使同一类型的所有对象共享同一个定义也是如此。 当调用成员函数时,(通常)指定函数要操作的对象。语法是使用点操作符 (.): item1.same_isbn 意思是“命名为 item1 的对象的 same_isbn 成员”。点操作符通过它的左 操作数取得右操作数。点操作符仅应用于类类型的对象:左操作数必须是类类型 的对象,右操作数必须指定该类型的成员。 与大多数其他操作符不同,点操作符(“.”)的右操作数不是 对象或值,而是成员的名字。 44 通常使用成员函数作为点操作符的右操作数来调用成员函数。执行成员函数 和执行其他函数相似:要调用函数,可将调用操作符(())放在函数名之后。调 用操作符是一对圆括号,括住传递给函数的实参列表(可能为空)。 same_isbn 函数接受单个参数,且该参数是另一个 Sales_item 对象。函数调用 item1.same_isbn(item2) 将 item2 作为参数传递给名为 same_isbn 的函数,该函数是名为 item1 的对象的成员。它将比较参数 item2 的 ISBN 与函数 same_isbn 要操作的对象 item1 的 ISBN。效果是测试两个对象是否具有相同的 ISBN。 如果对象具有相同的 ISBN,执行 if 后面的语句,输出两个 Sales_item 对 象的和;否则,如果对象具有不同的 ISBN,则执行 else 分支的语句块。该块 输出适当的错误信息并退出程序,返回 -1。回想 main 函数的返回值被视为状 态指示器;本例中,返回一个非零值表示程序未能产生期望的结果。 Exercises Section 1.5.2 Exercise 1.24: 编写程序,读入几笔不同的交易。对于每笔新读入的交易, 要确定它的 ISBN 是否和以前的交易的 ISBN 一样,并且 记下每一个 ISBN 的交易的总数。通过给定多笔不同的交 易来测试程序。这些交易必须代表多个不同的 ISBN,但 是每个 ISBN 的记录应分在同一组。 1.6. C++ 程序 现在我们已经做好准备,可以着手解决最初的书店问题了:我们需要读入销 售交易文件,并产生报告显示每本书的总销售收入、平均销售价格和销售册数。 假定给定 ISBN 的所有交易出现在一起。程序将把每个 ISBN 的数据组合至 命名为 total 的 Sales_item 对象中。从标准输入中读取的每一笔交易将被存 储到命名为 trans 的第二个 Sales_item 对象中。每读取一笔新的交易,就将 它与 total 中的 Sales_item 对象相比较,如果对象含有相同的 ISBN,就更新 total ;否则就输出 total 的值,并使用刚读入的交易重置 total。 #include 45 #include "Sales_item.h" int main() { // declare variables to hold running sum and data for the next record Sales_item total, trans; // is there data to process? if (std::cin >> total) { // if so, read the transaction records while (std::cin >> trans) if (total.same_isbn(trans)) // match: update the running total total = total + trans; else { // no match: print & assign to total std::cout << total << std::endl; total = trans; } // remember to print last record std::cout << total << std::endl; } else { // no input!, warn the user std::cout << "No data?!" << std::endl; return -1; // indicate failure } return 0; } 这个程序是到目前我们见到的程序中最为复杂的一个,但它仅使用了我们已 遇到过的工具。和平常一样,我们从包含所使用的头文件开始:标准库中的 iostream 和自定义的头文件 Sales_item.h。 在 main 中我们定义了所需要的对象 total 用来计算给定的 ISBN 的交易 的总数,trans 用来存储读取的交易。我们首先将交易读入 total 并测试是否 读取成功;如果读取失败,表示没有记录,程序进入最外层的 else 分支,输出 信息警告用户没有输入。 假如我们成功读取了一个记录,则执行 if 分支里的代码。首先执行 while 语句,循环遍历剩余的所有记录。就像第 1.4.3 节的程序一样,while 循环的 条件从标准输入中读取值并测试实际读取的是否是合法数据。本例中,我们将一 个 Sales_item 对象读至 trans。只要读取成功,就执行 while 循环体。 while 循环体只是一条 if 语句。我们测试 ISBN 是否相等。如果相等,我 们将这两个对象相加并将结果存储到 total 中。否则,我们就输出存储在 total 46 中的值,并将 trans 赋值给 total 来重置 total。执行完 if 语句之后,将返 回到 while 语句中的条件,读入下一个交易,直到执行完所有记录。 一旦 while 完成,我们仍须写出与最后一个 ISBN 相关联的数据。当 while 语句结束时,total 包含文件中最后一条 ISBN 数据,但是我们没有机会输出这 条数据。我们在结束最外层 if 语句的语句块的最后一条语句中进行输出。 Exercises Section 1.6 Exercise 使用源自本书配套网站的 Sales_item.h 头文件,编译并 1.25: 执行本节给出的书店程序。 Exercise 在书店程序中,我们使用了加法操作符而不是复合赋值操 1.26: 作符将 trans 加到 total 中,为什么我们不使用复合赋 值操作符? 小结 本章介绍了足够多的 C++ 知识,让读者能够编译和执行简单 C++ 程序。我们看 到了如何定义 main 函数,这是任何 C++ 程序首先执行的函数。我们也看到了 如何定义变量,如何进行输入和输出,以及如何编写 if、for 和 while 语句。 本章最后介绍 C++ 最基本的工具:类。在这一章中,我们看到了如何创建和使 用给定类的对象。后面的章节中将介绍如何自定义类。 术语 argument(实参) 传递给被调用函数的值。 block(块) 花括号括起来的语句序列。 buffer(缓冲区) 一段用来存放数据的存储区域。IO 设备常存储输入(或输出)到缓冲区, 并独立于程序动作对缓冲区进行读写。输出缓冲区通常必须显式刷新以强 制输出缓冲区内容。默认情况下,读 cin 会刷新 cout;当程序正常结束 时,cout 也被刷新。 47 built-in type(内置类型) C++ 语言本身定义的类型,如 int。 cerr 绑定到标准错误的 ostream 对象,这通常是与标准输出相同的流。默认 情况下,输出 cerr 不缓冲,通常用于不是程序正常逻辑部分的错误信息 或其他输出。 cin 用于从标准输入中读入的 istream 对象。 class 用于自定义数据结构的 C++ 机制。类是 C++ 中最基本的特征。标准库类 型,如 istream 和 ostream,都是类。 class type 由类所定义的类型,类型名就是类名。 clog 绑定到标准错误的 ostream 对象。默认情况下,写到 clog 时是带缓冲 的。通常用于将程序执行信息写入到日志文件中。 comments(注释) 编译器会忽略的程序文本。C++ 有单行注释和成对注释两种类型的注释。 单行注释以 // 开头,从 // 到行的结尾是一条注释。成对注释以 /* 开 始包括到下一个 */ 为止的所有文本。 condition(条件) 求值为真或假的表达式。值为 0 的算术表达式是假,其他所有非 0 值都 是真。 cout 用于写入到标准输出的 ostream 对象,一般情况下用于程序的输出。 curly brace(花括号) 花括号对语句块定界。左花括号“{”开始一个块,右花括号“}”结束块。 48 data structure(数据结构) 数据及数据上操作的逻辑组合。 edit-compile-debug(编辑—编译—调试) 使得程序正确执行的过程。 end-of-file(文件结束符) 文件中与特定系统有关的标记,表示这个文件中不再有其他输入。 expression(表达式) 最小的计算单元。表达式包含一个或多个操作数并经常含有一个操作符。 表达式被求值并产生一个结果。例如,假定 i 和 j 都为 int 型,则 i + j 是一个算术加法表达式并求这两个 int 值的和。表达式将在第五章详 细介绍。 for statement(for 语句) 提供迭代执行的控制语句,通常用于步进遍历数据结构或对一个计算重复 固定次数。 function(函数) 有名字的计算单元。 function body(函数体) 定义函数所执行的动作的语句块。 function name(函数名) 函数的名字标识,函数通过函数名调用。 header(头文件) 使得类或其他名字的定义在多个程序中可用的一种机制。程序中通过 #include 指示包含头文件。 if statement(if 语句) 根据指定条件的值执行的语句。如果条件为真,则执行 if 语句体;否则 控制流执行 else 后面的语句,如果没有 else 将执行 if 后面的语句。 49 iostream(输入输出流) 提供面向流的输入和输出的标准库类型。 istream(输入流) 提供面向流的输入的标准库类型。 library type(标准库类型) 标准库所定义的类型,如 istream。 main function(主函数) 执行 C++ 程序时,操作系统调用的函数。每一个程序有且仅有一个主函 数 main。 manipulator(操纵符) 在读或写时“操纵”流本身的对象,如 std::endl。A.3.1 节详细讲述操 纵符。 member function(成员函数) 类定义的操作。成员函数通常在特定的对象上进行操作。 method(方法) 成员函数的同义词。 namespace(命名空间) 将库所定义的名字放至单独一个地方的机制。命名空间有助于避免无意的 命名冲突。C++ 标准库所定义的名字在命名空间 std 中。 ostream(输出流) 提供面向流的输出的库类型。 parameter list(形参表) 函数定义的组成部分。指明可以用什么参数来调用函数,可能为空。 preprocessor directive(预处理指示) 50 C++ 预处理器的指示。#include 是一个预处理器指示。预处理器指示必 须出现在单独的行中。第 2.9.2 节将对预处理器作详细的介绍。 return type(返回类型) 函数返回值的类型。 source file(源文件) 用来描述包含在 C++ 程序中的文件的术语。 standard error(标准错误) 用于错误报告的输出流。通常,在视窗操作系统中,将标准输出和标准错 误绑定到程序的执行窗口。 standard input(标准输入) 和程序执行窗口相关联的输入流,通常这种关联由操作系统设定。 standard library(标准库) 每个 C++ 编译器必须支持的类型和函数的集合。标准库提供了强大的功 能,包括支持 IO 的类型。C++ 程序员谈到的“标准库”,是指整个标准 库,当提到某个标准库类型时也指标准库中某个特定的部分。例如,程序 员提到的“iostream 库”,专指标准库中由 iostream 类定义的那部分。 standard output(标准输出) 和程序执行窗口相关联的输出流,通常这种关联由操作系统设定。 statement(语句) C++ 程序中最小的独立单元,类似于自然语言中的句子。C++ 中的语句一 般以分号结束。 std 标准库命名空间的名字,std::cout 表明正在使用定义在 std 命名空间 中的名字 cout。 string literal(字符串字面值) 以双引号括起来的字符序列。 uninitialized variable(未初始化变量) 51 没有指定初始值的变量。类类型没有未初始化变量。没有指定初始值的类 类型变量由类定义初始化。在使用变量值之前必须给未初始化的变量赋 值。未初始化变量是造成 bug 的主要原因之一。 variable(变量) 有名字的对象。 while statement(while 语句) 一种迭代控制语句,只要指定的条件为真就执行 while 循环体。while 循 环体执行 0 次还是多次,依赖于条件的真值。 () operator[()操作符] 调用操作符。跟在函数名后且成对出现的圆括号。该操作符导致函数被调 用,给函数的实参可在括号里传递。 ++ operator(++操作符) 自增操作符。将操作数加 1,++i 等价于 i = i + 1。 += operator(+= 操作符) 复合赋值操作符,将右操作数和左操作数相加,并将结果存储到左操作数 中;a += b 等价于 a = a + b。 . operator(. 操作符) 点操作符。接受两个操作数:左操作数是一个对象,而右边是该对象的一 个成员的名字。这个操作符从指定对象中取得成员。 :: operator(:: 操作符) 作用域操作符。在 第二章中,我们将看到更多关于作用域的介绍。在其 他的使用过程中,:: 操作符用于在命名空间中访问名字。例如,std::cout 表示使用命名空间 std 中的名字 cout。 = operator(= 操作符) 表示把右操作数的值赋给左操作数表示的对象。 << operator(<< 操作符) 52 输出操作符。把右操作数写到左操作数指定的输出流:cout << "hi" 把 hi 写入到标准输出流。输出操作可以链接在一起使用:cout << "hi << "bye" 输出 hibye。 >> operator(>> 操作符) 输入操作符。从左操作数指定的输入流读入数据到右操作数:cin >> i 把 标准输入流中的下一个值读入到 i 中。输入操作能够链接在一起使用: cin >> i >> j 先读入 i 然后再读入 j。 == operator(== 操作符) 等于操作符,测试左右两边的操作数是否相等。 != operator(!=操作符) 不等于操作符。测试左右两边的操作数是否不等。 <= operator(<= 操作符) 小于或等于操作符。测试左操作数是否小于或等于右操作数。 < operator(< 操作符) 小于操作符。测试左操作数是否小于右操作数。 >= operator(>= 操作符) 大于或等于操作符。测试左操作数是否大于或等于右操作数。 > operator(> 操作符) 大于操作符。测试左操作数是否大于右操作数。 53 第一部分 基本语言 各种程序设计语言都具有许多独具特色的特征,这些特征决定了用每种语言 适合开发哪些类型的应用程序。程序设计语言也有一些共同的特征。基本上所有 的语言都要提供下列特征: • 内置数据类型,如整型、字符型等。 • 表达式和语句:表达式和语句用于操纵上述类型的值。 • 变量:程序员可以使用变量对所用的对象命名。 • 控制结构:如 if 或 while,程序员可以使用控制结构有条件地执行或重 复执行一组动作。 • 函数:程序员可以使用函数把行为抽象成可调用的计算单元。 大多数现代程序语言都采用两种方式扩充上述基本特征集:允许程序员通过 自定义数据类型扩展该语言;提供一组库例程,这些例程定义了一些并非内置在 语言中的实用函数和数据类型。 和大多数程序设计语言一样,C++ 中对象的类型决定了该对象可以执行的操 作。语句正确与否取决于该语句中对象的类型。一些程序设计语言,特别是 Smalltalk 和 Python,在运行时才检查语句中对象的类型。相反,C++ 是静态 类型(statically typed)语言,在编译时执行类型检查。结果是程序中使用某 个名字之前,必须先告知编译器该名字的类型。 C++ 提供了一组内置数据类型、操纵这些类型的操作符和一组少量的程序流 控制语句。这些元素形成了一个“词汇表”,使用这个词汇表可以而且已经编写 出许多大型、复杂的实际系统。从这个基本层面来看,C++ 是一门简单的语言。 C++ 的表达能力是通过支持一些允许程序员定义新数据结构的机制来提升的。 可能 C++ 中最重要的特征是类(class),程序员可以使用类自定义数据类 型。C++ 中这些类型有时也称为“类类型(class type)”,以区别于语言的内 置类型。有一些语言允许程序员定义的数据类型只能指定组成该类型的数据。包 括 C++ 在内的其他语言允许程序员定义的类型不仅有数据还包括操作。C++ 主 要设计目标之一就是允许程序员自定义类型,而且这些类型和内置类型一样易于 使用。C++ 标准库复用这些特征,实现了一个具有丰富类型和相关函数的标准库。 掌握 C++ 的第一步是学习语言的基本知识和标准库,这正是第一部分介绍的 内容。第二章介绍了内置数据类型,并简单探讨了自定义新类型的机制。第三章 引入了两种最基本的标准库类型:string 和 vector。第四章介绍了数组,数组 是一种低级的数据结构,内置于 C++ 和许多其他语言。数组类似于 vector 对 象,但较难使用。第五章到第七章介绍了表达式、语句和函数。第八章是第一部 分的最后一章,介绍了 IO 标准库中最重要的设施。 54 第二章 变量和基本类型 类型是所有程序的基础。类型告诉我们数据代表什么意思以及可以对数据执 行哪些操作。 C++ 语言定义了几种基本类型:字符型、整型、浮点型等。C++ 还提供了可 用于自定义数据类型的机制,标准库正是利用这些机制定义了许多更复杂的类 型,比如可变长字符串 string、vector 等。此外,我们还能修改已有的类型以 形成复合类型。本章介绍内置类型,并开始介绍 C++ 如何支持更复杂的类型。 类型确定了数据和操作在程序中的意义。我们在第一章已经看到,如下的语 句 i =i +j; 有不同的含义,具体含义取决于 i 和 j 的类型。如果 i 和 j 都是整型, 则这条语句表示一般的算术“+”运算;如果 i 和 j 都是 Sales_item 对象, 则这条语句是将这两个对象的组成成分分别加起来。 C++ 中对类型的支持是非常广泛的:语言本身定义了一组基本类型和修改已 有类型的方法,还提供了一组特征用于自定义类型。本章通过介绍内置类型和如 何关联类型与对象来探讨 C++ 中的类型。本章还将介绍更改类型和建立自定义 类型的方法。 2.1. 基本内置类型 C++ 定义了一组表示整数、浮点数、单个字符和布尔值的算术类型,另外还 定义了一种称为 void 的特殊类型。void 类型没有对应的值,仅用在有限的一 些情况下,通常用作无返回值函数的返回类型。 算术类型的存储空间依机器而定。这里的存储空间是指用来表示该类型的位 (bit)数。C++标准规定了每个算术类型的最小存储空间,但它并不阻止编译器 使用更大的存储空间。事实上,对于 int 类型,几乎所有的编译器使用的存储空 间都比所要求的大。int 表 2.1 列出了内置算术类型及其对应的最小存储空间。 表 2.1. C++ 算术类型 类型 bool char 含义 boolean character 最小存储空间 NA 8 bits 55 类型 含义 最小存储空间 wchar_t wide character 16 bits short short integer 16 bits int integer 16 bits long long integer 32 bits float single-precision floating-point 6 significant digits double double-precision floating-point 10 significant digits long double extended-precision floating-point 10 significant digits 因为位数的不同,这些类型所能表示的最大(最小)值也因机 器的不同而有所不同。 2.1.1. 整型 表示整数、字符和布尔值的算术类型合称为整型。 字符类型有两种:char 和 wchar_t。char 类型保证了有足够的空间,能够 存储机器基本字符集中任何字符相应的数值,因此,char 类型通常是单个机器 字节(byte)。wchar_t 类型用于扩展字符集,比如汉字和日语,这些字符集中 的一些字符不能用单个 char 表示。 short、int 和 long 类型都表示整型值,存储空间的大小不同。一般, short 类型为半个机器字长,int 类型为一个机器字长,而 long 类型为一个或两个机 器字长(在 32 位机器中 int 类型和 long 类型通常字长是相同的)。 56 内置类型的机器级表示 C++ 的内置类型与其在计算机的存储器中的表示方式紧密相关。计算机 以位序列存储数据,每一位存储 0 或 1。一段内存可能存储着 00011011011100010110010000111011 ... 在位这一级上,存储器是没有结构和意义的。 让存储具有结构的最基本方法是用块(chunk)处理存储。大部分计算机 都使用特定位数的块来处理存储,块的位数一般是 2 的幂,因为这样可 以一次处理 8、16 或 32 位。64 和 128 位的块如今也变得更为普遍。 虽然确切的大小因机器不同而不同,但是通常将 8 位的块作为一个字 节,32 位或 4 个字节作为一个“字(word)”。 大多数计算机将存储器中的每一个字节和一个称为地址的数关联起来。 对于一个 8 位字节和 32 位字的机器,我们可以将存储器的字表示如 下: 736424 0 0 0 1 1 0 1 1 736425 0 1 1 1 0 0 0 1 736426 0 1 1 0 0 1 0 0 736427 0 0 1 1 1 0 1 1 在这个图中,左边是字节的地址,地址后面为字节的 8 位。 可以用地址表示从该地址开始的任何几个不同大小的位集合。可以说地 址为 736424 的字,也可以说地址为 736426 的字节。例如,可以说地 址为 736425 的字节和地址为 736427 的字节不相等。 要让地址为 736425 的字节具有意义,必须要知道存储在该地址的值的 类型。一旦知道了该地址的值的类型,就知道了表示该类型的值需要多 少位和如何解释这些位。 如果知道地址为 736425 的字节的类型是 8 位无符号整数,那么就可以 知道该字节表示整数 112。另外,如果这个字节是 ISO-Latin-1 字符集 中的一个字符,那它就表示小写字母 q。虽然两种情况的位相同,但归 属于不同类型,解释也就不同。 57 bool 类型表示真值 true 和 false。可以将算术类型的任何值赋给 bool 对象。0 值算术类型代表 false,任何非 0 的值都代表 true。 带符号和无符号类型 除 bool 类型外,整型可以是带符号的(signed)也可以是无符号的 (unsigned)。顾名思义,带符号类型可以表示正数也可以表示负数(包括 0), 而无符号型只能表示大于或等于 0 的数。 整型 int、short 和 long 都默认为带符号型。要获得无符号型则必须指定 该类型为 unsigned,比如 unsigned long。unsigned int 类型可以简写为 unsigned,也就是说,unsigned 后不加其他类型说明符意味着是 unsigned int 。 和其他整型不同,char 有三种不同的类型:plain char 、unsigned char 和 signed char。虽然 char 有三种不同的类型,但只有两种表示方式。可以使用 unsigned char 或 signed char 表示 char 类型。使用哪种 char 表示方式由 编译器而定。 整型值的表示 无符号型中,所有的位都表示数值。如果在某种机器中,定义一种类型使用 8 位表示,那么这种类型的 unsigned 型可以取值 0 到 255。 C++ 标准并未定义 signed 类型如何用位来表示,而是由每个编译器自由决 定如何表示 signed 类型。这些表示方式会影响 signed 类型的取值范围。8 位 signed 类型的取值肯定至少是从 -127 到 127,但也有许多实现允许取值从 -128 到 127。 表示 signed 整型类型最常见的策略是用其中一个位作为符号位。符号位为 1,值就为负数;符号位为 0,值就为 0 或正数。一个 signed 整型取值是从 -128 到 127。 整型的赋值 对象的类型决定对象的取值。这会引起一个疑问:当我们试着把一个超出其 取值范围的值赋给一个指定类型的对象时,结果会怎样呢?答案取决于这种类型 是 signed 还是 unsigned 的。 对于 unsigned 类型来说,编译器必须调整越界值使其满足要求。编译器会 将该值对 unsigned 类型的可能取值数目求模,然后取所得值。比如 8 位的 unsigned char,其取值范围从 0 到 255(包括 255)。如果赋给超出这个范围 58 的值,那么编译器将会取该值对 256 求模后的值。例如,如果试图将 336 存储 到 8 位的 unsigned char 中,则实际赋值为 80,因为 80 是 336 对 256 求 模后的值。 对于 unsigned 类型来说,负数总是超出其取值范围。unsigned 类型的对 象可能永远不会保存负数。有些语言中将负数赋给 unsigned 类型是非法的,但 在 C++ 中这是合法的。 C++ 中,把负值赋给 unsigned 对象是完全合法的,其结果是 该负数对该类型的取值个数求模后的值。所以,如果把 -1 赋 给 8 位的 unsigned char,那么结果是 255,因为 255 是 -1 对 256 求模后的值。 当将超过取值范围的值赋给 signed 类型时,由编译器决定实际赋的值。在 实际操作中,很多的编译器处理 signed 类型的方式和 unsigned 类型类似。也 就是说,赋值时是取该值对该类型取值数目求模后的值。然而我们不能保证编译 器都会这样处理 signed 类型。 2.1.2. 浮点型 类型 float、 double 和 long double 分别表示单精度浮点数、双精度浮 点数和扩展精度浮点数。一般 float 类型用一个字(32 位)来表示,double 类 型用两个字(64 位)来表示,long double 类型用三个或四个字(96 或 128 位) 来表示。类型的取值范围决定了浮点数所含的有效数字位数。 对于实际的程序来说,float 类型精度通常是不够的——float 型只能 保证 6 位有效数字,而 double 型至少可以保证 10 位有效数字,能 满足大多数计算的需要。 59 建议:使用内置算术类型 C++ 中整型数有点令人迷惑不解。就像 C 语言一样,C++ 被设计成允许 程序在必要时直接处理硬件,因此整型被定义成满足各种各样硬件的特 性。大多数程序员可以(应该)通过限制实际使用的类型来忽略这些复 杂性。 实际上,许多人用整型进行计数。例如:程序经常计算像 vector 或数 组这种数据结构的元素个数。在第三章和第四章中,我们将看到标准库 定义了一组类型用于统计对象的大小。因此,当计数这些元素时使用标 准库定义的类型总是正确的。其他情况下,使用 unsigned 类型比较明 智,可以避免值越界导致结果为负数的可能性。 当执行整型算术运算时,很少使用 short 类型。大多数程序中,使用 short 类型可能会隐含赋值越界的错误。这个错误会产生什么后果将取 决于所使用的机器。比较典型的情况是值“截断(wrap around)”以至 于因越界而变成很大的负数。同样的道理,虽然 char 类型是整型,但 是 char 类型通常用来存储字符而不用于计算。事实上,在某些应用中 char 类型被当作 signed 类型,在另外一些应用中则被当作 unsigned 类型,因此把 char 类型作为计算类型使用时容易出问题。 在大多数机器上,使用 int 类型进行整型计算不易出错。就技术上而言, int 类型用 16 位表示——这对大多数应用来说太小了。实际应用中, 大多数通用机器都是使用和 long 类型一样长的 32 位来表示 int 类 型。整型运算时,用 32 位表示 int 类型和用 64 位表示 long 类型的 机器会出现应该选择 int 类型还是 long 类型的难题。在这些机器上, 用 long 类型进行计算所付出的运行时代价远远高于用 int 类型进行 同样计算的代价,所以选择类型前要先了解程序的细节并且比较 long 类型与 int 类型的实际运行时性能代价。 决定使用哪种浮点型就容易多了:使用 double 类型基本上不会有错。 在 float 类型中隐式的精度损失是不能忽视的,而 double 类型精度代 价相对于 float 类型精度代价可以忽略。事实上,有些机器上,double 类型比 float 类型的计算要快得多。long double 类型提供的精度通常 没有必要,而且还需要承担额外的运行代价。 60 Exercises Section 2.1.2 Exercise int、long 和 short 类型之间有什么差别? 2.1: Exercise unsigned 和 signed 类型有什么差别? 2.2: Exercise 如果在某机器上 short 类型占 16 位,那么可以赋给 2.3: short 类型的最大数是什么?unsigned short 类型的最 大数又是什么? Exercise 当给 16 位的 unsigned short 对象赋值 100 000 时, 2.4: 赋的值是什么? Exercise float 类型和 double 类型有什么差别? 2.5: Exercise 要计算抵押贷款的偿还金额,利率、本金和付款额应分别 2.6: 选用哪种类型?解释你选择的理由。 2.2. 字面值常量 像 42 这样的值,在程序中被当作字面值常量。称之为字面值是因为只能用 它的值称呼它,称之为常量是因为它的值不能修改。每个字面值都有相应的类型, 例如:0 是 int 型,3.14159 是 double 型。只有内置类型存在字面值,没有 类类型的字面值。因此,也没有任何标准库类型的字面值。 整型字面值规则 定义字面值整数常量可以使用以下三种进制中的任一种:十进制、八进制和 十六进制。当然这些进制不会改变其二进制位的表示形式。例如,我们能将值 20 定义成下列三种形式中的任意一种: 20 024 0x14 // decimal // octal // hexadecimal 61 以 0(零)开头的字面值整数常量表示八进制,以 0x 或 0X 开头的表示十 六进制。 字面值整数常量的类型默认为 int 或 long 类型。其精度类型决定于字面 值——其值适合 int 就是 int 类型,比 int 大的值就是 long 类型。通过增 加后缀,能够强制将字面值整数常量转换为 long、unsigned 或 unsigned long 类型。通过在数值后面加 L 或者 l(字母“l”大写或小写)指定常量为 long 类 型。 定义长整型时,应该使用大写字母 L。小写字母 l 很容易 和数值 1 混淆。 类似地,可通过在数值后面加 U 或 u 定义 unsigned 类型。同时加 L 和 U 就能够得到 unsigned long 类型的字面值常量。但其后缀不能有空格: 128u */ 1L */ /* unsigned */ /* long */ 1024UL 8Lu /* unsigned long /* unsigned long 没有 short 类型的字面值常量。 浮点字面值规则 通常可以用十进制或者科学计数法来表示浮点字面值常量。使用科学计数法 时,指数用 E 或者 e 表示。默认的浮点字面值常量为 double 类型。在数值的 后面加上 F 或 f 表示单精度。同样加上 L 或者 l 表示扩展精度(再次提醒, 不提倡使用小写字母 l)。下面每一组字面值表示相同的值: 3.14159F .001f 12.345L 0. 3.14159E0f 1E-3F 1.2345E1L 0e0 布尔字面值和字符字面值 单词 true 和 false 是布尔型的字面值: bool test = false; 可打印的字符型字面值通常用一对单引号来定义: 62 'a' '2' ',' ' ' // blank 这些字面值都是 char 类型的。在字符字面值前加 L 就能够得到 wchar_t 类型的宽字符字面值。如: L'a' 非打印字符的转义序列 有些字符是不可打印的。不可打印字符实际上是不可显示的字符,比如退格 或者控制符。还有一些在语言中有特殊意义的字符,例如单引号、双引号和反斜 线符号。不可打印字符和特殊字符都用转义字符书写。转义字符都以反斜线符号 开始,C++ 语言中定义了如下转义字符: 换行符 \n 水平制表符 \t 纵向制表符 \v 退格符 \b 回车符 \r 进纸符 \f 报警(响铃)符 \a 反斜线 \\ 疑问号 \? 单引号 \' 双引号 \" 我们可以将任何字符表示为以下形式的通用转义字符: \ooo 这里 ooo 表示三个八进制数字,这三个数字表示字符的数字值。下面的例 子是用 ASCII 码字符集表示字面值常量: \7 (bell) \0 (null) \12 (newline) \062 ('2') \40 (blank) \115 ('M') 字符’\0’通常表示“空字符(null character)”,我们将会看到它有着 非常特殊的意义。 同样也可以用十六进制转义字符来定义字符: \xddd 它由一个反斜线符、一个 x 和一个或者多个十六进制数字组成。 63 字符串字面值 之前见过的所有字面值都有基本内置类型。还有一种字面值(字符串字面值) 更加复杂。字符串字面值是一串常量字符,这种类型将在第 4.3 节详细说明。 字符串字面值常量用双引号括起来的零个或者多个字符表示。不可打印字符 表示成相应的转义字符。 "Hello World!" "" "\nCC\toptions\tfile.[cC]\n" and tabs // simple string literal // empty string literal // string literal using newlines 为了兼容 C 语言,C++ 中所有的字符串字面值都由编译器自动在末尾添加 一个空字符。字符字面值 'A' // single quote: character literal 表示单个字符 A,然而 "A" // double quote: character string literal 表示包含字母 A 和空字符两个字符的字符串。 正如存在宽字符字面值,如 L'a' 也存在宽字符串字面值,一样在前面加“L”,如 L"a wide string literal" 宽字符串字面值是一串常量宽字符,同样以一个宽空字符结束。 字符串字面值的连接 两个相邻的仅由空格、制表符或换行符分开的字符串字面值(或宽字符串字 面值),可连接成一个新字符串字面值。这使得多行书写长字符串字面值变得简 单: // concatenated long string literal std::cout << "a multi-line " "string literal " "using concatenation" 64 << std::endl; 执行这条语句将会输出: a multi-line string literal using concatenation 如果连接字符串字面值和宽字符串字面值,将会出现什么结果呢?例如: // Concatenating plain and wide character strings is undefined std::cout << "multi-line " L"literal " << std::endl; 其结果是未定义的,也就是说,连接不同类型的行为标准没有定义。这个程 序可能会执行,也可能会崩溃或者产生没有用的值,而且在不同的编译器下程序 的动作可能不同。 多行字面值 处理长字符串有一个更基本的(但不常使用)方法,这个方法依赖于很少使 用的程序格式化特性:在一行的末尾加一反斜线符号可将此行和下一行当作同一 行处理。 正如第 1.4.1 节提到的,C++ 的格式非常自由。特别是有一些地方不能插 入空格,其中之一是在单词中间。特别是不能在单词中间断开一行。但可以通过 使用反斜线符号巧妙实现: // ok: A \ before a newline ignores the line break std::cou\ t << "Hi" << st\ d::endl; 等价于 std::cout << "Hi" << std::endl; 可以使用这个特性来编写长字符串字面值: // multiline string literal std::cout << "a multi-line \ string literal \ using a backslash" << std::endl; return 0; } 65 注意反斜线符号必须是该行的尾字符——不允许有注释或空格符。同样,后 继行行首的任何空格和制表符都是字符串字面值的一部分。正因如此,长字符串 字面值的后继行才不会有正常的缩进。 建议:不要依赖未定义行为 使用了未定义行为的程序都是错误的,即使程序能够运行,也只是巧合。 未定义行为源于编译器不能检测到的程序错误或太麻烦以至无法检测的 错误。 不幸的是,含有未定义行为的程序在有些环境或编译器中可以正确执行, 但并不能保证同一程序在不同编译器中甚至在当前编译器的后继版本中 会继续正确运行,也不能保证程序在一组输入上可以正确运行且在另一 组输入上也能够正确运行。 程序不应该依赖未定义行为。同样地,通常程序不应该依赖机器相关的 行为,比如假定 int 的位数是个固定且已知的值。我们称这样的程序是 不可移植的。当程序移植到另一台机器上时,要寻找并更改任何依赖机 器相关操作的代码。在本来可以运行的程序中寻找这类问题是一项非常 不愉快的任务。 66 Exercises Section 2.2 Exercise 2.7: 解释下列字面值常量的不同之处。 (a) 'a',L 'a',"a",L"a" (b) 10, 10u, 10L, 10uL, 012, 0xC (c) 3.14, 3.14f, 3.14L Exercise 确定下列字面值常量的类型: 2.8: (a) -10 (b) -10u (c) -10. (d) -10e-2 Exercise 2.9: 下列哪些(如果有)是非法的? (a) "Who goes with F\145rgus?\012" (b) 3.14e1L (c) "two" L"some" (d) 1024f (e) 3.14UL (f) "multiple line comment" Exercise 使用转义字符编写一段程序,输出 2M,然后换行。修改 2.10: 程序,输出 2,跟着一个制表符,然后是 M,最后是换行 符。 2.3. 变量 如果要计算 2 的 10 次方,我们首先想到的可能是: #include int main() { // a first, not very good, solution std::cout << "2 raised to the power of 10: "; std::cout << 2*2*2*2*2*2*2*2*2*2; std::cout << std::endl; return 0; } 这个程序确实解决了问题,尽管我们可能要一而再、再而三地检查确保恰好 有 10 个字面值常量 2 相乘。这个程序产生正确的答案 1024。 67 接下来要计算 2 的 17 次方,然后是 23 次方。而每次都要改变程序是很 麻烦的事。更糟的是,这样做还容易引起错误。修改后的程序常常会产生多乘或 少乘 2 的结果。 替代这种蛮力型计算的方法包括两部分内容: 1. 使用已命名对象执行运算并输出每次计算。 2. 使用控制流结构,当某个条件为真时重复执行一系列程序语句。 以下是计算 2 的 10 次方的替代方法: #include int main() { // local objects of type int int value = 2; int pow = 10; int result = 1; // repeat calculation of result until cnt is equal to pow for (int cnt = 0; cnt != pow; ++cnt) result *= value; // result = result * value; std::cout << value << " raised to the power of " << pow << ": \t" << result << std::endl; return 0; } value、pow、result 和 cnt 都是变量,可以对数值进行存储、修改和查询。 for 循环使得计算过程重复执行 pow 次。 Exercises Section 2.3 Exercise 编写程序,要求用户输入两个数——底数(base)和指数 2.11: (exponent),输出底数的指数次方的结果。 68 关键概念:强静态类型 C++ 是一门静态类型语言,在编译时会作类型检查。 在大多数语言中,对象的类型限制了对象可以执行的操作。如果某种类 型不支持某种操作,那么这种类型的对象也就不能执行该操作。 在 C++ 中,操作是否合法是在编译时检查的。当编写表达式时,编译器 检查表达式中的对象是否按该对象的类型定义的使用方式使用。如果不 是的话,那么编译器会提示错误,而不产生可执行文件。 随着程序和使用的类型变得越来越复杂,我们将看到静态类型检查能帮 助我们更早地发现错误。静态类型检查使得编译器必须能识别程序中的 每个实体的类型。因此,程序中使用变量前必须先定义变量的类型 2.3.1. 什么是变量 变量提供了程序可以操作的有名字的存储区。C++ 中的每一个变量都有特定 的类型,该类型决定了变量的内存大小和布局、能够存储于该内存中的值的取值 范围以及可应用在该变量上的操作集。C++ 程序员常常把变量称为“变量”或 “对象(object)”。 左值和右值 我们在第五章再详细探讨表达式,现在先介绍 C++ 的两种表达式: 1. 左值(发音为 ell-value):左值可以出现在赋值语句的左边或右边。 2. 右值(发音为 are-value):右值只能出现在赋值的右边,不能出现在赋 值语句的左边。 变量是左值,因此可以出现在赋值语句的左边。数字字面值是右值,因此 不能被赋值。给定以下变量: int units_sold = 0; double sales_price = 0, total_revenue = 0; 下列两条语句都会产生编译错误: 69 // error: arithmetic expression is not an lvalue units_sold * sales_price = total_revenue; // error: literal constant is not an lvalue 0 = 1; 有些操作符,比如赋值,要求其中的一个操作数必须是左值。结果,可以使 用左值的上下文比右值更广。左值出现的上下文决定了左值是如何使用的。例如, 表达式 units_sold = units_sold + 1; 中,units_sold 变量被用作两种不同操作符的操作数。+ 操作符仅关心其操作 数的值。变量的值是当前存储在和该变量相关联的内存中的值。加法操作符的作 用是取得变量的值并加 1。 变量 units_sold 也被用作 = 操作符的左操作数。= 操作符读取右操作数 并写到左操作数。在这个表达式中,加法运算的结果被保存到与 units_sold 相 关联的存储单元中,而 units_sold 之前的值则被覆盖。 在本书中,我们将看到在许多情形中左值或右值的使用影响程 序的操作和/或性能——特别是在向函数传递值或从函数中返 回值的时候。 Exercises Section 2.3.1 Exercise 区分左值和右值,并举例说明。 2.12: Exercise 举出一个需要左值的例子。 2.13: 70 术语:什么是对象? C++ 程序员经常随意地使用术语对象。一般而言,对象就是内存中具有 类型的区域。说得更具体一些,计算左值表达式就会产生对象。 严格地说,有些人只把术语对象用于描述变量或类类型的值。有些人还 区别有名字的对象和没名字的对象,当谈到有名字的对象时一般指变量。 还有一些人区分对象和值,用术语对象描述可被程序改变的数据,用术 语值描述只读数据。 在本书中,我们遵循更为通用的用法,即对象是内存中具有类型的区域。 我们可以自由地使用对象描述程序中可操作的大部分数据,而不管这些 数据是内置类型还是类类型,是有名字的还是没名字的,是可读的还是 可写的。 2.3.2. 变量名 变量名,即变量的标识符,可以由字母、数字和下划线组成。变量名必须以 字母或下划线开头,并且区分大小写字母:C++ 中的标识符都是大小写敏感的。 下面定义了 4 个不同的标识符: // declares four different int variables int somename, someName, SomeName, SOMENAME; 语言本身并没有限制变量名的长度,但考虑到将会阅读 和/或修改我们的代码的其他人,变量名不应太长。 例如: gosh_this_is_an_impossibly_long_name_to_type 就是一个糟糕的标识符名。 C++ 关键字 C++ reserves a set of words for use within the language as keywords. Keywords may not be used as program identifiers. Table 2.2 on the next page lists the complete set of C++ keywords. 71 C++ 保留了一组词用作该语言的关键字。关键字不能用作程序的标识符。表 2.2 列出了 C++ 所有的关键字。 表 2.2. C++ 关键字 asm do if return try auto double inline short typedef bool dynamic_cast int signed typeid break else long sizeof typename case enum mutable static union catch explicit namespace static_cast unsigned char export new struct using class extern operator switch virtual const false private template void const_cast float protected this volatile continue for public throw wchar_t default friend register true while delete goto reinterpret_cast C++ 还保留了一些词用作各种操作符的替代名。这些替代名用于支持某些不 支持标准 C++操作符号集的字符集。它们也不能用作标识符。表 2.3 列出了这些 替代名。 表 2.3. C++ 操作符替代名 and bitand compl not_eq or_eq xor_eq and_eq bitor not or xor 除了关键字,C++ 标准还保留了一组标识符用于标准库。标识符不能包含两 个连续的下划线,也不能以下划线开头后面紧跟一个大写字母。有些标识符(在 函数外定义的标识符)不能以下划线开头。 72 变量命名习惯 变量命名有许多被普遍接受的习惯,遵循这些习惯可以提高程序的可读性。 • 变量名一般用小写字母。例如,通常会写成 index,而不写成 Index 或 INDEX。 • 标识符应使用能帮助记忆的名字,也就是说,能够提示其在程序中的用法 的名字,如 on_loan 或 salary。 • 包含多个词的标识符书写为在每个词之间添加一个下划线,或者每个内嵌 的词的第一个字母都大写。例如通常会写成 student_loan 或 studentLoan,而不写成 studentloan。 命名习惯最重要的是保持一致。 Exercises Section 2.3.2 Exercise 下面哪些(如果有)名字是非法的?更正每个非法的标 2.14: 识符名字。 (a) int double = 3.14159; (c) bool catch-22; 1_or_2 ='1'; (e) float Float = 3.14f; (b) char _; (d) char 2.3.3. 定义对象 下列语句定义了 5 个变量: int units_sold; double sales_price, avg_price; std::string title; Sales_item curr_book; 每个定义都是以类型说明符开始,后面紧跟着以逗号分开的含有一个或多个 说明符的列表。分号结束定义。类型说明符指定与对象相关联的类型:int 、 double、std::string 和 Sales_item 都是类型名。其中 int 和 double 是内 置类型,std::string 是标准库定义的类型,Sales_item 是我们在第 1.5 节使 73 用的类型,将会在后面章节定义。类型决定了分配给变量的存储空间的大小和可 以在其上执行的操作。 多个变量可以定义在同一条语句中: double salary, wage; int month, day, year; std::string address; std::string // defines two variables of type double // defines three variables of type int // defines one variable of type 初始化 变量定义指定了变量的类型和标识符,也可以为对象提供初始值。定义时指 定了初始值的对象被称为是已初始化的。C++ 支持两种初始化变量的形式:复制 初始化和直接初始化。复制初始化语法用等号(=),直接初始化则是把初始化 式放在括号中: int ival(1024); int ival = 1024; // direct-initialization // copy-initialization 这两种情形中,ival 都被初始化为 1024。 虽然在本书到目前为止还没有清楚说明,但是在 C++ 中理解 “初始化不是赋值”是必要的。初始化指创建变量并给它赋初 始值,而赋值则是擦除对象的当前值并用新值代替。 使用 = 来初始化变量使得许多 C++ 编程新手感到迷惑,他们很容易把初始 化当成是赋值的一种形式。但是在 C++ 中初始化和赋值是两种不同的操作。这 个概念特别容易误导人,因为在许多其他的语言中这两者的差别不过是枝节问题 因而可以被忽略。即使在 C++ 中也只有在编写非常复杂的类时才会凸显这两者 之间的区别。无论如何,这是一个关键的概念,也是我们将会在整本书中反复强 调的概念。 当初始化类类型对象时,复制初始化和直接初始化之间的差别 是很微妙的。我们在第十三章再详细解释它们之间的差别。现 在我们只需知道,直接初始化语法更灵活且效率更高。 74 使用多个初始化式 初始化内置类型的对象只有一种方法:提供一个值,并且把这个值复制到新 定义的对象中。对内置类型来说,复制初始化和直接初始化几乎没有差别。 对类类型的对象来说,有些初始化仅能用直接初始化完成。要想理解其中缘 由,需要初步了解类是如何控制初始化的。 每个类都可能会定义一个或几个特殊的成员函数(第 1.5.2 节)来告诉我 们如何初始化类类型的变量。定义如何进行初始化的成员函数称为构造函数。和 其他函数一样,构造函数能接受多个参数。一个类可以定义几个构造函数,每个 构造函数必须接受不同数目或者不同类型的参数。 我们以 string 类为例(string 类将在第三章详细讨论)。string 类型在 标准库中定义,用于存储不同长度的字符串。使用 string 时必须包含 string 头文件。和 IO 类型一样,string 定义在 std 命名空间中。 string 类定义了几个构造函数,使得我们可以用不同的方式初始化 string 对象。其中一种初始化 string 对象的方式是作为字符串字面值的副本: #include // alternative ways to initialize string from a character string literal std::string titleA = "C++ Primer, 4th Ed."; std::string titleB("C++ Primer, 4th Ed."); 本例中,两种初始化方式都可以使用。两种定义都创建了一个 string 对象, 其初始值都是指定的字符串字面值的副本。 也可以通过一个计数器和一个字符初始化 string 对象。这样创建的对象包 含重复多次的指定字符,重复次数由计数器指定: std::string all_nines(10, '9'); // all_nines= "9999999999" 本例中,初始化 all_nines 的唯一方法是直接初始化。有多个初始化式时 不能使用复制初始化。 初始化多个变量 当一个定义中定义了两个以上变量的时候,每个变量都可能有自己的初始化 式。 对象的名字立即变成可见,所以可以用同一个定义中前面已定义变量的值 初始化后面的变量。已初始化变量和未初始化变量可以在同一个定义中定义。两 种形式的初始化文法可以相互混合。 75 #include // ok: salary defined and initialized before it is used to initialize wage double salary = 9999.99, wage(salary + 0.01); // ok: mix of initialized and uninitialized int interval, month = 8, day = 7, year = 1955; // ok: both forms of initialization syntax used std::string title("C++ Primer, 4th Ed."), publisher = "A-W"; 对象可以用任意复杂的表达式(包括函数的返回值)来初始化: double price = 109.99, discount = 0.16; double sale_price = apply_discount(price, discount); 本例中,函数 apply_discount 接受两个 double 类型的值并返回一个 double 类型的值。将变量 price 和 discount 传递给函数,并且用它的返回值 来初始化 sale_price。 76 Exercises Section 2.3.3 Exercise 下面两个定义是否不同?有何不同? 2.15: int month = 9, day = 7; int month = 09, day = 07; 如果上述定义有错的话,那么应该怎样改正呢? Exercise 假设 calc 是一个返回 double 对象的函数。下面哪些是 2.16: 非法定义?改正所有的非法定义。 (a) int car = 1024, auto = 2048; (b) int ival = ival; (c) std::cin >> int input_value; (d) double salary = wage = 9999.99; (e) double calc = calc(); 2.3.4. 变量初始化规则 当定义没有初始化式的变量时,系统有时候会帮我们初始化变量。这时,系 统提供什么样的值取决于变量的类型,也取决于变量定义的位置。 内置类型变量的初始化 内置类型变量是否自动初始化取决于变量定义的位置。在函数体外定义的变 量都初始化成 0,在函数体里定义的内置类型变量不进行自动初始化。除了用作 赋值操作符的左操作数,未初始化变量用作任何其他用途都是没有定义的。未初 始化变量引起的错误难于发现。正如我们在第 2.2 节劝告的,永远不要依赖未 定义行为。 77 警告:未初始化的变量引起运行问题 使用未初始化的变量是常见的程序错误,通常也是难以发现的错误。虽 然许多编译器都至少会提醒不要使用未初始化变量,但是编译器并未被 要求去检测未初始化变量的使用。而且,没有一个编译器能检测出所有 未初始化变量的使用。 有时我们很幸运,使用未初始化的变量导致程序在运行时突然崩溃。一 旦跟踪到程序崩溃的位置,就可以轻易地发现没有正确地初始化变量。 但有时,程序运行完毕却产生错误的结果。更糟糕的是,程序运行在一 部机器上时能产生正确的结果,但在另外一部机器上却不能得到正确的 结果。添加代码到程序的一些不相关的位置,会导致我们认为是正确的 程序产生错误的结果。 问题出在未初始化的变量事实上都有一个值。编译器把该变量放到内存 中的某个位置,而把这个位置的无论哪种位模式都当成是变量初始的状 态。当被解释成整型值时,任何位模式都是合法的值——虽然这个值不 可能是程序员想要的。因为这个值合法,所以使用它也不可能会导致程 序崩溃。可能的结果是导致程序错误执行和/或错误计算。 建议每个内置类型的对象都要初始化。虽然这样做并不 总是必需的,但是会更加容易和安全,除非你确定忽略 初始化式不会带来风险。 类类型变量的初始化 每个类都定义了该类型的对象可以怎样初始化。类通过定义一个或多个构造 函数来控制类对象的初始化(第 2.3.3 节)。例如:我们知道 string 类至少 提供了两个构造函数,其中一个允许我们通过字符串字面值初始化 string 对 象,另外一个允许我们通过字符和计数器初始化 string 对象。 如果定义某个类的变量时没有提供初始化式,这个类也可以定义初始化时的 操作。它是通过定义一个特殊的构造函数即默认构造函数来实现的。这个构造函 数之所以被称作默认构造函数,是因为它是“默认”运行的。如果没有提供初始 化式,那么就会使用默认构造函数。不管变量在哪里定义,默认构造函数都会被 使用。 大多数类都提供了默认构造函数。如果类具有默认构造函数,那么就可以在 定义该类的变量时不用显式地初始化变量。例如,string 类定义了默认构造函 数来初始化 string 变量为空字符串,即没有字符的字符串: 78 std::string empty; // empty is the empty string; empty ="" 有些类类型没有默认构造函数。对于这些类型来说,每个定义都必须提供显 式的初始化式。没有初始值是根本不可能定义这种类型的变量的。 Exercises Section 2.3.4 Exercise 2.17: 下列变量的初始值(如果有)是什么? std::string global_str; int global_int; int main() { int local_int; std::string local_str; // ... return 0; } 2.3.5. 声明和定义 正如将在第 2.9 节所看到的那样,C++ 程序通常由许多文件组成。为了让 多个文件访问相同的变量,C++ 区分了声明和定义。 变量的定义用于为变量分配存储空间,还可以为变量指定初始值。在一个程 序中,变量有且仅有一个定义。 声明用于向程序表明变量的类型和名字。定义也是声明:当定义变量时我们 声明了它的类型和名字。可以通过使用 extern 关键字声明变量名而不定义它。 不定义变量的声明包括对象名、对象类型和对象类型前的关键字 extern: extern int i; // declares but does not define i int i; // declares and defines i extern 声明不是定义,也不分配存储空间。事实上,它只是说明变量定义 在程序的其他地方。程序中变量可以声明多次,但只能定义一次。 79 只有当声明也是定义时,声明才可以有初始化式,因为只有定义才分配存储 空间。初始化式必须要有存储空间来进行初始化。如果声明有初始化式,那么它 可被当作是定义,即使声明标记为 extern: extern double pi = 3.1416; // definition 虽然使用了 extern ,但是这条语句还是定义了 pi,分配并初始化了存储 空间。只有当 extern 声明位于函数外部时,才可以含有初始化式。 因为已初始化的 extern 声明被当作是定义,所以该变量任何随后的定义都 是错误的: extern double pi = 3.1416; // definition double pi; // error: redefinition of pi 同样,随后的含有初始化式的 extern 声明也是错误的: extern double pi = 3.1416; // definition extern double pi; // ok: declaration not definition extern double pi = 3.1416; // error: redefinition of pi 声明和定义之间的区别可能看起来微不足道,但事实上却是举足轻重的。 在 C++ 语言中,变量必须且仅能定义一次,而且在使用变量之 前必须定义或声明变量。 任何在多个文件中使用的变量都需要有与定义分离的声明。在这种情况下, 一个文件含有变量的定义,使用该变量的其他文件则包含该变量的声明(而不是 定义)。 80 Exercises Section 2.3.5 Exercise 2.18: 解释下列例子中 name 的意义 extern std::string name; std::string name("exercise 3.5a"); extern std::string name("exercise 3.5a"); 2.3.6. 名字的作用域 C++程序中,每个名字都与唯一的实体(比如变量、函数和类型等)相关联。 尽管有这样的要求,还是可以在程序中多次使用同一个名字,只要它用在不同的 上下文中,且通过这些上下文可以区分该名字的不同意义。用来区分名字的不同 意义的上下文称为作用域。作用域是程序的一段区域。一个名称可以和不同作用 域中的不同实体相关联。 C++ 语言中,大多数作用域是用花括号来界定的。一般来说,名字从其声明 点开始直到其声明所在的作用域结束处都是可见的。例如,思考第 1.4.2 节中 的程序: #include int main() { int sum = 0; // sum values from 1 up to 10 inclusive for (int val = 1; val <= 10; ++val) sum += val; // equivalent to sum = sum + val std::cout << "Sum of 1 to 10 inclusive is " << sum << std::endl; return 0; } 这个程序定义了三个名字,使用了两个标准库的名字。程序定义了一个名为 main 的函数,以及两个名为 sum 和 val 的变量。名字 main 定义在所有花括 号之外,在整个程序都可见。定义在所有函数外部的名字具有全局作用域,可以 在程序中的任何地方访问。名字 sum 定义在 main 函数的作用域中,在整个 main 函数中都可以访问,但在 main 函数外则不能。变量 sum 有局部作用域。 名字 val 更有意思,它定义在 for 语句的作用域中,只能在 for 语句中使用, 而不能用在 main 函数的其他地方。它具有语句作用域。 81 C++ 中作用域可嵌套 定义在全局作用域中的名字可以在局部作用域中使用,定义在全局作用域中 的名字和定义在函数的局部作用域中的名字可以在语句作用域中使用,等等。名 字还可以在内部作用域中重新定义。理解和名字相关联的实体需要明白定义名字 的作用域: #include #include /* Program for illustration purposes only: * It is bad style for a function to use a global variable and then * define a local variable with the same name */ std::string s1 = "hello"; // s1 has global scope int main() { std::string s2 = "world"; // s2 has local scope // uses global s1; prints "hello world" std::cout << s1 << " " << s2 << std::endl; int s1 = 42; // s1 is local and hides global s1 // uses local s1;prints "42 world" std::cout << s1 << " " << s2 << std::endl; return 0; } 这个程序中定义了三个变量:string 类型的全局变量 s1、string 类型的 局部变量 s2 和 int 类型的局部变量 s1。局部变量 s1 的定义屏蔽了全局变量 s1。 变量从声明开始才可见,因此执行第一次输出时局部变量 s1 不可见,输出 表达式中的 s1 是全局变量 s1,输出“hello world”。第二条输出语句跟在 s1 的局部定义后,现在局部变量 s1 在作用域中。第二条输出语句使用的是局部变 量 s1 而不是全局变量 s1,输出“42 world”。 像上面这样的程序很可能让人大惑不解。在函数内定义一个与 函数可能会用到的全局变量同名的局部变量总是不好的。局部 变量最好使用不同的名字。 第七章将详细讨论局部作用域和全局作用域,第六章将讨论语句作用域。C++ 还 有另外两种不同级别的作用域:类作用域(第十二章将介绍)和命名空间作用域 (第 17.2 节将介绍)。 82 2.3.7. 在变量使用处定义变量 一般来说,变量的定义或声明可以放在程序中能摆放语句的任何位置。变量 在使用前必须先声明或定义。 通常把一个对象定义在它首次使用的地方是一个很好 的办法。 在对象第一次被使用的地方定义对象可以提高程序的可读性。读者不需要返 回到代码段的开始位置去寻找某一特殊变量的定义,而且,在此处定义变量,更 容易给它赋以有意义的初始值。 放置声明的一个约束是,变量只在从其定义处开始到该声明所在的作用域的 结束处才可以访问。必须在使用该变量的最外层作用域里面或之前定义变量。 83 Exercises Section 2.3.6 Exercise 2.19: 下列程序中 j 的值是多少? int i = 42; int main() { int i = 100; int j = i; // ... } Exercise 2.20: 下列程序段将会输出什么? int i = 100, sum = 0; for (int i = 0; i != 10; ++i) sum += i; std::cout << i << " " << sum << std::endl; Exercise 2.21: 下列程序合法吗? int sum = 0; for (int i = 0; i != 10; ++i) sum += i; std::cout << "Sum from 0 to " << i << " is " << sum << std::endl; 2.4. const 限定符 下列 for 循环语句有两个问题,两个都和使用 512 作为循环上界有关。 for (int index = 0; index != 512; ++index) { // ... } 第一个问题是程序的可读性。比较 index 与 512 有什么意思呢?循环在做 什么呢?也就是说 512 作用何在?[本例中,512 被称为魔数(magic number), 它的意义在上下文中没有体现出来。好像这个数是魔术般地从空中出现的。 84 第二个问题是程序的可维护性。假设这个程序非常庞大,512 出现了 100 次。进一步假设在这 100 次中,有 80 次是表示某一特殊缓冲区的大小,剩余 20 次用于其他目的。现在我们需要把缓冲区的大小增大到 1024。要实现这一改变, 必须检查每个 512 出现的位置。我们必须确定(在每种情况下都准确地确定) 哪些 512 表示缓冲区大小,而哪些不是。改错一个都会使程序崩溃,又得回过 头来重新检查。 解决这两个问题的方法是使用一个初始化为 512 的对象: int bufSize = 512; // input buffer size for (int index = 0; index != bufSize; ++index) { // ... } 通过使用好记的名字如 bufSize,增强了程序的可读性。现在是对对象 bufSize 测试而不是字面值常量 512 测试: index != bufSize 现在如果想要改变缓冲区大小,就不再需要查找和改正 80 次出现的地方。 而只有初始化 bufSize 那行需要修改。这种方法不但明显减少了工作量,而且 还大大减少了出错的可能性。 定义 const 对象 定义一个变量代表某一常数的方法仍然有一个严重的问题。即 bufSize 是 可以被修改的。bufSize 可能被有意或无意地修改。const 限定符提供了一个解 决办法,它把一个对象转换成一个常量。 const int bufSize = 512; // input buffer size 定义 bufSize 为常量并初始化为 512。变量 bufSize 仍然是一个左值(第 2.3.1 节),但是现在这个左值是不可修改的。任何修改 bufSize 的尝试都会 导致编译错误: bufSize = 0; // error: attempt to write to const object 因为常量在定义后就不能被修改,所以定义时必须初始化: 85 const std::string hi = "hello!"; // ok: initialized const int i, j = 0; // error: i is uninitialized const const 对象默认为文件的局部变量 在全局作用域(第 2.3.6 节)里定义非 const 变量时,它在整个程序中都 可以访问。我们可以把一个非 const 变更定义在一个文件中,假设已经做了合 适的声明,就可在另外的文件中使用这个变量: // file_1.cc int counter; // definition // file_2.cc extern int counter; // uses counter from file_1 ++counter; // increments counter defined in file_1 与其他变量不同,除非特别说明,在全局作用域声明的 const 变量是定义 该对象的文件的局部变量。此变量只存在于那个文件中,不能被其他文件访问。 通过指定 const 变更为 extern,就可以在整个程序中访问 const 对象: // file_1.cc // defines and initializes a const that is accessible to other files extern const int bufSize = fcn(); // file_2.cc extern const int bufSize; // uses bufSize from file_1 // uses bufSize defined in file_1 for (int index = 0; index != bufSize; ++index) // ... 本程序中,file_1.cc 通过函数 fcn 的返回值来定义和初始化 bufSize。 而 bufSize 定义为 extern,也就意味着 bufSize 可以在其他的文件中使用。 file_2.cc 中 extern 的声明同样是 extern;这种情况下,extern 标志着 bufSize 是一个声明,所以没有初始化式。 我们将会在第 2.9.1 节看到为何 const 对象局部于文件创建。 非 const 变量默认为 extern。要使 const 变量能够在其他的 文件中访问,必须地指定它为 extern。 86 Exercises Section 2.4 Exercise 下种段虽然合法,但是风格很糟糕。有什么问题呢?怎样 2.22: 改善? for (int i = 0; i < 100; ++i) // process i Exercise 下列哪些语句合法?对于那些不合法的,请解释为什么不 2.23: 合法。 (a) const int buf; (b) int cnt = 0; const int sz = cnt; (c) cnt++; sz++; 2.5. 引用 引用就是对象的另一个名字。在实际程序中,引用主要用作函数的形式参数。 我们将在第 7.2.2 节 再详细介绍引用参数。在这一节,我们用独立的对象来介 绍并举例说明引用的用法。 引用是一种复合类型,通过在变量名前添加“&”符号来定义。复合类型是 指用其他类型定义的类型。在引用的情况下,每一种引用类型都“关联到”某一 其他类型。不能定义引用类型的引用,但可以定义任何其他类型的引用。 引用必须用与该引用同类型的对象初始化: int ival = 1024; int &refVal = ival; // ok: refVal refers to ival int &refVal2; // error: a reference must be initialized int &refVal3 = 10; // error: initializer must be an object 引用是别名 因为引用只是它绑定的对象的另一名字,作用在引用上的所有操作事实上都 是作用在该引用绑定的对象上: 87 refVal += 2; 将 refVal 指向的对象 ival 加 2。类似地, int ii = refVal; 把和 ival 相关联的值赋给 ii。 当引用初始化后,只要该引用存在,它就保持绑定到初始化时 指向的对象。不可能将引用绑定到另一个对象。 要理解的重要概念是引用只是对象的另一名字。事实上,我们可以通过 ival 的原名访问 ival,也可以通过它的别名 refVal 访问。赋值只是另外一种操作, 因此我们编写 refVal = 5; 的效果是把 ival 的值修改为 5。这一规则的结果是必须在定义引用时进行初始 化。初始化是指明引用指向哪个对象的唯一方法。 定义多个引用 可以在一个类型定义行中定义多个引用。必须在每个引用标识符前添加 “&”符号: int i = 1024, i2 = 2048; int &r = i, r2 = i2; // r is a reference, r2 is an int int i3 = 1024, &ri = i3; // defines one object, and one reference int &r3 = i3, &r4 = i2; // defines two references const 引用 const 引用是指向 const 对象的引用: const int ival = 1024; const int &refVal = ival; const int &ref2 = ival; const object // ok: both reference and object are // error: non const reference to a 88 可以读取但不能修改 refVal ,因此,任何对 refVal 的赋值都是不合法的。这 个限制有其意义:不能直接对 ival 赋值,因此不能通过使用 refVal 来修改 ival。 同理,用 ival 初始化 ref2 也是不合法的:ref2 是普通的非 const 引用,因 此可以用来修改 ref2 指向的对象的值。通过 ref2 对 ival 赋值会导致修改 const 对象的值。为阻止这样的修改,需要规定将普通的引用绑定到 const 对 象是不合法的。 术语:const 引用是指向 const 的引用 C++ 程序员常常随意地使用术语 const 引用。严格来说,“const 引 用”的意思是“指向 const 对象的引用”。类似地,程序员使用术语 “非 const 引用”表示指向非 const 类型的引用。这种用法非常普遍, 我们在本书中也遵循这种用法。 const 引用可以初始化为不同类型的对象或者初始化为右值(第 2.3.1 节),如字面值常量: int i = 42; // legal for const references only const int &r = 42; const int &r2 = r + i; 同样的初始化对于非 const 引用却是不合法的,而且会导致编译时错误。 其原因非常微妙,值得解释一下。 观察将引用绑定到不同的类型时所发生的事情,最容易理解上述行为。假如我们 编写 double dval = 3.14; const int &ri = dval; 编译器会把这些代码转换成如以下形式的编码: int temp = dval; // create temporary int from the double const int &ri = temp; // bind ri to that temporary 如果 ri 不是 const,那么可以给 ri 赋一新值。这样做不会修改 dval, 而是修改了 temp。期望对 ri 的赋值会修改 dval 的程序员会发现 dval 并没 89 有被修改。仅允许 const 引用绑定到需要临时使用的值完全避免了这个问题, 因为 const 引用是只读的。 非 const 引用只能绑定到与该引用同类型的对象。 const 引用则可以绑定到不同但相关的类型的对象或绑定到右值。 Exercises Section 2.5 Exercise 2.24: 下列哪些定义是非法的?为什么?如何改正? (a) int ival = 1.01; (b) int &rval1 = 1.01; (c) int &rval2 = ival; (d) const int &rval3 = 1; Exercise 在上题给出的定义下,下列哪些赋值是非法的?如果赋值 2.25: 合法,解释赋值的作用。 (a) rval2 = 3.14159; (b) rval2 = rval3; (c) ival = rval3; (d) rval3 = ival; Exercise (a) 中的定义和 (b) 中的赋值存在哪些不同?哪些是 2.26: 非法的? (a) int ival = 0; const int &ri = 0; (b) ival = ri; ri = ival; Exercise 2.27: 下列代码输出什么? int i, &ri = i; i = 5; ri =10; std::cout << i << " " << ri << std::endl; 2.6. typedef 名字 90 typedef 可以用来定义类型的同义词: typedef double wages; typedef int exam_score; typedef wages salary; // wages is a synonym for double // exam_score is a synonym for int // indirect synonym for double typedef 名字可以用作类型说明符: wages hourly, weekly; // double hourly, weekly; exam_score test_result; // int test_result; typedef 定义以关键字 typedef 开始,后面是数据类型和标识符。标识符 或类型名并没有引入新的类型,而只是现有数据类型的同义词。typedef 名字可 出现在程序中类型名可出现的任何位置。 typedef 通常被用于以下三种目的: • 为了隐藏特定类型的实现,强调使用类型的目的。 • 简化复杂的类型定义,使其更易理解。 • 允许一种类型用于多个目的,同时使得每次使用该类型的目的明确。 2.7. 枚举 我们经常需要为某些属性定义一组可选择的值。例如,文件打开的状态可能 会有三种:输入、输出和追加。记录这些状态值的一种方法是使每种状态都与一 个唯一的常数值相关联。我们可能会这样编写代码: const int input = 0; const int output = 1; const int append = 2; 虽然这种方法也能奏效,但是它有个明显的缺点:没有指出这些值是相关联 的。枚举提供了一种替代的方法,不但定义了整数常量集,而且还把它们聚集成 组。 定义和初始化枚举 枚举的定义包括关键字 enum,其后是一个可选的枚举类型名,和一个用花 括号括起来、用逗号分开的枚举成员列表。 // input is 0, output is 1, and append is 2 enum open_modes {input, output, append}; 91 默认地,第一个枚举成员赋值为 0,后面的每个枚举成员赋的值比前面的大 1。 枚举成员是常量 可以为一个或多个枚举成员提供初始值,用来初始化枚举成员的值必须是一 个常量表达式。常量表达式是编译器在编译时就能够计算出结果的整型表达式。 整型字面值常量是常量表达式,正如一个通过常量表达式自我初始化的 const 对象(第 2.4 节)也是常量表达式一样。 例如,可以定义下列枚举类型: // shape is 1, sphere is 2, cylinder is 3, polygon is 4 enum Forms {shape = 1, sphere, cylinder, polygon}; 在 枚举类型 Forms 中,显式将 shape 赋值为 1。其他枚举成员隐式初始 化:sphere 初始化为 2,cylinder 初始化为 3,polygon 初始化为 4。 枚举成员值可以是不唯一的。 // point2d is 2, point2w is 3, point3d is 3, point3w is 4 enum Points { point2d = 2, point2w, point3d = 3, point3w }; 本例中,枚举成员 point2d 显式初始化为 2。下一个枚举成员 point2w 默 认初始化,即它的值比前一枚举成员的值大 1。因此 point2w 初始化为 3。枚 举成员 point3d 显式初始化为 3。一样,point3w 默认初始化,结果为 4。 不能改变枚举成员的值。枚举成员本身就是一个常量表达式,所以也可用于 需要常量表达式的任何地方。 每个 enum 都定义一种唯一的类型 每个 enum 都定义了一种新的类型。和其他类型一样,可以定义和初始化 Points 类型的对象,也可以以不同的方式使用这些对象。枚举类型的对象的初 始化或赋值,只能通过其枚举成员或同一枚举类型的其他对象来进行: Points pt3d = point3d; // ok: point3d is a Points enumerator Points pt2w = 3; // error: pt2w initialized with int pt2w = polygon; // error: polygon is not a Points enumerator pt2w = pt3d; // ok: both are objects of Points enum type 92 注意把 3 赋给 Points 对象是非法的,即使 3 与一个 Points 枚举成员相关 联。 2.8. 类类型 C++ 中,通过定义类来自定义数据类型。类定义了该类型的对象包含的数据 和该类型的对象可以执行的操作。标准库类型 string、istream 和 ostream 都定 义成类。 C++对类的支持非常丰富——事实上,定义类是如此重要,我们把第三到第 五部分全部用来描述 C++ 对类及类操作的支持。 在第一章中,我们使用 Sales_item 类型来解决书店问题。使用 Sales_item 类型的对象来记录对应于特定 ISBN 的销售数据。在这节中,我们先了解如何定 义简单的类,如 Sales_item 类。 从操作开始设计类 每个类都定义了一个接口和一个实现。接口由使用该类的代码需要执行的操 作组成。实现一般包括该类所需要的数据。实现还包括定义该类需要的但又不供 一般性使用的函数。 定义类时,通常先定义该类的接口,即该类所提供的操作。通过这些操作, 可以决定该类完成其功能所需要的数据,以及是否需要定义一些函数来支持该类 的实现。 我们将要定义的类型所支持的操作,就是我们在第一章中所用到的操作。这 些操作如下(参见第 1.5.1 节): • 加法操作符,将两个 Sales_item 相加。 • 输入和输出操作符,读和写 Sales_item 对象。 • 赋值操作符,把 Sales_item 对象赋给另一个 Sales_item 对象。 • same_isbn 函数,检测两个对象是否指同一本书。 在学完怎样定义函数和操作符后,我们将会在第七章和第十四章看到该怎样 来定义这些操作。虽然现在不能实现这些函数,但通过思考这些操作必须要实现 的功能,我们可以看出该类需要什么样的数据。Sales_item 类必须 1. 记录特定书的销售册数。 2. 记录该书的总销售收入。 3. 计算该书的平均售价。 93 查看以上所列出的任务,可以知道需要一个 unsigned 类型的对象来记录书 的销售册数,一个 double 类型的对象来记录总销售收入,然后可以用总收入除 以销售册数计算出平均售价。因为我们还想知道是在记录哪本书,所以还需要定 义一个 string 类型的对象来记录书的 ISBN。 定义 Sales_item 类 很明显,我们需要能够定义一种包含这三个数据元素和在第一章所用到的操 作的数据类型。在 C++ 语言中,定义这种数据类型的方法就是定义类: class Sales_item { public: // operations on Sales_item objects will go here private: std::string isbn; unsigned units_sold; double revenue; }; 类定义以关键字 class 开始,其后是该类的名字标识符。类体位于花括号 里面。花括号后面必须要跟一个分号。 编程新手经常会忘记类定义后面的分号,这是个很普遍的错误! 类体可以为空。类体定义了组成该类型的数据和操作。这些操作和数据是类 的一部分,也称为类的成员。操作称为成员函数(第 1.5.2 节),而数据则称 为数据成员。 类也可以包含 0 个到多个 private 或 public 访问标号。访问标号控制类 的成员在类外部是否可访问。使用该类的代码可能只能访问 public 成员。 定义了类,也就定义了一种新的类型。类名就是该类型的名字。通过命名 Sales_item 类,表示 Sales_item 是一种新的类型,而且程序也可以定义该类 型的变量。 每一个类都定义了它自己的作用域(第 2.3.6 节)。也就是说,数据和操 作的名字在类的内部必须唯一,但可以重用定义在类外的名字。 94 类的数据成员 定义类的数据成员和定义普通变量有些相似。我们同样是指定一种类型并给 该成员一个名字: std::string isbn; unsigned units_sold; double revenue; 这个类含有三个数据成员:一个名为 isbn 的 string 类型成员,一个名为 units_sold 的 unsigned 类型成员,一个名为 revenue 的 double 类型成员。 类的数据成员定义了该类类型对象的内容。当定义 Sales_item 类型的对象时, 这些对象将包含一个 string 型变量,一个 unsigned 型变量和一个 double 型 变量。 定义变量和定义数据成员存在非常重要的区别:一般不能把类成员的初始化 作为其定义的一部分。当定义数据成员时,只能指定该数据成员的名字和类型。 类不是在类定义里定义数据成员时初始化数据成员,而是通过称为构造函数(第 2.3.3 节)的特殊成员函数控制初始化。我们将在第 7.7.3 节定义 Sales_item 的构造函数。 访问标号 访问标号负责控制使用该类的代码是否可以使用给定的成员。类的成员函数 可以使用类的任何成员,而不管其访问级别。访问标号 public、private 可以 多次出现在类定义中。给定的访问标号应用到下一个访问标号出现时为止。 类中 public 部分定义的成员在程序的任何部分都可以访问。一般把操作放 在 public 部分,这样程序的任何代码都可以执行这些操作。 不是类的组成部分的代码不能访问 private 成员。通过设定 Sales_item 的数据成员为 private,可以保证对 Sales_item 对象进行操作的代码不能直接 操纵其数据成员。就像我们在第一章编写的程序那样,程序不能访问类中的 private 成员。Sales_item 类型的对象可以执行那些操作,但是不能直接修改 这些数据。 使用 struct 关键字 C++ 支持另一个关键字 struct,它也可以定义类类型。struct 关键字是从 C 语言中继承过来的。 95 如果使用 class 关键字来定义类,那么定义在第一个访问标号前的任何成 员都隐式指定为 private;如果使用 struct 关键字,那么这些成员都是 public。使用 class 还是 struct 关键字来定义类,仅仅影响默认的初始访问 级别。 可以等效地定义 Sales_item 类为: struct Sales_item { // no need for public label, members are public by default // operations on Sales_item objects private: std::string isbn; unsigned units_sold; double revenue; }; 本例的类定义和前面的类定义只有两个区别:这里使用了关键字 struct, 并且没有在花括号后使用关键字 public。struct 的成员都是 public,除非有 其他特殊的声明,所以就没有必要添加 public 标号。 用 class 和 struct 关键字定义类的唯一差别在于默认访问 级别:默认情况下,struct 的成员为 public,而 class 的成 员为 private。 96 Exercises Section 2.8 Exercise 编译以下程序,确定你的编译器是否会警告遗漏了类定 2.28: 义后面的分号。 class Foo { // empty } // Note: no semicolon int main() { return 0; } 如果编译器的诊断结果难以理解,记住这些信息以备后 用。 Exercise 区分类中的 public 部分和 private 部分。 2.29: Exercise 2.30: 定义表示下列类型的类的数据成员: (a) a phone number (c) an employee or a company a university (b) an address (d) a student at 2.9. 编写自己的头文件 我们已经从第 1.5 节了解到,一般类定义都会放入头文件。在本节中我们 将看到怎样为 Sales_item 类定义头文件。 事实上,C++ 程序使用头文件包含的不仅仅是类定义。回想一下,名字在使 用前必须先声明或定义。到目前为止,我们编写的程序是把代码放到一个文件里 来处理这个要求。只要每个实体位于使用它的代码之前,这个策略就有效。但是, 很少有程序简单到可以放置在一个文件中。由多个文件组成的程序需要一种方法 连接名字的使用和声明,在 C++ 中这是通过头文件实现的。 为了允许把程序分成独立的逻辑块,C++ 支持所谓的分别编译。这样程序可 以由多个文件组成。为了支持分别编译,我们把 Sales_item 的定义放在一个头 文件里面。我们后面在第 7.7 节中定义的 Sales_item 成员函数将放在单独的 源文件中。像 main 这样使用 Sales_item 对象的函数放在其他的源文件中,任 何使用 Sales_item 的源文件都必须包含 Sales_item.h 头文件。 97 2.9.1. 设计自己的头文件 头文件为相关声明提供了一个集中存放的位置。头文件一般包含类的定义、 extern 变量的声明和函数的声明。函数的声明将在第 7.4 节介绍。使用或定义 这些实体的文件要包含适当的头文件。 头文件的正确使用能够带来两个好处:保证所有文件使用给定实体的同一声 明;当声明需要修改时,只有头文件需要更新。 设计头文件还需要注意以下几点:头文件中的声明在逻辑上应该是统一的。 编译头文件需要一定的时间。如果头文件太大,程序员可能不愿意承受包含该头 文件所带来的编译时代价。 为了减少处理头文件的编译时间,有些 C++ 的实现支持预编译头文件。欲进一步了解详细 情况,请参考你的 C++ 实现的手册。 98 编译和链接多个源文件 要产生可执行文件,我们不但要告诉编译器到哪里去查找 main 函数, 而且还要告诉编译器到哪里去查找 Sales_item 类所定义的成员函数 的定义。假设我们有两个文件:main.cc 含有 main 函数的定义, Sales_item.cc 含有 Sales_item 的成员函数。我们可以按以下方式编 译这两个文件: $ CC -c main.cc Sales_item.cc # by default generates a.exe # some compilers generate a.out # puts the executable in main.exe $ CC -c main.cc Sales_item.cc -o main 其中 $ 是我们的系统提示符,# 开始命令行注释。现在我们可以运行 可执行文件,它将运行我们的 main 程序。 如果我们只是修改了一个 .cc 源文件,较有效的方法是只重新编译修 改过的文件。大多数编译器都提供了分别编译每一个文件的方法。通常 这个过程产生 .o 文件,.o 扩展名暗示该文件含有目标代码。 编译器允许我们把目标文件链接在一起以形成可执行文件。我们所使用 的系统可以通过命令名 CC 调用编译。因此可以按以下方式编译程序: $ CC -c main.cc $ CC -c Sales_item.cc $ CC main.o Sales_item.o a.out # generates main.o # generates Sales_item.o # by default generates a.exe; # some compilers generate # puts the executable in main.exe $ CC main.o Sales_item.o -o main 你需要检查所用编译器的用户手册,了解如何编译和执行由多个源文件 组成的程序。 许多编译器提供了增强其错误检测能力的选项。查 看所用编译器的用户指南,了解有哪些额外的检测 方法。 头文件用于声明而不是用于定义 99 当设计头文件时,记住定义和声明的区别是很重要的。定义只可以出现一次, 而声明则可以出现多次(第 2.3.5 节)。下列语句是一些定义,所以不应该放 在头文件里: extern int ival = 10; double fica_rate; // initializer, so it's a definition // no extern, so it's a definition 虽然 ival 声明为 extern,但是它有初始化式,代表这条语句是一个定义。 类似地,fica_rate 的声明虽然没有初始化式,但也是一个定义,因为没有关键 字 extern。同一个程序中有两个以上文件含有上述任一个定义都会导致多重定 义链接错误。 因为头文件包含在多个源文件中,所以不应该含有变量或函数 的定义。 对于头文件不应该含有定义这一规则,有三个例外。头文件可以定义类、值 在编译时就已知道的 const 对象和 inline 函数(第 7.6 节介绍 inline 函 数)。这些实体可在多个源文件中定义,只要每个源文件中的定义是相同的。 在头文件中定义这些实体,是因为编译器需要它们的定义(不只是声明)来 产生代码。例如:为了产生能定义或使用类的对象的代码,编译器需要知道组成 该类型的数据成员。同样还需要知道能够在这些对象上执行的操作。类定义提供 所需要的信息。在头文件中定义 const 对象则需要更多的解释。 一些 const 对象定义在头文件中 回想一下,const 变量(第 2.4 节)默认时是定义该变量的文件的局部变 量。正如我们现在所看到的,这样设置默认情况的原因在于允许 const 变量定 义在头文件中。 在 C++ 中,有些地方需要放置常量表达式(第 2.7 节)。例如,枚举成员 的初始化式必须是常量表达式。在以后的章节中将会看到其他需要常量表达式的 例子。 一般来说,常量表达式是编译器在编译时就能够计算出结果的表达式。当 const 整型变量通过常量表达式自我初始化时,这个 const 整型变量就可能是 常量表达式。而 const 变量要成为常量表达式,初始化式必须为编译器可见。 为了能够让多个文件使用相同的常量值,const 变量和它的初始化式必须是每个 文件都可见的。而要使初始化式可见,一般都把这样的 const 变量定义在头文 件中。那样的话,无论该 const 变量何时使用,编译器都能够看见其初始化式。 100 但是,C++ 中的任何变量都只能定义一次(第 2.3.5 节)。定义会分配存 储空间,而所有对该变量的使用都关联到同一存储空间。因为 const 对象默认 为定义它的文件的局部变量,所以把它们的定义放在头文件中是合法的。 这种行为有一个很重要的含义:当我们在头文件中定义了 const 变量后, 每个包含该头文件的源文件都有了自己的 const 变量,其名称和值都一样。 当该 const 变量是用常量表达式初始化时,可以保证所有的变量都有相同 的值。但是在实践中,大部分的编译器在编译时都会用相应的常量表达式替换这 些 const 变量的任何使用。所以,在实践中不会有任何存储空间用于存储用常 量表达式初始化的 const 变量。 如果 const 变量不是用常量表达式初始化,那么它就不应该在头文件中定 义。相反,和其他的变量一样,该 const 变量应该在一个源文件中定义并初始 化。应在头文件中为它添加 extern 声明,以使其能被多个文件共享。 101 Exercises Section 2.9.1 Exercise 2.31: 判别下列语句哪些是声明,哪些是定义,请解释原因。 (a) extern int ix = 1024; (b) int iy; (c) extern int iz; (d) extern const int &ri; Exercise 下列声明和定义哪些应该放在头文件中?哪些应该放在 2.32: 源文件中?请解释原因。 (a) int var; (b) const double pi = 3.1416; (c) extern int total = 255; (d) const double sq2 = sqrt(2.0); Exercise 确定你的编译器提供了哪些提高警告级别的选项。使用这 2.33: 些选项重新编译以前选择的程序,查看是否会报告新的问 题。 2.9.2. 预处理器的简单介绍 既然已经知道了什么应该放在头文件中,那么我们下一个问题就是真正地编 写头文件。我们知道要使用头文件,必须在源文件中#include 该头文件。为了 编写头文件,我们需要进一步理解 #include 指示是怎样工作的。#include 设 施是 C++ 预处理器的一部分。预处理器处理程序的源代码,在编译器之前运行。 C++ 继承了 C 的非常精细的预处理器。现在的 C++ 程序以高度受限的方式使用 预处理器。 #include 指示只接受一个参数:头文件名。预处理器用指定的头文件的内 容替代每个 #include。我们自己的头文件存储在文件中。系统的头文件可能用 特定于编译器的更高效的格式保存。无论头文件以何种格式保存,一般都含有支 持分别编译所需的类定义及变量和函数的声明。 102 头文件经常需要其他头文件 头文件经常 #include 其他头文件。头文件定义的实体经常使用其他头文件 的设施。例如,定义 Sales_item 类的头文件必须包含 string 库。Sales_item 类含有一个 string 类型的数据成员,因此必须可以访问 string 头文件。 包含其他头文件是如此司空见惯,甚至一个头文件被多次包含进同一源文件 也不稀奇。例如,使用 Sales_item 头文件的程序也可能使用 string 库。该程 序不会(也不应该)知道 Sales_item 头文件使用了 string 库。在这种情况下, string 头文件被包含了两次:一次是通过程序本身直接包含,另一次是通过包 含 Sales_item 头文件而间接包含。 因此,设计头文件时,应使其可以多次包含在同一源文件中,这一点很重要。 我们必须保证多次包含同一头文件不会引起该头文件定义的类和对象被多次定 义。使得头文件安全的通用做法,是使用预处理器定义头文件保护符。头文件保 护符用于避免在已经见到头文件的情况下重新处理该头文件的内容。 避免多重包含 在编写头文件之前,我们需要引入一些额外的预处理器设施。预处理器允许 我们自定义变量。 预处理器变量 的名字在程序中必须是唯一的。任何与预处理器 变量相匹配的名字的使用都关联到该预处理器变量。 为了避免名字冲突,预处理器变量经常用全大写字母表示。 预处理器变量有两种状态:已定义或未定义。定义预处理器变量和检测其状 态所用的预处理器指示不同。#define 指示接受一个名字并定义该名字为预处理 器变量。#ifndef 指示检测指定的预处理器变量是否未定义。如果预处理器变量 未定义,那么跟在其后的所有指示都被处理,直到出现 #endif。 可以使用这些设施来预防多次包含同一头文件: #ifndef SALESITEM_H #define SALESITEM_H // Definition of Sales_itemclass and related functions goes here #endif 条件指示 103 #ifndef SALESITEM_H 测试 SALESITEM_H 预处理器变量是否未定义。如果 SALESITEM_H 未定义, 那么 #ifndef 测试成功,跟在 #ifndef 后面的所有行都被执行,直到发现 #endif。相反,如果 SALESITEM_H 已定义,那么 #ifndef 指示测试为假,该指 示和 #endif 指示间的代码都被忽略。 为了保证头文件在给定的源文件中只处理过一次,我们首先检测 #ifndef。 第一次处理头文件时,测试会成功,因为 SALESITEM_H 还未定义。下一条语句 定义了 SALESITEM_H。那样的话,如果我们编译的文件恰好又一次包含了该头文 件。#ifndef 指示会发现 SALESITEM_H 已经定义,并且忽略该头文件的剩余部 分。 头文件应该含有保护符,即使这些头文件不会被其他头 文件包含。编写头文件保护符并不困难,而且如果头文 件被包含多次,它可以避免难以理解的编译错误。 当没有两个头文件定义和使用同名的预处理器常量时,这个策略相当有效。 我们可以为定义在头文件里的实体(如类)命名预处理器变量来避免预处理器变 量重名的问题。一个程序只能含有一个名为 Sales_item 的类。通过使用类名来 组成头文件和预处理器变量的名字,可以使得很可能只有一个文件将会使用该预 处理器变量。 使用自定义的头文件 #include 指示接受以下两种形式: #include #include "my_file.h" 如果头文件名括在尖括号(< >)里,那么认为该头文件是标准头文件。编 译器将会在预定义的位置集查找该头文件,这些预定义的位置可以通过设置查找 路径环境变量或者通过命令行选项来修改。使用的查找方法因编译器的不同而差 别迥异。建议你咨询同事或者查阅编译器用户指南来获得更多的信息。如果头文 件名括在一对引号里,那么认为它是非系统头文件,非系统头文件的查找通常开 始于源文件所在的路径。 小结 类型是 C++ 程序设计的基础。 104 每种类型都定义了其存储空间要求和可以在该类型的所有对象上执行的操 作。C++ 提供了一组基本内置类型,如 int、char 等。这些类型与它们在机器 硬件上的表示方式紧密相关。 类型可以为 const 或非 const;const 对象必须要初始化,且其值不能被 修改。另外,我们还可以定义复合类型,如引用。引用为对象提供了另一个名字。 复合类型是用其他类型定义的类型。 C++ 语言支持通过定义类来自定义类型。标准库使用类设施来提供一组高级 的抽象概念,如 IO 和 string 类型。 C++ 是一种静态类型语言:变量和函数在使用前必须先声明。变量可以声明 多次但是只能定义一次。定义变量时就进行初始化几乎总是个好主意。 术语 access labels(访问标号) 类的成员可以定义为 private,这能够防止使用该类型的代码访问该成 员。成员还可以定义为 public,这将使该整个程序中都可访问成员。 address(地址) 一个数字,通过该数字可在存储器上找到一个字节。 arithmetic types(算术类型) 表示数值即整数和浮点数的类型。浮点型值有三种类型:long double 、 double 和 float,分别表示扩展精度值、双精度值和单精度值。一般总 是使用 double 型。特别地,float 只能保证六位有效数字,这对于大多 数的计算来说都不够。整型包括 bool、char、wchar_t、short 、int 和 long 。整型可以是带符号或无符号的。一般在算术计算中总是避免使用 short 和 char。 unsigned 可用于计数。bool 类型只有 true 和 false 两个值。wchar_t 类型用于扩展字符集的字符;char 类型用于适合 8 个 位的字符,比如 Latin-1 或者 ASCII。 array(数组) 存储一组可通过下标访问的未命名对象的数据结构。本章介绍了存储字符 串字面值的字符数组。第四章将会更加详细地介绍数组。 byte(字节) 最小的可寻址存储单元。大多数的机器上一个字节有 8 个位(bit)。 105 class(类) C++ 中定义数据类型的机制。类可以用 class 或 struct 关键字定义。 类可以有数据和函数成员。成员可以是 public 或 private。一般来说, 定义该类型的操作的函数成员设为 public ;用于实现该类的数据成员和 函数设为 private。默认情况下,用 class 关键字定义的类其成员为 private ,而用 struct 关键字定义的类其成员为 public。 class member(类成员) 类的一部分,可以是数据或操作。 compound type(复合类型) 用其他类型定义的类型,如引用。第四章将介绍另外两种复合类型:指针 和数组。 const reference(const 引用) 可以绑定到 const 对象、非 const 对象或右值的引用。const 引用不能 改变与其相关联的对象。 constant expression(常量表达式) 值可以在编译时计算出来的整型表达式。 constructor(构造函数) 用来初始化新建对象的特殊成员函数。构造函数的任务是保证对象的数据 成员拥有可靠且合理的初始值。 copy-initialization(复制初始化) 一种初始化形式,用“=”表明变量应初始化为初始化式的副本。 data member(数据成员) 组成对象的数据元素。数据成员一般应设为私有的。 declaration(声明) 表明在程序中其他地方定义的变量、函数或类型的存在性。有些声明也是 定义。只有定义才为变量分配存储空间。可以通过在类型前添加关键字 extern 来声明变量。名字直到定义或声明后才能使用。 default constructor(默认构造函数) 106 在没有为类类型对象的初始化式提供显式值时所使用的构造函数。例如, string 类的默认构造函数将新建的 string 对象初始化为空 string,而 其他构造函数都是在创建 string 对象时用指定的字符去初始化 string 对象。 definition(定义) 为指定类型的变量分配存储空间,也可能可选地初始化该变量。名字直到 定义或声明后才能使用。 direct-initialization(直接初始化) 一种初始化形式,将逗号分隔的初始化式列表放在圆括号内。 enumeration(枚举) 将一些命名整型常量聚成组的一种类型。 enumerator(枚举成员) 枚举类型的有名字的成员。每个枚举成员都初始化为整型值且值为 const。枚举成员可用在需要整型常量表达式的地方,比如数组定义的维 度。 escape sequence(转义字符) 一种表示字符的可选机制。通常用于表示不可打印字符如换行符或制表 符。转义字符是反斜线后面跟着一个字符、一个 3 位八进制数或一个十 六进制的数。C++ 语言定义的转义字符列在第 2.2 节。转义字符还可用 作字符字面值(括在单引号里)或用作字符串字面值的一部分(括在双引 号里)。 global scope(全局作用域) 位于任何其他作用域外的作用域。 header(头文件) 使得类的定义和其他声明在多个源文件中可见的一种机制。用户定义的头 文件以文件方式保存。系统头文件可能以文件方式保存,也可能以系统特 有的其他格式保存。 header guard(头文件保护符) 为防止头文件被同一源文件多次包含而定义的预处理器变量。 107 identifier(标识符) 名字。每个标识符都是字母、数字和下划线的非空序列,且序列不能以数 字开头。标识符是大小写敏感的:大写字母和小写字母含义不同。标识符 不能使用 C++中的关键字,不能包含相邻的下划线,也不能以下划线后跟 一个大写字母开始。 implementation(实现) 定义数据和操作的类成员(通常为 private),这些数据和操作并非为使 用该类型的代码所用。例如,istream 和 ostream 类管理的 IO 缓冲区 是它们的实现的一部分,但并不允许这些类的使用者直接访问。 initialized(已初始化的) 含有初始值的变量。当定义变量时,可指定初始值。变量通常要初始化。 integral types(整型) 见 arithmetic type。 interface(接口) 由某种类型支持的操作。设计良好的类分离了接口和实现,在类的 public 部分定义接口,private 部分定义实现。数据成员一般是实现的一部分。 当函数成员是期望该类型的使用者使用的操作时,函数成员就是接口的一 部分(因此为 public);当函数成员执行类所需要的、非一般性使用的 操作时,函数成员就是实现的一部分。 link(链接) 一个编译步骤,此时多个目标文件放置在一起以形成可执行程序。链接步 骤解决了文件间的依赖,如将一个文件中的函数调用链接到另一个文件中 的函数定义。 literal constant(字面值常量) 诸如数、字符或字符串的值,该值不能修改。字面值字符用单引号括住, 而字面值字符串则用双引号括住。 local scope(局部作用域) 用于描述函数作用域和函数内嵌套的作用域的术语。 lvalue(左值) 108 可以出现在赋值操作左边的值。非 const 左值可以读也可以写。 magic number(魔数) 程序中意义重要但又不明显的字面值数字。它的出现好像变魔术一般。 nonconst reference(非 const 引用) 只能绑定到与该引用同类型的非 const 左值的引用。非 const 引用可以 修改与其相关联的对象的值。 nonprintable character(非打印字符) 不可见字符。如控制符、回退删除符、换行符等。 object(对象) 具有类型的一段内存区域。变量就是一个有名字的对象。 preprocessor(预处理器) 预处理器是作为 C++ 程序编译的一部分运行的程序。预处理器继承于 C 语言,C++ 的特征大量减少了它的使用,但仍保存了一个很重要的用法: #include 设施,用来把头文件并入程序。 private member(私有成员) 使用该类的代码不可访问的成员。 public member(公用成员) 可被程序的任何部分使用的类成员。 reference(引用) 对象的别名。定义如下: type &id = object; 定义 id 为 object 的另一名字。任何对 id 的操作都会转变为对 object 的操作。 run time(运行时) 指程序正执行的那段时间。 109 rvalue(右值) 可用于赋值操作的右边但不能用于左边的值。右值只能读而不能写。 scope(作用域) 程序的一部分,在其中名字有意义。C++ 含有下列几种作用域: 全局——名字定义在任何其他作用域外。 类——名字由类定义。 命名空间——名字在命名空间中定义。 局部——名字在函数内定义。 块——名字定义在语句块中,也就是说,定义在一对花括号里。 语句——名字在语句( 如 if、while 和 for 语句)的条件内定 义。 作用域可嵌套。例如,在全局作用域中声明的名字在函数作用域和语句作 用域中都可以访问。 separate compilation(分别编译) 将程序分成多个分离的源文件进行编译。 signed(带符号型) 保存负数、正数或零的整型。 statically typed(静态类型的) 描述进行编译时类型检查的语言(如 C++)的术语。C++ 在编译时验证表 达式使用的类型可以执行该表达式需要的操作。 struct 用来定义类的关键字。除非有特殊的声明,默认情况下 struct 的成员都 为公用的。 type-checking(类型检查) 编译器验证给定类型的对象的使用方式是否与该类型的定义一致,描述这 一过程的术语。 110 type specifier(类型说明符) 定义或声明中命名其后变量的类型的部分。 typedef 为某种类型引入同义词。格式: typedef type synonym; 定义 synonym 为名为 type 的类型的另一名字。 undefined behavior(未定义行为) 语言没有规定其意义的用法。编译器可以自由地做它想做的事。有意或无 意地依赖未定义行为将产生大量难于跟踪的运行时错误和可移值性问题。 uninitialized(未初始化的) 没有指定初始值的变量。未初始化变量不是 0 也不是“空”,相反,它会 保存碰巧遗留在分配给它的内存里的任何位。未初始化变量会产生很多错 误。 unsigned(无符号型) 保存大于等于零的值的整型。 variable initialization(变量初始化) 描述当没有给出显式初始化式时初始化变量或数组元素的规则的术语。对 类类型来说,通过运行类的默认构造函数来初始化对象。如果没有默认构 造函数,那么将会出现编译时错误:必须要给对象指定显式的初始化式。 对于内置类型来说,初始化取决于作用域。定义在全局作用域的对象初始 化为 0,而定义在局部作用域的对象则未初始化,拥有未定义值。 void type(空类型) 用于特殊目的的没有操作也没有值的类型。不可能定义一个 void 类型的 变量。最经常用作不返回结果的函数的返回类型。 word(字) 机器上的自然的整型计算单元。通常一个字足以容纳一个地址。一般在 32 位的机器上,机器字长为 4 个字节。 111 第三章 标准库类型 除第二章介绍的基本数据类型外,C++ 还定义了一个内容丰富的抽象数据类 型标准库。其中最重要的标准库类型是 string 和 vector,它们分别定义了大 小可变的字符串和集合。string 和 vector 往往将迭代器用作配套类型 (companion type),用于访问 string 中的字符,或者 vector 中的元素。这 些标准库类型是语言组成部分中更基本的那些数据类型(如数组和指针)的抽象。 另一种标准库类型 bitset,提供了一种抽象方法来操作位的集合。与整型 值上的内置位操作符相比,bitset 类类型提供了一种更方便的处理位的方式。 本章将介绍标准库中的 vector、string 和 bitset 类型。第四章将讨论数 组和指针,第五章将讲述内置位操作符。 第二章所涉及的类型都是低层数据类型:这些类型表示数值或字符的抽象, 并根据其具体机器表示来定义。 除了这些在语言中定义的类型外,C++ 标准库还定义了许多更高级的抽象数 据类型之所以说这些标准库类型是更高级的,是因为其中反映了更复杂的概念; 之所以说它们是抽象的,是因为我们在使用时不需要关心它们是如何表示的,只 需知道这些抽象数据类型支持哪些操作就可以了。 两种最重要的标准库类型是 string 和 vector。string 类型支持长度可变 的字符串,vector 可用于保存一组指定类型的对象。说它们重要,是因为它们 在 C++ 定义的基本类型基础上作了一些改进。第四章还将学习类似于标准库中 string 和 vector 类型的语言级构造,但标准库的 string 和 vector 类型可 能更灵活,且不易出错。 另一种标准库类型提供了更方便和合理有效的语言级的抽象设施,它就是 bitset 类。通过这个类可以把某个值当作们的集合来处理。与 第 5.3 节介绍 的位操作符相比,bitset 类提供操作位更直接的方法。 在继续探究标准库类型之前,我们先看一种机制,这种机制能够简化对标准 库中所定义名字的访问。 3.1. 命名空间的 using 声明 在本章之前看到的程序,都是通过直接说明名字来自 std 命名空间,来引 用标准库中的名字。例如,需要从标准输入读取数据时,就用 std::cin。这些 名字都用了:: 操作符,该操作符是作用域操作符(第 1.2.2 节)。它的含义是 右操作数的名字可以在左操作数的作用域中找到。因此,std::cin 的意思是说 所需要名字 cin 是在命名空间 std 中定义的。显然,通过这种符号引用标准库 名字的方式是非常麻烦的。 112 幸运的是,C++ 提供了更简洁的方式来使用命名空间成员。本节将介绍一种 最安全的机制:using 声明。关于其他简化使用命名空间中名字的方法将在第 17.2 节中介绍 使用 using 声明可以在不需要加前缀 namespace_name:: 的情况下访问命 名空间中的名字。using 声明的形式如下: using namespace::name; 一旦使用了 using 声明,我们就可以直接引用名字,而不需要再引用该名 字的命名空间。 #include #include // using declarations states our intent to use these names from the namespace std using std::cin; using std::string; int main() { string s; // ok: string is now a synonym for std::string cin >> s; // ok: cin is now a synonym for std::cin cout << s; // error: no using declaration; we must use full name std::cout << s; // ok: explicitly use cout from namepsace std } 没有 using 声明,而直接使用命名空间中名字的未限定版本是错误的,尽 管有些编译器也许无法检测出这种错误。 每个名字都需要一个 using 声明 一个 using 声明一次只能作用于一个命名空间成员。using 声明可用来明 确指定在程序中用到的命名空间中的名字,如果希望使用 std(或其他的命名空 间)中的几个名字,则必须为要用到的每个名字都提供一个 using 声明。例如, 利用 using 声明可以这样重新编写第 1.2.2 节中的加法程序: #include // using declarations for names from the standard library using std::cin; using std::cout; using std::endl; int main() { 113 cout << "Enter two numbers:" << endl; int v1, v2; cin >> v1 >> v2; cout << "The sum of " << v1 << " and " << v2 << " is " << v1 + v2 << endl; return 0; } 对 cin,cout 和 endl 进行 using 声明,就意味着以后可以省前缀 std::, 直接使用命名空间中的名字,这样代码可以更易读。 从这里开始,假定本书所有例子中所用到的标准库中的名字都已提供 了 using 声明。这样,无论是在文档还是在代码实例中引用 cin, 我们都不再 写为前缀形式 std::cin,为了使代码实例简短,我们还省略了编译时所必需 的 using 声明。同样的,程序实例也会省略必需的 #include 指示。本书附录 A 中的表 A.1 列出了本书中用到的标准为名字的库名和相应的头文件。 在编译我们提供的实例程序前,读者一定要注意在程序中添加 适当的 #include 和 using 声明。 使用标准库类型的类定义 有一种情况下,必须总是使用完全限定的标准库名字:在头文件中。理由是 头文件的内容会被预处理器复制到程序中。用 #include 包含文件时,相当于头 文件中的文本将成为我们编写的文件的一部分。如果在头文件中放置 using 声 明,就相当于在包含该头文件 using 的每个程序中都放置了同一 using,不论 该程序是否需要 using 声明。 通常,头文件中应该只定义确实必要的东西。请养成这 个好习惯。 114 Exercises Section 3.1 Exercise 用适当的 using 声明,而不用 std::,访问标准库中名 3.1: 字的方法,重新编写第 2.3 节的程序,计算一给定数的 给定次幂的结果。 3.2. 标准库 string 类型 string 类型支持长度可变的字符串,C++ 标准库将负责管理与存储字符相 关的内存,以及提供各种有用的操作。标准库 string 类型的目的就是满足对字 符串的一般应用。 与其他的标准库类型一样,用户程序要使用 string 类型对象,必须包含相关头 文件。如果提供了合适的 using 声明,那么编写出来的程序将会变得简短些: #include using std::string; 3.2.1. string 对象的定义和初始化 string 标准库支持几个构造函数(第 2.3.3 节)。构造函数是一个特殊成 员函数,定义如何初始化该类型的对象。表 3.1 列出了几个 string 类型常用 的构造函数。当没有明确指定对象初始化式时,系统将使用默认构造函数(第 2.3.4 节)。 表 3.1. 几种初始化 string 对象的方式 string s1; 默认构造函数 s1 为空串 string s2(s1); 将 s2 初始化为 s1 的一个副本 string s3("value"); 将 s3 初始化为一个字符串字面值副本 string s4(n, 'c'); 将 s4 初始化为字符 'c' 的 n 个副本 115 警告:标准库 string 类型和字符串字面值 因为历史原因以及为了与 C 语言兼容,字符串字面值与标准 库 string 类型不是同一种类型。这一点很容易引起混乱,编程时一定 要注意区分字符串字面值和 string 数据类型的使用,这很重要。 Exercises Section 3.2.1 Exercise 什么是默认构造函数? 3.2: Exercise 列举出三种初始化 string 对象的方法。 3.3: Exercise 3.4: s 和 s2 的值分别是什么? string s; int main() { string s2; } 3.2.2. string 对象的读写 我们已在第一章学习了用 iostream 标准库来读写内置类型的值, 如 int double 等。同样地,也可以用 iostream 和 string 标准库,使用标准 输入输出操作符来读写 string 对象: // Note: #include and using declarations must be added to compile this code int main() { string s; // empty string cin >> s; // read whitespace-separated string into s cout << s << endl; // write s to the output return 0; } 116 以上程序首先定义命名为 s 的 string 第二行代码: cin >> s; // read whitespace-separated string into s 从标准输入读取 string 并将读入的串存储在 s 中。string 类型的输入操 作符: • 读取并忽略开头所有的空白字符(如空格,换行符,制表符)。 • 读取字符直至再次遇到空白字符,读取终止。 如果给定和上一个程序同样的输入,则输出的结果是"Hello World!"(注意 到开头和结尾的空格),则屏幕上将输出"Hello",而不含任何空格。 输入和输出操作的行为与内置类型操作符基本类似。尤其是,这些操作符返 回左操作数作为运算结果。因此,我们可以把多个读操作或多个写操作放在一起: string s1, s2; cin >> s1 >> s2; // read first input into s1, second into s2 cout << s1 << s2 << endl; // write both strings 如果给定和上一个程序同样的输入,则输出的结果将是: HelloWorld! 对于上例,编译时必须加上 #include 来标 示 iostream 和 string 标准库,以及给出用到的所有标准库 中的名字(如 string,cin,cout,endl)的 using 声明。 从本例开始的程序均假设程序中所有必须 #include 和 using 声明已给 出。 读入未知数目的 string 对象 和内置类型的输入操作一样,string 的输入操作符也会返回所读的数据流。 因此,可以把输入操作作为判断条件,这与我们在 1.4.4 节读取整型数据的程 序做法是一样的。下面的程序将从标准输入读取一组 string 对象,然后在标准 输出上逐行输出: int main() { string word; // read until end-of-file, writing each word to a new line while (cin >> word) 117 cout << word << endl; return 0; } 上例中,用输入操作符来读取 string 对象。该操作符返回所读 的 istream 对象,并在读取结束后,作为 while 的判断条件。如果输入流是有 效的,即还未到达文件尾且未遇到无效输入,则执行 while 循环体,并将读取 到的字符串输出到标准输出。如果到达了文件尾,则跳出 while 循环。 使用 getline 读取整行文本 另外还有一个有用的 string IO 操作:getline。这个函数接受两个参数: 一个输入流对象和一个 string 对象。getline 函数从输入流的下一行读取,并 保存读取的内容到不包括换行符。和输入操作符不一样的是,getline 并不忽略 行开头的换行符。只要 getline 遇到换行符,即便它是输入的第一个字符, getline 也将停止读入并返回。如果第一个字符就是换行符,则 string 参数将 被置为空 string。 getline 函数将 istream 参数作为返回值,和输入操作符一样也把它用作 判断条件。例如,重写前面那段程序,把每行输出一个单词改为每次输出一行文 本: int main() { string line; // read line at time until end-of-file while (getline(cin, line)) cout << line << endl; return 0; } 由于 line 不含换行符,若要逐行输出需要自行添加。照常,我们用 endl 来 输出一个换行符并刷新输出缓冲区。 由于 getline 函数返回时丢弃换行符,换行符将不会存储 在 string 对象中。 118 Exercises Section 3.2.2 Exercise 编写程序实现从标准输入每次读入一行文本。然后改写 3.5: 程序,每次读入一个单词。 Exercise 解释 string 类型的输入操作符和 getline 函数分别 3.6: 如何处理空白字符。 3.2.3. string 对象的操作 表 3.2 列出了常用的 string 操作。 s.empty() 如果 s 为空串,则返回 true,否则返回 false。 s.size() 返回 s 中字符的个数 s[n] 返回 s 中位置为 n 的字符,位置从 0 开始计数 s1 + s2 把 s1 和 s2 连接成一个新字符串,返回新生成的字符串 s1 = s2 把 s1 内容替换为 s2 的副本 v1 == v2 比较 v1 与 v2 的内容,相等则返回 true,否则返 回 false !=, <, <=, >, and >= 保持这些操作符惯有的含义 string 的 size 和 empty 操作 string 对象的长度指的是 string 对象中字符的个数,可以通过 size 操 作获取: int main() { string st("The expense of spirit\n"); cout << "The size of " << st << "is " << st.size() << " characters, including the newline" << endl; return 0; } 编译并运行这个程序,得到的结果为: 119 The size of The expense of spirit is 22 characters, including the newline 了解 string 对象是否空是有用的。一种方法是将 size 与 0 进行比较: if (st.size() == 0) // ok: empty 本例中,程序员并不需要知道 string 对象中有多少个字符,只想知 道 size 是否为 0。用 string 的成员函数 empty() 可以更直接地回答这个问 题: if (st.empty()) // ok: empty empty() 成员函数将返回 bool(2.1 节),如果 string 对象为空则返 回 true 否则返回 false。 string::size_type 类型 从逻辑上来讲,size() 成员函数似乎应该返回整形数值,或如 2.2 节“建 议”中所述的无符号整数。但事实上,size 操作返回的 是 string::size_type 类型的值。我们需要对这种类型做一些解释。 string 类类型和许多其他库类型都定义了一些配套类型(companion type)。 通过这些配套类型,库类型的使用就能与机器无关(machine-independent)。 size_type 就是这些配套类型中的一种。它定义为与 unsigned 型(unsigned int 或 unsigned long)具有相同的含义,而且可以保证足够大能够存储任 意 string 对象的长度。为了使用由 string 类型定义的 size_type 类型是 由 string 类定义。 任何存储 string 的 size 操作结果的变量必须 为 string::size_type 类型。特别重要的是,还要 把 size 的返回值赋给一个 int 变量。 虽然我们不知道 string::size_type 的确切类型,但可以知道它 是 unsigned 型(2.1.1 节)。对于任意一种给定的数据类型,它的 unsigned 型 所能表示的最大正数值比对应的 signed 型要大倍。这个事实表 明 size_type 存储的 string 长度是 int 所能存储的两倍。 120 使用 int 变量的另一个问题是,有些机器上 int 变量的表示范围太小,甚 至无法存储实际并不长的 string 对象。如在有 16 位 int 型的机器上,int 类 型变量最大只能表示 32767 个字符的 string 个字符的 string 对象。而能容 纳一个文件内容的 string 对象轻易就会超过这个数字。因此,为了避免溢出, 保存一个 stirng 对象 size 的最安全的方法就是使用标准库类 型 string::size_type。 string 关系操作符 string 类定义了几种关系操作符用来比较两个 string 值的大小。这些操 作符实际上是比较每个 string string 对象比较操作是区分大小写的,即同一个字符的大小写 形式被认为是两个不同的字符。在多数计算机上,大写的字母 位于小写之前:任何一个大写之母都小于任意的小写字母。 == 操作符比较两个 string 对象,如果它们相等,则返回 true。两 个 string 对象相等是指它们的长度相同,且含有相同的字符。标准库还定义 了 != 操作符来测试两个 string 对象是否不等。 关系操作符 <,<=,>,>= 分别用于测试一个 string 对象是否小于、小于或等 于、大于、大于或等于另一个 string 对象: string big = "big", small = "small"; string s1 = big; // s1 is a copy of big if (big == small) // false // ... if (big <= s1) // true, they're equal, so big is less than or equal to s1 // ... 关系操作符比较两个 string 对象时采用了和(大小写敏感的)字典排序相 同的策略: • 如果两个 string 对象长度不同,且短的 string 对象与长的 string 对 象的前面部分相匹配,则短的 string 对象小于长的 string 对象。 • 如果 string 对象的字符不同,则比较第一个不匹配的字符。string 举例来说,给定 string 对象; 121 string substr = "Hello"; string phrase = "Hello World"; string slang = "Hiya"; 则 substr 小于 phrase,而 slang 则大于 substr 或 phrase string 对象的赋值 总体上说,标准库类型尽量设计得和基本数据类型一样方便易用。因此,大 多数库类型支持赋值操作。对 string 对象来说,可以把一个 string 对象赋值 给另一个 string 对象; // st1 is an empty string, st2 is a copy of the literal string st1, st2 = "The expense of spirit"; st1 = st2; // replace st1 by a copy of st2 赋值操作后,st1 就包含了 st2 串所有字符的一个副本。 大多数 string 库类型的赋值等操作的实现都会遇到一些效率上的问题,但 值得注意的是,从概念上讲,赋值操作确实需要做一些工作。它必须先把 st1 占 用的相关内存释放掉,然后再分配给 st2 足够存放 st2 副本的内存空间,最后 把 st2 中的所有字符复制到新分配的内存空间。 两个 string 对象相加 string 对象的加法被定义为连接(concatenation)。也就是说,两个(或 多个)string 对象可以通过使用加操作符 + 或者复合赋值操作符 += (1.4.1 节)连接起来。给定两个 string 对象: string s1("hello, "); string s2("world\n"); 下面把两个 string 对象连接起来产生第三个 string 对象: string s3 = s1 + s2; // s3 is hello, world\n 如果要把 s2 直接追加到 s1 的末尾,可以使用 += 操作符: s1 += s2; // equivalent to s1 = s1 + s2 122 和字符串字面值的连接 上面的字符串对象 s1 和 s2 直接包含了标点符号。也可以通过 将 string 对象和字符串字面值混合连接得到同样的结果: string s1("hello"); string s2("world"); string s3 = s1 + ", " + s2 + "\n"; 当进行 string 对象和字符串字面值混合连接操作时,+ 操作符的左右操作 数必须至少有一个是 string 类型的: string s1 = "hello"; // no punctuation string s2 = "world"; string s3 = s1 + ", "; // ok: adding a string and a literal string s4 = "hello" + ", "; // error: no string operand string s5 = s1 + ", " + "world"; // ok: each + has string operand string s6 = "hello" + ", " + s2; // error: can't add string literals s3 和 s4 的初始化只用了一个单独的操作。在这些例子中,很容易判 断 s3 的初始化是合法的:把一个 string 对象和一个字符串字面值连接起来。 而 s4 的初始化试图将两个字符串字面值相加,因此是非法的。 s5 的初始化方法显得有点不可思议,但这种用法和标准输入输出的串联效 果是一样的(1.2 节)。本例中,string 标准库定义加操作返回一个 string 对 象。这样,在对 s5 进行初始化时,子表达式 s1 + ", " 将返回一个新 string 对 象,后者再和字面值 "world\n"连接。整个初始化过程可以改写为: string tmp = s1 + ", "; // ok: + has a string operand s5 = tmp + "world"; // ok: + has a string operand 而 s6 的初始化是非法的。依次来看每个子表达式,则第一个子表达式试图 把两个字符串字面值连接起来。这是不允许的,因此这个语句是错误的。 从 string 对象获取字符 string 类型通过下标操作符([ ])来访问 string 对象中的单个字符。下 标操作符需要取一个 size_type 类型的值,来标明要访问字符的位置。这个下 标中的值通常被称为“下标”或“索引”(index) 123 string 对象的下标从 0 开始。如果 s 是一个 string 对象 且 s 不空,则 s[0] 就是字符串的第一个字符, s[1] 就表示 第二个字符(如果有的话),而 s[s.size() - 1] 则表示 s 的 最后一个字符。 引用下标时如果超出下标作用范围就会引起溢出错误。 可用下标操作符分别取出 string 对象的每个字符,分行输出: string str("some string"); for (string::size_type ix = 0; ix != str.size(); ++ix) cout << str[ix] << endl; 每次通过循环,就从 str 对象中读取下一个字符,输出该字符并换行。 下标操作可用作左值 前面说过,变量是左值(2.3.1 节),且赋值操作的左操作的必须是左值。 和变量一样,string 对象的下标操作返回值也是左值。因此,下标操作可以放 于赋值操作符的左边或右边。通过下面循环把 str 对象的每一个字符置 为 ‘*’: for (string::size_type ix = 0; ix != str.size(); ++ix) str[ix] = '*'; 计算下标值 任何可产生整型值的表达式可用作下标操作符的索引。例如,假 设 someval 和 someotherval 是两个整形对象,可以这样写: str[someotherval * someval] = someval; 虽然任何整型数值都可作为索引,但索引的实际数据类型却是类 型 unsigned 类型 string::size_type。 前面讲过,应该用 string::size_type 类型的变量接 受 size 函数的返回值。在定义用作索引的变量时,出 于同样的道理,string 对象的索引变量最好也 用 string::size_type 类型。 124 在使用下标索引 string 对象时,必须保证索引值“在上下界范围内”。 “在上下界范围内”就是指索引值是一个赋值为 size_type 类型的值,其取值 范围在 0 到 string 对象长度减 1 之间。使用 string::size_type 类型或其 他 unsigned 类型,就只需要检测它是否小于 string 对象的长度。 标准库不要求检查索引值,所用索引的下标越界是没有定义的, 这样往往会导致严重的运行时错误。 3.2.4. string 对象中字符的处理 我们经常要对 string 对象中的单个字符进行处理,例如,通常需要知道某 个特殊字符是否为空白字符、字母或数字。表 3.3 列出了各种字符操作函数, 适用于 string 对象的字符(或其他任何 char 值)。这些函数都在 cctype 头 文件中定义。 表 3.3. cctype 中的函数 isalnum(c) 如果 c 是字母或数字,则为 True。 isalpha(c) 如果 c 是字母,则为 true。 iscntrl(c) 如果 c 是控制字符,则为 true isdigit(c) 如果 c 是数字,则为 true。 isgraph(c) 如果 c 不是空格,但可打印,则为 true。 islower(c) 如果 c 是小写字母,则为 true。 isprint(c) 如果 c 是可打印的字符,则为 true。 ispunct(c) 如果 c 是标点符号,则 true。 isspace(c) 如果 c 是空白字符,则为 true。 isupper(c) 如果 c 是大写字母,则 true。 isxdigit(c) 如果是 c 十六进制数,则为 true。 tolower(c) 如果 c 大写字母,返回其小写字母形式,否则直接返回 c。 toupper(c) 如果 c 是小写字母,则返回其大写字母形式,否则直接返回 c。 125 表中的大部分函数是测试一个给定的字符是否符合条件,并返回一 个 int 作为真值。如果测试失败,则该函数返回 0 ,否则返回一个(无意义的) 非 0 ,表示被测字符符合条件。 表中的这些函数,可打印的字符是指那些可以表示的字符,空白字符则是空 格、制表符、垂直制表符、回车符、换行符和进纸符中的任意一种;标点符号则 是除了数字、字母或(可打印的)空白字符(如空格)以外的其他可打印字符。 这里给出一个例子,运用这些函数输出一给定 string 对象中标点符号的个 数: string s("Hello World!!!"); string::size_type punct_cnt = 0; // count number of punctuation characters in s for (string::size_type index = 0; index != s.size(); ++index) if (ispunct(s[index])) ++punct_cnt; cout << punct_cnt << " punctuation characters in " << s << endl; 这个程序的输出结果是: 3 punctuation characters in Hello World!!! 和返回真值的函数不同的是,tolower 和 toupper 函数返回的是字符,返 回实参字符本身或返回该字符相应的大小写字符。我们可以用 tolower 函数 把 string 对象 s 中的字母改为小写字母,程序如下: // convert s to lowercase for (string::size_type index = 0; index != s.size(); ++index) s[index] = tolower(s[index]); cout << s << endl; 得到的结果为: hello world!!! 126 建议:采用 C 标准库头文件的 C++ 版本 C++ 标准库除了定义了一些选定于 C++ 的设施外,还包括 C 标准库。 C++ 中的头文件 cctype 其实就是利用了 C 标准库函数,这些库函数就 定义在 C 标准库的 ctype.h 头文件中。 C 标准库头文件命名形式为 name 而 C++ 版本则命名为 cname ,少了 后缀,.h 而在头文件名前加了 c 表示这个头文件源自 C 标准库。因此, cctype 与 ctype.h 文件的内容是一样的,只是采用了更适合 C++程序 的形式。特别地,cname 头文件中定义的名字都定义在命名空间 std 内, 而 .h 版本中的名字却不是这样。 通常,C++ 程序中应采用 cname 这种头文件的版本,而不采 用 name.h 版本,这样,标准库中的名字在命名空间 std 中保持一致。 使用 .h 版本会给程序员带来负担,因为他们必须记得哪些标准库名字 是从 C 继承来的,而哪些是 C++ 所特有的。 Exercises Section 3.2.4 Exercise 编一个程序读入两个 string 对象,测试它们是否相等。 3.7: 若不相等,则指出两个中哪个较大。接着,改写程序测试 它们的长度是否相等,若不相等指出哪个较长。 Exercise 3.8: 编一个程序,从标准输入读取多个 string 对象,把它们 连接起来存放到一个更大的 string 对象中。并输出连接 后的 string 对象。接着,改写程序,将连接后相 邻 string 对象以空格隔开。 Exercise 下列程序实现什么功能?实现合法?如果不合法,说明理 3.9: 由。 string s; cout << s[0] << endl; Exercise 编一个程序,从 string 对象中去掉标点符号。要求输入 3.10: 到程序的字符串必须含有标点符号,输出结果则是去掉标 点符号后的 string 对象。 3.3. 标准库 vector 类型 127 vector 是同一种类型的对象的集合,每个对象都有一个对应的整数索引值。 和 string 对象一样,标准库将负责管理与存储元素相关的内存。我们把 vector 称为容器,是因为它可以包含其他对象。一个容器中的所有对象都必须是同一种 类型的。我们将在第九章更详细地介绍容器。 使用 vector 之前,必须包含相应的头文件。本书给出的例子,都是假设已 作了相应的 using 声明: #include using std::vector; vector 是一个类模板(class template)。使用模板可以编写一个类定义 或函数定义,而用于多个不同的数据类型。因此,我们可以定义保存 string 对 象的 vector,或保存 int 值的 vector,又或是保存自定义的类类型对象(如 Sales_items 对象)的 vector。将在第十六章介绍如何定义程序员自己的类模 板。幸运的是,使用类模板时只需要简单了解类模板是如何定义的就可以了。 声明从类模板产生的某种类型的对象,需要提供附加信息,信息的种类取决 于模板。以 vector 为例,必须说明 vector 保存何种对象的类型,通过将类型 放在类型放在类模板名称后面的尖括号中来指定类型: vector ivec; // ivec holds objects of type int vector Sales_vec; // holds Sales_items 和其他变量定义一样,定义 vector 对象要指定类型和一个变量的列表。上 面的第一个定义,类型是 vector,该类型即是含有若干 int 类型对象的 vector,变量名为 ivec。第二个定义的变量名是 Sales_vec,它所保存的元素 是 Sales_item 类型的对象。 vector 不是一种数据类型,而只是一个类模板,可用来定义任 意多种数据类型。vector 类型的每一种都指定了其保存元素的 类型。因此,vector 和 vector 都是数据类型。 3.3.1. vector 对象的定义和初始化 vector 类定义了好几种构造函数(2.3.3 节),用来定义和初始化 vector 对象。表 3.4 列出了这些构造函数。 128 表 3.4. 初始化 vector vector v1; vector 保存类型为 T 对象。 默认构造函数 v1 为空。 vector v2(v1); v2 是 v1 的一个副本。 vector v3(n, i); v3 包含 n 个值为 i 的元素。 vector v4(n); v4 含有值初始化的元素的 n 个副本。 创建确定个数的元素 若要创建非空的 vector 对象,必须给出初始化元素的值。当把一个 vector 对象复制到另一个 vector 对象时,新复制的 vector 中每一个元素都初始化为 原 vectors 中相应元素的副本。但这两个 vector 对象必须保存同一种元素类 型: vector ivec1; vector ivec2(ivec1); ivec2 vector svec(ivec1); // ivec1 holds objects of type int // ok: copy elements of ivec1 into // error: svec holds strings, not ints 可以用元素个数和元素值对 vector 对象进行初始化。构造函数用元素个数 来决定 vector 对象保存元素的个数,元素值指定每个元素的初始值: vector ivec4(10, -1); to -1 vector svec(10, "hi!"); "hi!" // 10 elements, each initialized // 10 strings, each initialized to 129 关键概念:vector 对象动态增长 vector 对象(以及其他标准库容器对象)的重要属性就在于可以在运行 时高效地添加元素。因为 vector 增长的效率高,在元素值已知的情况 下,最好是动态地添加元素。 正如第四章将介绍的,这种增长方式不同于 C 语言中的内置数据类型, 也不同于大多数其他编程语言的数据类型。具体而言,如果读者习惯了 C 或 Java 的风格,由于 vector 元素连续存储,可能希望最好是预先分 配合适的空间。但事实上,为了达到连续性,C++ 的做法恰好相反,具 体原因将在第九章探讨。 虽然可以对给定元素个数的 vector 对象预先分配内 存,但更有效的方法是先初始化一个空 vector 对象, 然后再动态地增加元素(我们随后将学习如何进行这样 的操作)。 值初始化 如果没有指定元素的初始化式,那么标准库将自行提供一个元素初始值进行 值初始化(value initializationd)。这个由库生成的初始值将用来初始化容 器中的每个元素,具体值为何,取决于存储在 vector 中元素的数据类型。 如果 vector 保存内置类型(如 int 类型)的元素,那么标准库将用 0 值 创建元素初始化式: vector fvec(10); // 10 elements, each initialized to 0 如果 vector 保存的是含有构造函数的类类型(如 string)的元素,标准 库将用该类型的默认构造函数创建元素初始化式: vector svec(10); // 10 elements, each an empty string 第十二章将介绍一些有自定义构造函数但没有默认构造函数的 类,在初始化这种类型的 vector 对象时,程序员就不能仅提 供元素个数,还需要提供元素初始值。 130 还有第三种可能性:元素类型可能是没有定义任何构造函数的类类型。这种 情况下,标准库仍产生一个带初始值的对象,这个对象的每个成员进行了值初始 化。 Exercises Section 3.3.1 Exercise 3.11: 下面哪些 vector 定义不正确? (a) vector< vector > ivec; (b) vector svec = ivec; (c) vector svec(10, "null"); Exercise 下列每个 vector 对象中元素个数是多少?各元素的值 3.12: 是什么? (a) vector ivec1; (b) vector ivec2(10); (c) vector ivec3(10, 42); (d) vector svec1; (e) vector svec2(10); (f) vector svec3(10, "hello"); 3.3.2. vector 对象的操作 vector 标准库提供了许多类似于 string 对象的操作,表 3.5 列出了几种 最重要的 vector 操作。 v.empty() v.size() 表 3.5. vector 操作 如果 v 为空,则返回 true,否则返回 false。 返回 v 中元素的个数。 131 表 3.5. vector 操作 v.empty() 如果 v 为空,则返回 true,否则返回 false。 v.push_back(t) 在 v 的末尾增加一个值为 t 的元素。 v[n] 返回 v 中位置为 n 的元素。 v1 = v2 把 v1 的元素替换为 v2 中元素的副本。 v1 == v2 如果 v1 与 v2 相等,则返回 true。 !=, <, <=, >, and >= 保持这些操作符惯有的含义。 vector 对象的 size empty 和 size 操作类似于 string 的相关操作(3.2.3 节)。成员函数 size 返回相应 vector 类定义的 size_type 的值。 使用 size_type 类型时,必须指出该类型是在哪里定义的。 vector 类型总是包括总是包括 vector 的元素类型: vector::size_type vector::size_type // ok // error 向 vector 添加元素 push_back 操作接受一个元素值,并将它作为一个新的元素添加到 vector 对象的后面,也就是“插入(push)”到 vector 对象的“后面(back)”: // read words from the standard input and store them as elements in a vector string word; vector text; // empty vector 132 while (cin >> word) { text.push_back(word); } // append word to text 该循环从标准输入读取一系列 string 对象,逐一追加到 vector 对象的后 面。首先定义一个空的 vector 对象 text。每循环一次就添加一个新元素到 vector 对象,并将从输入读取的 word 值赋予该元素。当循环结束时,text 就 包含了所有读入的元素。 vector 的下标操作 vector 中的对象是没有命名的,可以按 vector 中对象的位置来访问它们。 通常使用下标操作符来获取元素。vector 的下标操作类似于 string 类型的下 标操作(3.2.3 节)。. vector 的下标操作符接受一个值,并返回 vector 中该对应位置的元素。 vector 元素的位置从 0 开始。下例使用 for 循环把 vector 中的每个元素值 都重置为 0: // reset the elements in the vector to zero for (vector::size_type ix = 0; ix != ivec.size(); ++ix) ivec[ix] = 0; 和 string 类型的下标操作符一样,vector 下标操作的结果为左值,因此 可以像循环体中所做的那样实现写入。另外,和 string 对象的下标操作类似, 这里用 size_type 类型作为 vector 下标的类型。 在上例中,即使 ivec 为空,for 循环也会正确执行。ivec 为 空则调用 size 返回 0,并且 for 中的测试比较 ix 和 0。第 一次循环时,由于 ix 本身就是 0 就是 0,则条件测试失败, for 循环体一次也不执行。 下标操作不添加元素 初学 C++ 的程序员可能会认为 vector 的下标操作可以添加元素,其实不 然: vector ivec; // empty vector for (vector::size_type ix = 0; ix != 10; ++ix) ivec[ix] = ix; // disaster: ivec has no elements 133 关键概念:安全的泛型编程 习惯于 C 或 Java 编程的 C++ 程序员可能会觉得难以理解,for 循环 的判断条件用 != 而不是用 < 来测试 vector 下标值是否越界。C 程序 员难以理解的还有,上例中没有在 for 循环之前就调用 size 成员函数 并保存其返回的值,而是在 for 语句头中调用 size 成员函数。 C++ 程序员习惯于优先选用 != 而不是 < 来编写循环判断条件。在上例 中,选用或不用某种操作符并没有特别的取舍理由。学习完本书第二部 分的泛型编程后,你将会明白这种习惯的合理性。 调用 size 成员函数而不保存它返回的值,在这个例子中同样不是必需 的,但这反映了一种良好的编程习惯。在 C++ 中,有些数据结构(如 vector)可以动态增长。上例中循环仅需要读取元素,而不需要增加新 的元素。但是,循环可以容易地增加新元素,如果确实增加了新元素的 话,那么测试已保存的 size 值作为循环的结束条件就会有问题,因为 没有将新加入的元素计算在内。所以我们倾向于在每次循环中测试 size 的当前值,而不是在进入循环前,存储 size 值的副本。 我们将在第七章学习到,C++ 中有些函数可以声明为内联(inline)函 数。编译器遇到内联函数时就会直接扩展相应代码,而不是进行实际的 函数调用。像 size 这样的小库函数几乎都定义为内联函数,所以每次 循环过程中调用它的运行时代价是比较小的。 上述程序试图在 ivec 中插入 10 个新元素,元素值依次为 0 到 9 的整 数。但是,这里 ivec 是空的 vector 对象,而且下标只能用于获取已存在的元 素。 这个循环的正确写法应该是: for (vector::size_type ix = 0; ix != 10; ++ix) ivec.push_back(ix); // ok: adds new element with value ix 必须是已存在的元素才能用下标操作符进行索引。通过下标操 作进行赋值时,不会添加任何元素。 134 警告:仅能对确知已存在的元素进行下标操作 对于下标操作符([] 操作符)的使用有一点非常重要,就是仅能提取确 实已存在的元素,例如: vector ivec; cout << ivec[0]; // empty vector // Error: ivec has no elements! vector ivec2(10); // vector with 10 elements cout << ivec[10]; // Error: ivec has elements 0...9 试图获取不存在的元素必须产生运行时错误。和大多数同类错误一样, 不能确保执行过程可以捕捉到这类错误,运行程序的结果是不确定的。 由于取不存在的元素的结果标准没有定义,因而不同的编译器实现会导 致不同的结果,但程序运行时几乎肯定会以某种有趣的方式失败。 本警告适用于任何使用下标操作的时候,如 string 类型的下标操作, 以及将要简要介绍的内置数组的下标操作。 不幸的是,试图对不存在的元素进行下标操作是程序设计过程中经常会 犯的严重错误。所谓的“缓冲区溢出”错误就是对不存在的元素进行下 标操作的结果。这样的缺陷往往导致 PC 机和其他应用中最常见的安全 问题。 135 Exercises Section 3.3.2 Exercise 3.13: 读一组整数到 vector 对象,计算并输出每对相邻元素的 和。如果读入元素个数为奇数,则提示用户最后一个元素 没有求和,并输出其值。然后修改程序:头尾元素两两配 对(第一个和最后一个,第二个和倒数第二个,以此类推), 计算每对元素的和,并输出。 Exercise 3.14: 读入一段文本到 vector 对象,每个单词存储为 vector 中的一个元素。把 vector 对象中每个单词转化为大写字 母。输出 vector 对象中转化后的元素,每八个单词为一 行输出。 Exercise 3.15: 下面程序合法吗?如果不合法,如何更正? vector ivec; ivec[0] = 42; Exercise 列出三种定义 vector 对象的方法,给定 10 个元素,每 3.16: 个元素值为 42。指出是否还有更好的实现方法,并说明 为什么。 3.4. 迭代器简介 除了使用下标来访问 vector 对象的元素外,标准库还提供了另一种访问元 素的方法:使用迭代器(iterator)。迭代器是一种检查容器内元素并遍历元素 的数据类型。 标准库为每一种标准容器(包括 vector)定义了一种迭代器类型。迭代器 类型提供了比下标操作更通用化的方法:所有的标准库容器都定义了相应的迭代 器类型,而只有少数的容器支持下标操作。因为迭代器对所有的容器都适用,现 代 C++ 程序更倾向于使用迭代器而不是下标操作访问容器元素,即使对支持下 标操作的 vector 类型也是这样。 第十一章将详细讨论迭代器的工作原理,但使用迭代器并不需要完全了解它 复杂的实现细节。 136 容器的 iterator 类型 每种容器类型都定义了自己的迭代器类型,如 vector: vector::iterator iter; 这符语句定义了一个名为 iter 的变量,它的数据类型是 vector 定 义的 iterator 类型。每个标准库容器类型都定义了一个名为 iterator 的成 员,这里的 iterator 与迭代器实际类型的含义相同。 术语:迭代器和迭代器类型 程序员首次遇到有关迭代器的术语时可能会困惑不解,原因之一是由于 同一个术语 iterator 往往表示两个不同的事物。一般意义上指的是迭 代器的概念;而具体而言时指的则是由容器定义的具体的 iterator 类 型,如 vector。 重点要理解的是,有许多用作迭代器的类型,这些类型在概念上是相关 的。若一种类型支持一组确定的操作(这些操作可用来遍历容器内的元 素,并访问这些元素的值),我们就称这种类型为迭代器。 各容器类都定义了自己的 iterator 类型,用于访问容器内的元素。换 句话说,每个容器都定义了一个名为 iterator 的类型,而这种类型支 持(概念上的)迭代器的各种操作。 begin 和 end 操作 每种容器都定义了一对命名为 begin 和 end 的函数,用于返回迭代器。如 果容器中有元素的话,由 begin 返回的迭代器指向第一个元素: vector::iterator iter = ivec.begin(); 上述语句把 iter 初始化为由名为 vector 操作返回的值。假设 vector 不 空,初始化后,iter 即指该元素为 ivec[0]。 由 end 操作返回的迭代器指向 vector 的“末端元素的下一个”。“超出 末端迭代器”(off-the-end iterator)。表明它指向了一个不存在的元素。 如果 vector 为空,begin 返回的迭代器与 end 返回的迭代器相同。 137 由 end 操作返回的迭代器并不指向 vector 中任何实际的元 素,相反,它只是起一个哨兵(sentinel)的作用,表示我们 已处理完 vector 中所有元素。 vector 迭代器的自增和解引用运算 迭代器类型定义了一些操作来获取迭代器所指向的元素,并允许程序员将迭 代器从一个元素移动到另一个元素。 迭代器类型可使用解引用操作符(dereference operator)(*)来访问迭 代器所指向的元素: *iter = 0; 解引用操作符返回迭代器当前所指向的元素。假设 iter 指向 vector 对 象 ivec 的第一元素,那么 *iter 和 ivec[0] 就是指向同一个元素。上面这个 语句的效果就是把这个元素的值赋为 0。 迭代器使用自增操作符(1.4.1 节)向前移动迭代器指向容器中下一个元素。 从逻辑上说,迭代器的自增操作和 int 型对象的自增操作类似。对 int 对象来 说,操作结果就是把 int 型值“加 1”,而对迭代器对象则是把容器中的迭代 器“向前移动一个位置”。因此,如果 iter 指向第一个元素,则 ++iter 指向 第二个元素。 由于 end 操作返回的迭代器不指向任何元素,因此不能对它进 行解引用或自增操作。 迭代器的其他操作 另一对可执行于迭代器的操作就是比较:用 == 或 != 操作符来比较两个迭 代器,如果两个迭代器对象指向同一个元素,则它们相等,否则就不相等。 138 迭代器应用的程序示例 假设已声明了一个 vector 型的 ivec 变量,要把它所有元素值重置 为 0,可以用下标操作来完成: // reset all the elements in ivec to 0 for (vector::size_type ix = 0; ix != ivec.size(); ++ix) ivec[ix] = 0; 上述程序用 for 循环遍历 ivec 的元素,for 循环定义了一个索引 ix , 每循环迭代一次 ix 就自增 1。for 循环体将 ivec 的每个元素赋值为 0。 更典型的做法是用迭代器来编写循环: // equivalent loop using iterators to reset all the elements in ivec to 0 for (vector::iterator iter = ivec.begin(); iter != ivec.end(); ++iter) *iter = 0; // set element to which iter refers to 0 for 循环首先定义了 iter,并将它初始化为指向 ivec 的第一个元素。 for 循环的条件测试 iter 是否与 end 操作返回的迭代器不等。每次迭 代 iter 都自增 1,这个 for 循环的效果是从 ivec 第一个元素开始,顺序处 理 vector 中的每一元素。最后, iter 将指向 ivec 中的最后一个元素,处理 完最后一个元素后,iter 再增加 1,就会与 end 操作的返回值相等,在这种情 况下,循环终止。 for 循环体内的语句用解引用操作符来访问当前元素的值。和下标操作符一 样,解引用操作符的返回值是一个左值,因此可以对它进行赋值来改变它的值。 上述循环的效果就是把 ivec 中所有元素都赋值为 0。 通过上述对代码的详细分析,可以看出这段程序与用下标操作符的版本达到 相同的操作效果:从 vector 的第一个元素开始,把 vector 中每个元素都置 为 0。 本节给出的例子程序和 3.3.2 节 vector 的下标操作的程序 一样,如果 vector 为空,程序是安全的。如果 ivec 为空, 则 begin 返回的迭代器不指向任何元素——由于没有元素,所 以它不能指向任何元素。在这种情况下,从 begin 操作返回的 迭代器与从 end 操作返回的迭代器的值相同,因此 for 语句 中的测试条件立即失败。 139 const_iterator 前面的程序用 vector::iterator 改变 vector 中的元素值。每种容器类型 还定义了一种名为 const_iterator 的类型,该类型只能用于读取容器内元素, 但不能改变其值。 当我们对普通 iterator 类型解引用时,得到对某个元素的非 const (2.5 节)。而如果我们对 const_iterator 类型解引用时,则可以得到一个指 向 const 对象的引用(2.4 节),如同任何常量一样,该对象不能进行重写。 例如,如果 text 是 vector 类型,程序员想要遍历它,输出每个 元素,可以这样编写程序: // use const_iterator because we won't change the elements for (vector::const_iterator iter = text.begin(); iter != text.end(); ++iter) cout << *iter << endl; // print each element in text 除了是从迭代器读取元素值而不是对它进行赋值之外,这个循环与前一个相 似。由于这里只需要借助迭代器进行读,不需要写,这里把 iter 定义 为 const_iterator 类型。当对 const_iterator 类型解引用时,返回的是一 个 const 值。不允许用 const_iterator: 进行赋值 for (vector::const_iterator iter = text.begin(); iter != text.end(); ++ iter) *iter = " "; // error: *iter is const 使用 const_iterator 类型时,我们可以得到一个迭代器,它自身的值可以 改变,但不能用来改变其所指向的元素的值。可以对迭代器进行自增以及使用解 引用操作符来读取值,但不能对该元素赋值。 不要把 const_iterator 对象与 const 的 iterator 对象混淆起来。声明 一个 const 迭代器时,必须初始化迭代器。一旦被初始化后,就不能改变它的 值: vector nums(10); // nums is nonconst const vector::iterator cit = nums.begin(); *cit = 1; // ok: cit can change its underlying element ++cit; // error: can't change the value of cit 140 const_iterator 对象可以用于 const vector 或非 const vector,因为不 能改写元素值。const 迭代器这种类型几乎没什么用处:一旦它被初始化后,只 能用它来改写其指向的元素,但不能使它指向任何其他元素。 const vector nines(10, 9); // cannot change elements in nines // error: cit2 could change the element it refers to and nines is const const vector::iterator cit2 = nines.begin(); // ok: it can't change an element value, so it can be used with a const vector vector::const_iterator it = nines.begin(); *it = 10; // error: *it is const ++it; // ok: it isn't const so we can change its value // an iterator that cannot write elements vector::const_iterator // an iterator whose value cannot change const vector::iterator 141 Exercises Section 3.4 Exercise 重做 3.3.2 节 的习题,用迭代器而不是下标操作来访 3.17: 问 vector 中的元素。 Exercise 编写程序来创建有 10 个元素的 vector 对象。用迭代器 3.18: 把每个元素值改为当前值的 2 倍。 Exercise 验证习题 3.18 的程序,输出 vector 的所有元素。 3.19: Exercise 解释一下在上几个习题的程序实现中你用了哪种迭代器, 3.20: 并说明原因。 Exercise 何时使用 const 迭代器的?又在何时使 3.21: 用 const_iterator?解释两者的区别。 3.4.1. 迭代器的算术操作 除了一次移动迭代器的一个元素的增量操作符外,vector 迭代器(其他标准 库容器迭代器很少)也支持其他的算术操作。这些操作称为迭代器算术操作 (iterator arithmetic),包括: • iter + n iter - n 可以对迭代器对象加上或减去一个整形值。这样做将产生一个新的迭代 器,其位置在 iter 所指元素之前(加)或之后(减) n 个元素的位置。 加或减之后的结果必须指向 iter 所指 vector 中的某个元素,或者 是 vector 末端的后一个元素。加上或减去的值的类型应该 是 vector 的 size_type 或 difference_type 类型(参考下面的解释)。 • iter1 - iter2 该表达式用来计算两个迭代器对象的距离,该距离是名 为 difference_type 的 signed 类型 size_type 的值,这里 的 difference_type 是 signed 类型,因为减法运算可能产生负数的结 果。该类型可以保证足够大以存储任何两个迭代器对象间的距离。 142 iter1 与 iter2 两者必须都指向同一 vector 中的元素,或者指 向 vector 末端之后的下一个元素。 可以用迭代器算术操作来移动迭代器直接指向某个元素,例如,下面语句直 接定位于 vector 中间元素: vector::iterator mid = vi.begin() + vi.size() / 2; 上述代码用来初始化 mid 使其指向 vi 中最靠近正中间的元素。这种直接 计算迭代器的方法,与用迭代器逐个元素自增操作到达中间元素的方法是等价 的,但前者的效率要高得多。 任何改变 vector 长度的操作都会使已存在的迭代器失效。例 如,在调用 push_back 之后,就不能再信赖指向 vector 的迭 代器的值了。 Exercises Section 3.4.1 Exercise 3.22: What happens if we compute mid as follows: vector::iterator mid = (vi.begin() + vi.end()) / 2; 3.5. 标准库 bitset 有些程序要处理二进制位的有序集,每个位可能包含 0(关)1(开)值。 位是用来保存一组项或条件的 yes/no 信息(有时也称标志)的简洁方法。标准 库提供的 bitset 类简化了位集的处理。要使用 bitset 类就必须包含相关的头 文件。在本书提供的例子中,假设都使用 std::bitset 的 using 声明: #include using std::bitset; 143 3.5.1. bitset 对象的定义和初始化 表 3.6 列出了 bitset 的构造函数。类似于 vector,bitset 类是一种类 模板;而与 vector 不一样的是 bitset 类型对象的区别仅在其长度而不在其类 型。在定义 bitset 时,要明确 bitset 含有多少位,须在尖括号内给出它的长 度值: 表 3.6. 初始化 bitset 对象的方法 bitset b; b 有 n 位,每位都 0 bitset b(u); b 是 unsigned long 型 u 的一个副本 bitset b(s); b 是 string 对象 s 中含有的位串的副本 bitset b(s, pos, n); b 是 s 中从位置 pos 开始的&nbps;n 个位的副 本。 bitset<32> bitvec; // 32 bits, all zero 给出的长度值必须是常量表达式(2.7 节)。正如这里给出的,长度值值必 须定义为整型字面值常量或是已用常量值初始化的整型的 const 对象。 这条语句把 bitvec 定义为含有 32 个位的 bitset 对象。和 vector 的元 素一样,bitset 中的位是没有命名的,程序员只能按位置来访问。位集合的位 置编号从 0 开始,因此,bitvec 的位序是从 0 到 31。以 0 位开始的位串是 低阶位(low-order),以 31 位结束的位串是高阶位(high-order)。 用 unsigned 值初始化 bitset 对象 当用 unsigned long 值作为 bitset 对象的初始值时,该值将转化为二进 制的位模式。而 bitset 对象中的位集作为这种位模式的副本。如果 bitset 类 型长度大于 unsigned long 值的二进制位数,则其余的高阶位将置为 0;如 果 bitset 类型长度小于 unsigned long 值的二进制位数,则只使 用 unsigned 值中的低阶位,超过 bistset 类型长度的高阶位将被丢弃。 在 32 位 unsigned long 的机器上,十六进制值 0xffff 表示为二进制位 就是十六个 1 和十六个 0(每个 0xf 可表示为 1111)。可以用 0xffff 初始 化 bitset 对象: 144 // bitvec1 is smaller than the initializer bitset<16> bitvec1(0xffff); // bits 0 ... 15 are set to 1 // bitvec2 same size as initializer bitset<32> bitvec2(0xffff); // bits 0 ... 15 are set to 1; 16 ... 31 are 0 // on a 32-bit machine, bits 0 to 31 initialized from 0xffff bitset<128> bitvec3(0xffff); // bits 32 through 127 initialized to zero 上面的三个例子中,0 到 15 位都置为 1。由于 bitvec1 位数少 于 unsigned long 的位数,因此 bitvec1 的初始值的高阶被丢弃。 bitvec2 和 unsigned long 长度相同,因此所有位正好放置了初始值。 bitvec3 长度大于 32,31 位以上的高阶位就被置为 0。 用 string 对象初始化 bitset 对象 当用 string 对象初始化 bitset 对象时,string 对象直接表示为位模式。 从 string 对象读入位集的顺序是从右向左(from right to left): string strval("1100"); bitset<32> bitvec4(strval); bitvec4 的位模式中第 2 和 3 的位置为 1,其余位置都为 0。如果 string 对 象的字符个数小于 bitset 类型的长度,则高阶位置为 0。 string 对象和 bitsets 对象之间是反向转化的:string 对象 的最右边字符(即下标最大的那个字符)用来初始化 bitset 对 象的低阶位(即下标为 0 的位)。当用 string 对象初始 化 bitset 对象时,记住这一差别很重要。 不一定要把整个 string 对象都作为 bitset 对象的初始值。相反,可以只 用某个子串作为初始值: string str("1111111000000011001101"); bitset<32> bitvec5(str, 5, 4); // 4 bits starting at str[5], 1100 bitset<32> bitvec6(str, str.size() - 4); // use last 4 characters 145 这里用 str 从 str[5] 开始包含四个字符的子串来初始化 bitvec5。照常, 初始化 bitset 对象时总是从子串最右边结尾字符开始的,bitvec5 的 从 3 到 0 的二进制位置为 1100 ,其他二进制位都置为 0。如果省略第三个参 数则意味着取从开始位置一直到 string 末尾的所有字符。本例中,取出 str 末 尾的四位来对 bitvec6 的低四位进行初始化。bitvec6 其余的位初始化为 0。 这些初始化过程的图示如下: 3.5.2. bitset 对象上的操作 多种 bitset 操作(表 3.7)用来测试或设置 bitset 对象中的单个或多个 二进制位。 表 3.7. bitset 操作 b.any() b 中是否存在置为 1 的二进制位? b.none() b 中不存在置为 1 的二进制位吗? b.count() b 中置为 1 的二进制位的个数 b.size() b 中二进制位的个数 b[pos] 访问 b 中在 pos 处二进制位 b.test(pos) b 中在 pos 处的二进制位置为 1 么? b.set() 把 b 中所有二进制位都置为 1 b.set(pos) 把 b 中在 pos 处的二进制位置为 1 146 表 3.7. bitset 操作 b.any() b 中是否存在置为 1 的二进制位? b.reset() 把 b 中所有二进制位都置为 0 b.reset(pos) 把 b 中在 pos 处的二进制位置为 0 b.flip() 把 b 中所有二进制位逐位取反 b.flip(pos) 把 b 中在 pos 处的二进制位取反 b.to_ulong() 用 b 中同样的二进制位返回一个 unsigned long 值 os << b 把 b 中的位集输出到 os 流 测试整个 bitset 对象 如果 bitset 对象中有一个或几个二进制位置为 1,则 any 操作返 回 true,也就是说,其返回值等于 1;相反,如果 bitset 对象中二进制位全 为 0,则 none 操作返回 true。 bitset<32> bitvec; // 32 bits, all zero bool is_set = bitvec.any(); // false, all bits are zero bool is_not_set = bitvec.none(); // true, all bits are zero 如果需要知道置为 1 的二进制位的个数,可以使用 count 操作,该操作返 回置为 1 的二进制位的个数: size_t bits_set = bitvec.count(); // returns number of bits that are on count 操作的返回类型是标准库中命名为 size_t 类型。size_t 类型定义 在 cstddef 头文件中,该文件是 C 标准库的头文件 stddef.h 的 C++ 版本。 它是一个与机器相关的 unsigned 类型,其大小足以保证存储内在中对象的大 小。 与 vector 和 string 中的 size 操作一样,bitset 的 size 操作返 回 bitset 对象中二进制位的个数,返回值的类型是 size_t:: size_t sz = bitvec.size(); // returns 32 147 访问 bitset 对象中的位 可以用下标操作符来读或写某个索引位置的二进制位,同样地,也可以用下 标操作符测试给定二进制位的值或设置某个二进制们的值: // assign 1 to even numbered bits for (int index = 0; index != 32; index += 2) bitvec[index] = 1; 上面的循环把 bitvec 中的偶数下标的位都置为 1。 除了用下标操作符,还可以用 set;、test 和 reset 操作来测试或设置给 定二进制位的值: // equivalent loop using set operation for (int index = 0; index != 32; index += 2) bitvec.set(index); 为了测试某个二进制位是否为 1,可以用 test 操作或者测试下标操作符的 返回值: if (bitvec.test(i)) // bitvec[i] is on // equivalent test using subscript if (bitvec[i]) // bitvec[i] is on 如果下标操作符测试的二进制位为 1,则返回的测试值的结果为 true,否 则返回 false。 对整个 bitset 对象进行设置 set 和 reset 操作分别用来对整个 bitset 对象的所有二进制位全 置 1 和全置 0: bitvec.reset(); // set all the bits to 0. bitvec.set(); // set all the bits to 1 148 flip 操作可以对 bitset 对象的所有位或个别位取反: bitvec.flip(0); // reverses value of first bit bitvec[0].flip(); // also reverses the first bit bitvec.flip(); // reverses value of all bits 获取 bitset 对象的值 to_ulong 操作返回一个 unsigned long 值,该值与 bitset 对象的位模式 存储值相同。仅当 bitset 类型的长度小于或等于 unsigned long 的长度时, 才可以使用 to_ulong 操作: unsigned long ulong = bitvec3.to_ulong(); cout << "ulong = " << ulong << endl; to_ulong 操作主要用于把 bitset 对象转到 C 风格或标准 C++ 之前风格 的程序上。如果 bitset 对象包含的二进制位数超过 unsigned long 长度,将 会产生运行时异常。本书将在 6.13 节介绍异常(exception),并在 17.1 节 中详细地讨论它。 输出二进制位 可以用输出操作符输出 bitset 对象中的位模式: bitset<32> bitvec2(0xffff); // bits 0 ... 15 are set to 1; 16 ... 31 are 0 cout << "bitvec2: " << bitvec2 << endl; 输出结果为: bitvec2: 00000000000000001111111111111111 使用位操作符 bitset 类也支持内置的位操作符。C++ 定义的这些操作符都只适用于整型 操作数,它们所提供的操作类似于本节所介绍的 bitset 操作。5.3 节将介绍这 些操作符。 149 Exercises Section 3.5.2 Exercise 3.23: 解释下面每个 bitset 对象包含的位模式: (a) bitset<64> bitvec(32); (b) bitset<32> bv(1010101); (c) string bstr; cin >> bstr; bitset<8>bv(bstr); Exercise 3.24: 考虑这样的序列 1,2,3,5,8,13,21,并初始化一个 将该序列数字所对应的位置置为 1 的 bitset<32> 对 象。然后换个方法,给定一个空的 bitset,编写一小段 程序把相应的数位设置为 1。 小结 C++ 标准库定义了几种更高级的抽象数据类型,包括 string 和 vector 类 型。string 类型提供了变长的字符串,而 vector 类型则可用于管理同一类型 的对象集合。 迭代器实现了对存储于容器中对象的间接访问。迭代器可以用于访问和遍 历 string 类型和 vectors 类型的元素。 下一章将介绍 C++ 的内置数据类型:数组和指针。这两种类型提供了类似 于 vector 和 string 标准库类型的低级抽象类型。总的来说,相对于 C++ 内 置数据类型的数组和指针而言,程序员应优先使用标准库类类型。 术语 abstract data type(抽象数据类型) 隐藏其实现的数据类型。使用抽象数据类型时,只需要了解该类型所支持 的操作。 bitset 一种标准库类型,用于保存位置,并提供地各个位的测试和置位操作。 cctype header(cctype 头文件) 150 从 C 标准库继承而来的头文件,包含一组测试字符值的例程。第 8.3.4 节的表 3.3 列出了常用的例程。 class template(类模板) 一个可创建许多潜在类类型的蓝图。使用类模板时,必须给出实际的类型 和值。例如,vector 类型是保存给定类型对象的模板。创建一个 vector 对象是,必须指出这个 vector 对象所保存的元素的类型。vector 保存 int 的对象,而 vector 则保存 string 对象,以此类推。 container(容器) 一种类型,其对象保存一组给定类型的对象的集合。 difference_type 一种由 vector 类型定义的 signed 整型,用于存储任意两个迭代器间的 距离。 empty 由 string 类型和 vector 类型定义的成员函数。empty 返回布尔值,用于 检测 string 是否有字符或 vector 是否有元素。如果 string 或 vector 的 size 为 0,则返回 true,否则返回 false。 getline string 头文件中定义的函数,该函数接受一个 istream 对象和一个 string 对象,读取输入流直到下一个换行符,存储读入的输入流到 string 对象 中,并返回 istream 对象。换行符被读入并丢弃。 high-order(高阶) bitset 对象中索引值最大的位。 index(索引) 下标操作符所使用的值,用于表示从 string 对象或 vector 对象中获取的 元素。也称“下标”。 iterator(迭代器) 用于对容器类型的元素进行检查和遍历的数据类型。 iterator arithmetic(迭代器的算术操作) 151 应用于一些(并非全部)迭代器类型的算术操作。迭代器对象可以加上或 减去一个整型数值,结果迭代器指向处于原迭代器之前或之后若干个元素 的位置。两个迭代器对象可以相减,得到的结果是它们之间的距离。迭代 器算术操作只适用于指向同一容器中的元素或指向容器末端的下一元素 迭代器。 low-order(低阶) bitset 对象中索引值最小的位。 off-the-end iterator(超出末端的迭代器) 由 end 操作返回的迭代器,是一种指向容器末端之后的不存在元素的迭代 器。 push_back 由 vector 类型定义的成员函数,用于把元素追加到 vector 对象的尾部。 sentinel(哨兵) 一种程序设计技术,使用一个值来控制处理过程。在本章中使用由 end 操作返回的迭代器作为保护符,当处理完 vector 对象中的所有元素后, 用它来停止处理 vector 中的元素。 size 由库类型 string、vector 和 bitset 定义的函数,分别用于返回此三个类 型的字符个数、元素个素、二进制位的个数。string 和 vector 类的 size 成员函数返回 size_type 类型的值(例如,string 对象的 size 操作返回 string::size_type 类型值)。bitset 对象的 size 操作返回 size_t 类型 值。 size_t 在 cstddef 头文件中定义的机器相关的无符号整型,该类型足以保存最大 数组的长度。 在 cstddef 头文件中定义的机器相关的无符号整型,该类型足以保存最大数组的 长度。 size_type 由 string 类类型和 vector 类类型定义的类型,用以保存任意 string 对 象或 vecotr 对象的长度。标准库类型将 size_type 定义为 unsigned 类型。 using declarations(using 声明) 152 使命名空间的名字可以直接引用。比如: using namespace::name; 可以直接访问 name 而无须前缀 namespace::。 value initialization(值初始化) 当给定容器的长度,但没有显式提供元素的初始式时,对容器元素进行的 初始化。元素被初始化为一个编译器产生的值的副本。如果容器保存内置 类型变量,则元素的初始值将置为 0。如果容器用于保存类对象,则元素 的初始值由类的默认构造函数产生。只有类提供了默认构造函数时,类类 型的容器元素才能进行值初始化。 ++ operator(++操作符) 迭代器类型定义的自增操作符,通过“加 1”移动迭代器指向下一个元 素。 :: operator(::操作符) 作用域操作符。::操作符在其左操作数的作用域内找到其右操作数的名 字。用于访问某个命名空间中的名字,如 std::cout,表明名字 cout 来 自命名空间 std。同样地,可用来从某个类取名字,如 string::size_type, 表明 size_type 是由 string 类定义的。 [] operator([]操作符) 由 string, vector 和 bitset 类型定义的重载操作符。它接受两个操作数: 左操作数是对象名字,右操作数是一个索引。该操作符用于取出位置与索 引相符的元素,索引计数从 0 开始,即第一个元素的索引为 0,最后一个 元素的索引为 obj.size() -1。下标操作返回左值,因此可将下标操作作 为赋值操作的左操作数。对下标操作的结果赋值是赋一个新值到相应的元 素。 * operator(*操作符) 迭代器类型定义了解引用操作符来返回迭代器所指向的对象。解引用返回 左值,因此可将解引用操作符用作赋值操作的左操作数。对解引用操作的 结果赋值是赋一个新值到相应的元素。 << operator(<< 操作符) 153 标准库类型 string 和 bitset 定义了输出操作符。string 类型的输出操 作符将输出 string 对象中的字符。bitset 类型的输出操作符则输出 bitset 对象的位模式。 >> operator(>> 操作符) 标准库类型 string 和 bitset 定义了输入操作符。string 类型的输入操 作符读入以空白字符为分隔符的字符串,并把读入的内容存储在右操作数 (string 对象)中。bitset 类型的输入操作符则读入一个位序列到其 bitset 操作数中。 154 第四章 数组和指针 C++ 语言提供了两种类似于 vector 和迭代器类型的低级复合类型——数 组和指针。与 vector 类型相似,数组也可以保存某种类型的一组对象;而它们 的区别在于,数组的长度是固定的。数组一经创建,就不允许添加新的元素。指 针则可以像迭代器一样用于遍历和检查数组中的元素。 现代 C++ 程序应尽量使用 vector 和迭代器类型,而避免使用低级的数组 和指针。设计良好的程序只有在强调速度时才在类实现的内部使用数组和指针。 数组是 C++ 语言中类似于标准库 vector 类型的内置数据结构。与 vector 类似,数组也是一种存储单一数据类型对象的容器,其中每个对象都没有单独的 名字,而是通过它在数组中的位置对它进行访问。 与 vector 类型相比,数组的显著缺陷在于:数组的长度是固定的,而且程 序员无法知道一个给定数组的长度。数组没有获取其容量大小的 size 操作,也 不提供 push_back 操作在其中自动添加元素。如果需要更改数组的长度,程序 员只能创建一个更大的新数组,然后把原数组的所有元素复制到新数组空间中 去。 与使用标准 vector 类型的程序相比,依赖于内置数组的程序 更容易出错而且难于调试。 在出现标准库之前,C++ 程序大量使用数组保存一组对象。而现代的 C++ 程 序则更多地使用 vector 来取代数组,数组被严格限制于程序内部使用,只有当 性能测试表明使用 vector 无法达到必要的速度要求时,才使用数组。然而,在 将来一段时间之内,原来依赖于数组的程序仍大量存在,因此,C++ 程序员还是 必须掌握数组的使用方法。 4.1. 数组 数组是由类型名、标识符和维数组成的复合数据类型(第 2.5 节),类型 名规定了存放在数组中的元素的类型,而维数则指定数组中包含的元素个数。 数组定义中的类型名可以是内置数据类型或类类型;除引用之 外,数组元素的类型还可以是任意的复合类型。没有所有元素 都是引用的数组。 155 4.1.1. 数组的定义和初始化 数组的维数必须用值大于等于 1 的常量表达式定义(第 2.7 节)。此常量 表达式只能包含整型字面值常量、枚举常量(第 2.7 节)或者用常量表达式初 始化的整型 const 对象。非 const 变量以及要到运行阶段才知道其值的 const 变量都不能用于定义数组的维数。 数组的维数必须在一对方括号 [] 内指定: // both buf_size and max_files are const const unsigned buf_size = 512, max_files = 20; int staff_size = 27; // nonconst const unsigned sz = get_size(); // const value not known until run time char input_buffer[buf_size]; // ok: const variable string fileTable[max_files + 1]; // ok: constant expression double salaries[staff_size]; // error: non const variable int test_scores[get_size()]; // error: non const expression int vals[sz]; // error: size not known until run time 虽然 staff_size 是用字面值常量进行初始化,但 staff_size 本身是一个 非 const 对象,只有在运行时才能获得它的值,因此,使用该变量来定义数组 维数是非法的。而对于 sz,尽管它是一个 const 对象,但它的值要到运行时调 用 get_size 函数后才知道,因此,它也不能用于定义数组维数。 max_files + 1 另一方面,由于 max_files 是 const 变量,因此表达式是常量表达式,编 译时即可计算出该表达式的值为 21。 显式初始化数组元素 在定义数组时,可为其元素提供一组用逗号分隔的初值,这些初值用花括号 {}括起来,称为初始化列表: const unsigned array_size = 3; int ia[array_size] = {0, 1, 2}; 156 如果没有显式提供元素初值,则数组元素会像普通变量一样初始化(第 2.3.4 节): • 在函数体外定义的内置数组,其元素均初始化为 0。 • 在函数体内定义的内置数组,其元素无初始化。 • 不管数组在哪里定义,如果其元素为类类型,则自动调用该类的默认构造 函数进行初始化;如果该类没有默认构造函数,则必须为该数组的元素提 供显式初始化。 除非显式地提供元素初值,否则内置类型的局部数组的元素没 有初始化。此时,除了给元素赋值外,其他使用这些元素的操 作没有定义。 显式初始化的数组不需要指定数组的维数值,编译器会根据列出的元素个数 来确定数组的长度: int ia[] = {0, 1, 2}; // an array of dimension 3 如果指定了数组维数,那么初始化列表提供的元素个数不能超过维数值。如 果维数大于列出的元素初值个数,则只初始化前面的数组元素;剩下的其他元素, 若是内置类型则初始化为 0,若是类类型则调用该类的默认构造函数进行初始 化: const unsigned array_size = 5; // Equivalent to ia = {0, 1, 2, 0, 0} // ia[3] and ia[4] default initialized to 0 int ia[array_size] = {0, 1, 2}; // Equivalent to str_arr = {"hi", "bye", "", "", ""} // str_arr[2] through str_arr[4] default initialized to the empty string string str_arr[array_size] = {"hi", "bye"}; 特殊的字符数组 字符数组既可以用一组由花括号括起来、逗号隔开的字符字面值进行初始 化,也可以用一个字符串字面值进行初始化。然而,要注意这两种初始化形式并 不完全相同,字符串字面值(第 2.2 节)包含一个额外的空字符(null)用于 结束字符串。当使用字符串字面值来初始化创建的新数组时,将在新数组中加入 空字符: char ca1[] = {'C', '+', '+'}; // no null char ca2[] = {'C', '+', '+', '\0'}; // explicit null char ca3[] = "C++"; // null terminator added automatically 157 ca1 的维数是 3,而 ca2 和 ca3 的维数则是 4。使用一组字符字面值初始 化字符数组时,一定要记得添加结束字符串的空字符。例如,下面的初始化将导 致编译时的错误: const char ch3[6] = "Daniel"; // error: Daniel is 7 elements 上述字符串字面值包含了 6 个显式字符,存放该字符串的数组则必须有 7 个元素——6 个用于存储字符字面值,而 1 个用于存放空字符 null。 不允许数组直接复制和赋值 与 vector 不同,一个数组不能用另外一个数组初始化,也不能将一个数组 赋值给另一个数组,这些操作都是非法的: int ia[] = {0, 1, 2}; // ok: array of ints int ia2[](ia); // error: cannot initialize one array with another int main() { const unsigned array_size = 3; int ia3[array_size]; // ok: but elements are uninitialized! another ia3 = ia; // error: cannot assign one array to return 0; } 一些编译器允许将数组赋值作为编译器扩展。但是如果希望 编写的程序能在不同的编译器上运行,则应该避免使用像数 组赋值这类依赖于编译器的非标准功能。 158 警告:数组的长度是固定的 与 vector 类型不同,数组不提供 push_back 或者其他的操作在数组 中添加新元素,数组一经定义,就不允许再添加新元素。 如果必须在数组中添加新元素,程序员就必须自己管理内存:要求系统 重新分配一个新的内存空间用于存放更大的数组,然后把原数组的所有 元素复制到新分配的内存空间中。我们将会在第 4.3.1 节学习如何去 实现。 159 Exercises Section 4.1.1 Exercise 假设 get_size 是一个没有参数并返回 int 值的函数, 4.1: 下列哪些定义是非法的?为什么? unsigned buf_size = 1024; (a) int ia[buf_size]; (b) int ia[get_size()]; (c) int ia[4 * 7 - 14]; (d) char st[11] = "fundamental"; Exercise 4.2: 下列数组的值是什么? string sa[10]; int ia[10]; int main() { string sa2[10]; int ia2[10]; } Exercise 4.3: 下列哪些定义是错误的? 8 }; (a) int ia[7] = { 0, 1, 1, 2, 3, 5, 8 }; (b) vector ivec = { 0, 1, 1, 2, 3, 5, (c) int ia2[ ] = ia1; (d) int ia3[ ] = ivec; Exercise 如何初始化数组的一部分或全部元素? 4.4: Exercise 列出使用数组而不是 vector 的缺点。 4.5: 160 4.1.2. 数组操作 与 vector 元素一样,数组元素可用下标操作符(第 3.3.2 节)来访问,数 组元素也是从 0 开始计数。对于一个包含 10 个元素的数组,正确的下标值是 从 0 到 9,而不是从 1 到 10。 在用下标访问元素时,vector 使用 vector::size_type 作为下标的类型, 而数组下标的正确类型则是 size_t(第 3.5.2 节)。 在下面的例子中,for 循环遍历数组的 10 个元素,并以其下标值作为各个 元素的初始值: int main() { const size_t array_size = 10; int ia[array_size]; // 10 ints, elements are uninitialized // loop through array, assigning value of its index to each element for (size_t ix = 0; ix != array_size; ++ix) ia[ix] = ix; return 0; } 使用类似的循环,可以实现把一个数组复制给另一个数组: int main() { const size_t array_size = 7; int ia1[] = { 0, 1, 2, 3, 4, 5, 6 }; int ia2[array_size]; // local array, elements uninitialized // copy elements from ia1 into ia2 for (size_t ix = 0; ix != array_size; ++ix) ia2[ix] = ia1[ix]; return 0; } 161 检查数组下标值 正如 string 和 vector 类型,程序员在使用数组时,也必须保证其下标值 在正确范围之内,即数组在该下标位置应对应一个元素。 除了程序员自己注意细节,并彻底测试自己的程序之外,没有别的办法可防 止数组越界。通过编译并执行的程序仍然存在致命的错误,这并不是不可能的。 导致安全问题的最常见原因是所谓“缓冲区溢出(buffer overflow)” 错误。当我们在编程时没有检查下标,并且引用了越出数组或其他类似 数据结构边界的元素时,就会导致这类错误。 Exercises Section 4.1.2 Exercise 下面的程序段企图将下标值赋给数组的每个元素,其中在 4.6: 下标操作上有一些错误,请指出这些错误。 const size_t array_size = 10; int ia[array_size]; for (size_t ix = 1; ix <= array_size; ++ix) ia[ix] = ix; Exercise 编写必要的代码将一个数组赋给另一个数组,然后把这段 4.7: 代码改用 vector 实现。考虑如何将一个 vector 赋给另 一个 vector。 Exercise 编写程序判断两个数组是否相等,然后编写一段类似的程 4.8: 序比较两个 vector。 Exercise 编写程序定义一个有 10 个 int 型元素的数组,并以其 4.9: 在数组中的位置作为各元素的初值。 4.2. 指针的引入 vector 的遍历可使用下标或迭代器实现,同理,也可用下标或指针来遍历 数组。指针是指向某种类型对象的复合数据类型,是用于数组的迭代器:指向数 162 组中的一个元素。在指向数组元素的指针上使用解引用操作符 *(dereference operator)和自增操作符 ++(increment operator),与在迭代器上的用法类 似。对指针进行解引用操作,可获得该指针所指对象的值。而当指针做自增操作 时,则移动指针使其指向数组中的下一个元素。在使用指针编写程序之前,我们 需进一步了解一下指针。 4.2.1. 什么是指针 对初学者来说,指针通常比较难理解。而由指针错误引起的调试问题连富有 经验的程序员都感到头疼。然而,指针是大多数 C 程序的重要部分,而且在许多 C++ 程序中仍然受到重用。 指针的概念很简单:指针用于指向对象。与迭代器一样,指针提供对其所指 对象的间接访问,只是指针结构更通用一些。与迭代器不同的是,指针用于指向 单个对象,而迭代器只能用于访问容器内的元素。 具体来说,指针保存的是另一个对象的地址: string s("hello world"); string *sp = &s; // sp holds the address of s 第二条语句定义了一个指向 string 类型的指针 sp,并初始化 sp 使其指 向 string 类型的对象 s。*sp 中的 * 操作符表明 sp 是一个指针变量,&s 中 的 & 符号是取地址操作符,当此操作符用于一个对象上时,返回的是该对象的 存储地址。取地址操作符只能用于左值(第 2.3.1 节),因为只有当变量用作 左值时,才能取其地址。同样地,由于用于 vector 类型、string 类型或内置 数组的下标操作和解引用操作生成左值,因此可对这两种操作的结果做取地址操 作,这样即可获取某一特定对象的存储地址。 建议:尽量避免使用指针和数组 指针和数组容易产生不可预料的错误。其中一部分是概念上的问题:指 针用于低级操作,容易产生与繁琐细节相关的(bookkeeping)错误。其 他错误则源于使用指针的语法规则,特别是声明指针的语法。 许多有用的程序都可不使用数组或指针实现,现代 C++程序采用 vector 类型和迭代器取代一般的数组、采用 string 类型取代 C 风格字符串。 163 4.2.2. 指针的定义和初始化 每个指针都有一个与之关联的数据类型,该数据类型决定了指针所指向的对 象的类型。例如,一个 int 型指针只能指向 int 型对象。 指针变量的定义 C++ 语言使用 * 符号把一个标识符声明为指针: vector *pvec; // pvec can point to a vector int *ip1, *ip2; // ip1 and ip2 can point to an int string *pstring; // pstring can point to a string double *dp; // dp can point to a double 理解指针声明语句时,请从右向左阅读。 从右向左阅读 pstring 变量的定义,可以看到 string *pstring; 语句把 pstring 定义为一个指向 string 类型对象的指针变量。类似地, 语句 int *ip1, *ip2; // ip1 and ip2 can point to an int 把 ip1 和 ip2 都定义为指向 int 型对象的指针。 在声明语句中,符号 * 可用在指定类型的对象列表的任何位置: double double dp, *dp2; // dp2 is a ponter, dp is an object: both type 该语句定义了一个 double 类型的 dp 对象以及一个指向 double 类型对 象的指针 dp2。 164 另一种声明指针的风格 在定义指针变量时,可用空格将符号 * 与其后的标识符分隔开来。下面的 写法是合法的: string* ps; // legal but can be misleading 也就是说,该语句把 ps 定义为一个指向 string 类型对象的指针。 这种指针声明风格容易引起这样的误解:把 string* 理解为一种数据类型, 认为在同一声明语句中定义的其他变量也是指向 string 类型对象的指针。然 而,语句 string* ps1, ps2; // ps1 is a pointer to string, ps2 is a string 实际上只把 ps1 定义为指针,而 ps2 并非指针,只是一个普通的 string 对象而已。如果需要在一个声明语句中定义两个指针,必须在每个变量标识符前 再加符号 * 声明: string* ps1, *ps2; // both ps1 and ps2 are pointers to string 连续声明多个指针易导致混淆 连续声明同一类型的多个指针有两种通用的声明风格。其中一种风格是一个 声明语句只声明一个变量,此时,符号 * 紧挨着类型名放置,强调这个声明语 句定义的是一个指针: string* ps1; string* ps2; 另一种风格则允许在一条声明语句中声明多个指针,声明时把符号 * 靠近 标识符放置。这种风格强调对象是一个指针: string *ps1, *ps2; 关于指针的声明,不能说哪种声明风格是唯一正确的方式, 重要的是选择一种风格并持续使用。 在本书中,我们将采用第二种声明风格:将符号 * 紧贴着指针变量名放置。 165 指针可能的取值 一个有效的指针必然是以下三种状态之一:保存一个特定对象的地址;指向 某个对象后面的另一对象;或者是 0 值。若指针保存 0 值,表明它不指向任何对 象。未初始化的指针是无效的,直到给该指针赋值后,才可使用它。下列定义和 赋值都是合法的: ival int ival = 1024; int *pi = 0; // pi initialized to address no object int *pi2 = & ival; // pi2 initialized to address of ival int *pi3; // ok, but dangerous, pi3 is uninitialized pi = pi2; // pi and pi2 address the same object, e.g. pi2 = 0; // pi2 now addresses no object 避免使用未初始化的指针 很多运行时错误都源于使用了未初始化的指针。 就像使用其他没有初始化的变量一样,使用未初始化的指针时的行为 C++标 准中并没有定义使用未初始化的指针,它几乎总会导致运行时崩溃。然而,导致 崩溃的这一原因很难发现。 对大多数的编译器来说,如果使用未初始化的指针,会将指针中存放的不确 定值视为地址,然后操纵该内存地址中存放的位内容。使用未初始化的指针相当 于操纵这个不确定地址中存储的基础数据。因此,在对未初始化的指针进行解引 用时,通常会导致程序崩溃。 C++ 语言无法检测指针是否未被初始化,也无法区分有效地址和由指针分配 到的存储空间中存放的二进制位形成的地址。建议程序员在使用之前初始化所有 的变量,尤其是指针。 如果可能的话,除非所指向的对象已经存在,否则不要 先定义指针,这样可避免定义一个未初始化的指针。 166 如果必须分开定义指针和其所指向的对象,则将指针初始化为 0。因为编译 器可检测出 0 值的指针,程序可判断该指针并未指向一个对象。 指针初始化和赋值操作的约束 对指针进行初始化或赋值只能使用以下四种类型的值: 1. 0 值常量表达式(第 2.7 节),例如,在编译时可获得 0 值的整型 const 对象或字面值常量 0。 2. 类型匹配的对象的地址。 3. 另一对象末的下一地址。 4. 同类型的另一个有效指针。 把 int 型变量赋给指针是非法的,尽管此 int 型变量的值可能为 0。但允 许把数值 0 或在编译时可获得 0 值的 const 量赋给指针: of 0 0 int ival; int zero = 0; const int c_ival = 0; int *pi = ival; // error: pi initialized from int value of ival pi = zero; // error: pi assigned int value of zero pi = c_ival; // ok: c_ival is a const with compile-time value pi = 0; // ok: directly initialize to literal constant 除了使用数值 0 或在编译时值为 0 的 const 量外,还可以使用 C++ 语言 从 C 语言中继承下来的预处理器变量 NULL(第 2.9.2 节),该变量在 cstdlib 头文件中定义,其值为 0。如果在代码中使用了这个预处理器变量,则编译时会 自动被数值 0 替换。因此,把指针初始化为 NULL 等效于初始化为 0 值: // cstdlib #defines NULL to 0 int *pi = NULL; // ok: equivalent to int *pi = 0; 正如其他的预处理器变量一样(第 2.9.2 节),不可以使用 NULL 这个标 识符给自定义的变量命名。 预处理器变量不是在 std 命名空间中定义的,因此其名字应为 NULL,而非 std::NULL。 167 除了将在第 4.2.5 节和第 15.3 节介绍的两种例外情况之外,指针只能初 始化或赋值为同类型的变量地址或另一指针: double dval; double *pd = &dval; double *pd2 = pd; // ok: initializer is address of a double // ok: initializer is a pointer to double int *pi = pd; // error: types of pi and pd differ pi = &dval; // error: attempt to assign address of a double to int * 由于指针的类型用于确定指针所指对象的类型,因此初始化或赋值时必须保 证类型匹配。指针用于间接访问对象,并基于指针的类型提供可执行的操作,例 如,int 型指针只能把其指向的对象当作 int 型数据来处理,如果该指针确实 指向了其他类型(如 double 类型)的对象,则在指针上执行的任何操作都有可 能出错。 void* 指针 C++ 提供了一种特殊的指针类型 void*,它可以保存任何类型对象的地址: double obj = 3.14; double *pd = &obj; // ok: void* can hold the address value of any data pointer type void *pv = &obj; // obj can be an object of any type pv = pd; // pd can be a pointer to any type void* 表明该指针与一地址值相关,但不清楚存储在此地址上的对象的类型。 void* 指针只支持几种有限的操作:与另一个指针进行比较;向函数传递 void* 指针或从函数返回 void* 指针;给另一个 void* 指针赋值。不允许使用 void* 指针操纵它所指向的对象。我们将在第 5.12.4 节讨论如何重新获取存储 在 void* 指针中的地址。 168 Exercises Section 4.2.2 Exercise 下面提供了两种指针声明的形式,解释宁愿使用第一种形 4.10: 式的原因: int *ip; // good practice int* ip; // legal but misleading Exercise 4.11: 解释下列声明语句,并指出哪些是非法的,为什么? (a) int* ip; (b) string s, *sp = 0; (c) int i; double* dp = &i; (d) int* ip, ip2; (e) const int i = 0, *p = i; (f) string *p = NULL; Exercise 已知一指针 p,你可以确定该指针是否指向一个有效的对 4.12: 象吗?如果可以,如何确定?如果不可以,请说明原因。 Exercise 下列代码中,为什么第一个指针的初始化是合法的,而第 4.13: 二个则不合法? int i = 42; void *p = &i; long *lp = &i; 4.2.3. 指针操作 指针提供间接操纵其所指对象的功能。与对迭代器进行解引用操作(第 3.4 节)一样,对指针进行解引用可访问它所指的对象,* 操作符(解引用操作符) 将获取指针所指的对象: string s("hello world"); string *sp = &s; // sp holds the address of s cout <<*sp; // prints hello world 对 sp 进行解引用将获得 s 的值,然后用输出操作符输出该值,于是最后 一条语句输出了 s 的内容 hello world。 169 生成左值的解引用操作 解引用操作符返回指定对象的左值,利用这个功能可修改指针所指对象的 值: *sp = "goodbye"; // contents of s now changed 因为 sp 指向 s,所以给 *sp 赋值也就修改了 s 的值。 也可以修改指针 sp 本身的值,使 sp 指向另外一个新对象: string s2 = "some value"; sp = &s2; // sp now points to s2 给指针直接赋值即可修改指针的值——不需要对指针进行解引用。 关键概念:给指针赋值或通过指针进行赋值 对于初学指针者,给指针赋值和通过指针进行赋值这两种操作的差别确 实让人费解。谨记区分的重要方法是:如果对左操作数进行解引用,则 修改的是指针所指对象的值;如果没有使用解引用操作,则修改的是指 针本身的值。如图所示,帮助理解下列例子: 170 指针和引用的比较 虽然使用引用(reference)和指针都可间接访问另一个值,但它们之间有 两个重要区别。第一个区别在于引用总是指向某个对象:定义引用时没有初始化 是错误的。第二个重要区别则是赋值行为的差异:给引用赋值修改的是该引用所 关联的对象的值,而并不是使引用与另一个对象关联。引用一经初始化,就始终 指向同一个特定对象(这就是为什么引用必须在定义时初始化的原因)。 考虑以下两个程序段。第一个程序段将一个指针赋给另一指针: int ival = 1024, ival2 = 2048; int *pi = &ival, *pi2 = &ival2; pi = pi2; // pi now points to ival2 赋值结束后,pi 所指向的 ival 对象值保持不变,赋值操作修改了 pi 指 针的值,使其指向另一个不同的对象。现在考虑另一段相似的程序,使用两个引 用赋值: int &ri = ival, &ri2 = ival2; ri = ri2; // assigns ival2 to ival 这个赋值操作修改了 ri 引用的值 ival 对象,而并非引用本身。赋值后, 这两个引用还是分别指向原来关联的对象,此时这两个对象的值相等。 指向指针的指针 指针本身也是可用指针指向的内存对象。指针占用内存空间存放其值,因此 指针的存储地址可存放在指针中。下面程序段: int ival = 1024; int *pi = &ival; // pi points to an int int **ppi = π // ppi points to a pointer to int 定义了指向指针的指针。C++ 使用 ** 操作符指派一个指针指向另一指针。 这些对象可表示为: 对 ppi 进行解引用照常获得 ppi 所指的对象,在本例中,所获得的对象是 指向 int 型变量的指针 pi: int *pi2 = *ppi; // ppi points to a pointer 171 为了真正地访问到 ival 对象,必须对 ppi 进行两次解引用: cout << "The value of ival\n" << "direct value: " << ival << "\n" << "indirect value: " << *pi << "\n" << "doubly indirect value: " << **ppi << endl; 这段程序用三种不同的方式输出 ival 的值。首先,采用直接引用变量的方 式输出;然后使用指向 int 型对象的指针 pi 输出;最后,通过对 ppi 进行两 次解引用获得 ival 的特定值。 Exercises Section 4.2.3 Exercise 编写代码修改指针的值;然后再编写代码修改指针所指对 4.14: 象的值。 Exercise 解释指针和引用的主要区别。 4.15: Exercise 4.16: 下列程序段实现什么功能? int i = 42, j = 1024; int *p1 = &i, *p2 = &j; *p2 = *p1 * *p2; *p1 *= *p1; 4.2.4. 使用指针访问数组元素 C++ 语言中,指针和数组密切相关。特别是在表达式中使用数组名时,该名 字会自动转换为指向数组第一个元素的指针: int ia[] = {0,2,4,6,8}; int *ip = ia; // ip points to ia[0] 如果希望使指针指向数组中的另一个元素,则可使用下标操作符给某个元素 定位,然后用取地址操作符 & 获取该元素的存储地址: ip = &ia[4]; // ip points to last element in ia 172 指针的算术操作 与其使用下标操作,倒不如通过指针的算术操作来获取指定内容的存储地 址。指针的算术操作和迭代器的算术操作(第 3.4.1 节)以相同的方式实现(也 具有相同的约束)。使用指针的算术操作在指向数组某个元素的指针上加上(或 减去)一个整型数值,就可以计算出指向数组另一元素的指针值: in ia ip = ia; // ok: ip points to ia[0] int *ip2 = ip + 4; // ok: ip2 points to ia[4], the last element 在指针 ip 上加 4 得到一个新的指针,指向数组中 ip 当前指向的元素后 的第 4 个元素。 通常,在指针上加上(或减去)一个整型数值 n 等效于获得一个新指针, 该新指针指向指针原来指向的元素之后(或之前)的第 n 个元素。 指针的算术操作只有在原指针和计算出来的新指针都指向同一 个数组的元素,或指向该数组存储空间的下一单元时才是合法 的。如果指针指向一对象,我们还可以在指针上加 1 从而获取 指向相邻的下一个对象的指针。 假设数组 ia 只有 4 个元素,则在 ia 上加 10 是错误的: // error: ia has only 4 elements, ia + 10 is an invalid address int *ip3 = ia + 10; 只要两个指针指向同一数组或有一个指向该数组末端的下一单元,C++ 还支 持对这两个指针做减法操作: ptrdiff_t n = ip2 - ip; // ok: distance between the pointers 结果是 4,这两个指针所指向的元素间隔为 4 个对象。两个指针减法操作 的结果是标准库类型(library type)ptrdiff_t 的数据。与 size_t 类型一样, ptrdiff_t 也是一种与机器相关的类型,在 cstddef 头文件中定义。size_t 是 unsigned 类型,而 ptrdiff_t 则是 signed 整型。 这两种类型的差别体现了它们各自的用途:size_t 类型用于指明数组长度, 它必须是一个正数;ptrdiff_t 类型则应保证足以存放同一数组中两个指针之间 的差距,它有可能是负数。例如,ip 减去 ip2,结果为 -4。 173 允许在指针上加减 0,使指针保持不变。更有趣的是,如果一指针具有 0 值 (空指针),则在该指针上加 0 仍然是合法的,结果得到另一个值为 0 的指针。 也可以对两个空指针做减法操作,得到的结果仍是 0。 解引用和指针算术操作之间的相互作用 在指针上加一个整型数值,其结果仍然是指针。允许在这个结果上直接进行 解引用操作,而不必先把它赋给一个新指针: int last = *(ia + 4); // ok: initializes last to 8, the value of ia[4] 这个表达式计算出 ia 所指向元素后面的第 4 个元素的地址,然后对该地址进 行解引用操作,等价于 ia[4]。 加法操作两边用圆括号括起来是必要的。如果写为: last = *ia + 4; // ok: last = 4, equivalent to ia[0]+4 意味着对 ia 进行解引用,获得 ia 所指元素的值 ia[0],然后加 4。 由于加法操作和解引用操作的优先级不同,上述表达式中的圆括号是必要 的。我们将在第 5.10.1 节讨论操作符的优先级。简单地说,优先级决定了有多 个操作符的表达式如何对操作数分组。解引用操作符的优先级比加法操作符高。 与低优先级的操作符相比,优先级高的操作符的操作数先被组合起来操作。 如果没有圆括号,解引用操作符的操作数是 ia,该表达式先对 ia 解引用,获 得 ia 数组中的第一个元素,并将该值与 4 相加。 如果表达式加上圆括号,则不管一般的优先级规则,将 (ia + 4) 作为单个 操作数,这是 ia 所指向的元素后面第 4 个元素的地址,然后对这个新地址进行 解引用。 下标和指针 我们已经看到,在表达式中使用数组名时,实际上使用的是指向数组第一个 元素的指针。这种用法涉及很多方面,当它们出现时我们会逐一指出来。 174 其中一个重要的应用是使用下标访问数组时,实际上是使用下标访问指针: int ia[] = {0,2,4,6,8}; int i = ia[0]; // ia points to the first element in ia ia[0] 是一个使用数组名的表达式。在使用下标访问数组时,实际上是对指向数 组元素的指针做下标操作。只要指针指向数组元素,就可以对它进行下标操作: int *p = &ia[2]; // ok: p points to the element indexed by 2 int j = p[1]; // ok: p[1] equivalent to *(p + 1), // p[1] is the same element as ia[3] int k = p[-2]; // ok: p[-2] is the same element as ia[0] 计算数组的超出末端指针 vector 类型提供的 end 操作将返回指向超出 vector 末端位置的一个迭 代器。这个迭代器常用作哨兵,来控制处理 vector 中元素的循环。类似地,可 以计算数组的超出末端指针的值: const size_t arr_size = 5; int arr[arr_size] = {1,2,3,4,5}; int *p = arr; // ok: p points to arr[0] int *p2 = p + arr_size; // ok: p2 points one past the end of arr // use caution -- do not dereference! 本例中,p 指向数组 arr 的第一个元素,在指针 p 上加数组长度即可计算 出数组 arr 的超出末端指针。p 加 5 即得 p 所指向的元素后面的第五个 int 元素的地址——换句话说,p + 5 指向数组的超出末端的位置。 C++ 允许计算数组或对象的超出末端的地址,但不允许对此地 址进行解引用操作。而计算数组超出末端位置之后或数组首地 址之前的地址都是不合法的。 计算并存储在 p2 中的地址,与在 vector 上做 end 操作所返回的迭代器具 有相同的功能。由 end 返回的迭代器标志了该 vector 对象的“超出末端位 置”,不能进行解引用运算,但是可将它与别的迭代器比较,从而判断是否已经 处理完 vector 中所有的元素。同理,p2 也只能用来与其他指针比较,或者用 175 做指针算术操作表达式的操作数。对 p2 进行解引用将得到无效值。对大多数的 编译器来说,会把对 p2 进行解引用的结果(恰好存储在 arr 数组的最后一个 元素后面的内存中的二进制位)视为一个 int 型数据。 输出数组元素 用指针编写以下程序: last const size_t arr_sz = 5; int int_arr[arr_sz] = { 0, 1, 2, 3, 4 }; // pbegin points to first element, pend points just after the for (int *pbegin = int_arr, *pend = int_arr + arr_sz; pbegin != pend; ++pbegin) cout << *pbegin << ' '; // print the current element 这段程序使用了一个我们以前没有用过的 for 循环性质:只要定义的多个 变量具有相同的类型,就可以在 for 循环的初始化语句(第 1.4.2 节)中同时 定义它们。本例在初始化语句中定义了两个 int 型指针 pbegin 和 pend。 C++ 允许使用指针遍历数组。和其他内置类型一样,数组也没有成员函数。 因此,数组不提供 begin 和 end 操作,程序员只能自己给指针定位,使之分别 标志数组的起始位置和超出末端位置。可在初始化中实现这两个指针的定位:初 始化指针 pbegin 指向 int_arr 数组的第一个元素,而指针 pend 则指向该数 组的超出末端的位置: 指针 pend 是标志 for 循环结束的哨兵。for 循环的每次迭代都会使 pbegin 递增 1 以指向数组的下一个元素。第一次执行 for 循环时,pbegin 指 向数组中的第一个元素;第二次循环,指向第二个元素;这样依次类推。当处理 完数组的最后一个元素后,pbegin 再加 1 则与 pend 值相等,表示整个数组已 遍历完毕。 指针是数组的迭代器 聪明的读者可能已经注意到这段程序与第 3.4 节的一段程序非常相像,该 程序使用下面的循环遍历并输出一个 string 类型的 vector 的内容: 176 // equivalent loop using iterators to reset all the elements in ivec to 0 for (vector::iterator iter = ivec.begin(); iter != ivec.end(); ++iter) *iter = 0; // set element to which iter refers to 0 这段程序使用迭代器的方式就像上个程序使用指针实现输出数组内容一样。 指针和迭代器的这个相似之处并不是巧合。实际上,内置数组类型具有标准库容 器的许多性质,与数组联合使用的指针本身就是迭代器。在第二部分中,我们还 会详细介绍容器和迭代器类型。 Exercises Section 4.2.4 Exercise 已知 p1 和 p2 指向同一个数组中的元素,下面语句实现 4.17: 什么功能? p1 += p2 - p1; 当 p1 和 p2 具有什么值时这个语句是非法的? Exercise 编写程序,使用指针把一个 int 型数组的所有元素设置 4.18: 为 0。 4.2.5. 指针和 const 限定符 第 2.4 节介绍了指针和 const 限定符之间的两种交互类型:指向 const 对象的指针和 const 指针。我们在本节中详细讨论这两类指针。 指向 const 对象的指针 到目前为止,我们使用指针来修改其所指对象的值。但是如果指针指向 const 对象,则不允许用指针来改变其所指的 const 值。为了保证这个特性, C++ 语言强制要求指向 const 对象的指针也必须具有 const 特性: const double *cptr; // cptr may point to a double that is const 这里的 cptr 是一个指向 double 类型 const 对象的指针,const 限定了 cptr 指针所指向的对象类型,而并非 cptr 本身。也就是说,cptr 本身并不是 177 const。在定义时不需要对它进行初始化,如果需要的话,允许给 cptr 重新赋 值,使其指向另一个 const 对象。但不能通过 cptr 修改其所指对象的值: *cptr = 42; // error: *cptr might be const 把一个 const 对象的地址赋给一个普通的、非 const 对象的指针也会导致 编译时的错误: const double pi = 3.14; double *ptr = π // error: ptr is a plain pointer const double *cptr = π // ok: cptr is a pointer to const 不能使用 void* 指针(第 4.2.2 节)保存 const 对象的地址,而必须使 用 const void* 类型的指针保存 const 对象的地址: const int universe = 42; const void *cpv = &universe; // ok: cpv is const void *pv = &universe; // error: universe is const 允许把非 const 对象的地址赋给指向 const 对象的指针,例如: double dval = 3.14; // dval is a double; its value can be changed cptr = &dval; // ok: but can't change dval through cptr 尽管 dval 不是 const 对象,但任何企图通过指针 cptr 修改其值的行为 都会导致编译时的错误。cptr 一经定义,就不允许修改其所指对象的值。如果 该指针恰好指向非 const 对象时,同样必须遵循这个规则。 不能使用指向 const 对象的指针修改基础对象,然而如果该指 针指向的是一个非 const 对象,可用其他方法修改其所指的对 象。 事实是,可以修改 const 指针所指向的值,这一点常常容易引起误会。考 虑: dval = 3.14159; *cptr = 3.14159; double *ptr = &dval; *ptr = 2.72; cout << *cptr; // dval is not const // error: cptr is a pointer to const // ok: ptr points at non-const double // ok: ptr is plain pointer // ok: prints 2.72 178 在此例题中,指向 const 的指针 cptr 实际上指向了一个非 const 对象。 尽管它所指的对象并非 const,但仍不能使用 cptr 修改该对象的值。本质上来 说,由于没有方法分辩 cptr 所指的对象是否为 const,系统会把它所指的所有 对象都视为 const。 如果指向 const 的指针所指的对象并非 const,则可直接给该对象赋值或 间接地利用普通的非 const 指针修改其值:毕竟这个值不是 const。重要的是 要记住:不能保证指向 const 的指针所指对象的值一定不可修改。 如果把指向 const 的指针理解为“自以为指向 const 的 指针”,这可能会对理解有所帮助。 在实际的程序中,指向 const 的指针常用作函数的形参。将形参定义为指 向 const 的指针,以此确保传递给函数的实际对象在函数中不因为形参而被修 改。 const 指针 除指向 const 对象的指针外,C++ 语言还提供了 const 指针——本身的值 不能修改: int errNumb = 0; int *const curErr = &errNumb; // curErr is a constant pointer 我们可以从右向左把上述定义语句读作“curErr 是指向 int 型对象的 const 指针”。与其他 const 量一样,const 指针的值不能修改,这就意味着 不能使 curErr 指向其他对象。任何企图给 const 指针赋值的行为(即使给 curErr 赋回同样的值)都会导致编译时的错误: curErr = curErr; // error: curErr is const 与任何 const 量一样,const 指针也必须在定义时初始化。 指针本身是 const 的事实并没有说明是否能使用该指针修改它所指向对象 的值。指针所指对象的值能否修改完全取决于该对象的类型。例如,curErr 指 向一个普通的非常量 int 型对象 errNumb,则可使用 curErr 修改该对象的值: is bound if (*curErr) { errorHandler(); *curErr = 0; // ok: reset value of the object to which curErr } 179 指向 const 对象的 const 指针 还可以如下定义指向 const 对象的 const 指针: const double pi = 3.14159; // pi_ptr is const and points to a const object const double *const pi_ptr = π 本例中,既不能修改 pi_ptr 所指向对象的值,也不允许修改该指针的指向 (即 pi_ptr 中存放的地址值)。可从右向左阅读上述声明语句:“pi_ptr 首 先是一个 const 指针,指向 double 类型的 const 对象”。 指针和 typedef 在 typedef(第 2.6 节)中使用指针往往会带来意外的结果。下面是一个 几乎所有人刚开始时都会答错的问题。假设给出以下语句: typedef string *pstring; const pstring cstr; 请问 cstr 变量是什么类型?简单的回答是 const pstring 类型的指针。 进一步问:const pstring 指针所表示的真实类型是什么?很多人都认为真正的 类型是: cstr const string *cstr; // wrong interpretation of const pstring 也就是说,const pstring 是一种指针,指向 string 类型的 const 对象, 但这是错误的。 错误的原因在于将 typedef 当做文本扩展了。声明 const pstring 时, const 修饰的是 pstring 的类型,这是一个指针。因此,该声明语句应该是把 cstr 定义为指向 string 类型对象的 const 指针,这个定义等价于: // cstr is a const pointer to string string *const cstr; // equivalent to const pstring cstr 建议:理解复杂的 const 类型的声明 阅读 const 声明语句产生的部分问题,源于 const 限定符既可以放 在类型前也可以放在类型后: 180 const string const s1; // s1 and s2 have same type, const string s2; // they're both strings that are 用 typedef 写 const 类型定义时,const 限定符加在类型名前面容 易引起对所定义的真正类型的误解: [View full width] string s; typedef string *pstring; const pstring cstr1 = &s; // written this way the type is obscured pstring const cstr2 = &s; // all three decreations are the same type string *const cstr3 = &s; // they're all const pointers to string 把 const 放在类型 pstring 之后,然后从右向左阅读该声明语句就 会非常清楚地知道 cstr2 是 const pstring 类型,即指向 string 对 象的 const 指针。 不幸的是,大多数人在阅读 C++ 程序时都习惯看到 const 放在类型 前面。于是为了遵照惯例,只好建议编程时把 const 放在类型前面。 但是,把声明语句重写为置 const 于类型之后更便于理解。 181 Exercises Section 4.3 Exercise 4.19: 解释下列 5 个定义的含义,指出其中哪些定义是非法的: (a) int i; (b) const int ic; (c) const int *pic; (d) int *const cpi; (e) const int *const cpic; Exercise 4.20: 下列哪些初始化是合法的?为什么? (a) int i = -1; (b) const int ic = i; (c) const int *pic = ⁣ (d) int *const cpi = ⁣ (e) const int *const cpic = ⁣ Exercise 4.21: 根据上述定义,下列哪些赋值运算是合法的?为什么? (a) i = ic; (b) pic = ⁣ (c) cpi = pic; (d) pic = cpic; (e) cpic = ⁣ (f) ic = *cpic; 4.3. C 风格字符串 尽管 C++ 支持 C 风格字符串,但不应该在 C++ 程序中使用这 个类型。C 风格字符串常常带来许多错误,是导致大量安全问 题的根源。 在第 2.2 节中我们第一次使用了字符串字面值,并了解字符串字面值的类 型是字符常量的数组,现在可以更明确地认识到:字符串字面值的类型就是 182 const char 类型的数组。C++ 从 C 语言继承下来的一种通用结构是 C 风格字 符串,而字符串字面值就是该类型的实例。实际上,C 风格字符串既不能确切地 归结为 C 语言的类型,也不能归结为 C++ 语言的类型,而是以空字符 null 结 束的字符数组: char ca1[] = {'C', '+', '+'}; // no null, not C-style string char ca2[] = {'C', '+', '+', '\0'}; // explicit null char ca3[] = "C++"; // null terminator added automatically const char *cp = "C++"; // null terminator added automatically char *cp1 = ca1; // points to first element of a array, but not C-style string char *cp2 = ca2; // points to first element of a null-terminated char array ca1 和 cp1 都不是 C 风格字符串:ca1 是一个不带结束符 null 的字符数 组,而指针 cp1 指向 ca1,因此,它指向的并不是以 null 结束的数组。其他 的声明则都是 C 风格字符串,数组的名字即是指向该数组第一个元素的指针。 于是,ca2 和 ca3 分别是指向各自数组第一个元素的指针。 C 风格字符串的使用 C++ 语言通过(const)char*类型的指针来操纵 C 风格字符串。一般来说, 我们使用指针的算术操作来遍历 C 风格字符串,每次对指针进行测试并递增 1, 直到到达结束符 null 为止: const char *cp = "some value"; while (*cp) { // do something to *cp ++cp; } while 语句的循环条件是对 const char* 类型的指针 cp 进行解引用,并 判断 cp 当前指向的字符是 true 值还是 false 值。真值表明这是除 null 外 的任意字符,则继续循环直到 cp 指向结束字符数组的 null 时,循环结束。 while 循环体做完必要的处理后,cp 加 1,向下移动指针指向数组中的下一个 字符。 如果 cp 所指向的字符数组没有 null 结束符,则此循环将会 失败。这时,循环会从 cp 指向的位置开始读数,直到遇到内 存中某处 null 结束符为止。 183 C 风格字符串的标准库函数 表 4-1 列出了 C 语言标准库提供的一系列处理 C 风格字符串的库函数。要 使用这些标准库函数,必须包含相应的 C 头文件: cstring 是 string.h 头文件的 C++ 版本,而 string.h 则是 C 语言提供 的标准库。 这些标准库函数不会检查其字符串参数。 表 4.1. 操纵 C 风格字符串的标准库函数 strlen(s) 返回 s 的长度,不包括字符串结束符 null strcmp(s1, s2) 比较两个字符串 s1 和 s2 是否相同。若 s1 与 s2 相等,返 回 0;若 s1 大于 s2,返回正数;若 s1 小于 s2,则返回负 数 strcat(s1, s2) 将字符串 s2 连接到 s1 后,并返回 s1 strcpy(s1, s2) 将 s2 复制给 s1,并返回 s1 strncat(s1, s2,n) 将 s2 的前 n 个字符连接到 s1 后面,并返回 s1 strncpy(s1, s2, n) 将 s2 的前 n 个字符复制给 s1,并返回 s1 #include 传递给这些标准库函数例程的指针必须具有非零值,并且指向以 null 结束 的字符数组中的第一个元素。其中一些标准库函数会修改传递给它的字符串,这 些函数将假定它们所修改的字符串具有足够大的空间接收本函数新生成的字符, 程序员必须确保目标字符串必须足够大。 184 C++ 语言提供普通的关系操作符实现标准库类型 string 的对象的比较。这 些操作符也可用于比较指向 C 风格字符串的指针,但效果却很不相同:实际上, 此时比较的是指针上存放的地址值,而并非它们所指向的字符串: if (cp1 < cp2) // compares addresses, not the values pointed to 如果 cp1 和 cp2 指向同一数组中的元素(或该数组的溢出位置),上述表 达式等效于比较在 cp1 和 cp2 中存放的地址;如果这两个指针指向不同的数 组,则该表达式实现的比较没有定义。 字符串的比较和比较结果的解释都须使用标准库函数 strcmp 进行: const char *cp1 = "A string example"; const char *cp2 = "A different string"; int i = strcmp(cp1, cp2); // i is positive i = strcmp(cp2, cp1); // i is negative i = strcmp(cp1, cp1); // i is zero 标准库函数 strcmp 有 3 种可能的返回值:若两个字符串相等,则返回 0 值;若第一个字符串大于第二个字符串,则返回正数,否则返回负数。 永远不要忘记字符串结束符 null 在使用处理 C 风格字符串的标准库函数时,牢记字符串必须以结束符 null 结束: char ca[] = {'C', '+', '+'}; // not null-terminated cout << strlen(ca) << endl; // disaster: ca isn't null-terminated 在这个例题中,ca 是一个没有 null 结束符的字符数组,则计算的结果不 可预料。标准库函数 strlen 总是假定其参数字符串以 null 字符结束,当调用 该标准库函数时,系统将会从实参 ca 指向的内存空间开始一直搜索结束符,直 到恰好遇到 null 为止。strlen 返回这一段内存空间中总共有多少个字符,无 论如何这个数值不可能是正确的。 调用者必须确保目标字符串具有足够的大小 传递给标准库函数 strcat 和 strcpy 的第一个实参数组必须具有足够大 的空间存放新生成的字符串。以下代码虽然演示了一种通常的用法,但是却有潜 在的严重错误: 185 // Dangerous: What happens if we miscalculate the size of largeStr? char largeStr[16 + 18 + 2]; // will hold cp1 a space and cp2 strcpy(largeStr, cp1); // copies cp1 into largeStr strcat(largeStr, " "); // adds a space at end of largeStr strcat(largeStr, cp2); // concatenates cp2 to largeStr // prints A string example A different string cout << largeStr << endl; 问题在于我们经常会算错 largeStr 需要的大小。同样地,如果 cp1 或 cp2 所指向的字符串大小发生了变化,largeStr 所需要的大小则会计算错误。不幸 的是,类似于上述代码的程序应用非常广泛,这类程序往往容易出错,并导致严 重的安全漏洞。 使用 strn 函数处理 C 风格字符串 如果必须使用 C 风格字符串,则使用标准库函数 strncat 和 strncpy 比 strcat 和 strcpy 函数更安全: a null char largeStr[16 + 18 + 2]; // to hold cp1 a space and cp2 strncpy(largeStr, cp1, 17); // size to copy includes the null strncat(largeStr, " ", 2); // pedantic, but a good habit strncat(largeStr, cp2, 19); // adds at most 18 characters, plus 使用标准库函数 strncat 和 strncpy 的诀窍在于可以适当地控制复制字符 的个数。特别是在复制和串连字符串时,一定要时刻记住算上结束符 null。在 定义字符串时要切记预留存放 null 字符的空间,因为每次调用标准库函数后都 必须以此结束字符串 largeStr。让我们详细分析一下这些标准库函数的调用: • 调用 strncpy 时,要求复制 17 个字符:字符串 cp1 中所有字符,加上 结束符 null。留下存储结束符 null 的空间是必要的,这样 largeStr 才 可以正确地结束。调用 strncpy 后,字符串 largeStr 的长度 strlen 值 是 16。记住:标准库函数 strlen 用于计算 C 风格字符串中的字符个数, 不包括 null 结束符。 • 调用 strncat 时,要求复制 2 个字符:一个空格和结束该字符串字面值 的 null。调用结束后,字符串 largeStr 的长度是 17,原来用于结束 largeStr 的 null 被新添加的空格覆盖了,然后在空格后面写入新的结 束符 null。 186 • 第二次调用 strncat 串接 cp2 时,要求复制 cp2 中所有字符,包括字 符串结束符 null。调用结束后,字符串 largeStr 的长度是 35:cp1 的 16 个字符和 cp2 的 18 个字符,再加上分隔这两个字符串的一个空格。 整个过程中,存储 largeStr 的数组大小始终保持为 36(包括结束符)。 只要可以正确计算出 size 实参的值,使用 strn 版本要比没有 size 参数 的简化版本更安全。但是,如果要向目标数组复制或串接比其 size 更多的字符, 数组溢出的现象仍然会发生。如果要复制或串接的字符串比实际要复制或串接的 size 大,我们会不经意地把新生成的字符串截短了。截短字符串比数组溢出要 安全,但这仍是错误的。 尽可能使用标准库类型 string 如果使用 C++ 标准库类型 string,则不存在上述问题: string largeStr = cp1; // initialize large Str as a copy of cp1 largeStr += " "; // add space at end of largeStr largeStr += cp2; // concatenate cp2 onto end of largeStr 此时,标准库负责处理所有的内存管理问题,我们不必再担心每一次修改字 符串时涉及到的大小问题。 对大部分的应用而言,使用标准库类型 string,除了增强安全 性外,效率也提高了,因此应该尽量避免使用 C 风格字符串。 187 Exercises Section 4.3 Exercise 4.22: 解释下列两个 while 循环的差别: const char *cp = "hello"; int cnt; while (cp) { ++cnt; ++cp; } while (*cp) { ++cnt; ++cp; } Exercise 4.23: 下列程序实现什么功能? 'o'}; const char ca[] = {'h', 'e', 'l', 'l', const char *cp = ca; while (*cp) { cout << *cp << endl; ++cp; } Exercise 解释 strcpy 和 strncpy 的差别在哪里,各自的优缺点 4.24: 是什么? Exercise 编写程序比较两个 string 类型的字符串,然后编写另一 4.25: 个程序比较两个 C 风格字符串的值。 Exercise 编写程序从标准输入设备读入一个 string 类型的字符 4.26: 串。考虑如何编程实现从标准输入设备读入一个 C 风格 字符串。 4.3.1. 创建动态数组 数组类型的变量有三个重要的限制:数组长度固定不变,在编译时必须知道 其长度,数组只在定义它的块语句内存在。实际的程序往往不能忍受这样的限制 ——它们需要在运行时动态地分配数组。虽然数组长度是固定的,但动态分配的 数组不必在编译时知道其长度,可以(通常也是)在运行时才确定数组长度。与 数组变量不同,动态分配的数组将一直存在,直到程序显式释放它为止。 每一个程序在执行时都占用一块可用的内存空间,用于存放动态分配的对 象,此内存空间称为程序的自由存储区或堆。C 语言程序使用一对标准库函数 188 malloc 和 free 在自由存储区中分配存储空间,而 C++ 语言则使用 new 和 delete 表达式实现相同的功能。 动态数组的定义 数组变量通过指定类型、数组名和维数来定义。而动态分配数组时,只需指 定类型和数组长度,不必为数组对象命名,new 表达式返回指向新分配数组的第 一个元素的指针: int *pia = new int[10]; // array of 10 uninitialized ints 此 new 表达式分配了一个含有 10 个 int 型元素的数组,并返回指向该数 组第一个元素的指针,此返回值初始化了指针 pia。 new 表达式需要指定指针类型以及在方括号中给出的数组维数,该维数可以 是任意的复杂表达式。创建数组后,new 将返回指向数组第一个元素的指针。在 自由存储区中创建的数组对象是没有名字的,程序员只能通过其地址间接地访问 堆中的对象。 初始化动态分配的数组 动态分配数组时,如果数组元素具有类类型,将使用该类的默认构造函数(第 2.3.4 节)实现初始化;如果数组元素是内置类型,则无初始化: string *psa = new string[10]; // array of 10 empty strings int *pia = new int[10]; // array of 10 uninitialized ints 这两个 new 表达式都分配了含有 10 个对象的数组。其中第一个数组是 string 类型,分配了保存对象的内存空间后,将调用 string 类型的默认构造函数依次 初始化数组中的每个元素。第二个数组则具有内置类型的元素,分配了存储 10 个 int 对象的内存空间,但这些元素没有初始化。 也可使用跟在数组长度后面的一对空圆括号,对数组元素做值初始化(第 3.3.1 节): int *pia2 = new int[10] (); // array of 10 uninitialized ints 圆括号要求编译器对数组做值初始化,在本例中即把数组元素都设置为 0。 对于动态分配的数组,其元素只能初始化为元素类型的默认值, 而不能像数组变量一样,用初始化列表为数组元素提供各不相 同的初值。 189 const 对象的动态数组 如果我们在自由存储区中创建的数组存储了内置类型的 const 对象,则必 须为这个数组提供初始化:因为数组元素都是 const 对象,无法赋值。实现这 个要求的唯一方法是对数组做值初始化: // error: uninitialized const array const int *pci_bad = new const int[100]; // ok: value-initialized const array const int *pci_ok = new const int[100](); C++ 允许定义类类型的 const 数组,但该类类型必须提供默认构造函数: // ok: array of 100 empty strings const string *pcs = new const string[100]; 在这里,将使用 string 类的默认构造函数初始化数组元素。 当然,已创建的常量元素不允许修改——因此这样的数组实际上用处不大。 允许动态分配空数组 之所以要动态分配数组,往往是由于编译时并不知道数组的长度。我们可以 编写如下代码 needed size_t n = get_size(); // get_size returns number of elements int* p = new int[n]; for (int* q = p; q != p + n; ++q) /* process the array */ ; 计算数组长度,然后创建和处理该数组。 有趣的是,如果 get_size 返回 0 则会怎么样?答案是:代码仍然正确执 行。C++ 虽然不允许定义长度为 0 的数组变量,但明确指出,调用 new 动态创 建长度为 0 的数组是合法的: array char arr[0]; // error: cannot define zero-length char *cp = new char[0]; // ok: but cp can't be dereferenced 用 new 动态创建长度为 0 的数组时,new 返回有效的非零指针。该指针与 new 返回的其他指针不同,不能进行解引用操作,因为它毕竟没有指向任何元素。 190 而允许的操作包括:比较运算,因此该指针能在循环中使用;在该指针上加(减) 0;或者减去本身,得 0 值。 在上述例题中,如果 get_size 返回 0,则仍然可以成功调用 new,但是 p 并没有指向任何对象,数组是空的。因为 n 为 0,所以 for 循环实际比较的是 p 和 q,而 q 是用 p 初始化的,两者具有相等的值,因此 for 循环条件不成 立,循环体一次都没有执行。 动态空间的释放 动态分配的内存最后必须进行释放,否则,内存最终将会逐渐耗尽。如果不 再需要使用动态创建的数组,程序员必须显式地将其占用的存储空间返还给程序 的自由存储区。C++ 语言为指针提供 delete [] 表达式释放指针所指向的数组 空间: delete [] pia; 该语句回收了 pia 所指向的数组,把相应的内存返还给自由存储区。在关 键字 delete 和指针之间的空方括号对是必不可少的:它告诉编译器该指针指向 的是自由存储区中的数组,而并非单个对象。 如果遗漏了空方括号对,这是一个编译器无法发现的错误,将 导致程序在运行时出错。 理论上,回收数组时缺少空方括号对,至少会导致运行时少释放了内存空间, 从而产生内存泄漏(memory leak)。对于某些系统和/或元素类型,有可能会带 来更严重的运行时错误。因此,在释放动态数组时千万别忘了方括号对。 191 C 风格字符串与 C++ 的标准库类型 string 的比较 以下两段程序反映了使用 C 风格字符串与 C++ 的标准库类型 string 的不同之处。使用 string 类型的版本更短、更容易理解,而且出错的 可能性更小: [View full width] // C-style character string implementation const char *pc = "a very long literal string"; const size_t len = strlen(pc +1); // space to allocate // performance test on string allocation and copy for (size_t ix = 0; ix != 1000000; ++ix) { char *pc2 = new char[len + 1]; // allocate the space strcpy(pc2, pc); // do the copy if (strcmp(pc2, pc)) // use the new string ; // do nothing delete [] pc2; // free the memory } // string implementation string str("a very long literal string"); // performance test on string allocation and copy for (int ix = 0; ix != 1000000; ++ix) { string str2 = str; // do the copy, automatically allocated if (str != str2) // use the new string ; // do nothing } // str2 is automatically freed 这些程序将在 4.3.1 节的习题中做进一步探讨。 192 动态数组的使用 通常是因为在编译时无法知道数组的维数,所以才需要动态创建该数组。例 如,在程序执行过程中,常常使用 char*指针指向多个 C 风格字符串,于是必须 根据每个字符串的长度实时地动态分配存储空间。采用这种技术要比建立固定大 小的数组安全。如果程序员能够准确计算出运行时需要的数组长度,就不必再担 心因数组变量具有固定的长度而造成的溢出问题。 假设有以下 C 风格字符串: const char *noerr = "success"; // ... const char *err189 = "Error: a function declaration must " "specify a function return type!"; 我们想在运行时把这两个字符串中的一个复制给新的字符数组,于是可以用 以下程序在运行时计算维数: const char *errorTxt; if (errorFound) errorTxt = err189; else errorTxt = noerr; // remember the 1 for the terminating null int dimension = strlen(errorTxt) + 1; char *errMsg = new char[dimension]; // copy the text for the error into errMsg strncpy (errMsg, errorTxt, dimension); 别忘记标准库函数 strlen 返回的是字符串的长度,并不包括字符串结束 符,在获得的字符串长度上必须加 1 以便在动态分配时预留结束符的存储空间。 193 Exercises Section 4.3.1 Exercise 假设有下面的 new 表达式,请问如何释放 pa? 4.27: int *pa = new int[10]; Exercise 4.28: 编写程序由从标准输入设备读入的元素数据建立一个 int 型 vector 对象,然后动态创建一个与该 vector 对 象大小一致的数组,把 vector 对象的所有元素复制给新 数组。 Exercise 4.29: 对本小节第 5 条框中的两段程序: a. 解释这两个程序实现什么功能? b. 平均来说,使用 string 类型的程序执行速度要比 用 C 风格字符串的快很多,在我们用了五年的 PC 机上其平均执行速度分别是: user user character string 0.47 2.55 # string class # C-style 你预计的也一样吗?请说明原因。 Exercise 4.30: 编写程序连接两个 C 风格字符串字面值,把结果存储在一 个 C 风格字符串中。然后再编写程序连接两个 string 类 型字符串,这两个 string 类型字符串与前面的 C 风格字 符串字面值具有相同的内容。 4.3.2. 新旧代码的兼容 许多 C++ 程序在有标准类之前就已经存在了,因此既没有使用标准库类型 string 也没有使用 vector。而且,许多 C++ 程序为了兼容现存的 C 程序,也 不能使用 C++ 标准库。因此,现代的 C++ 程序经常必须兼容使用数组和/或 C 风格字符串的代码,标准库提供了使兼容界面更容易管理的手段。 194 混合使用标准库类 string 和 C 风格字符串 正如第 3.2.1 节中显示的,可用字符串字面值初始化 string 类对象: string st3("Hello World"); // st3 holds Hello World 通常,由于 C 风格字符串与字符串字面值具有相同的数据类型,而且都是以 空字符 null 结束,因此可以把 C 风格字符串用在任何可以使用字符串字面值 的地方: • 可以使用 C 风格字符串对 string 对象进行初始化或赋值。 • string 类型的加法操作需要两个操作数,可以使用 C 风格字符串作为其 中的一个操作数,也允许将 C 风格字符串用作复合赋值操作的右操作数。 反之则不成立:在要求 C 风格字符串的地方不可直接使用标准库 string 类 型对象。例如,无法使用 string 对象初始化字符指针: char *str = st2; // compile-time type error 但是,string 类提供了一个名为 c_str 的成员函数,以实现我们的要求: char *str = st2.c_str(); // almost ok, but not quite c_str 函数返回 C 风格字符串,其字面意思是:“返回 C 风格字符串的表 示方法”,即返回指向字符数组首地址的指针,该数组存放了与 string 对象相 同的内容,并且以结束符 null 结束。 如果 c_str 返回的指针指向 const char 类型的数组,则上述初始化失败, 这样做是为了避免修改该数组。正确的初始化应为: const char *str = st2.c_str(); // ok c_str 返回的数组并不保证一定是有效的,接下来对 st2 的操 作有可能会改变 st2 的值,使刚才返回的数组失效。如果程序 需要持续访问该数据,则应该复制 c_str 函数返回的数组。 使用数组初始化 vector 对象 第 4.1.1 节提到不能用一个数组直接初始化另一数组,程序员只能创建新 数组,然后显式地把源数组的元素逐个复制给新数组。这反映 C++ 允许使用数 组初始化 vector 对象,尽管这种初始化形式起初看起来有点陌生。使用数组初 始化 vector 对象,必须指出用于初始化式的第一个元素以及数组最后一个元素 的下一位置的地址: 195 const size_t arr_size = 6; int int_arr[arr_size] = {0, 1, 2, 3, 4, 5}; // ivec has 6 elements: each a copy of the corresponding element in int_arr vector ivec(int_arr, int_arr + arr_size); 传递给 ivec 的两个指针标出了 vector 初值的范围。第二个指针指向被复 制的最后一个元素之后的地址空间。被标出的元素范围可以是数组的子集: // copies 3 elements: int_arr[1], int_arr[2], int_arr[3] vector ivec(int_arr + 1, int_arr + 4); 这个初始化创建了含有三个元素的 ivec,三个元素的值分别是 int_arr[1] 到 int_arr[3] 的副本。 Exercises Section 4.3.2 Exercise 编写程序从标准输入设备读入字符串,并把该串存放在字 4.31: 符数组中。描述你的程序如何处理可变长的输入。提供比 你分配的数组长度长的字符串数据测试你的程序。 Exercise 编写程序用 int 型数组初始化 vector 对象。 4.32: Exercise 编写程序把 int 型 vector 复制给 int 型数组。 4.33: Exercise 4.34: 编写程序读入一组 string 类型的数据,并将它们存储在 vector 中。接着,把该 vector 对象复制给一个字符指 针数组。为 vector 中的每个元素创建一个新的字符数 组,并把该 vector 元素的数据复制到相应的字符数组 中,最后把指向该数组的指针插入字符指针数组。 Exercise 输出习题 4.34 中建立的 vector 对象和数组的内容。输 4.35: 出数组后,记得释放字符数组。 4.4. 多维数组 196 严格地说,C++ 中没有多维数组,通常所指的多维数组其实就 是数组的数组: // array of size 3, each element is an array of ints of size 4 int ia[3][4]; 在使用多维数组时,记住这一点有利于理解其应用。 如果数组的元素又是数组,则称为二维数组,其每一维对应一个下标: ia[2][3] // fetches last element from the array in the last row 第一维通常称为行(row),第二维则称为列(column)。C++ 中并未限制 可用的下标个数,也就是说,我们可以定义元素是数组(其元素又是数组,如此 类推)的数组。 多维数组的初始化 和处理一维数组一样,程序员可以使用由花括号括起来的初始化式列表来初 始化多维数组的元素。对于多维数组的每一行,可以再用花括号指定其元素的初 始化式: int ia[3][4] = { /* 3 elements, each element is an array of size 4 */ {0, 1, 2, 3} , /* initializers for row indexed by 0 */ {4, 5, 6, 7} , /* initializers for row indexed by 1 */ {8, 9, 10, 11} /* initializers for row indexed by 2 */ }; 其中用来标志每一行的内嵌的花括号是可选的。下面的初始化尽管有点不清 楚,但与前面的声明完全等价: // equivalent initialization without the optional nested braces for each row int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11}; 与一维数组一样,有些元素将不使用初始化列表提供的初始化式进行初始 化。下面的声明只初始化了每行的第一个元素: 197 // explicitly initialize only element 0 in each row int ia[3][4] = {{ 0 } , { 4 } , { 8 } }; 其余元素根据其元素类型用 第 4.1.1 节描述的规则初始化。 如果省略内嵌的花括号,结果会完全不同: // explicitly initialize row 0 int ia[3][4] = {0, 3, 6, 9}; 该声明初始化了第一行的元素,其余元素都被初始化为 0。 多维数组的下标引用 为了对多维数组进行索引,每一维都需要一个下标。例如,下面的嵌套 for 循环初始化了一个二维数组: const size_t rowSize = 3; const size_t colSize = 4; int ia [rowSize][colSize]; // 12 uninitialized elements // for each row for (size_t i = 0; i != rowSize; ++i) // for each column within the row for (size_t j = 0; j != colSize; ++j) // initialize to its positional index ia[i][j] = i * colSize + j; 当需要访问数组中的特定元素时,必须提供其行下标和列下标。行下标指出 需要哪个内部数组,列下标则选取该内部数组的指定元素。了解多维数组下标引 用策略有助于正确计算其下标值,以及理解多维数组如何初始化。 如果表达式只提供了一个下标,则结果获取的元素是该行下标索引的内层数 组。如 ia[2] 将获得 ia 数组的最后一行,即这一行的内层数组本身,而并非 该数组中的任何元素。 4.4.1. 指针和多维数组 与普通数组一样,使用多维数组名时,实际上将其自动转换为指向该数组第 一个元素的指针。 定义指向多维数组的指针时,千万别忘了该指针所指向的多维 数组其实是数组的数组。 198 因为多维数组其实就是数组的数组,所以由多维数组转换而成的指针类型应 是指向第一个内层数组的指针。尽管这个概念非常明了,但声明这种指针的语法 还是不容易理解: int ia[3][4]; // array of size 3, each element is an array of ints of size 4 int (*ip)[4] = ia; // ip points to an array of 4 ints ip = &ia[2]; // ia[2] is an array of 4 ints 定义指向数组的指针与如何定义数组本身类似:首先声明元素类型,后接(数 组)变量名字和维数。窍门在于(数组)变量的名字其实是指针,因此需在标识 符前加上 *。如果从内向外阅读 ip 的声明,则可理解为:*ip 是 int[4] 类型 ——即 ip 是一个指向含有 4 个元素的数组的指针。 在下面的声明中,圆括号是必不可少的: int *ip[4]; // array of pointers to int int (*ip)[4]; // pointer to an array of 4 ints 用 typedef 简化指向多维数组的指针 typedef 类型定义(第 2.6 节)可使指向多维数组元素的指针更容易读、 写和理解。以下程序用 typedef 为 ia 的元素类型定义新的类型名: typedef int int_array[4]; int_array *ip = ia; 可使用 typedef 类型输出 ia 的元素: for (int_array *p = ia; p != ia + 3; ++p) for (int *q = *p; q != *p + 4; ++q) cout << *q << endl; 外层的 for 循环首先初始化 p 指向 ia 的第一个内部数组,然后一直循环 到 ia 的三行数据都处理完为止。++p 使 p 加 1,等效于移动指针使其指向 ia 的下一行(例如:下一个元素)。 内层的 for 循环实际上处理的是存储在内部数组中的 int 型元素值。首先 让 q 指向 p 所指向的数组的第一个元素。对 p 进行解引用获得一个有 4 个 int 型元素的数组,通常,使用这个数组时,系统会自动将它转换为指向该数组 第一个元素的指针。在本例中,第一个元素是 int 型数据,q 指向这个整数。系 统执行内层的 for 循环直到处理完当前 p 指向的内部数组中所有的元素为止。 199 当 q 指针刚达到该内部数组的超出末端位置时,再次对 p 进行解引用以获得指 向下一个内部数组第一个元素的指针。在 p 指向的地址上加 4 使得系统可循环 处理每一个内部数组的 4 个元素。 Exercises Section 4.4.1 Exercise 重写程序输出 ia 数组的内容,要求在外层循环中不能使 4.36: 用 typedef 定义的类型。 小结 本章介绍了数组和指针。数组和指针所提供的功能类似于标准库的 vector 类与 string 类和相关的迭代器所提供。我们可以把 vector 类型理解为更灵 活、更容易管理的数组,同样,string 是 C 风格字符串的改进类型,而 C 风 格字符串是以空字符结束的字符数组。 迭代器和指针都能用于间接地访问所指向的对象。vector 类型所包含的元 素通过迭代器来操纵,类似地,指针则用于访问数组元素。尽管道理都很简单, 但在实际应用中,指针的难用是出了名的。 某些低级任务必须使用指针和数组,但由于使用指针和数组容易出错而且难 以调试,应尽量避免使用。一般而言,应该优先使用标准库抽象类而少用语言内 置的低级数组和指针。尤其是应该使用 string 类型取代 C 风格以空字符结束 的字符数组。现代 C++ 程序不应使用 C 风格字符串。 术语 C-style strings(C 风格字符串) C 程序把指向以空字符结束的字符数组的指针视为字符串。在 C++ 中, 字符串字面值就是 C 风格字符串。C 标准库定义了一系列处理这种字符 串的库函数,C++ 中将这些标准库函数放在 cstring 头文件中。由于 C 风格字符串本质上容易出错,C++ 程序应该优先使用 C++ 标准库类 string 而少用 C 风格字符串。网络程序中大量的安全漏洞都源于与使用 C 风格字符串和数组相关的缺陷。 compiler extension(编译器扩展) 200 特定编译器为语言添加的特性。依赖于编译器扩展的程序很难移植到其他 的编译器。 compound type(复合类型) 使用其他类型定义的类型。数组、指针和引用都是复合类型。 const void* 可以指向任意 const 类型的指针类型,参见 void *。 delete expression(delete 表达式) delete 表达式用于释放由 new 动态分配的内存: delete [] p; 在此表达式中,p 必须是指向动态创建的数组中第一个元素的指针,其中 方括号必不可少:它告诉编译器该指针指向数组,而非单个对象。C++ 程 序使用 delete 取代 C 语言的标准库函数 free。 dimension(维数) 数组大小。 dynamically allocated(动态分配的) 在程序自由存储区中建立的对象。该对象一经创建就一直存在,直到显式 释放为止。 free store(自由存储区) 程序用来存储动态创建对象的内存区域。 heap(堆) 自由存储区的同义词。 new expression(new 表达式) 用于分配动态内存的表达式。下面的语句分配了一个有 n 个元素的数组: new type[n]; 201 该数组存放 type 类型的元素。new 返回指向该数组第一个元素的指针。 C++ 程序使用 new 取代 C 语言的标准库函数 malloc。 pointer(指针) 存放对象地址的对象。 pointer arithmetic(指针算术操作) 可用于指针的算术操作。允许在指针上做加上或减去整型值的操作,以获 得当前指针之前或之后若干个元素处的地址。两个指针可做减法操作,得 到它们之间的差值。只有当指针指向同一个数组或其超出末端的位置时, 指针的算术操作才有意义。 precedence(优先级) 在复杂的表达式中,优先级确定了操作数分组的次序。 ptrdiff_t 在 cstddef 头文件中定义的与机器相关的有符号整型,该类型具有足够 的大小存储两个指针的差值,这两个指针指向同一个可能的最大数组。 size_t 在 cstddef 头文件中定义的与机器相关的无符号整型,它具有足够的大 小存储一个可能的最大数组。 * operator(* 操作符) 对指针进行解引用操作获得该指针所指向的对象。解引用操作符返回左 值,因此可为其结果赋值,等效于为该指针所指向的特定对象赋新值。 ++ operator(++ 操作符) 用于指针时,自增操作符给指针“加 1”,移动指针使其指向数组的下一 个元素。 [] operator([] 操作符) 下标操作符接受两个操作数:一个是指向数组元素的指针,一个是下标 n。 该操作返回偏离指针当前指向 n 个位置的元素值。数组下标从 0 开始计 数——数组第一个元素的下标为 0,最后一个元素的下标是数组长度减 1。下标操作返回左值,可用做赋值操作的左操作数,等效于为该下标引 用的元素赋新值。 202 & operator(& 操作符) 取地址操作符需要一个操作数,其唯一的操作数必须是左值对象,该操作 返回操作数对象在内存中的存储地址。 void* 可以指向任何非 const 对象的指针类型。void* 指针只提供有限的几种 操作:可用作函数形参类型或返回类型,也可与其他指针做比较操作,但 是不能进行解引用操作。 203 第五章 表达式 C++ 提供了丰富的操作符,并定义操作数为内置类型时,这些操作符的含义。 除此之外,C++ 还支持操作符重载,允许程序员自定义用于类类型时操作符的含 义。标准库正是使用这种功能定义用于库类型的操作符。 本章重点介绍 C++ 语言定义的操作符,它们使用内置类型的操作数;本章 还会介绍一些标准库定义的操作符。第十四章将学习如何定义自己的重载操作 符。 表达式由一个或多个操作数通过操作符组合而成。最简单的表达式仅包含一 个字面值常量或变量。较复杂的表达式则由操作符以及一个或多个操作数构成。 每个表达式都会产生一个结果。如果表达式中没有操作符,则其结果就是操 作数本身(例如,字面值常量或变量)的值。当一个对象用在需要使用其值的地 方,则计算该对象的值。例如,假设 ival 是一个 int 型对象: if (ival) // .... // evaluate ival as a condition 上述语句将 ival 作为 if 语句的条件表达式。当 ival 为非零值时, if 条件成立;否则条件不成立。 对于含有操作符的表达式,它的值通过对操作数做指定操作获得。除了特殊 用法外,表达式的结果是右值(第 2.3.1 节),可以读取该结果值,但是不允 许对它进行赋值。 操作符的含义——该操作符执行什么操作以及操作结果的类型 ——取决于操作数的类型。 除非已知道操作数的类型,否则无法确定一个特定表达式的含义。下面的表 达式 i+j 既可能是整数的加法操作、字符串的串接或者浮点数的加法操作,也完全可 能是其他的操作。如何计算该表达式的值,完全取决于 i 和 j 的数据类型。 204 C++提供了一元操作符和二元操作符两种操作符。作用在一个操作数上的操 作符称为一元操作符,如取地址操作符(&)和解引用操作符(*);而二元操作 符则作用于两个操作数上,如加法操作符(+)和减法操作符(-)。除此之外,C++ 还 提供了一个使用三个操作数的三元操作符(ternary operator),我们将在第 5.7 节介绍它。 有些符号(symbols)既可表示一元操作也可表示二元操作。例如,符号 * 既 可以作为(一元)解引用操作符,也可以作为(二元)乘法操作符,这两种用法 相互独立、各不相关,如果将其视为两个不同的符号可能会更容易理解些。对于 这类操作符,需要根据该符号所处的上下文来确定它代表一元操作还是二元操 作。 操作符对其操作数的类型有要求,如果操作符应用于内置或复合类型的操作 数,则由 C++语言定义其类型要求。例如,用于内置类型对象的解引用操作符要 求其操作数必须是指针类型,对任何其他内置类型或复合类型对象进行解引用将 导致错误的产生。 对于操作数为内置或复合类型的二元操作符,通常要求它的两个操作数具有 相同的数据类型,或者其类型可以转换为同一种数据类型。关于类型转换,我们 将在第 5.12 节学习。尽管规则可能比较复杂,但大部分的类型转换都可按预期 的方式进行。例如,整型可转换为浮点类型,反之亦然,但不能将指针类型转换 为浮点类型。 要理解由多个操作符组成的表达式,必须先理解操作符的优先级、结合性和 操作数的求值顺序。例如,表达式 5 + 10 * 20/2; 使用了加法、乘法和除法操作。该表达式的值取决于操作数与操作符如何结 合。例如,乘法操作符 * 的操作数可以是 10 和 20,也可以是 10 和 20 /2, 或者 15 和 20 、 15 和 20/2。结合性和优先级规则规定了操作数与操作符的 结合方式。在 C++ 语言中,该表达式的值应是 105,10 和 20 先做乘法操作, 然后其结果除以 2,再加 5 即为最后结果。 求解表达式时,仅了解操作数和操作符如何结合是不足够的,还必须清楚操 作符上每一个操作数的求值顺序。每个操作符都控制了其假定的求值顺序,即, 我们是否可以假定左操作数总是先于右操作数求值。大部分的操作符无法保证某 种特定的求值次序,我们将于第 5.10 节讨论这个问题。 5.1. 算术操作符 除非特别说明,表 5-1 所示操作符可用于任意算术类型(第 2.1 节)或者 任何可转换为算术类型的数据类型。 205 表 5.1 按优先级来对操作符进行分组——一元操作符优先级最高,其次是 乘、除操作,接着是二元的加、减法操作。高优先级的操作符要比低优先级的结 合得更紧密。这些算术操作符都是左结合,这就意味着当操作符的优先级相同时, 这些操作符从左向右依次与操作数结合。 表 5.1. 算术操作符 操作符 功能 用法 + unary plus(一元正号) + expr - unary minus(一元负号) - expr * multiplication(乘法) expr * expr / division(除法) expr / expr % remainder(求余) expr % expr + addition(加法) expr + expr - subtraction(减法) expr - expr 对于前述表达式 5 + 10 * 20/2; 考虑优先级与结合性,可知该表达式先做乘法( *)操作,其操作数为 10 和 20,然后以该操作的结果和 2 为操作数做除法(/)操作,其结果最后与操作数 5 做加法( +)操作。 一元负号操作符具有直观的含义,它对其操作数取负: int i = 1024; int k = -i; // negates the value of its operand 一元正号操作符则返回操作数本身,对操作数不作任何修改。 206 警告:溢出和其他算术异常 某些算术表达式的求解结果未定义,其中一部分由数学特性引起,例如 除零操作;其他则归咎于计算机特性,如溢出:计算出的数值超出了其 类型的表示范围。 考虑某台机器,其 short 类型为 16 位,能表示的最大值是 32767。假 设 short 类型只有 16 位,下面的复合赋值操作将会溢出: // max value if shorts are 8 bits short short_value = 32767; short ival = 1; // this calculation overflows short_value += ival; cout << "short_value: " << short_value << endl; 表示 32768 这个有符号数需 17 位的存储空间,但是这里仅有 16 位, 于是导致溢出现象的发生,此时,许多系统都不会给出编译时或运行时 的警告。对于不同的机器,上述例子的 short_value 变量真正获得的值 不尽相同。在我们的系统上执行该程序后将得到: short_value: -32768 其值“截断(wrapped around)”,将符号位的值由 0 设为 1,于是结 果变为负数。因为算术类型具有有限的长度,因此计算后溢出的现象常 常发生。遵循第 2.2 节建议框中给出的建议将有助于避免此类问题。 二元 +、 - 操作符也可用于指针值,对指针使用这些操作符的用法将在第 4.2.4 节介绍。 算术操作符 +、-、* 和 / 具有直观的含义:加法、减法、乘法和除法。对 两个整数做除法,结果仍为整数,如果它的商包含小数部分,则小数部分会被截 除: int ival1 = 21/6; // integral result obtained by truncating the remainder int ival2 = 21/7; // no remainder, result is an integral value ival1 和 ival2 均被初始化为 3。 207 操作符 % 称为“求余(remainder)”或“求模(modulus)”操作符,用 于计算左操作数除以右操作数的余数。该操作符的操作数只能为整型,包括 bool、char、short 、int 和 long 类型,以及对应的 unsigned 类型: int ival = 42; double dval = 3.14; ival % 12; // ok: returns 6 ival % dval; // error: floating point operand 如果两个操作数为正,除法(/)和求模(%)操作的结果也是正数(或零); 如果两个操作数都是负数,除法操作的结果为正数(或零),而求模操作的结果 则为负数(或零);如果只有一个操作数为负数,这两种操作的结果取决于机器; 求模结果的符号也取决于机器,而除法操作的值则是负数(或零): 21 % 6; // 21 % 7; // -21 % -8; // 21 % -5; // 21 / 6; // 21 / 7; // -21 / -8; // 21 / -5; // ok: result is 3 ok: result is 0 ok: result is -5 machine-dependent: result is 1 or -4 ok: result is 3 ok: result is 3 ok: result is 2 machine-dependent: result -4 or -5 当只有一个操作数为负数时,求模操作结果值的符号可依据分子(被除数) 或分母(除数)的符号而定。如果求模的结果随分子的符号,则除出来的值向零 一侧取整;如果求模与分母的符号匹配,则除出来的值向负无穷一侧取整。 208 Exercises Section 5.1 Exercise 在下列表达式中,加入适当的圆括号以标明其计算顺序。 5.1: 编译该表达式并输出其值,从而检查你的回答是否正确。 12 / 3 * 4 + 5 * 15 + 24 % 4 / 2 Exercise 5.2: 计算下列表达式的值,并指出哪些结果值依赖于机器? -30 * 3 + 21 / 5 -30 + 3 * 21 / 5 30 / 3 * 21 % 5 -30 / 3 * 21 % 4 Exercise 编写一个表达式判断一个 int 型数值是偶数还是奇数。 5.3: Exercise 定义术语“溢出”的含义,并给出导致溢出的三个表达 5.4: 式。 5.2. 关系操作符和逻辑操作符 关系操作符和逻辑操作符(表 5.2)使用算术或指针类型的操作数,并返回 bool 类型的值。 表 5.2. 关系操作符和逻辑操作符 下列操作符都产生 bool 值 操作符 功能 用法 ! logical NOT(逻辑非) !expr < less than(小于) expr < expr <= less than or equal(小于等于) expr <= expr > greater than(大于) expr > expr >= greater than or equal(大于等于) expr >= expr 209 下列操作符都产生 bool 值 操作符 功能 == equality(相等) != inequality(不等) && logical AND(逻辑与) || logical OR(逻辑或) 用法 expr == expr expr != expr expr && expr expr || expr 逻辑与、逻辑或操作符 逻辑操作符将其操作数视为条件表达式(第 1.4.1 节):首先对操作数求 值;若结果为 0,则条件为假(false),否则为真(true)。仅当逻辑与(&&) 操作符的两个操作数都为 true,其结果才得 true 。对于逻辑或(||)操作符, 只要两个操作数之一为 true,它的值就为 true。给定以下形式: expr1 && expr2 // logical AND expr1 || expr2 // logical OR 仅当由 expr1 不能确定表达式的值时,才会求解 expr2。也就是说,当且仅 当下列情况出现时,必须确保 expr2 是可以计算的: • 在逻辑与表达式中,expr1 的计算结果为 true。如果 expr1 的值为 false,则无论 expr2 的值是什么,逻辑与表达式的值都为 false 。当 expr1 的值为 true 时,只有 expr2 的值也是 true ,逻辑与表达式的 值才为 true。 • 在逻辑或表达式中,expr1 的计算结果为 false。如果 expr1 的值为 false,则逻辑或表达式的值取决于 expr2 的值是否为 true。 逻辑与和逻辑或操作符总是先计算其左操作数,然后再计算其 右操作数。只有在仅靠左操作数的值无法确定该逻辑表达式的 结果时,才会求解其右操作数。我们常常称这种求值策略为“短 路求值(short-circuit evaluation)”。 对于逻辑与操作符,一个很有价值的用法是:如果某边界条件使 expr2 的 计算变得危险,则应在该条件出现之前,先让 expr1 的计算结果为 false。例 如,编写程序使用一个 string 类型的对象存储一个句子,然后将该句子的第一 个单词的各字符全部变成大写,可如下实现: 210 string s("Expressions in C++ are composed..."); string::iterator it = s.begin(); // convert first word in s to uppercase while (it != s.end() && !isspace(*it)) { *it = toupper(*it); // toupper covered in section 3.2.4 (p. 88) ++it; } 在这个例子中,while 循环判断了两个条件。首先检查 it 是否已经到达 string 类型对象的结尾,如果不是,则 it 指向 s 中的一个字符。只有当该检 验条件成立时,系统才会计算逻辑与操作符的右操作数,即在保证 it 确实指向 一个真正的字符之后,才检查该字符是否为空格。如果遇到空格,或者 s 中没 有空格而已经到达 s 的结尾时,循环结束。 逻辑非操作符 逻辑非操作符(!)将其操作数视为条件表达式,产生与其操作数值相反的 条件值。如果其操作数为非零值,则做 ! 操作后的结果为 false。例如,可如 下在 vector 类型对象的 empty 成员函数上使用逻辑非操作符,根据函数返回 值判断该对象是否为空: // assign value of first element in vec to x if there is one int x = 0; if (!vec.empty()) x = *vec.begin(); 如果调用 empty 函数返回 false,则子表达式 !vec.empty() 的值为 true。 不应该串接使用关系操作符 关系操作符(<、<=、>、<=)具有左结合特性。事实上,由于关系操作符返 回 bool 类型的结果,因此很少使用其左结合特性。如果把多个关系操作符串接 起来使用,结果往往出乎预料: // oops! this condition does not determine if the 3 values are unequal if (i < j < k) { /* ... */ } 这种写法只要 k 大于 1,上述表达式的值就为 true。这是因为第二个小于 操作符的左操作数是第一个小于操作符的结果:true 或 false。也就是,该条 211 件将 k 与整数 0 或 1 做比较。为了实现我们想要的条件检验,应重写上述表 达式如下: if (i < j && j < k) { /* ... */ } 相等测试与 bool 字面值 正如第 5.12.2 节将介绍的,bool 类型可转换为任何算术类型——bool 值 false 用 0 表示,而 true 则为 1。 由于 true 转换为 1,因此要检测某值是否与 bool 字面值 true 相等,其等效判断条件通常很难正确编写: if (val == true) { /* ... */ } val 本身是 bool 类型,或者 val 具有可转换为 bool 类型的数据类型。 如果 val 是 bool 类型,则该判断条件等效于: if (val) { /* ... */ } 这样的代码更短而且更直接(尽管对初学者来说,这样的缩写可能会令人费 解)。 更重要的是,如果 val 不是 bool 值,val 和 true 的比较等效于: if (val == 1) { /* ... */ } 这与下面的条件判断完全不同: // condition succeeds if val is any nonzero value if (val) { /* ... */ } 此时,只要 val 为任意非零值,条件判断都得 true。如果显式地书写条件 比较,则只有当 val 等于指定的 1 值时,条件才成立。 212 Exercises Section 5.2 Exercise 解释逻辑与操作符、逻辑或操作符以及相等操作符的操作 5.5: 数在什么时候计算。 Exercise 5.6: 解释下列 while 循环条件的行为: char *cp = "Hello World"; while (cp && *cp) Exercise 编写 while 循环条件从标准输入设备读入整型(int)数 5.7: 据,当读入值为 42 时循环结束。 Exercise 编写表达式判断四个值 a、b、c 和 d 是否满足 a 大于 5.8: b、b 大于 c 而且 c 大于 d 的条件。 5.3. 位操作符 位操作符(表 5-3)使用整型的操作数。位操作符将其整型操作数视为二进 制位的集合,为每一位提供检验和设置的功能。另外,这类操作符还可用于 bitset 类型(第 3.5 节)的操作数,该类型具有这里所描述的整型操作数的行 为。 表 5.3. 位操作符 操作符 功能 用法 ~ bitwise NOT(位求反) ~expr << left shift(左移) expr1 << expr2 >> right shift(右移) expr1 >> expr2 & bitwise AND(位与) expr1 & expr2 ^ bitwise XOR(位异或) expr1 ^ expr2 | bitwise OR(位或) expr1 | expr2 213 位操作符操纵的整数的类型可以是有符号的也可以是无符号的。如果操作数 为负数,则位操作符如何处理其操作数的符号位依赖于机器。于是它们的应用可 能不同:在一个应用环境中实现的程序可能无法用于另一应用环境。 对于位操作符,由于系统不能确保如何处理其操作数的 符号位,所以强烈建议使用 unsigned 整型操作数。 在下面的例子中,假设 unsigned char 类型有 8 位。位求反操作符(~) 的功能类似于 bitset 的 flip 操作(第 3.5.2 节):将操作数的每一个二进 制位取反:将 1 设置为 0、0 设置为 1,生成一个新值: unsigned char bits = 0227; bits = ~bits; << 和 >> 操作符提供移位操作,其右操作数标志要移动的位数。这两种操 作符将其左操作数的各个位向左(<<)或向右(>>)移动若干个位(移动的位数 由其右操作数指定),从而产生新的值,并丢弃移出去的位。 unsigned char bits = 1; bits << 1; // left shift bits << 2; // left shift bits >> 3; // right shift 左移操作符(<<)在右边插入 0 以补充空位。对于右移操作符(>>),如 果其操作数是无符号数,则从左边开始插入 0;如果操作数是有符号数,则插入 符号位的副本或者 0 值,如何选择需依据具体的实现而定。移位操作的右操作 数不可以是负数,而且必须是严格小于左操作数位数的值。否则,操作的效果未 定义。 位与操作(&)需要两个整型操作数,在每个位的位置,如果两个操作数对 应的位都为 1,则操作结果中该位为 1,否则为 0。 214 常犯的错误是把位与操作(&)和逻辑与操作(&&)(第 5.2 节) 混淆了。同样地,位或操作(|)和逻辑或操作(||)也很容易 搞混。 下面我们用图解的方法说明两个 unsigned char 类型值的位与操作,这两个 操作数均用八进制字面常量初始化: unsigned char b1 = 0145; unsigned char b2 = 0257; unsigned char result = b1 & b2; 位异或(互斥或,exclusive or)操作符(^)也需要两个整型操作数。在 每个位的位置,如果两个操作数对应的位只有一个(不是两个)为 1,则操作结 果中该位为 1,否则为 0。 result = b1 ^ b2; 位或(包含或,inclusive or)操作符(|)需要两个整型操作数。在每个 位的位置,如果两个操作数对应的位有一个或者两个都为 1,则操作结果中该位 为 1,否则为 0。 result = b1 | b2; 5.3.1. bitset 对象或整型值的使用 bitset 类比整型值上的低级位操作更容易使用。观察下面简单的例子,了 解如何使用 bitset 类型或者位操作来解决问题。假设某老师带了一个班,班中 有 30 个学生,每个星期在班上做一次测验,只有及格和不及格两种测验成绩, 对每个学生用一个二进制位来记录一次测试及格或不及格,以方便我们跟踪每次 测验的结果,这样就可以用一个 bitset 对象或整数值来代表一次测验: bitset<30> bitset_quiz1; // bitset solution unsigned long int_quiz1 = 0; // simulated collection of bits 215 使用 bitset 类型时,可根据所需要的大小明确地定义 bitset_quiz1,它 的每一个位都默认设置为 0 值。如果使用内置类型来存放测验成绩,则应将变 量 int_quiz1 定义为 unsigned long 类型,这种数据类型在所有机器上都至少 拥有 32 位的长度。最后,显式地初始化 int_quiz1 以保证该变量在使用前具有 明确定义的值。 老师可以设置和检查每个位。例如,假设第 27 位所表示的学生及格了,则 可以使用下面的语句适当地设置对应的位: bitset_quiz1.set(27); // indicate student number 27 passed int_quiz1 |= 1UL<<27; // indicate student number 27 passed 如果使用 bitset 实现,可直接传递要置位的位给 set 函数。而用 unsigned long 实现时,实现的方法则比较复杂。设置指定位的方法是:将测验 数据与一个整数做位或操作,该整数只有一个指定的位为 1。也就是说,我们需 要一个只有第 27 位为 1 其他位都为 0 的无符号长整数(unsigned long),这 样的整数可用左移操作符和整型常量 1 生成: 1UL << 27; // generate a value with only bit number 27 set 然后让这个整数与 int_quiz1 做位或操作,操作后,除了第 27 位外其他 所有位的值都保持不变,而第 27 位则被设置为 1。这里,使用复合赋值操作(第 1.4.1 节)将位或操作的结果赋给 int_quiz1,该操作符 |= 操作的方法与 += 相同。于是,上述功能等效于下面更详细的形式: // following assignment is equivalent to int_quiz1 |= 1UL << 27; int_quiz1 = int_quiz1 | 1UL << 27; 如果老师重新复核测验成绩,发现第 27 个学生实际上在该次测验中不及 格,这时老师应把第 27 位设置为 0: bitset_quiz1.reset(27); // student number 27 failed int_quiz1 &= ~(1UL<<27); // student number 27 failed 使用 bitset 的版本可直接实现该功能,只要复位(reset)指定的位即可。 而对于另一种情况,则需通过反转左移操作后的结果来实现设置:此时,我们需 要一个只有第 27 位为 0 而其他位都为 1 的整数。然后将这个整数与测验数据 做位与操作,把指定的位设置为 0。位求反操作使得除了第 27 位外其他位都设 置为 1,然后此值和 int_quiz1 做位与操作,保证了除第 27 位外所有的位都 保持不变。 最后,可通过以下代码获知第 27 个学生是否及格: bool status; status = bitset_quiz1[27]; // how did student number 27 do? 216 status = int_quiz1 & (1UL<<27); // how did student number 27 do? 使用 bitset 的版本中,可直接读取其值判断他是否及格。使用 unsigned long 时,首先要把一个整数的第 27 位设置为 1,然后用该整数和 int_quiz1 做位与操作,如果 int_quiz1 的第 27 位为 1,则结果为非零值,否则,结果 为零。 一般而言,标准库提供的 bitset 操作更直接、更容易 阅读和书写、正确使用的可能性更高。而且,bitset 对 象的大小不受 unsigned 数的位数限制。通常来说, bitset 优于整型数据的低级直接位操作。 Exercises Section 5.3.1 Exercise 假设有下面两个定义 5.9: unsigned long ul1 = 3, ul2 = 7; 下列表达式的结果是什么? (a) ul1 & ul2 (b) ul1 && ul2 (c) ul1 | ul2 (d) ul1 || ul2 Exercise 重写 bitset 表达式:使用下标操作符对测验结果进行置 5.10: 位(置 1)和复位(置 0)。 5.3.2. 将移位操作符用于 IO 输入输出标准库(IO library)分别重载了位操作符 >> 和 << 用于输入和 输出。即使很多程序员从未直接使用过位操作符,但是相当多的程序都大量用到 这些操作符在 IO 标准库中的重载版本。重载的操作符与该操作符的内置类型版 本有相同的优先级和结合性。因此,即使程序员从不使用这些操作符的内置含义 来实现移位操作,但是还是应该先了解这些操作符的优先级和结合性。 IO 操作符为左结合 像其他二元操作符一样,移位操作符也是左结合的。这类操作符从左向右地 结合,正好说明了程序员为什么可以把多个输入或输出操作连接为单个语句: 217 cout << "hi" << " there" << endl; 执行为: ( (cout << "hi") << " there" ) << endl; 在这个语句中,操作数"hi"与第一个 << 符号结合,其计算结果与第二个 << 符号结合,第二个 << 符号操作后,其结果再与第三个 << 符号结合。 移位操作符具有中等优先级:其优先级比算术操作符低,但比关系操作符、 赋值操作符和条件操作符优先级高。若 IO 表达式的操作数包含了比 IO 操作符 优先级低的操作符,相关的优先级别将影响书写该表达式的方式。通常需使用圆 括号强制先实现右结合: cout << 42 + 10; // ok, + has higher precedence, so the sum is printed cout << (10 < 42); // ok: parentheses force intended grouping; prints 1 cout << 10 < 42; // error: attempt to compare cout to 42! The second cout is interpreted as 第二个 cout 语句解释为: (cout << 10) < 42; 该表达式说“将 10 写到 cout,然后用此操作(也就是 cout)的结果与 42 做比较”。 5.4. 赋值操作符 赋值操作符的左操作数必须是非 const 的左值。下面的赋值语句是不合法 的: int i, j, ival; const int ci = i; 1024 = ival; i + j = ival; ci = ival; // ok: initialization not assignment // error: literals are rvalues // error: arithmetic expressions are rvalues // error: can't write to ci 数组名是不可修改的左值:因此数组不可用作赋值操作的目标。而下标和解 引用操作符都返回左值,因此当将这两种操作用于非 const 数组时,其结果可 作为赋值操作的左操作数: int ia[10]; 218 ia[0] = 0; *ia = 0; // ok: subscript is an lvalue // ok: dereference also is an lvalue 赋值表达式的值是其左操作数的值,其结果的类型为左操作数的类型。 通常,赋值操作将其右操作数的值赋给左操作数。然而,当左、右操作数的 类型不同时,该操作实现的类型转换可能会修改被赋的值。此时,存放在左、右 操作数里的值并不相同: ival = 0; // result: type int value 0 ival = 3.14159; // result: type int value 3 上述两个赋值语句都产生 int 类型的值,第一个语句中 ival 的值与右操作 数的值相同;但是在第二个语句中,ival 的值则与右操作数的值不相同。 5.4.1. 赋值操作的右结合性 与下标和解引用操作符一样,赋值操作也返回左值。同理,只要被赋值的每 个操作数都具有相同的通用类型,C++语言允许将这多个赋值操作写在一个表达 式中: int ival, jval; ival = jval = 0; // ok: each assigned 0 与其他二元操作符不同,赋值操作具有右结合特性。当表达式含有多个赋值 操作符时,从右向左结合。上述表达式,将右边赋值操作的结果(也就是 jval) 赋给 ival。多个赋值操作中,各对象必须具有相同的数据类型,或者具有可转 换(第 5.12 节)为同一类型的数据类型: int ival; int *pval; ival = pval = 0; // error: cannot assign the value of a pointer to an int string s1, s2; s1 = s2 = "OK"; // ok: "OK" converted to string 第一个赋值语句是不合法的,因为 ival 和 pval 是不同类型的对象。虽然 0 值恰好都可以赋给这两个对象,但该语句仍然错误。因为问题在于给 pval 赋 值的结果是一个 int* 类型的值,不能将此值赋给 int 类型的对象。另一方面, 第二个赋值语句则是正确的。字符串字面值可以转换为 string 类型,string 类 型的值可赋给 s2 变量。右边赋值操作的结果为 s2,再将此结果值赋给 s1。 219 5.4.2. 赋值操作具有低优先级 另一种通常的用法,是将赋值操作写在条件表达式中,把赋值操作用作长表 达式的一部分。这种做法可缩短程序代码并阐明程序员的意图。例如,下面的循 环调用函数 get_value,假设该函数返回 int 数值,通过循环检查这些返回值, 直到获得需要的值为止——这里是 42: int i = get_value(); // get_value returns an int while (i != 42) { // do something ... i = get_value(); } 首先,程序将所获得的第一个值存储在 i 中,然后建立循环检查 i 的值是 否为 42,如果不是,则做某些处理。循环中的最后一条语句调用 get_value() 返 回一个值,然后继续循环。该循环可更简洁地写为: int i; while ((i = get_value()) != 42) { // do something ... } 现在,循环条件更清晰地表达了程序员的意图:持续循环直到 get_value 返 回 42 为止。在循环条件中,将 get_value 返回的值赋给 i,然后判断赋值的 结果是否为 42。 在赋值操作上加圆括号是必需的,因为赋值操作符的优先级低 于不等操作符。 如果没有圆括号,操作符 != 的操作数则是调用 get_value 返回的值和 42,然后将该操作的结果 true 或 false 赋给 i—— 显然这并不是我们想要 的。 谨防混淆相等操作符和赋值操作符 可在条件表达式中使用赋值操作,这个事实往往会带来意外的效果: if (i = 42) 此代码是合法的:将 42 赋给 i,然后检验赋值的结果。此时,42 为非零 值,因此解释为 true。其实,程序员的目的显然是想判断 i 的值是否为 42: 220 if (i == 42) 这种类型的程序错误很难发现。有些(并非全部)编译器会为类似于上述例 子的代码提出警告。 Exercises Section 5.4.2 Exercise 5.11: 请问每次赋值操作完成后,i 和 d 的值分别是多少? int i; double d; d = i = 3.5; i = d = 3.5; Exercise 5.12: 解释每个 if 条件判断产生什么结果? if (42 = i) // . . . if (i = 42) // . . . 5.4.3. 复合赋值操作符 我们常常在对某个对象做某种操作后,再将操作结果重新赋给该对象。例如, 考虑第 1.4.2 节的求和程序: int sum = 0; // sum values from 1 up to 10 inclusive for (int val = 1; val <= 10; ++val) sum += val; // equivalent to sum = sum + val C++ 语言不仅对加法,而且还对其他算术操作符和位操作符提供了这种用 法,称为复合赋值操作。复合赋值操作符的一般语法格式为: a op= b; 其中,op= 可以是下列十个操作符之一: += -= *= /= %= // arithmetic operators <<= >>= &= ^= |= // bitwise operators 这两种语法形式存在一个显著的差别:使用复合赋值操作时,左操作数只计 算了一次;而使用相似的长表达式时,该操作数则计算了两次,第一次作为右操 221 作数,而第二次则用做左操作数。除非考虑可能的性能价值,在很多(可能是大 部分的)上下文环境里这个差别不是本质性的。 a = a op b; 这两种语法形式存在一个显著的差别:使用复合赋值操作时,左操作数只计 算了一次;而使用相似的长表达式时,该操作数则计算了两次,第一次作为右操 作数,而第二次则用做左操作数。除非考虑可能的性能价值,在很多(可能是大 部分的)上下文环境里这个差别不是本质性的。 Exercises Section 5.4.3 Exercise 5.13: 下列赋值操作是不合法的,为什么?怎样改正? double dval; int ival; int *pi; dval = ival = pi = 0; Exercise 虽然下列表达式都是合法的,但并不是程序员期望的操 5.14: 作,为什么?怎样修改这些表达式以使其能反映程序员的 意图? (a) if (ptr = retrieve_pointer() != 0) (b) if (ival = 1024) (c) ival += ival + 1; 5.5. 自增和自减操作符 自增(++)和自减(--)操作符为对象加 1 或减 1 操作提供了方便简短的实 现方式。它们有前置和后置两种使用形式。到目前为止,我们已经使用过前自增 操作,该操作使其操作数加 1,操作结果是修改后的值。同理,前自减操作使其 操作数减 1。这两种操作符的后置形式同样对其操作数加 1(或减 1),但操作 后产生操作数原来的、未修改的值作为表达式的结果: int i = 0, j; j = ++i; // j = 1, i = 1: prefix yields incremented value j = i++; // j = 1, i = 2: postfix yields unincremented value 因为前置操作返回加 1 后的值,所以返回对象本身,这是左值。而后置操作 返回的则是右值。 222 建议:只有在必要时才使用后置操作符 有使用 C 语言背景的读者可能会觉得奇怪,为什么要在程序中使用前自 增操作。道理很简单:因为前置操作需要做的工作更少,只需加 1 后返 回加 1 后的结果即可。而后置操作符则必须先保存操作数原来的值,以 便返回未加 1 之前的值作为操作的结果。对于 int 型对象和指针,编 译器可优化掉这项额外工作。但是对于更多的复杂迭代器类型,这种额 外工作可能会花费更大的代价。因此,养成使用前置操作这个好习惯, 就不必操心性能差异的问题。 后置操作符返回未加 1 的值 当我们希望在单个复合表达式中使用变量的当前值,然后再加 1 时,通常会 使用后置的 ++ 和 -- 操作: vector ivec; // empty vector int cnt = 10; // add elements 10...1 to ivec while (cnt > 0) ivec.push_back(cnt--); // int postfix decrement 这段程序使用了后置的 -- 操作实现 cnt 减 1。我们希望把 cnt 的值赋给 vector 对象的下一个元素,然后在下次迭代前 cnt 的值减 1。如果在循环中使 用前置操作,则是用 cnt 减 1 后的值创建 ivec 的新元素,结果是将 9 至 0 十个元素依次添加到 ivec 中。 在单个表达式中组合使用解引用和自增操作 下面的程序使用了一种非常通用的 C++ 编程模式输出 ivec 的内容: vector::iterator iter = ivec.begin(); // prints 10 9 8 ... 1 while (iter != ivec.end()) cout << *iter++ << endl; // iterator postfix increment 如果程序员对 C++ 和 C 语言都不太熟悉,则常常会弄不清楚 表达式 *iter++ 的含义。 223 由于后自增操作的优先级高于解引用操作,因此 *iter++ 等效于 *(iter++)。子表达式 iter++ 使 iter 加 1,然后返回 iter 原值的副本作为 该表达式的结果。因此,解引用操作 * 的操作数是 iter 未加 1 前的副本。 这种用法的根据在于后自增操作返回其操作数原值(没有加 1)的副本。如 果返回的是加 1 后的值,则解引用该值将导致错误的结果:ivec 的第一个元素 没有输出,并企图对一个多余的元素进行解引用。 建议:简洁即是美 没有 C 语言基础的 C++ 新手,时常会因精简的表达式而苦恼,特别是 像 *iter++ 这类令人困惑的表达式。有经验的 C++程序员非常重视简 练,他们更喜欢这么写: cout << *iter++ << endl; 而不采用下面这种冗长的等效代码: cout << *iter << endl; ++iter; 对于初学 C++ 的程序员来说,第二种形式更清晰,因为给迭代器加 1 和 获取输出值这两个操作是分开来实现的。但是更多的 C++ 程序员更习惯 使用第一种形式。 要不断地研究类似的代码,最后达到一目了然的地步。大部分的 C++ 程 序员更喜欢使用简洁的表达式而非冗长的等效表达式。因此,C++ 程序 员必须熟悉这种用法。而且,一旦熟悉了这类表达式,我们会发现使用 起来更不容易出错。 224 Exercises Section 5.5 Exercise 解释前自增操作和后自增操作的差别。 5.15: Exercise 你认为为什么 C++不叫做++C? 5.16: Exercise 如果输出 vector 内容的 while 循环使用前自增操作符, 5.17: 那会怎么样? 5.6. 箭头操作符 C++ 语言为包含点操作符和解引用操作符的表达式提供了一个同义词:箭头 操作符(->)。点操作符(第 1.5.2 节)用于获取类类型对象的成员: item1.same_isbn(item2); // run the same_isbn member of item1 如果有一个指向 Sales_item 对象的指针(或迭代器),则在使用点操作符 前,需对该指针(或迭代器)进行解引用: Sales_item *sp = &item1; (*sp).same_isbn(item2); // run same_isbn on object to which sp points 这里,对 sp 进行解引用以获得指定的 Sales_item 对象。然后使用点操作 符调用指定对象的 same_isbn 成员函数。在上述用法中,注意必须用圆括号把 解引用括起来,因为解引用的优先级低于点操作符。如果漏掉圆括号,则这段代 码的含义就完全不同了: // run the same_isbn member of sp then dereference the result! *sp.same_isbn(item2); // error: sp has no member named same_isbn 这个表达式企图获得 sp 对象的 same_isbn 成员。等价于: *(sp.same_isbn(item2)); // equivalent to *sp.same_isbn(item2); 然而,sp 是一个没有成员的指针;这段代码无法通过编译。 225 因为编程时很容易忘记圆括号,而且这类代码又经常使用,所以 C++ 为在 点操作符后使用的解引用操作定义了一个同义词:箭头操作符(->)。假设有一 个指向类类型对象的指针(或迭代器),下面的表达式相互等价: (*p).foo; // dereference p to get an object and fetch its member named foo p->foo; // equivalent way to fetch the foo from the object to which p points 具体地,可将 same_isbn 的调用重写为: sp->same_isbn(item2); // equivalent to (*sp).same_isbn(item2) Exercises Section 5.6 Exercise 编写程序定义一个 vector 对象,其每个元素都是指向 5.18: string 类型的指针,读取该 vector 对象,输出每个 string 的内容及其相应的长度。 Exercise 假设 iter 为 vector::iterator 类型的变量, 5.19: 指出下面哪些表达式是合法的,并解释这些合法表达式的 行为。 (a) *iter++; (c) *iter.empty() (e) ++*iter; (b) (*iter)++; (d) iter->empty(); (f) iter++->empty(); 5.7. 条件操作符 条件操作符是 C++ 中唯一的三元操作符,它允许将简单的 if-else 判断语 句嵌入表达式中。条件操作符的语法格式为: cond ? expr1 : expr2; 其中,cond 是一个条件判断表达式(第 1.4.1 节)。条件操作符首先计算 cond 的值,如果 cond 的值为 0,则条件为 false;如果 cond 非 0,则条件 为 true。无论如何,cond 总是要被计算的。然后,条件为 true 时计算 expr1 , 否则计算 expr2 。和逻辑与、逻辑或(&& 和 ||)操作符一样,条件操作符保 证了上述操作数的求解次序。expr1 和 expr2 中只有一个表达式被计算。下面 的程序说明了条件操作符的用法: int i = 10, j = 20, k = 30; 226 // if i > j then maxVal = i else maxVal = j int maxVal = i > j ? i : j; 避免条件操作符的深度嵌套 可以使用一组嵌套的条件操作符求出三个变量的最大值,并将最大值赋给 max: int max = i > j ?i>k?i:k : j > k ? j : k; 我们也可以用下面更长却更简单的比较语句实现相同的功能: int max = i; if (j > max) max = j; if (k > max) max = k; 在输出表达式中使用条件操作符 条件操作符的优先级相当低。当我们要在一个更大的表达式中嵌入条件表达 式时,通常必须用圆括号把条件表达式括起来。例如,经常使用条件操作符根据 一定的条件输出一个或另一个值,在输出表达式中,如果不严格使用圆括号将条 件操作符括起来,将会得到意外的结果: cout << (i < j ? i : j); // ok: prints larger of i and j cout << (i < j) ? i : j; // prints 1 or 0! cout << i < j ? i : j; // error: compares cout to int 第二个表达式比较有趣:它将 i 和 j 的比较结果视为 << 操作符的操作数, 输出 1 或 0。 << 操作符返回 cout 值,然后将返回结果作为条件操作符的判 断条件。也就是,第二个表达式等效于: cout << (i < j); // prints 1 or 0 cout ? i : j; // test cout and then evaluate i or j // depending on whether cout evaluates to true or false 227 Exercises Section 5.7 Exercise 编写程序提示用户输入两个数,然后报告哪个数比较小。 5.20: Exercise 编写程序处理 vector 对象的元素:将每个奇数值 5.21: 元素用该值的两倍替换。 5.8. sizeof 操作符 sizeof 操作符的作用是返回一个对象或类型名的长度,返回值的类型为 size_t(第 3.5.2 节),长度的单位是字节(第 2.1 节)。size_t 表达式的 结果是编译时常量,该操作符有以下三种语法形式: sizeof (type name); sizeof (expr); sizeof expr; 将 sizeof 应用在表达式 expr 上,将获得该表达式的结果的类型长度: Sales_item item, *p; // three ways to obtain size required to hold an object of type Sales_item sizeof(Sales_item); // size required to hold an object of type Sales_item sizeof item; // size of item's type, e.g., sizeof(Sales_item) sizeof *p; // size of type to which p points, e.g., sizeof(Sales_item) 将 sizeof 用于 expr 时,并没有计算表达式 expr 的值。特别是在 sizeof *p 中,指针 p 可以持有一个无效地址,因为不需要对 p 做解引用操作。 使用 sizeof 的结果部分地依赖所涉及的类型: • 对 char 类型或值为 char 类型的表达式做 sizeof 操作保证得 1。 • 对引用类型做 sizeof 操作将返回存放此引用类型对象所需的内在空间 大小。 • 对指针做 sizeof 操作将返回存放指针所需的内在大小;注意,如果要获 取该指针所指向对象的大小,则必须对指针进行引用。 228 • 对数组做 sizeof 操作等效于将对其元素类型做 sizeof 操作的结果乘 上数组元素的个数。 因为 sizeof 返回整个数组在内存中的存储长度,所以用 sizeof 数组的结 果除以 sizeof 其元素类型的结果,即可求出数组元素的个数: // sizeof(ia)/sizeof(*ia) returns the number of elements in ia int sz = sizeof(ia)/sizeof(*ia); Exercises Section 5.8 Exercise 编写程序输出的每种内置类型的长度。 5.22: Exercise 预测下列程序的输出是,并解释你的理由。然后运行该程 5.23: 序,输出的结果和你的预测的一样吗?如果不一样,为什 么? int x[10]; int *p = x; cout << sizeof(x)/sizeof(*x) << endl; cout << sizeof(p)/sizeof(*p) << endl; 5.9. 逗号操作符 逗号表达式是一组由逗号分隔的表达式,这些表达式从左向右计算。逗号表 达式的结果是其最右边表达式的值。如果最右边的操作数是左值,则逗号表达式 的值也是左值。此类表达式通常用于 for 循环: int cnt = ivec.size(); // add elements from size... 1 to ivec for(vector::size_type ix = 0; ix != ivec.size(); ++ix, --cnt) ivec[ix] = cnt; 上述的 for 语句在循环表达式中使 ix 自增 1 而 cnt 自减 1。每次循环 均要修改 ix 和 cnt 的值。当检验 ix 的条件判断成立时,程序将下一个元素 重新设置为 cnt 的当前值。 229 Exercises Section 5.9 Exercise 5.24: 本节的程序与第 5.5 节在 vector 对象中添加元素的程 序类似。两段程序都使用递减的计数器生成元素的值。本 程序中,我们使用了前自减操作,而第 5.5 节的程序则 使用了后自减操作。解释为什么一段程序中使用前自减操 作而在另一段程序中使用后自减操作。 5.10. 复合表达式的求值 含有两个或更多操作符的表达式称为复合表达式。在复合表达式中,操作数 和操作符的结合方式决定了整个表达式的值。表达式的结果会因为操作符和操作 数的分组结合方式的不同而不同。 操作数的分组结合方式取决于操作符的优先级和结合性。也就是说,优先级 和结合性决定了表达式的哪个部分用作哪个操作符的操作数。如果程序员不想考 虑这些规则,可以在复合表达式中使用圆括号强制实现某个特殊的分组。 优先级规定的是操作数的结合方式,但并没有说明操作数的计 算顺序。在大多数情况下,操作数一般以最方便的次序求解。 5.10.1. 优先级 表达式的值取决于其子表达式如何分组。例如,下面的表达式,如果纯粹从 左向右计算,结果为 20: 6 + 3 * 4 / 2 + 2; 想像中其他可能的结果包括 9、14 和 36。在 C++ 中,该表达式的值应为 14。 乘法和除法的优先级高于加法操作,于是它们的操作数先于加法操作的操作 数计算。但乘法和除法的优先级相同。当操作符的优先级相同时,由其结合性决 定求解次序。算术操作具有左结合性,这意味着它们从左向右结合。因此上面表 达式等效于: int temp = 3 * 4; int temp2 = temp / 2; // 12 // 6 230 int temp3 = temp2 + 6; int result = temp3 + 2; // 12 // 14 圆括号凌驾于优先级之上 我们可使用圆括号推翻优先级的限制。使用圆括号的表达式将用圆括号括起 来的子表达式视为独立单元先计算,其他部分则以普通的优先级规则处理。例如, 下面的程序在前述表达式上添加圆括号,强行更改其操作次序,可能得到四种结 果: // parentheses on this expression match default precedence/associativity cout << ((6 + ((3 * 4) / 2)) + 2) << endl; // prints 14 // parentheses result in alternative groupings cout << (6 + 3) * (4 / 2 + 2) << endl; // prints 36 cout << ((6 + 3) * 4) / 2 + 2 << endl; // prints 20 cout << 6 + 3 * 4 / (2 + 2) << endl; // prints 9 我们已经通过前面的例子了解了优先级规则如何影响程序的正确性。例如, 考虑第 5.5 节第二个建议框中描述的表达式: *iter++; 其中,++ 的优先级高于*操作符,这就意味着 iter++ 先结合。而操作符 * 的操作数是 iter 做了自增操作后的结果。如果我们希望对 iter 所指向的值做 自增操作,则必须使用圆括号强制实现我们的目的: (*iter)++; // increment value to which iter refers and yield unincremented value 圆括号指明操作符 * 的操作数是 iter,然后表达式以 *iter 作为 ++操作 符的操作数。 另一个例子,回顾一下第 5.4.2 节中的 while 循环条件: while ((i = get_value()) != 42) { 赋值操作上的圆括号是必需的,这样才能实现预期的操作:将 get_value 的 返回值赋给 i,然后检查刚才赋值的结果是否为 42。如果赋值操作上没有加圆 括号,结果将是先判断 get_value 的返回值是否为 42,然后将判断结果 true 或 false 值赋给 i,这意味着 i 的值只能是 1 或 0。 231 5.10.2. 结合性 结合性规定了具有相同优先级的操作符如何分组。我们已经遇到过涉及结合 性的例子。其中之一使用了赋值操作的右结合性,这个特性允许将多个赋值操作 串接起来: ival = jval = kval = lval // right associative (ival = (jval = (kval = lval))) // equivalent, parenthesized version 该表达式首先将 lval 赋给 kval ,然后将 kval 的值赋给 jval ,最后将 jval 的值再赋给 ival。 另一方面,算术操作符为左结合。表达式 ival * jval / kval * lval // left associative (((ival * jval) / kval) * lval) // equivalent, parenthesized version 先对 ival 和 jval 做乘法操作,然后乘积除以 kval,最后再将其商与 lval 相乘。 表 5.4 按照优先级顺序列出了 C++ 的全部操作符。该表以双横线分割成不 同的段,每段内各个操作符的优先级相同,且都高于后面各段中的操作符。例如, 前自增操作符和解引用操作符的优先级相同,它们的优先级都比算术操作符或关 系操作符高。此表中大部分操作符已经介绍过,而少数未介绍的操作符将在后续 章节中学习。 表 5.4. 操作符的优先级 Associativity and Operator 操作符及其结合性 L :: L :: L :: L. L -> Function 功能 Use 用法 global scope(全局作用域) :: name class scope(类作用域) class :: name namespace scope(名字空 namespace :: name 间作用域) member selectors(成员选 object . member 择) member selectors(成员选 pointer -> member 择) 232 Associativity and Operator 操作符及其结合性 Function 功能 Use 用法 L [] subscript(下标) variable [ expr ] L () function call(函数调用) name (expr_list) L () type construction(类型 type (expr_list) 构造) R ++ postfix increment(后自 lvalue++ 增操作) R -- postfix decrement(后自 lvalue-减操作) R typeid type ID(类型 ID) typeid (type) R typeid run-time type ID(运行时 typeid (expr) 类型 ID) R explicit cast(显式 type conversion(类型转 cast_name 强制类型转换) 换) (expr) R sizeof size of object(对象的大 sizeof expr 小) R sizeof size of type(类型的大小) sizeof(type) R ++ prefix increment(前自增 ++ lvalue 操作) R -- prefix decrement(前自减 -- lvalue 操作) R~ bitwise NOT(位求反) ~expr R! logical NOT(逻辑非) !expr R- unary minus(一元负号) -expr R+ unary plus(一元正号) +expr R* dereference(解引用) *expr R& address-of(取地址) &expr R () type conversion(类型转 (type) expr 换) 233 Associativity and Operator 操作符及其结合性 R new R delete R delete[] L ->* L .* L* L/ L% L+ LL << L >> L< L <= L> L >= L == L != L& Function 功能 Use 用法 allocate object(创建对 new type 象) deallocate object(释放 delete expr 对象) deallocate array(释放数 delete[] expr 组) ptr to member select(指 ptr ->* ptr_to_member 向成员操作的指针) ptr to member select(指 obj .*ptr_to_member 向成员操作的指针) multiply(乘法) expr * expr divide(除法) expr / expr modulo (remainder)(求模 expr % expr (求余)) add(加法) expr + expr subtract(减法) expr - expr bitwise shift left(位左 expr << expr 移) bitwise shift right(位 expr >> expr 右移) less than(小于) expr < expr less than or equal(小于 expr <= expr 或等于) greater than(大于) expr > expr greater than or equal(大 expr >= expr 于或等于) equality(相等) expr == expr inequality(不等) expr != expr bitwise AND(位与) expr & expr 234 Associativity and Operator 操作符及其结合性 L^ L| L && L || R ?: R= R *=, /=, %=, R +=, -=, R <<=, >>=, R &=,|=, ^= R throw L, Function 功能 Use 用法 bitwise XOR() expr ^ expr bitwise OR(位异或) expr | expr logical AND(逻辑与) expr && expr logical OR(逻辑或) expr || expr conditional(条件操作) expr ? expr : expr assignment(赋值操作) lvalue = expr compound assign(复合赋 lvalue += expr, etc. 值操作) throw exception(抛出异 throw expr 常) comma(逗号) expr , expr 235 Exercises Section 5.10.2 Exercise 根据表 5.4 的内容,在下列表达式中添加圆括号说明其 5.25: 操作数分组的顺序(即计算顺序): (a) ! ptr == ptr->next (b) ch = buf[ bp++ ] != '\n' Exercise 习题 5.25 中的表达式的计算次序与你的意图不同,给它 5.26: 们加上圆括号使其以你所希望的操作次序求解。 Exercise 由于操作符优先级的问题,下列表达式编译失败。请参照 5.27: 表 5.4 解释原因,应该如何改正? string s = "word"; // add an 's' to the end, if the word doesn't already end in 's' string pl = s + s[s.size() - 1] == 's' ? "" : "s" ; 5.10.3. 求值顺序 在第 5.2 节中,我们讨论了 && 和 || 操作符计算其操作数的次序:当且 仅当其右操作数确实影响了整个表达式的值时,才计算这两个操作符的右操作 数。根据这个原则,可编写如下代码: // iter only dereferenced if it isn't at end while (iter != vec.end() && *iter != some_val) C++中,规定了操作数计算顺序的操作符还有条件(?:)和逗号操作符。除 此之外,其他操作符并未指定其操作数的求值顺序。 例如,表达式 f1() * f2(); 在做乘法操作之前,必须调用 f1 函数和 f2 函数,毕竟其调用结果要相乘。 然而,我们却无法知道到底是先调用 f1 还是先调用 f2。 236 其实,以什么次序求解操作数通常没有多大关系。只有当操作 符的两个操作数涉及到同一个对象,并改变其值时,操作数的 计算次序才会影响结果。 如果一个子表达式修改了另一个子表达式的操作数,则操作数的求解次序就 变得相当重要: // oops! language does not define order of evaluation if (ia[index++] < ia[index]) 此表达式的行为没有明确定义。问题在于:< 操作符的左右操作数都使用了 index 变量,但是,左操作数更改了该变量的值。假设 index 初值为 0,编译 器可以用下面两种方式之一求该表达式的值: if (ia[0] < ia[0]) // execution if rhs is evaluated first if (ia[0] < ia[1]) // execution if lhs is evaluated first 可以假设程序员希望先求左操作数的值,因此 index 的值加 1。如果是这 样的话,比较 ia[0] 和 ia[1] 的值。然而,C++ 语言不能确保从左到右的计算 次序。事实上,这类表达式的行为没有明确定义。一种实现可能是先计算右操作 数,于是 ia[0] 与自己做比较,要不然就是做完全不同的操作。 237 建议:复合表达式的处理 初学 C 和 C++ 的程序员一般很难理解求值顺序、优先级和结合性规则。 误解表达式和操作数如何求解将导致大量的程序错误。此外,除非程序 员已经完全理解了相关规则,否则这类错误很难发现,因为仅靠阅读程 序是无法排除这些错误的。 下面两个指导原则有助于处理复合表达式: 1. 如果有怀疑,则在表达式上按程序逻辑要求使用圆括号强制操作 数的组合。 2. 如果要修改操作数的值,则不要在同一个语句的其他地方使用该 操作数。如果必须使用改变的值,则把该表达式分割成两个独立 语句:在一个语句中改变该操作数的值,再在下一个语句使用它。 第二个规则有一个重要的例外:如果一个子表达式修改操作数的值,然 后将该子表达式的结果用于另一个子表达式,这样则是安全的。例如, *++iter 表达式的自增操作修改了 iter 的值,然后将 iter(修改后) 的值用作 * 操作符的操作数。对于这个表达式或其他类似的表达式,其 操作数的计算次序无关紧要。而为了计算更复杂的表达式,改变操作数 值的子表达式必须首先计算。这种方法很常用,不会产生什么问题。 一个表达式里,不要在两个或更多的子表达式中对同一对象做 自增或自减操作。 以一种安全而且独立于机器的方式重写上述比较两个数组元素的程序: if (ia[index] < ia[index + 1]) { // do whatever } ++index; 现在,两个操作数的值不会相互影响。 238 Exercises Section 5.10.3 Exercise 5.28: 除了逻辑与和逻辑或外,C++ 没有明确定义二元操作符的 求解次序,编译器可自由地提供最佳的实现方式。只能在 “实现效率”和程序语言使用中“潜在的缺陷”之间寻 求平衡。你认为这可以接受吗?说出你的理由。 Exercise 5.29: 假设 ptr 指向类类型对象,该类拥有一个名为 ival 的 int 型数据成员, vec 是保存 int 型元素的 vector 对 象,而 ival、 jval 和 kval 都是 int 型变量。请解释 下列表达式的行为,并指出哪些(如果有的话)可能是不 正确的,为什么?如何改正? (a) ptr->ival != 0 (b) ival != jval < kval (c) ptr != 0 && *ptr++ (d) ival++ && ival (e) vec[ival++] <= vec[ival] 5.11. new 和 delete 表达式 第 4.3.1 节介绍了如何使用 new 和 delete 表达式动态创建和释放数组, 这两种表达式也可用于动态创建和释放单个对象。 定义变量时,必须指定其数据类型和名字。而动态创建对象时,只需指定其 数据类型,而不必为该对象命名。取而代之的是,new 表达式返回指向新创建对 象的指针,我们通过该指针来访问此对象: int i; // named, uninitialized int variable int *pi = new int; // pi points to dynamically allocated, // unnamed, uninitialized int 这个 new 表达式在自由存储区中分配创建了一个整型对象,并返回此对象 的地址,并用该地址初始化指针 pi。 动态创建对象的初始化 动态创建的对象可用初始化变量的方式实现初始化: int i(1024); // value of i is 1024 239 int *pi = new int(1024); // object to which pi points is 1024 string s(10, '9'); // value of s is "9999999999" string *ps = new string(10, '9'); // *ps is "9999999999" C++ 使用直接初始化(direct-initialization)语法规则(第 2.3.3 节) 初始化动态创建的对象。如果提供了初值,new 表达式分配到所需要的内存后, 用给定的初值初始化该内存空间。在本例中,pi 所指向的新创建对象将被初始 化为 1024,而 ps 所指向的对象则初始化为十个 9 的字符串。 动态创建对象的默认初始化 如果不提供显式初始化,动态创建的对象与在函数内定义的变量初始化方式 相同(第 2.3.4 节)。对于类类型的对象,用该类的默认构造函数初始化;而 内置类型的对象则无初始化。 string *ps = new string; // initialized to empty string int *pi = new int; // pi points to an uninitialized int 通常,除了对其赋值之外,对未初始化的对象所关联的值的任何使用都是没 有定义的。 正如我们(几乎)总是要初始化定义为变量的对象一样, 在动态创建对象时,(几乎)总是对它做初始化也是一 个好办法。 同样也可对动态创建的对象做值初始化(value-initialize)(第 3.3.1 节): string *ps = new string(); // initialized to empty string int *pi = new int(); // pi points to an int value-initialized to 0 cls *pc = new cls(); // pc points to a value-initialized object of type cls 以上表明程序员想通过在类型名后面使用一对内容为空的圆括号对动态创 建的对象做值初始化。内容为空的圆括号表示虽然要做初始化,但实际上并未提 供特定的初值。对于提供了默认构造函数的类类型(例如 string),没有必要 对其对象进行值初始化:无论程序是明确地不初始化还是要求进行值初始化,都 会自动调用其默认构造函数初始化该对象。而对于内置类型或没有定义默认构造 函数的类型,采用不同初始化方式则有显著的差别: int *pi = new int; // pi points to an uninitialized int 240 int *pi = new int(); to 0 // pi points to an int value-initialized 第一个语句的 int 型变量没有初始化,而第二个语句的 int 型变量则被初 始化为 0。 值初始化的 () 语法必须置于类型名后面,而不是变量后。正 如我们将要学习的第 7.4 节的例子: int x(); // does not value initialize x 这个语句声明了一个名为 x、没有参数而且返回 int 值的函数。 耗尽内存 尽管现代机器的内存容量越来越大,但是自由存储区总有可能被耗尽。如果 程序用完了所有可用的内存,new 表达式就有可能失败。如果 new 表达式无法 获取需要的内存空间,系统将抛出名为 bad_alloc 的异常。我们将在第 6.13 节 介绍如何抛出异常。 撤销动态创建的对象 动态创建的对象用完后,程序员必须显式地将该对象占用的内存返回给自由 存储区。C++ 提供了 delete 表达式释放指针所指向的地址空间。 delete pi; 该命令释放 pi 指向的 int 型对象所占用的内存空间。 如果指针指向不是用 new 分配的内存地址,则在该指针上使用 delete 是不合法的。 C++ 没有明确定义如何释放指向不是用 new 分配的内存地址的指针。下面 提供了一些安全的和不安全的 delete expressions 表达式。 int i; 241 int *pi = &i; string str = "dwarves"; double *pd = new double(33); delete str; // error: str is not a dynamic object delete pi; // error: pi refers to a local delete pd; // ok 值得注意的是:编译器可能会拒绝编译 str 的 delete 语句。编译器知道 str 并不是一个指针,因此会在编译时就能检查出这个错误。第二个错误则比较 隐蔽:通常来说,编译器不能断定一个指针指向什么类型的对象,因此尽管这个 语句是错误的,但在大部分编译器上仍能通过。 零值指针的删除 如果指针的值为 0,则在其上做 delete 操作是合法的,但这样做没有任何 意义: int *ip = 0; delete ip; // ok: always ok to delete a pointer that is equal to 0 C++ 保证:删除 0 值的指针是安全的。 在 delete 之后,重设指针的值 执行语句 delete p; 后,p 变成没有定义。在很多机器上,尽管 p 没有定义,但仍然存放了它之前 所指向对象的地址,然而 p 所指向的内存已经被释放,因此 p 不再有效。 删除指针后,该指针变成悬垂指针。悬垂指针指向曾经存放对象的内存,但 该对象已经不再存在了。悬垂指针往往导致程序错误,而且很难检测出来。 一旦删除了指针所指向的对象,立即将指针置为 0,这 样就非常清楚地表明指针不再指向任何对象。 242 const 对象的动态分配和回收 C++ 允许动态创建 const 对象: // allocate and initialize a const object const int *pci = new const int(1024); 与其他常量一样,动态创建的 const 对象必须在创建时初始化,并且一经 初始化,其值就不能再修改。上述 new 表达式返回指向 int 型 const 对象的 指针。与其他 const 对象的地址一样,由于 new 返回的地址上存放的是 const 对象,因此该地址只能赋给指向 const 的指针。 对于类类型的 const 动态对象,如果该类提供了默认的构造函数,则此对 象可隐式初始化: // allocate default initialized const empty string const string *pcs = new const string; new 表达式没有显式初始化 pcs 所指向的对象,而是隐式地将 pcs 所指向 的对象初始化为空的 string 对象。内置类型对象或未提供默认构造函数的类类 型对象必须显式初始化。 警告:动态内存的管理容易出错 下面三种常见的程序错误都与动态内存分配相关: 1. 删除( delete )指向动态分配内存的指针失败,因而无法将该 块内存返还给自由存储区。删除动态分配内存失败称为“内存泄 漏(memory leak)”。内存泄漏很难发现,一般需等应用程序运 行了一段时间后,耗尽了所有内存空间时,内存泄漏才会显露出 来。 2. 读写已删除的对象。如果删除指针所指向的对象之后,将指针置 为 0 值,则比较容易检测出这类错误。 3. 对同一个内存空间使用两次 delete 表达式。当两个指针指向同 一个动态创建的对象,删除时就会发生错误。如果在其中一个指 针上做 delete 运算,将该对象的内存空间返还给自由存储区, 然后接着 delete 第二个指针,此时则自由存储区可能会被破坏。 操纵动态分配的内存时,很容易发生上述错误,但这些错误却难以跟踪 和修正。 243 删除 const 对象 尽管程序员不能改变 const 对象的值,但可撤销对象本身。如同其他动态 对象一样, const 动态对象也是使用删除指针来释放的: delete pci; // ok: deletes a const object 即使 delete 表达式的操作数是指向 int 型 const 对象的指针,该语句同 样有效地回收 pci 所指向的内容。 Exercises Section 5.11 Exercise 5.30: 下列语句哪些(如果有的话)是非法的或错误的? (a) vector svec(10); (b) vector *pvec1 = new vector(10); (c) vector **pvec2 = new vector[10]; (d) vector *pv1 = &svec; (e) vector *pv2 = pvec1; (f) delete svec; (g) delete pvec1; (h) delete [] pvec2; (i) delete pv1; (j) delete pv2; 5.12. 类型转换 表达式是否合法取决于操作数的类型,而且合法的表达式其含义也由其操作 数类型决定。但是,在 C++ 中,某些类型之间存在相关的依赖关系。若两种类 型相关,则可在需要某种类型的操作数位置上,使用该类型的相关类型对象或值。 如果两个类型之间可以相互转换,则称这两个类型相关。 考虑下列例子: int ival = 0; ival = 3.541 + 3; // typically compiles with a warning 244 ival 的值为 6。 首先做加法操作,其操作数是两个不同类型的值:3.541 是 double 型的字 面值常量,而 3 则是 int 型的字面值常量。C++ 并不是把两个不同类型的值直 接加在一起,而是提供了一组转换规则,以便在执行算术操作之前,将两个操作 数转换为同一种数据类型。这些转换规则由编译器自动执行,无需程序员介入 ——有时甚至不需要程序员了解。因此,它们也被称为隐式类型转换。 C++ 定义了算术类型之间的内置转换以尽可能防止精度损失。通常,如果表 达式的操作数分别为整型和浮点型,则整型的操作数被转换为浮点型。本例中, 整数 3 被转换为 double 类型,然后执行浮点类型的加法操作,得 double 类型 的结果 6.541。 下一步是将 double 类型的值赋给 int 型变量 ival。在赋值操作中,因为 不可能更改左操作数对象的类型,因此左操作数的类型占主导地位。如果赋值操 作的左右操作数类型不相同,则右操作数会被转换为左边的类型。本例中,double 型的加法结果转换为 int 型。double 向 int 的转换自动按截尾形式进行,小 数部分被舍弃。于是 6.541 变成 6,然后赋给 ival。因为从 double 到 int 的 转换会导致精度损失,因此大多数编译器会给出警告。例如,本书所用的测试例 程的编译器给出如下警告: warning: assignment to 'int' from 'double' 为了理解隐式类型转换,我们需要知道它们在什么时候发生,以及可能出现 什么类型的转换。 5.12.1. 何时发生隐式类型转换 编译器在必要时将类型转换规则应用到内置类型和类类型的对象上。在下列 情况下,将发生隐式类型转换: • 在混合类型的表达式中,其操作数被转换为相同的类型: int ival; double dval; ival >= dval // ival converted to double • 用作条件的表达式被转换为 bool 类型: int ival; if (ival) // ival converted to bool while (cin) // cin converted to bool 245 条件操作符(?:)中的第一个操作数以及逻辑非(!)、逻辑与(&&)和 逻辑或(||)的操作数都是条件表达式。出现在 if、while、for 和 do while 语句中的同样也是条件表达式(其中 do while 将在第六章中学 习)。 • 用一表达式初始化某个变量,或将一表达式赋值给某个变量,则该表达式 被转换为该变量的类型: int ival = 3.14; // 3.14 converted to int int *ip; ip = 0; // the int 0 converted to a null pointer of type int * 另外,在函数调用中也可能发生隐式类型转换,我们将在第七章学习这方面 的内容。 5.12.2. 算术转换 C++ 语言为内置类型提供了一组转换规则,其中最常用的是算术转换。算术 转换保证在执行操作之前,将二元操作符(如算术或逻辑操作符)的两个操作数 转换为同一类型,并使表达式的值也具有相同的类型。 算术转换规则定义了一个类型转换层次,该层次规定了操作数应按什么次序 转换为表达式中最宽的类型。在包含多种类型的表达式中,转换规则要确保计算 值的精度。例如,如果一个操作数的类型是 long double,则无论另一个操作数 是什么类型,都将被转换为 long double。 最简单的转换为整型提升:对于所有比 int 小的整型,包括 char、signed char、unsigned char、short 和 unsigned short,如果该类型的所有可能的值 都能包容在 int 内,它们就会被提升为 int 型,否则,它们将被提升为 unsigned int。如果将 bool 值提升为 int ,则 false 转换为 0,而 true 则 转换为 1。 有符号与无符号类型之间的转换 若表达式中使用了无符号( unsigned )数值,所定义的转换规则需保护操 作数的精度。unsigned 操作数的转换依赖于机器中整型的相对大小,因此,这 类转换本质上依赖于机器。 包含 short 和 int 类型的表达式, short 类型的值转换为 int 。如果 int 型足够表示所有 unsigned short 型的值,则将 unsigned short 转换为 int,否则,将两个操作数均转换为 unsigned int 。例如,如果 short 用半字 246 表示而 int 用一个字表示,则所有 unsigned 值都能包容在 int 内,在这种机 器上, unsigned short 转换为 int。 long 和 unsigned int 的转换也是一样的。只要机器上的 long 型足够表 示 unsigned int 型的所有值,就将 unsigned int 转换为 long 型,否则,将 两个操作数均转换为 unsigned long 。 在 32 位的机器上,long 和 int 型通常用一个字长表示,因此当表达式包 含 unsigned int 和 long 两种类型,其操作数都应转换为 unsigned long 型。 对于包含 signed 和 unsigned int 型的表达式,其转换可能出乎我们的意 料。表达式中的 signed 型数值会被转换为 unsigned 型。例如,比较 int 型 和 unsigned int 型的简单变量,系统首先将 int 型数值转换为 unsigned int 型,如果 int 型的值恰好为负数,其结果将以第 2.1.1 节介绍的方法转换,并 带来该节描述的所有副作用。 理解算术转换 研究大量例题是帮助理解算术转换的最好方法。下面大部分例题中,要么是 将操作数转换为表达式中的最大类型,要么是在赋值表达式中将右操作数转换为 左操作数的类型。 bool flag; char cval; short sval; unsigned short usval; int ival; unsigned int uival; long lval; unsigned long ulval; float fval; double dval; 3.14159L + 'a'; // promote 'a' to int, then convert to long double dval + ival; // ival converted to double dval + fval; // fval converted to double ival = dval; // dval converted (by truncation) to int flag = dval; // if dval is 0, then flag is false, otherwise true cval + fval; // cval promoted to int, that int converted to float sval + cval; // sval and cval promoted to int cval + lval; // cval converted to long ival + ulval; // ival converted to unsigned long usval + ival; // promotion depends on size of unsigned short and int uival + lval; // conversion depends on size of unsigned int and long 第一个加法操作的小写字母 'a' 是一个 char 类型的字符常量,正如我们 在第 2.1.1 节介绍的,它是一个数值。字母 'a' 表示的数值取决于机器字符集。 在 ASCII 机器中,字母 'a' 的值为 97。将 'a' 与 long double 型数据相加 247 时,char 型的值被提升为 int 型,然后将 int 型转换为 long double 型,转 换后的值再与 long double 型字面值相加。另一个有趣的现象是最后两个表达 式都包含 unsigned 数值。 5.12.3. 其他隐式转换 指针转换 在使用数组时,大多数情况下数组都会自动转换为指向第一个元素的指针: int ia[10]; // array of 10 ints int* ip = ia; // convert ia to pointer to first element 不将数组转换为指针的例外情况有:数组用作取地址(&)操作符的操作数 或 sizeof 操作符的操作数时,或用数组对数组的引用进行初始化时,不会将数 组转换为指针。我们将在第 7.2.4 节学习如何定义指向数组的引用(或指针)。 C++ 还提供了另外两种指针转换:指向任意数据类型的指针都可转换为 void* 类型;整型数值常量 0 可转换为任意指针类型。 转换为 bool 类型 算术值和指针值都可以转换为 bool 类型。如果指针或算术值为 0,则其 bool 值为 false ,而其他值则为 true: if (cp) /* ... */ // true if cp is not zero while (*cp) /* ... */ // dereference cp and convert resulting char to bool 这里,if 语句将 cp 的非零值转换为 true。 while 语句则对 cp 进行解 引用,操作结果产生一个 char 型的值。空字符( null )具有 0 值,被转换 为 false,而其他字符值则转换为 true。 算术类型与 bool 类型的转换 可将算术对象转换为 bool 类型,bool 对象也可转换为 int 型。将算术类 型转换为 bool 型时,零转换为 false ,而其他值则转换为 true 。将 bool 对 象转换为算术类型时,true 变成 1,而 false 则为 0: bool b = true; int ival = b; // ival == 1 248 double pi = 3.14; bool b2 = pi; // b2 is true pi = false; // pi == 0 转换与枚举类型 C++ 自动将枚举类型(第 2.7 节)的对象或枚举成员( enumerator )转 换为整型,其转换结果可用于任何要求使用整数值的地方。例如,用于算术表达 式: // point2d is 2, point2w is 3, point3d is 3, point3w is 4 enum Points { point2d = 2, point2w, point3d = 3, point3w }; const size_t array_size = 1024; // ok: pt2w promoted to int int chunk_size = array_size * pt2w; int array_3d = array_size * point3d; 将 enum 对象或枚举成员提升为什么类型由机器定义,并且依赖于枚举成员 的最大值。无论其最大值是什么, enum 对象或枚举成员至少提升为 int 型。 如果 int 型无法表示枚举成员的最大值,则提升到能表示所有枚举成员值的、 大于 int 型的最小类型( unsigned int、long 或 unsigned long)。 转换为 const 对象 当使用非 const 对象初始化 const 对象的引用时,系统将非 const 对象 转换为 const 对象。此外,还可以将非 const 对象的地址(或非 const 指针) 转换为指向相关 const 类型的指针: int i; const int ci = 0; const int &j = i; // ok: convert non-const to reference to const int const int *p = &ci; // ok: convert address of non-const to address of a const 由标准库类型定义的转换 类类型可以定义由编译器自动执行的类型转换。迄今为止,我们使用过的标 准库类型中,有一个重要的类型转换。从 istream 中读取数据,并将此表达式 作为 while 循环条件: 249 string s; while (cin >> s) 这里隐式使用了 IO 标准库定义的类型转换。在与此类似的条件中,求解表 达式 cin >> s,即读 cin。无论读入是否成功,该表达式的结果都是 cin。 while 循环条件应为 bool 类型的值,但此时给出的却是 istream 类类型 的值,于是 istream 类型的值应转换为 bool 类型。将 istream 类型转换为 bool 类型意味着要检验流的状态。如果最后一次读 cin 的尝试是成功的,则流 的状态将导致上述类型转换为 bool 类型后获得 true 值——while 循环条件 成立。如果最后一次尝试失败,比如说已经读到文件尾了,此时将 istream 类 型转换为 bool 类型后得 false,while 循环条件不成立。 Exercises Section 5.12.3 Exercise 记住,你可能需要考虑操作符的结合性,以便在表达式含 5.31: 有多个操作符的情况下确定答案。 (a) if (fval) (b) dval = fval + ival; (c) dval + ival + cval; 记住,你可能需要考虑操作符的结合性,以便 在表达式含有多个操作符 的情况下确定答案。 5.12.4. 显式转换 显式转换也称为强制类型转换(cast),包括以下列名字命名的强制类型转换操 作符:static_cast、dynamic_cast、const_cast 和 reinterpret_cast。 虽然有时候确实需要强制类型转换,但是它们本质上是非常危 险的。 250 5.12.5. 何时需要强制类型转换 因为要覆盖通常的标准转换,所以需显式使用强制类型转换。下面的复合赋 值: double dval; int ival; ival *= dval; // ival = ival * dval 为了与 dval 做乘法操作,需将 ival 转换为 double 型,然后将乘法操作 的 double 型结果截尾为 int 型,再赋值给 ival。为了去掉将 ival 转换为 double 型这个不必要的转换,可通过如下强制将 dval 转换为 int 型: ival *= static_cast(dval); // converts dval to int 显式使用强制类型转换的另一个原因是:可能存在多种转换时,需要选择一 种特定的类型转换。我们将在第 14 章中详细讨论这种情况。 5.12.6. 命名的强制类型转换 命名的强制类型转换符号的一般形式如下: cast-name(expression); 其中 cast-name 为 static_cast、dynamic_cast、const_cast 和 reinterpret_cast 之一,type 为转换的目标类型,而 expression 则是被强制 转换的值。强制转换的类型指定了在 expression 上执行某种特定类型的转换。 dynamic_cast dynamic_cast 支持运行时识别指针或引用所指向的对象。对 dynamic_cast 的讨论将在第 18.2 节中进行。 const_cast const_cast ,顾名思义,将转换掉表达式的 const 性质。例如,假设有函 数 string_copy,只有唯一的参数,为 char* 类型,我们对该函数只读不写。 在访问该函数时,最好的选择是修改它让它接受 const char* 类型的参数。如 果不行,可通过 const_cast 用一个 const 值调用 string_copy 函数: const char *pc_str; 251 char *pc = string_copy(const_cast(pc_str)); 只有使用 const_cast 才能将 const 性质转换掉。在这种情况下,试图使 用其他三种形式的强制转换都会导致编译时的错误。类似地,除了添加或删除 const 特性,用 const_cast 符来执行其他任何类型转换,都会引起编译错误。 static_cast 编译器隐式执行的任何类型转换都可以由 static_cast 显式完成: double d = 97.0; // cast specified to indicate that the conversion is intentional char ch = static_cast(d); 当需要将一个较大的算术类型赋值给较小的类型时,使用强制转换非常有 用。此时,强制类型转换告诉程序的读者和编译器:我们知道并且不关心潜在的 精度损失。对于从一个较大的算术类型到一个较小类型的赋值,编译器通常会产 生警告。当我们显式地提供强制类型转换时,警告信息就会被关闭。 如果编译器不提供自动转换,使用 static_cast 来执行类型转换也是很有 用的。例如,下面的程序使用 static_cast 找回存放在 void* 指针中的值(第 4.2.2 节): void* p = &d; // ok: address of any data object can be stored in a void* // ok: converts void* back to the original pointer type double *dp = static_cast(p); 可通过 static_cast 将存放在 void* 中的指针值强制转换为原来的指针 类型,此时我们应确保保持指针值。也就是说,强制转换的结果应与原来的地址 值相等。 reinterpret_cast reinterpret_cast 通常为操作数的位模式提供较低层次的重新解释。 reinterpret_cast 本质上依赖于机器。为了安全地使用 reinterpret_cast,要求程序员完全理解所涉及的数据类型, 以及编译器实现强制类型转换的细节。 例如,对于下面的强制转换: 252 int *ip; char *pc = reinterpret_cast(ip); 程序员必须永远记得 pc 所指向的真实对象其实是 int 型,而并非字符数 组。任何假设 pc 是普通字符指针的应用,都有可能带来有趣的运行时错误。例 如,下面语句用 pc 来初始化一个 string 对象: string str(pc); 它可能会引起运行时的怪异行为。 用 pc 初始化 str 这个例子很好地说明了显式强制转换是多么的危险。问 题源于类型已经改变时编译器没有提供任何警告或错误提示。当我们用 int 型 地址初始化 pc 时,由于显式地声明了这样的转换是正确的,因此编译器不提供 任何错误或警告信息。后面对 pc 的使用都假设它存放的是 char* 型对象的地 址,编译器确实无法知道 pc 实际上是指向 int 型对象的指针。因此用 pc 初 始化 str 是完全正确的——虽然实际上是无意义的或是错误的。查找这类问题 的原因相当困难,特别是如果 ip 到 pc 的强制转换和使用 pc 初始化 string 对象这两个应用发生在不同文件中的时候。 建议:避免使用强制类型转换 强制类型转换关闭或挂起了正常的类型检查(第 2.3 节)。强烈建议程 序员避免使用强制类型转换,不依赖强制类型转换也能写出很好的 C++ 程序。 这个建议在如何看待 reinterpret_cast 的使用时非常重要。此类强制 转换总是非常危险的。相似地,使用 const_cast 也总是预示着设计缺 陷。设计合理的系统应不需要使用强制转换抛弃 const 特性。其他的强 制转换,如 static_cast 和 dynamic_cast,各有各的用途,但都不应 频繁使用。每次使用强制转换前,程序员应该仔细考虑是否还有其他不 同的方法可以达到同一目的。如果非强制转换不可,则应限制强制转换 值的作用域,并且记录所有假定涉及的类型,这样能减少错误发生的机 会。 5.12.7. 旧式强制类型转换 在引入命名的强制类型转换操作符之前,显式强制转换用圆括号将类型括起 来实现: char *pc = (char*) ip; 253 效果与使用 reinterpret_cast 符号相同,但这种强制转换的可视性比较 差,难以跟踪错误的转换。 标准 C++ 为了加强类型转换的可视性,引入命名的强制转换操作符,为程 序员在必须使用强制转换时提供了更好的工具。例如,非指针的 static_cast 和 const_cast 要比 reinterpret_cast 更安全。结果使程序员(以及读者和操纵 程序的工具)可清楚地辨别代码中每个显式的强制转换潜在的风险级别。 虽然标准 C++ 仍然支持旧式强制转换符号,但是我们 建议,只有在 C 语言或标准 C++ 之前的编译器上编写 代码时,才使用这种语法。 旧式强制转换符号有下列两种形式: type (expr); // Function-style cast notation (type) expr; // C-language-style cast notation 旧式强制转换依赖于所涉及的数据类型,具有与 const_cast、 static_cast 和 reinterpret_cast 一样的行为。在合法使用 static_cast 或 const_cast 的地方,旧式强制转换提供了与各自对应的命名强制转换一样的功能。如果这两 种强制转换均不合法,则旧式强制转换执行 reinterpret_cast 功能。例如,我 们可用旧式符号重写上一节的强制转换: int ival; double dval; ival += int (dval); // static_cast: converts double to int const char* pc_str; string_copy((char*)pc_str); // const_cast: casts away const int *ip; char *pc = (char*)ip; // reinterpret_cast: treats int* as char* 支持旧式强制转换符号是为了对“在标准 C++ 之前编写的程序”保持向后 兼容性,并保持与 C 语言的兼容性。 254 Exercises Section 5.12.7 Exercise 5.32: 给定下列定义: char cval; int ival; float fval; unsigned int ui; double dval; 指出可能发生的(如果有的话)隐式类型转换: (a) cval = 'a' + 3; * 1.0; (c) dval = ui * fval; + dval; (b) fval = ui - ival (d) cval = ival + fval Exercise 5.33: 给定下列定义: int ival; dval; const string *ps; char *pc; double void *pv; 用命名的强制类型转换符号重写下列语句: (a) pv = (void*)ps; (c) pv = &dval; (b) ival = int(*pc); (d) pc = (char*) pv; 小结 C++ 提供了丰富的操作符,并定义了用于内置类型值时操作符的含义。除此 之外,C++ 还支持操作符重载,允许由程序员自己来定义用于类类型时操作符的 含义。我们将在第十四章中学习如何重载自定义的操作符。 要理解复合表达式(即含有多个操作符的表达式)就必须先了解优先级、结 合性以及操作数的求值次序。每一个操作符都有自己的优先级别和结合性。优先 级规定复合表达式中操作符结合的方式,而结合性则决定同一个优先级的操作符 如何结合。 255 大多数操作符没有规定其操作数的求值顺序:由编译器自由选择先计算左操 作数还是右操作数。通常,操作数的求值顺序不会影响表达式的结果。但是,如 果操作符的两个操作数都与同一个对象相关,而且其中一个操作数改变了该对象 的值,则程序将会因此而产生严重的错误——而且这类错误很难发现。 最后,可以使用某种类型编写表达式,而实际需要的是另一类型的值。此时, 编译器自动实现类型转换(既可以是内置类型也可以是为类类型而定义的),将 特定类型转换为所需的类型。C++ 还提供了强制类型转换显式地将数值转换为所 需的数据类型。 术语 arithmetic conversion(算术转换) 算术类型之间的转换。在使用二元算术操作符的地方,算术转换通常将较 小的类型转换为较大的类型,以确保精度(例如,将小的整型 char 型和 short 型转换为 int 型)。 associativity(结合性) 决定同一优先级的操作符如何结合。C++ 的操作符要么是左结合(操作符 从左向右结合)要么是右结合(操作符从右向左结合)。 binary operators(二元操作符) 有两个操作数的操作符。 cast(强制类型转换) 显式的类型转换。 compound expression(复合表达式) 含有多个操作符的表达式。 const_cast 将 const 对象转换为相应的非 const 类型的强制转换。 conversion(类型转换) 将某种类型的值转换为另一种类型值的处理方式。C++ 语言定义了内置类 型之间的类型转换,也允许将某种类型转换为类类型或将类类型转换为某 种类型。 256 dangling pointer(悬垂指针) 指向曾经存在的对象的指针,但该对象已经不再存在了。悬垂指针容易导 致程序错误,而且这种错误很难检测出来。 delete expression(delete 表达式) delete 表达式用于释放由 new 动态分配的内存。delete 有两种语法形 式: delete p; // delete object delete [] p; // delete array 第一种形式的 p 必须是指向动态创建对象的指针;第二种形式的 p 则应 指向动态创建数组的第一个元素。C++ 程序使用 delete 取代 C 语言的 标准库函数 free。 dynamic_cast 用于结合继承和运行时类型识别。参见第 18.2 节。 expression(表达式) C++程序中的最低级的计算。表达式通常将一个操作符用于一个或多个操 作数。每个表达式产生一个结果。表达式也可用作操作数,因此可用多个 操作符编写复合表达式。 implicit conversion(隐式类型转换) 编译器自动实现的类型转换。假设表达式需要某种特定类型的数值,但其 操作数却是其他不同的类型,此时如果系统定义了适当的类型转换,编译 器会自动根据转换规则将该操作数转换为需要的类型。 integral promotions(整型提升) 整型提升是标准类型转换规则的子集,它将较小的整型转换为最接近的较 大数据类型。整型(如 short、char 等)被提升为 int 型或 unsigned int 型。 new expression(new 表达式) new 表达式用于运行时从自由存储区中分配内存空间。本章使用 new 创 建单个对象,其语法形式为: new type; 257 new type(inits); new 表达式创建指定 type 类型的对象,并且可选择在创建时使用 inits 初值初始化该对象,然后返回指向该对象的指针。C++ 程序使用 new 取 代 C 语言的标准库函数 malloc。 operands(操作数) 表达式操纵的值。 operator(操作符) 决定表达式执行什么功能的符号。C++ 语言定义了一组操作符以及将它们 用于内置类型时的含义,还定义了每个操作符的优先级和结合性以及它们 所需要的操作数个数。C++ 语言允许重载操作符,以使它们能用于类类型 的对象。 operator overloading(操作符重载) 对操作符的功能重定义以用于类类型。我们将在第十四章中学习如何重载 不同的操作符版本。 order of evaluation(求值顺序) 操作符的操作数计算顺序(如果有的话)。大多数情况下,C++ 编译器可 自由选择操作数求解的次序。 precedence(优先级) 定义了复合表达式中不同操作符的结合方式。高优先级的操作符要比低优 先级操作符结合得更紧密。 reinterpret_cast 将操作数内容解释为另一种不同的类型。这类强制转换本质上依赖于机 器,而且是非常危险的。 result(结果) 计算表达式所获得的值或对象。 static_cast 编译器隐式执行的任何类型转换都可以由 static_cast 显式完成。我们 常常使用 static_cast 取代由编译器实现的隐式转换。 258 unary operators(一元操作符) 只有一个操作数的操作符。 ~ operator 逐位求反操作符,将其操作数的每个位都取反。 , operator 逗号操作符。用逗号隔开的表达式从左向右计算,整个逗号表达式的结果 为其最右边的表达式的值。 ^ operator 位异或操作符。在做位异或操作时,如果两个操作数对应位置上的位只有 一个(注意不是两个)为 1,则操作结果中该位为 1,否则为 0,位异或 操作产生一个新的整数值。 | operator 位或操作符。在做位或操作时,如果两个操作数对应位置上的位至少有一 个为 1,则操作结果中该位为 1,否则为 0,位或操作产生一个新的整数 值。 ++ operator 自增操作符。自增操作符有两种形式:前置操作和后置操作。前自增操作 生成左值,在给操作数加 1 后返回改变后的操作数值。后自增操作生成右 值,给操作数加 1 但返回未改变的操作数原值。 -- operator 自减操作符。自减操作符也有两种形式:前置操作和后置操作。前自减操 作生成左值,在给操作数减 1 后返回改变后的操作数值。后自减操作生成 右值,给操作数减 1 但返回未改变的操作数原值。 << operator 左移操作符。左移操作符将其左操作数的各个位向左移动若干个位,移动 的位数由其右操作数指定。左移操作符的右操作数必须是 0 值或正整数, 而且它的值必须严格小于左操作数的位数。 >> operator 259 右移操作符。与左移操作符类似,右移操作符将其左操作数的各个位向右 移动,其右操作数必须是 0 值或正整数,而且它的值必须严格小于左操作 数的位数。 260 第六章 语句 语句类似于自然语言中的句子。C++ 语言既有只完成单一任务的简单语句, 也有作为一个单元执行的由一组语句组成的复合语句。和大多数语言一样,C++ 也提供了实现条件分支结构的语句以及重复地执行同一段代码的循环结构。本章 将详细讨论 C++ 所支持的语句。 通常情况下,语句是顺序执行的。但是,除了最简单的程序外,只有顺序执 行往往并不足够。为此,C++ 定义了一组控制流语句,允许有条件地执行或者重 复地执行某部分功能。if 和 switch 语句提供了条件分支结构,而 for、while 和 do while 语句则支持重复执行的功能。后几种语句常称为循环或者迭代语 句。 6.1. 简单语句 C++ 中,大多数语句以分号结束。例如,像 ival + 5 这样的表达式,在后面加 上分号,就是一条表达式语句。表达式语句用于计算表达式。但执行下面的语句 ival + 5; // expression statement 却没有任何意义:因为计算出来的结果没有用于赋值或其他用途。通常,表达式 语句所包含的表达式在计算时会影响程序的状态,使用赋值、自增、输入或输出 操作符的表达式就是很好的例子。 空语句 程序语句最简单的形式是空语句,它使用以下的形式(只有一个单独的分 号): ; // null statement 如果在程序的某个地方,语法上需要一个语句,但逻辑上并不需要,此时应 该使用空语句。这种用法常见于在循环条件判断部分就能完成全部循环工作的情 况。例如,下面程序从输入流中读取数据,在获得某个特殊值前无需作任何操作: // read until we hit end-of-file or find an input equal to sought while (cin >> s && s != sought) ; // null statement 261 循环条件从标准输入中读入一个值并检验 cin 的读入是否成功。如果成功 读取数据,循环条件紧接着检查该值是否等于 sought。如果找到了需要的值, 则退出 while 循环;否则,循环条件再次从 cin 里读入另一个值继续检验。 使用空语句时应该加上注释,以便任何读这段代码的人 都知道该语句是有意省略的。 由于空语句也是一个语句,因此可用在任何允许使用语句的地方。由于这个 原因,那些看似非法的分号往往只不过是一个空语句而已: // ok: second semicolon is superfluous null statement ival = v1 + v2;; 这个程序段由两条语句组成:一条表达式语句和一条空语句。 无关的空语句并非总是无害的。 在 while 或 if 条件后面额外添加分号,往往会彻底改变程序员的意图: // disaster: extra semicolon: loop body is this null statement while (iter != svec.end()) ; // null statement--while body is empty! ++iter; // increment is not part of the loop 这个程序将会无限次循环。与缩进的意义相反,此自增语句并不是循环的一 部分。由于循环条件后面多了一个分号,因此循环体为空语句。 6.2. 声明语句 在 C++ 中,对象或类的定义或声明也是语句。尽管定义语句这种说法也许 更准确些,但定义语句经常被称为声明语句。第 2.3 节介绍了变量的定义和声 明。第 2.8 节介绍了类的定义,相关内容将在第十二章进一步探讨。 262 6.3. 复合语句(块) 复合语句,通常被称为块,是用一对花括号括起来的语句序列(也可能是空 的)。块标识了一个作用域,在块中引入的名字只能在该块内部或嵌套在块中的 子块里访问。通常,一个名字只从其定义处到该块的结尾这段范围内可见。 复合语句用在语法规则要求使用单个语句但程序逻辑却需要不止一个语句 的地方。例如,while 或 for 语句的循环体必须是单个语句。然而,大多数情 况都需要在循环体里执行多个语句。因而可使用一对花括号将语句序列括起来, 使其成为块语句。 回顾一下第 1.6 节处理书店问题的程序,以其中用到的 while 循环为例: // if so, read the transaction records while (std::cin >> trans) if (total.same_isbn(trans)) // match: update the running total total = total + trans; else { // no match: print & assign to total std::cout << total << std::endl; total = trans; } 在 else 分支中,程序逻辑需要输出 total 的值,然后用 trans 重置 total。但是,else 分支只能后接单个语句。于是,用一对花括号将上述两条语 句括起来,使其在语法上成为单个语句(复合语句)。这个语句既符合语法规则 又满足程序的需要。 与其他大多数语句不同,块并不是以分号结束的。 像空语句一样,程序员也可以定义空块,用一对内部没有语句的花括号实现: while (cin >> s && s != sought) { } // empty block 263 Exercises Section 6.3 Exercise 什么是空语句?请给出一个使用空语句的例子。 6.1: Exercise 什么是块语句?请给出一个使用块的例子。 6.2: Exercise 使用逗号操作符(第 5.9 节)重写书店问题中 while 循 6.3: 环里的 else 分支,使它不再需要用块实现。解释一下重 写后是提高还是降低了该段代码的可读性。 Exercise 在解决书店问题的 while 循环中,如果删去 while 后面 6.4: 的左花括号及相应的右花括号,将会给程序带来什么影 响? 6.4. 语句作用域 有些语句允许在它们的控制结构中定义变量: while (int i = get_num()) cout << i << endl; i = 0; // error: i is not accessible outside the loop 在条件表达式中定义的变量必须初始化,该条件检验的就是初 始化对象的值。 在语句的控制结构中定义的变量,仅在定义它们的块语句结束前有效。这种 变量的作用域限制在语句体内。通常,语句体本身就是一个块语句,其中也可能 包含了其他的块。一个在控制结构里引入的名字是该语句的局部变量,其作用域 局限在语句内部。 // index is visible only within the for statement for (vector::size_type index = 0; index != vec.size(); ++index) { // new scope, nested within the scope of this for statement int square = 0; 264 if (index % 2) // ok: index is in scope square = index * index; vec[index] = square; } if (index != vec.size()) // error: index is not visible here 如果程序需要访问某个控制结构中的变量,那么这个变量必须在控制语句外 部定义。 vector::size_type index = 0; for ( /* empty */ ; index != vec.size(); ++index) // as before if (index != vec.size()) // ok: now index is in scope // as before 早期的 C++ 版本以不同的方式处理 for 语句中定义的变量的 作用域:将 for 语句头定义的变量视为在 for 语句之前定义。 有些更旧式的 C++ 程序代码允许在 for 语句作用域外访问控 制变量。 对于在控制语句中定义的变量,限制其作用域的一个好处是,这些变量名可 以重复使用而不必担心它们的当前值在每一次使用时是否正确。对于作用域外的 变量,是不可能用到其在作用域内的残留值的。 6.5. if 语句 if 语句根据特定表达式是否为真来有条件地执行另一个语句。if 语句有两种形 式:其中一种带 else 分支而另一种则没有。根据语法结构,最简单的 if 语句 是这样的: if (condition) statement 其中的 condition 部分必须用圆括号括起来。它可以是一个表达式,例如: if (a + b > c) {/* ... */} 或者一个初始化声明,例如: 265 // ival only accessible within the if statement if (int ival = compute_value()) {/* ... */} 通常,statement 部分可以是复合语句,即用花括号括起来的块语句。 如果在条件表达式中定义了变量,那么变量必须初始化。将已初始化的变量值转 换为 bool 值(第 5.12.3 节)后,该 bool 值决定条件是否成立。变量类型可 以是任何可转换为 bool 型的类型,这意味着它可以是算术类型或指针类型。正 如第十四章要提及的,一个类类型能否用在条件表达式中取决于类本身。迄今为 止,在所有用过的类类型中,IO 类型可以用作条件,但 vector 类型和 string 类型一般不可用作条件。 为了说明 if 语句的用法,下面程序用于寻找 vector 对象中的最小值, 并且记录这个最小值出现的次数。为了解决这个问题,需要两个 if 语句:一个 判断是否得到一个新的最小值,而另一个则用来增加当前最小值的数目。 if (minVal > ivec[i]) { /* process new minVal */ } if (minVal == ivec[i]) { /* increment occurrence count */ } 语句块用作 if 语句的对象 现在单独考虑上述例子中的每个 if 语句。其中一个 if 语句将要决定是否出现 了一个新的最小值,如果是的话,则要重置计数器并更新最小值: if (minVal > ivec[i]) { // execute both statements if condition is true minVal = ivec[i]; occurs = 1; } 另一个 if 语句则有条件地更新计数器,它只需要一个语句,因此不必用花括号 起来: if (minVal == ivec[i]) ++occurs; 266 当多个语句必须作为单个语句执行时,比较常见的错误是漏掉 了花括号。 在下面的程序中,与程序员缩进目的相反,对 occurs 的赋值并不是 if 语句的 一部分: // error: missing curly brackets to make a block! if (minVal > ivec[i]) minVal = ivec[i]; occurs = 1; // executed unconditionally: not part of the if 这样写的话,对 occurs 的赋值将会无条件地执行。这种错误很难发现,因为程 序代码看起来是正确的。 很多编辑器和开发环境都是提供工具自动根据语句结 构缩排源代码。有效地利用这些工具将是一种很好的编 程方法。 6.5.1. if 语句的 else 分支 紧接着,我们要考虑如何将那些 if 语句放在一起形成一个执行语句序列。这些 if 语句的排列顺序非常重要。如果采用下面的顺序: if (minVal > ivec[i]) { minVal = ivec[i]; occurs = 1; } // potential error if minVal has just been set to ivec[i] if (minVal == ivec[i]) ++occurs; 那么计数器将永远得不到 1。这段代码只是对第一次出现的最小值重复计数。 267 这样两个 if 语句不但在值相同时执行起来有潜在的危险,而且还是没必要的。 同一个元素不可能既小于 minVal 又等于它。如果其中一个条件是真的,那么另 一个条件就可以安全地忽略掉。if 语句为这种只能二选一的条件提供了 else 子句。 if else 语句的语法形式为: if (condition) statement1 else statement2 如果 condition 为真,则执行 statement1;否则,执行 statement2: if (minVal == ivec[i]) ++occurs; else if (minVal > ivec[i]) { minVal = ivec[i]; occurs = 1; } 值得注意的是,statement2 既可以是任意语句,也可以是用花括号起来的块语 句。在这个例子里,statement2 本身是一个 if 语句。 悬垂 else 对于 if 语句的使用,还有一个重要的复杂问题没有考虑。上述例子中,注意到 没有一个 if 分支能直接处理元素值大于 minVal 的情况。从逻辑上来说,可以 忽略这些元素——如果该元素比当前已找到的最小值大,那就应该没什么要做 的。然而,通常需要使用 if 语句为三种不同情况提供执行的内容,即如果一个 值大于、小于或等于其他值时,可能都需要执行特定的步骤。为此重写循环部分, 显式地处理这三种情况: // note: indented to make clear how the else branches align with the corresponding if if (minVal < ivec[i]) {} // empty block else if (minVal == ivec[i]) ++occurs; else { // minVal > ivec[i] minVal = ivec[i]; occurs = 1; 268 } 上述的三路测试精确地控制了所有的情况。然而,简单地把前两个情况用一个嵌 套 if 语句实现将会产生问题: // oops: incorrect rewrite: This code won't work! if (minVal <= ivec[i]) if (minVal == ivec[i]) ++occurs; else { // this else goes with the inner if, not the outer one! minVal = ivec[i]; occurs = 1; } 这个版本表明了所有语言的 if 语句都普通存在着潜在的二义 性。这种情况往往称为悬垂 else 问题,产生于一个语句包含 的 if 子句多于 else 子句时:对于每一个 else,究竟它们归 属哪个 if 语句? 在上述的代码中,缩进的用法表明 else 应该与外层的 if 子句匹配。然而,C++ 中悬垂 else 问题带来的二义性,通过将 else 匹配给最后出现的尚未匹配的 if 子句来解决。在这个情况下,这个 if else 语句实际上等价于下面的程序: // oops: still wrong, but now the indentation matches execution path if (minVal <= ivec[i]) // indented to match handling of dangling-else if (minVal == ivec[i]) ++occurs; else { minVal = ivec[i]; occurs = 1; } a compound statement: if (minVal <= ivec[i]) { if (minVal == ivec[i]) ++occurs; } else { 269 minVal = ivec[i]; occurs = 1; } 有些编程风格建议总是在 if 后面使用花括号。这样做可以 避免日后修改代码时产生混乱和错误。至少,无论 if(或 者 while)后面是简单语句,例如赋值和输出语句,还是其 他任意语句,使用花括号都是一个比较好的做法。 Exercises Section 6.5.1 Exercise 6.5: 改正下列代码: (a) if (ival1 != ival2) ival1 = ival2 else ival1 = ival2 = 0; (b) if (ival < minval) minval = ival; minimum occurs = 1; counter // remember new // reset occurrence (c) if (int ival = get_value()) cout << "ival = " << ival << endl; if (!ival) cout << "ival = 0\n"; (d) if (ival = 0) ival = get_value(); Exercise 什么是“悬垂 else”?C++ 是如何匹配 else 子句的? 6.6: 6.6. switch 语句 深层嵌套的 if else 语句往往在语法上是正确的,但逻辑上去却没有正确地反 映程序员的意图。例如,错误的 else if 匹配很容易被忽略。添加新的条件和 270 逻辑关系,或者对语句做其他修改,都很难保证正确。switch 语句提供了一种 更方便的方法来实现深层嵌套的 if/else 逻辑。 假设要统计五个元音在文本里分别出现的次数,程序逻辑结构如下: • 按顺序读入每个字符直到读入完成为止。 • 把每个字符与元音字符集做比较。 • 如果该字符与某个元音匹配,则该元音的计数器加 1。 • 显示结果。 使用此程序分析本章的英文版,得到以下输出结果: Number of vowel a: 3499 Number of vowel e: 7132 Number of vowel i: 3577 Number of vowel o: 3530 Number of vowel u: 1185 6.6.1. 使用 switch 直接使用 switch 语句解决上述问题: char ch; // initialize counters for each vowel int aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0; while (cin >> ch) { // if ch is a vowel, increment the appropriate counter switch (ch) { case 'a': ++aCnt; break; case 'e': ++eCnt; break; case 'i': ++iCnt; break; case 'o': ++oCnt; break; case 'u': ++uCnt; 271 break; } } // print results cout << "Number of vowel a: \t" << aCnt << '\n' << "Number of vowel e: \t" << eCnt << '\n' << "Number of vowel i: \t" << iCnt << '\n' << "Number of vowel o: \t" << oCnt << '\n' << "Number of vowel u: \t" << uCnt << endl; 通过对圆括号内表达式的值与其后列出的关键字做比较,实现 switch 语句的功 能。表达式必须产生一个整数结果,其值与每个 case 的值比较。关键字 case 和 它所关联的值称为 case 标号。每个 case 标号的值都必须是一个常量表达式 (第 2.7 节)。除此之外,还有一个特殊的 case 标号——default 标号,我 们将在第 6.6.3 节介绍它。 如果表达式与其中一个 case 标号的值匹配,则程序将从该标号后面的第一个语 句开始依次执行各个语句,直到 switch 结束或遇到 break 语句为止。如果没 有发现匹配的 case 标号(并且也没有 default 标号),则程序从 switch 语 句后面的第一条继续执行。在这个程序中,switch 语句是 while 循环体中唯一 的语句,于是,switch 语句匹配失败后,将控制流返回给 while 循环条件。 第 6.10 节将讨论 break 语句。简单地说,break 语句中断当前的控制流。对 于 switch 的应用,break 语句将控制跳出 switch,继续执行 switch 语句后 面的第一个语句。在这个例子中,正如大家所知道的,将会把控制转移到 switch 后面的下一语句,即交回给 while。 6.6.2. switch 中的控制流 了解 case 标号的执行流是必要的。 存在一个普遍的误解:以为程序只会执行匹配的 case 标号相 关联的语句。实际上,程序从该点开始执行,并跨越 case 边 界继续执行其他语句,直到 switch 结束或遇到 break 语句为 止。 Sometimes this behavior is indeed correct. We want to execute the code for a particular label as well as the code for following labels. More often, we want to execute only the code particular to a given label. To avoid executing code for subsequent cases, the programmer must explicitly tell the compiler to stop execution by specifying a break statement. Under most 272 有时候,这种行为的确是正确的。程序员也许希望执行完某个特定标号的代码后, 接着执行后续标号关联的语句。但更常见的是,我们只需要执行某个特定标号对 应的代码。为了避免继续执行其后续 case 标号的内容,程序员必须利用 break 语句清楚地告诉编译器停止执行 switch 中的语句。大多数情况下,在下一个 case 标号之前的最后一条语句是 break。例如,下面统计元音出现次数的 switch 语句是不正确的: // warning: deliberately incorrect! switch (ch) { case 'a': ++aCnt; // oops: should have a break statement case 'e': ++eCnt; // oops: should have a break statement case 'i': ++iCnt; // oops: should have a break statement case 'o': ++oCnt; // oops: should have a break statement case 'u': ++uCnt; // oops: should have a break statement } 为了搞清楚该程序导致了什么结果,假设 ch 的值是 'i' 来跟踪这个版本的代 码。程序从 case 'i' 后面的语句开始执行,iCnt 的值加 1。但是,程序的执 行并没有在这里停止,而是越过 case 标号继续执行,同时将 oCnt 和 uCnt 的 值都加了 1.如果 ch 是 'e' 的话,那么 eCnt、iCnt、oCnt 以及 uCnt 的值都 会加 1。 对于 switch 结构,漏写 break 语句是常见的程序错误。 尽管没有严格要求在 switch 结构的最后一个标号之 后指定 break 语句,但是,为了安全起见,最好在每 个标号后面提供一个 break 语句,即使是最后一个标 号也一样。如果以后在 switch 结构的末尾又需要添加 一个新的 case 标号,则不用再在前面加 break 语句 了。 273 慎用 break 语句,它并不总是恰当的 有这么一种常见的情况,程序员希望在 case 标号后省略 break 语句,允许程 序向下执行多个 case 标号。这时,两个或多个 case 值由相同的动作序列来处 理。由于系统限定一个 case 标号只能与一个值相关联,于是为了指定一个范围, 典型的做法是,把 case 标号依次排列。例如,如果只希望计算文本中元音的总 数,而不是每一个元音的个数,则可以这样写: int vowelCnt = 0; // ... switch (ch) { // any occurrence of a,e,i,o,u increments vowelCnt case 'a': case 'e': case 'i': case 'o': case 'u': ++vowelCnt; break; } 每个 case 标号不一定要另起一行。为了强调这些 case 标号表示的是一个要匹 配的范围,可以将它们全部在一行中列出: switch (ch) { // alternative legal syntax case 'a': case 'e': case 'i': case 'o': case 'u': ++vowelCnt; break; } 比较少见的用法是,为了执行某个 case 的代码后继续执行下一个 case 的代 码,故意省略 break 语句。 故意省略 case 后面的 break 语句是很罕见的,因此 应该提供一些注释说明其逻辑。 274 6.6.3. default 标号 default 标号提供了相当于 else 子句的功能。如果所有的 case 标号与 switch 表达式的值都不匹配,并且 default 标号存在,则执行 default 标号 后面的语句。例如,在上述例子中添加一个计数器 otherCnt 统计读入多少个辅 音字母,为 switch 结构增加 default 标号,其标志的分支实现 otherCnt 的 自增: // if ch is a vowel, increment the appropriate counter switch (ch) { case 'a': ++aCnt; break; // remaining vowel cases as before default: ++otherCnt; break; } } 在这个版本的代码中,如果 ch 不是元音,程序流程将执行 default 标号的相 关语句,使 otherCnt 的值加 1。 哪怕没有语句要在 default 标号下执行,定义 default 标号仍然是有用的。定义 default 标号是为 了告诉它的读者,表明这种情况已经考虑到了,只是没 什么要执行的。 一个标号不能独立存在,它必须位于语句之前。如果 switch 结构以 default 标 号结束,而且 default 分支不需要完成任何任务,那么该标号后面必须有一个 空语句。 6.6.4. switch 表达式与 case 标号 switch 求解的表达式可以非常复杂。特别是,该表达式也可以定义和初始化一 个变量: 275 switch(int ival = get_response()) 在这个例子中,ival 被初始化为 get_response 函数的调用结果,其值将要与 每个 case 标号作比较。变量 ival 始终存在于整个 switch 语句中,在 switch 结构外面该变量就不再有效了。 case 标号必须是整型常量表达式(第 2.7 节)。例如,下面的标号将导致编译 时的错误: // illegal case label values case 3.14: // noninteger case ival: // nonconstant 如果两个 case 标号具有相同的值,同样也会导致编译时的错误。 6.6.5. switch 内部的变量定义 对于 switch 结构,只能在它的最后一个 case 标号或 default 标号后面定义 变量: case true: // error: declaration precedes a case label string file_name = get_file_name(); break; case false: // ... 制定这个规则是为避免出现代码跳过变量的定义和初始化的情况。 回顾变量的作用域,变量从它的定义点开始有效,直到它所在块结束为止。现在 考虑如果在两个 case 标号之间定义变量会出现什么情况。该变量会在块结束之 前一直存在。对于定义该变量的标号后面的其他 case 标号,它们所关联的代码 都可以使用这个变量。如果 switch 从那些后续 case 标号开始执行,那么这个 变量可能还未定义就要使用了。 在这种情况下,如果需要为某个特殊的 case 定义变量,则可以引入块语句,在 该块语句中定义变量,从而保证这个变量在使用前被定义和初始化。 case true: { // ok: declaration statement within a statement block 276 string file_name = get_file_name(); // ... } break; case false: // ... 277 Exercises Section 6.6.5 Exercise 6.7: 前面已实现的统计元音的程序存在一个问题:不能统计大 写的元音字母。编写程序统计大小写的元音,也就是说, 你的程序计算出来的 aCnt,既包括 'a' 也包括 'A' 出 现的次数,其他四个元音也一样。 Exercise 修改元音统计程序使其可统计出读入的空格、制表符和换 6.8: 行符的个数。 Exercise 修改元音统计程序使其可统计以下双字符序列出现的次 6.9: 数:ff、fl 以及 fi。 Exercise 下面每段代码都暴露了一个常见编程错误。请指出并修改 6.10: 之。 Code for Exercises in Section 6.6.5 (a) switch (ival) { case 'a': aCnt++; case 'e': eCnt++; default: iouCnt++; } (b) switch (ival) { case 1: int ix = get_value(); ivec[ ix ] = ival; break; default: ix = ivec.size()-1; ivec[ ix ] = ival; } (c) switch (ival) { case 1, 3, 5, 7, 9: oddcnt++; 278 break; case 2, 4, 6, 8, 10: evencnt++; break; } (d) int ival=512 jval=1024, kval=4096; int bufsize; // ... switch(swt) { case ival: bufsize = ival * sizeof(int); break; case jval: bufsize = jval * sizeof(int); break; case kval: bufsize = kval * sizeof(int); break; } 6.7. while 语句 当条件为真时,while 语句反复执行目标语句。它的语法形式如下: while (condition) statement 只要条件 condition 的值为 true,执行语句 statement(通常是一个块语 句)。condition 不能为空。如果第一次求解 condition 就产生 false 值,则 不执行 statement。 循环条件 condition 可以是一个表达式,或者是提供初始化的变量定义。 bool quit = false; while (!quit) { // expression as condition 279 quit = do_something(); } while (int loc = search(name)) { // initialized variable as condition // do something } 在循环条件中定义的任意变量都只在与 while 关联的块语句中可见。每一 次循环都将该变量的初值转换为 bool(第 5.12.3 节)。如果求得的值为 true, 则执行 while 的循环体。通常,循环条件自身或者在循环体内必须做一些相关 操作来改变循环条件表达式的值。否则,循环可能永远不会结束。 在循环条件中定义的变量在每次循环里都要经历创建和撤销的 过程。 while 循环的使用 前面的章节已经用过很多 while 循环,但为更完整地了解该结构,考虑下 面将一个数组的内容复制到另一个数组的例子: // arr1 is an array of ints int *source = arr1; size_t sz = sizeof(arr1)/sizeof(*arr1); // number of elements int *dest = new int[sz]; // uninitialized elements while (source != arr1 + sz) *dest++ = *source++; // copy element and increment pointers 首先初始化 source 和 dest,并使它们各自指向所关联的数组的第一个元 素。while 循环条件判断是否已经到达要复制的数组的末尾。如果没有,继续执 行循环。循环体只有单个语句,实现元素的复制,并对两个指针做自增操作,使 它们指向对应数组的下一个元素。 正如 第 5.5. 节提出的关于“简洁即是美”的建议,C++ 程序员应尝试编 写简洁的表达式。while 循环体中的语句: *dest++ = *source++; 是一个经典的例子。这个表达式等价于: 280 { *dest = *source; // copy element ++dest; // increment the pointers ++source; } while 循环内的赋值操作是一种常见的用法。因为这类代码广为流 传,所以学习这种表达式非常重要,要一眼就能看出其含义来。 281 Exercises Section 6.7 Exercise 6.11: 解释下面的循环,更正你发现的问题。 (a) string bufString, word; while (cin >> bufString >> word) { /* ... */ } (b) while (vector::iterator iter != ivec.end()) {/*... */ } (c) while (ptr = 0) ptr = find_a_value(); (d) while (bool status = find(word)) { word = get_next_word(); } if (!status) cout << "Did not find any words\n"; Exercise 6.12: 编写一个小程序,从标准输入读入一系列 string 对象, 寻找连续重复出现的单词。程序应该找出满足以下条件的 单词的输入位置:该单词的后面紧跟着再次出现自己本 身。跟踪重复次数最多的单词及其重复次数。输出重复次 数的最大值,若没有单词重复则输出说明信息。例如,如 果输入是: how, now now now brown cow cow 则输出应表明“now”这个单词出现了三次。 Exercise 详细解释下面 while 循环中的语句是如何执行的: 6.13: *dest++ = *source++; 6.8. for 循环语句 for 语句的语法形式是: 282 for (init-statement condition; expression) statement init-statement 必须是声明语句、表达式语句或空语句。这些语句都以分号结 束,因此其语法形式也可以看成: for (initializer; condition; expression) statement 当然,从技术上说,在 initializer 后面的分号是 for 语句头的一部分。 一般来说,init-statement 用于对每次循环过程中都要修改的变量进行初 始化,或者赋给一个起始值。而 condition 则是用来控制循环的。当 condition 为 true 时,循环执行 statement。如果第一次求解 condition 就得 false 值, 则不执行 statement。expression 通常用于修改在 init-statement 中初始化 并在 condition 中检查的变量。它在每次循环迭代后都要求解。如果第一次求 解 condition 就得 false 值,则始终不执行 expression。通常,statement 既 可以是单个语句也可以是复合语句。 for 循环的使用 假设有下面的 for 循环,用于输出一个 vector 对象的内容: for (vector::size_type ind = 0; ind != svec.size(); ++ind) { cout << svec[ind]; // print current element // if not the last element, print a space to separate from the next one if (ind + 1 != svec.size()) cout << " "; } 它的计算顺序如下: 1. 循环开始时,执行一次 init-statement。在这个例子中,定义了 ind,并将它初始化为 0。 2. 接着,求解 condition。如果 ind 不等于 svec.size(),则执行 for 循环体。否则, 循环结束。如果在第一次循环时,条件就为 flase,则不执行 for 循环体。 3. 如果条件为 true,则执行 for 循环体。本例中,for 循环体输出当前元素值,并检 验这个元素是否是最后一个。如果不是,则输出一个空格,用于分隔当前元素和下一个 元素。 283 4. 最后,求解 expression。本例中,ind 自增 1。 这四步描述了 for 循环的第一次完整迭代。接着重复第 2 步,然后是和 3、 4 步,直到 condition 的值为 false,即 ind 等于 svec.size() 为止。 应该谨记:在 for 语句头定义的任何对象只限制在 for 循环 体里可见。因此,对本例而言,在执行完 for 语句后,ind 不 再有效(即不可访问)。 6.8.1. 省略 for 语句头的某些部分 for 语句头中,可以省略 init-statement、condition 或者 expression(表 达式)中的任何一个(或全部)。 如果不需要初始化或者初始化已经在别处实现了,则可以省略 init-statement。例如,使用迭代器代替下标重写输出 vector 对象内容的程序, 为了提高易读性,可将初始化移到循环外面: vector::iterator iter = svec.begin(); for( /* null */ ; iter != svec.end(); ++iter) { cout << *iter; // print current element // if not the last element, print a space to separate from the next one if (iter+1 != svec.end()) cout << " "; } 注意此时必须要有一个分号表明活力了 init-statement——更准确地说, 分号代表一个空的 init-statement。 省略 condition,则等效于循环条件永远为 true: for (int i = 0; /* no condition */ ; ++i) 相当于程序写为: for (int i = 0; true ; ++i) 284 这么一来,循环体内就必须包含一个 break 或者 return 语句。否则,循 环会一直执行直到耗尽系统的资源为止。同样地,如果省略 expression,则必 须利用 break 或 return 语句跳出循环,或者在循环体内安排语句修改 condition 所检查的变量值。 for (int i = 0; i != 10; /* no expression */ ) { // body must change i or the loop won't terminate } 如果循环体不修改 i 的值,则 i 始终为 0,循环条件永远成立。 6.8.2. for 语句头中的多个定义 可以在 for 语句的 init-statement 中定义多个对象;但是不管怎么样, 该处只能出现一个语句,因此所有的对象必须具有相同的一般类型: const int size = 42; int val = 0, ia[size]; // declare 3 variables local to the for loop: // ival is an int, pi a pointer to int, and ri a reference to int for (int ival = 0, *pi = ia, &ri = val; ival != size; ++ival, ++pi, ++ri) // ... 285 Exercises Section 6.8.2 Exercise 6.14: 解释下面每个循环,更正你发现的任何问题。 (a) for (int *ptr = &ia, ix = 0; ix != size && ptr != ia+size; ++ix, ++ptr) { /* ... */ } (b) for (; ;) { if (some_condition) return; // ... } (c) for (int ix = 0; ix != sz; ++ix) { /* ... */ } if (ix != sz) // ... (d) int ix; for (ix != sz; ++ix) { /* ... */ } (e) for (int ix = 0; ix != sz; ++ix, ++ sz) { /* ... */ } Exercise 6.15: while 循环特别擅长在某个条件保持为真时反复地执行; 例如,当未到达文件尾时,一直读取下一个值。一般认为 for 循环是一种按步骤执行的循环;使用下标依次遍历集 合中一定范围内的元素。按每种循环的习惯用法编写程 序,然后再用另外一种结构重写。如果只能用一种循环来 编写程序,你会选择哪种结构?为什么? Exercise 6.16: 给出两个 int 型的 vector 对象,编写程序判断一个对 象是否是另一个对象的前缀。如果两个 vector 对象的长 度不相同,假设较短的 vector 对象长度为 n,则只对这 两个对象的前面 n 个元素做比较。例如,对于 (0, 1, 1, 2) 和 (0, 1, 1, 2, 3, 5, 8) 这两个 vector,你的程 序应该返回 true。 6.9. do while 语句 在实际应用中,可能会要求程序员编写一个交互程序,为用户实现某种计算。 一个简单的例子是:程序提示用户输入两个数,然后输出读入数之和。在输出和 值后,程序可以让用户选择是否重复这个过程计算下一个和。 286 程序的实现相当简单。只需输出一个提示,接着读入两个数,然后输出读入 数之和。输出结果后,询问用户是否继续。 关键在于控制结构的选择。问题是要到用户要求退出时,才中止循环的执行。 尤其是,在第一次循环时就要求一次和。do while 循环正好满足这样的需要。 它保证循环体至少执行一次。 do while statement (condition); 与 while 语句不同。do-while 语句总是以分号结束。 在求解 condition 之前,先执行了 do 里面的 statement。condition 不 能为空。如果 condition 的值为假,则循环结束,否则循环重复执行。使用 do while 循环,可以编写程序如下: // repeatedly ask user for pair of numbers to sum string rsp; // used in the condition; can't be defined inside the do do { cout << "please enter two values: "; int val1, val2; cin >> val1 >> val2; cout << "The sum of " << val1 << " and " << val2 << " = " << val1 + val2 << "\n\n" << "More? [yes][no] "; cin >> rsp; } while (!rsp.empty() && rsp[0] != 'n'); 循环体与之前编写的其他循环语句相似,因此很容易理解。奇怪的是此代码 把 rsp 定义在 do 之前而不是在循环体内部。如果把 rsp 定义在 do 内部,那 么 rsp 的作用域就被限制在 while 前的右花括号之前了。任何在循环条件中引 用变量都必须在 do 语句之前就已经存在。 因为要到循环语句或者语句块执行之后,才求解循环条件,因此 do while 循 环不可以采用如下方式定义变量: // error: declaration statement within do condition is not supported 287 do { // ... mumble(foo); } while (int foo = get_foo()); // error: declaration in do condition 如果可以在循环条件中定义变量的话,则对变量的任何使用都将发生在变量 定义之前! 288 Exercises Section 6.9 Exercise 6.17: 解释下列的循环。更正你发现的问题。 (a) do int v1, v2; cout << "Please enter two numbers to sum:" ; cin >> v1 >> v2; if (cin) cout << "Sum is: " << v1 + v2 << endl; while (cin); (b) do { // ... } while (int ival = get_response()); (c) do { int ival = get_response(); if (ival == some_value()) break; } while (ival); if (!ival) // ... Exercise 6.18: 编写一个小程序,由用户输入两个 string 对象,然后报 告哪个 string 对象按字母排列次序而言比较小(也就是 说,哪个的字典序靠前)。继续要求用户输入,直到用户 请求退出为止。请使用 string 类型、string 类型的小 于操作符以及 do while 循环实现。 6.10. break 语句 break 语句用于结束最近的 while、do while、for 或 switch 语句,并将 程序的执行权传递给紧接在被终止语句之后的语句。例如,下面的循环在 vector 中搜索某个特殊值的第一次出现。一旦找到,则退出循环: vector::iterator iter = vec.begin(); 289 while (iter != vec.end()) { if (value == *iter) break; // ok: found it! else ++iter; // not found: keep looking }// end of while if (iter != vec.end()) // break to here ... // continue processing 本例中,break 终止了 while 循环。执行权交给紧跟在 while 语句后面的 if 语句,程序继续执行。 break 只能出现在循环或 switch 结构中,或者出现在嵌套于循环或 switch 结构中的语句里。对于 if 语句,只有当它嵌套在 switch 或循环里面 时,才能使用 break。break 出现在循环外或者 switch 外将会导致编译时错误。 当 break 出现在嵌套的 switch 或者循环语句中时,将会终止里层的 switch 或循环语句,而外层的 switch 或者循环不受影响: string inBuf; while (cin >> inBuf && !inBuf.empty()) { switch(inBuf[0]) { case '-': // process up to the first blank for (string::size_type ix = 1; ix != inBuf.size(); ++ix) { if (inBuf[ix] == ' ') break; // #1, leaves the for loop // ... } // remaining '-' processing: break #1 transfers control here break; // #2, leaves the switch statement case '+': // ... } // end switch // end of switch: break #2 transfers control here } // end while #1 标记的 break 终止了连字符('-')case 标号内的 for 循环,但并没 有终止外层的 switch 语句,而且事实上也并没有结束当前 case 语句的执行。 接着程序继续执行 for 语句后面的第一个语句,即处理连字符 case 标号下的 其他代码,或者执行结束这个 case 的 break 语句。 290 #2 标记的 break 终止了处理连字符情况的 switch 语句,但没有终止 while 循环。程序接着执行 break 后面的语句,即求解 while 的循环条件,从 标准输入读入下一个 string 对象。 Exercises Section 6.10 Exercise 本节的第一个程序可以写得更简洁。事实上,该程序的所 6.19: 有工作可以全部包含在 while 的循环条件中。重写这个 循环,使得它的循环体为空,并找出满足条件的元素。 Exercise 6.20: 编写程序从标准输入读入一系列 string 对象,直到同一 个单词连续出现两次,或者所有的单词都已读完,才结束 读取。请使用 while 循环,每次循环读入一个单词。如 果连续出现相同的单词,便以 break 语句结束循环,此 时,请输出这个重复出现的单词;否则输出没有任何单词 连续重复出现的信息。 6.11. continue 语句 continue 语句导致最近的循环语句的当次迭代提前结束。对于 while 和 do while 语句,继续求解循环条件。而对于 for 循环,程序流程接着求解 for 语句头中的 expression 表达式。 例如,下面的循环每次从标准输入中读入一个单词,只有以下划线开头的单 词才做处理。如果是其他的值,终止当前循环,接着读取下一个单词: string inBuf; while (cin >> inBuf && !inBuf.empty()) { if (inBuf[0] != '_') continue; // get another input // still here? process string ... } continue 语句只能出现在 for、while 或者 do while 循环中,包括嵌套 在这些循环内部的块语句中。 291 Exercises Section 6.11 Exercise 修改第 6.10 节最后一个习题的程序,使得它只寻找以大 6.21: 写字母开头的连续出现的单词。 6.12. goto 语句 goto 语句提供了函数内部的无条件跳转,实现从 goto 语句跳转到同一函 数内某个带标号的语句。 从上世纪 60 年代后期开始,不主张使用 goto 语句。goto 语 句使跟踪程序控制流程变得很困难,并且使程序难以理解,也 难以修改。所有使用 goto 的程序都可以改写为不用 goto 语 句,因此也就没有必要使用 goto 语句了。 goto 语句的语法规则如下: goto label; 其中 label 是用于标识带标号的语句的标识符。在任何语句前提供一个标 识符和冒号,即得带标号的语句: end: return; // labeled statement, may be target of a goto 形成标号的标识符只能用作 goto 的目标。因为这个原因,标号标识符可以 与变量名以及程序里的其他标识符一样,不与别的标识符重名。goto 语句和获 得所转移的控制权的带标号的语句必须位于于同一个函数内。 goto 语句不能跨越变量的定义语句向前跳转: // ... goto end; int ix = 10; // error: goto bypasses declaration statement end: // error: code here could use ix but the goto bypassed its declaration 292 ix = 42; 如果确实需要在 goto 和其跳转对应的标号之间定义变量,则定义必须放在 一个块语句中: // ... goto end; // ok: jumps to a point where ix is not defined { int ix = 10; // ... code using ix } end: // ix no longer visible here 向后跳过已经执行的变量定义语句则是合法的。为什么?向前跳过未执行的 变量定义语句,意味着变量可能在没有定义的情况下使用。向后跳回到一个变量 定义之前,则会使系统撤销这个变量,然后再重新创建它: // backward jump over declaration statement ok begin: int sz = get_size(); if (sz <= 0) { goto begin; } 注意:执行 goto 语句时,首先撤销变量 sz,然后程序的控制流程跳转到 带 begin: 标号的语句继续执行,再次重新创建和初始化 sz 变量。 Exercises Section 6.12 Exercise 对于本节的最后一个例子,跳回到 begin 标号的功能可 6.22: 以用循环更好地实现。请不使用 goto 语句重写这段代 码。 6.13. try 块和异常处理 在设计各种软件系统的过程中,处理程序中的错误和其他反常行为是困难的 部分之一。像通信交换机和路由器这类长期运行的交互式系统必须将 90% 的程 293 序代码用于实现错误检测和错误处理。随着基于 Web 的应用程序在运行时不确 定性的增多,越来越多的程序员更加注重错误的处理。 异常就是运行时出现的不正常,例如运行时耗尽了内存或遇到意外的非法输 入。异常存在于程序的正常功能之外,并要求程序立即处理。 在设计良好的系统中,异常是程序错误处理的一部分。当程序代码检查到无 法处理的问题时,异常处理就特别有用。在这些情况下,检测出问题的那部分程 序需要一种方法把控制权转到可以处理这个问题的那部分程序。错误检测程序还 必须指出具体出现了什么问题,并且可能需要提供一些附加信息。 异常机制提供程序中错误检测与错误处理部分之间的通信。C++ 的异常处理 中包括: 1. throw 表达式,错误检测部分使用这种表达式来说明遇到了不可处理的错 误。可以说,throw 引发了异常条件。 2. try 块,错误处理部分使用它来处理异常。try 语句块以 try 关键字开 始,并以一个或多个 catch 子句结束。在 try 块中执行的代码所抛出 (throw)的异常,通常会被其中一个 catch 子句处理。由于它们“处理” 异常,catch 子句也称为处理代码。 3. 由标准库定义的一组 异常类,用来在 throw 和相应的 catch 之间传递 有关的错误信息。 在本节接下来的部分将要介绍这三种异常处理的构成。而第 17.1 节将会进 一步了解异常的相关内容。 6.13.1 throw 表达式 系统通过 throw 表达式抛出异常。throw 表达式由关键字 throw 以及尾随 的表达式组成,通常以分号结束,这样它就成为了表达式语句。throw 表达式的 类型决定了所抛出异常的类型。 回顾第 1.5.2 节将两个 Sales_item 类型对象相加的程序,就是一个简单 的例子。该程序检查读入的记录是否来自同一本书。如果不是,就输出一条信息 然后退出程序。 Sales_item item1, item2; std::cin >> item1 >> item2; // first check that item1 and item2 represent the same book if (item1.same_isbn(item2)) { std::cout << item1 + item2 << std::endl; return 0; // indicate success } else { std::cerr << "Data must refer to same ISBN" 294 << std::endl; return -1; // indicate failure } 在使用 Sales_items 的更简单的程序中,把将对象相加的部分和负责跟用 户交互的部分分开。在这个例子中,用 throw 抛出异常来改写检测代码: // first check that data is for the same item if (!item1.same_isbn(item2)) throw runtime_error("Data must refer to same ISBN"); // ok, if we're still here the ISBNs are the same std::cout << item1 + item2 << std::endl; 这段代码检查 ISBN 对象是否不相同。如果不同的话,停止程序的执行,并 将控制转移给处理这种错误的处理代码。 throw 语句使用了一个表达式。在本例中,该表达式是 runtime_error 类 型的对象。runtime_error 类型是标准库异常类中的一种,在 stdexcept 头文 件中定义。在后续章节中很快就会更详细地介绍这些类型。我们通过传递 string 对象来创建 runtime_error 对象,这样就可以提供更多关于所出现问题的相关 信息。 6.13.2. try 块 try 块的通用语法形式是: try { program-statements } catch (exception-specifier) { handler-statements } catch (exception-specifier) { handler-statements } //... try 块以关键字 try 开始,后面是用花括号起来的语句序列块。try 块后 面是一个或多个 catch 子句。每个 catch 子句包括三部分:关键字 catch,圆 括号内单个类型或者单个对象的声明——称为异常说明符,以及通常用花括号括 起来的语句块。如果选择了一个 catch 子句来处理异常,则执行相关的块语句。 一旦 catch 子句执行结束,程序流程立即继续执行紧随着最后一个 catch 子句 的语句。 295 try 语句内的 program-statements 形成程序的正常逻辑。这里面可以包含 任意 C++ 语句,包括变量声明。与其他块语句一样,try 块引入局部作用域, 在 try 块中声明的变量,包括 catch 子句声明的变量,不能在 try 外面引用。 编写处理代码 在前面的例子中,使用了 throw 来避免将两个表示不同书的 Sales_items 对象相加。想象一下将 Sales_items 对象相加的那部分程序与负责与用户交流 的那部分是分开的,则与用户交互的部分也许会包含下面的用于处理所捕获异常 的代码: while (cin >> item1 >> item2) { try { // execute code that will add the two Sales_items // if the addition fails, the code throws a runtime_error exception } catch (runtime_error err) { // remind the user that ISBN must match and prompt for another pair cout << err.what() << "\nTry Again? Enter y or n" << endl; char c; cin >> c; if (cin && c == 'n') break; // break out of the while loop } } 关键字 try 后面是一个块语句。这个块语句调用处理 Sales_item 对象的 程序部分。这部分也可能会抛出 runtime_error 类型的异常。 上述 try 块提供单个 catch 子句,用来处理 runtime_error 类型的异常。 在执行 try 块代码的过程中,如果在 try 块中的代码抛出 runtime_error 类 型的异常,则处理这类异常的动作在 catch 后面的块语句中定义。本例中,catch 输出信息并且询问用户是否继续进行异常处理。如果用户输入'n',则结束 while;否则继续循环,读入两个新的 Sales_items 对象。 通过输出 err.what() 的返回值提示用户。大家都知道 err 返回 runtime_error 类型的值,因此可以推断出 what 是 runtime_error 类的一个 成员函数(1.5.2 节)。每一个标准库异常类都定义了名为 what 的成员函数。 这个函数不需要参数,返回 C 风格字符串。在出现 runtime_error 的情况下, what 返回的 C 风格字符串,是用于初始化 runtime_error 的 string 对象的 副本。如果在前面章节描述的代码抛出异常,那么执行这个 catch 将输出。 296 Data must refer to same ISBN Try Again? Enter y or n 函数在寻找处理代码的过程中退出 在复杂的系统中,程序的执行路径也许在遇到抛出异常的代码之前,就已经 经过了多个 try 块。例如,一个 try 块可能调用了包含另一 try 块的函数, 它的 try 块又调用了含有 try 块的另一个函数,如此类推。 寻找处理代码的过程与函数调用链刚好相反。抛出一个异常时,首先要搜索 的是抛出异常的函数。如果没有找到匹配的 catch,则终止这个函数的执行,并 在调用这个函数的函数中寻找相配的 catch。如果仍然找到相应的处理代码,该 函数同样要终止,搜索调用它的函数。如此类推,继续按执行路径回退,直到找 到适当类型的 catch 为止。 如果不存在处理该异常的 catch 子句,程序的运行就要跳转到名为 terminate 的标准库函数,该函数在 exception 头文件中定义。这个标准库函 数的行为依赖于系统,通常情况下,它的执行将导致程序非正常退出。 在程序中出现的异常,如果没有经 try 块定义,则都以相同的方式来处理: 毕竟,如果没有任何 try 块,也就没有捕获异常的处理代码(catch 子句)。 此时,如果发生了异常,系统将自动调用 terminate 终止程序的执行。 Exercises Section 6.13.2 Exercise bitset 类提供 to_ulong 操作,如果 bitset 提供的位 6.23: 数大于 unsigned long 的长度时,抛出一个 overflow_error 异常。编写产生这种异常的程序。 Exercise 修改上述的程序,使它能捕获这种异常并输出提示信息。 6.24: 6.13.3. 标准异常 C++ 标准库定义了一组类,用于报告在标准库中的函数遇到的问题。程序员 可在自己编写的程序中使用这些标准异常类。标准库异常类定义在四个头文件 中: 297 1. exception 头文件定义了最常见的异常类,它的类名是 exception。这个 类只通知异常的产生,但不会提供更多的信息。 2. stdexcept 头文件定义了几种常见的异常类,这些类型在表 6.1 中列出。 表 6.1 在 头文件中定义的标准异常类 exception 最常见的问题。 runtime_error 运行时错误:仅在运行时才能检测到问题 range_error 运行时错误:生成的结果超出了有意义的值域范围 overflow_error 运行时错误:计算上溢 underflow_error 运行时错误:计算下溢 logic_error 逻辑错误:可在运行前检测到问题 domain_error 逻辑错误:参数的结果值不存在 invalid_argument 逻辑错误:不合适的参数 length_error 逻辑错误:试图生成一个超出该类型最大长度的对象 out_of_range 逻辑错误:使用一个超出有效范围的值 new 头文件定义了 bad_alloc 异常类型,提供因无法分配内在而由 new (第 5.11 节)抛出的异常。 3. type_info 头文件定义了 bad_cast 异常类型,这种类型将第 18.2 节讨 论。 标准库异常类 标准库异常类只提供很少的操作,包括创建、复制异常类型对象以及异常类 型对象的赋值。 exception、bad_alloc 以及 bad_cast 类型只定义了默认构造 函数(第 2.3.4 节),无法在创建这些类型的对象时为它们提供初值。其他的 异常类型则只定义了一个使用 string 初始化式的构造函数。当需要定义这些异 常类型的对象时,必须提供一想 string 参数。string 初始化式用于为所发生 的错误提供更多的信息。 异常类型只定义了一个名为 what 的操作。这个函数不需要任何参数,并且 返回 const char* 类型值。它返回的指针指向一个 C 风格字符串(第 4.3 节)。 使用 C 风格字符串的目的是为所抛出的异常提出更详细的文字描述。 298 what 函数所返回的指针指向 C 风格字符数组的内容,这个数组的内容依赖 于异常对象的类型。对于接受 string 初始化式的异常类型,what 函数将返回 该 string 作为 C 风格字符数组。对于其他异常类型,返回的值则根据编译器 的变化而不同。 6.14. 使用预处理器进行调试 第 2.9.2 节介绍了如何使用预处理变量来避免重复包含头文件。C++ 程序 员有时也会使用类似的技术有条件地执行用于调试的代码。这种想法是:程序所 包含的调试代码仅在开发过程中执行。当应用程序已经完成,并且准备提交时, 就会将调试代码关闭。可使用 NDEBUG 预处理变量实现有条件的调试代码: int main() { #ifndef NDEBUG cerr << "starting main" << endl; #endif // ... 如果 NDEBUG 未定义,那么程序就会将信息写到 cerr 中。如果 NDEBUG 已 经定义了,那么程序执行时将会跳过 #ifndef 和 #endif 之间的代码。 默认情况下,NDEBUG 未定义,这也就意味着必须执行 #ifndef 和 #endif 之间的代码。在开发程序的过程中,只要保持 NDEBUG 未定义就会执行其中的调 试语句。开发完成后,要将程序交付给客户时,可通过定义 NDEBUG 预处理变量, (有效地)删除这些调试语句。大多数的编译器都提供定义 NDEBUG 命令行选项: $ CC -DNDEBUG main.C 这样的命令行行将于在 main.c 的开头提供 #define NDEBUG 预处理命令。 预处理器还定义了其余四种在调试时非常有用的常量: __FILE__ 文件名 __LINE__ 当前行号 __TIME__ 文件被编译的时间 __DATE__ 文件被编译的日期 可使用这些常量在错误消息中提供更多的信息: 299 if (word.size() < threshold) cerr << "Error: " << _ _FILE_ _ << " : line " << _ _LINE_ _ << endl << " Compiled on " << _ _DATE_ _ << " at " << _ _TIME_ _ << endl << " Word read was " << word << ": Length too short" << endl; 如果给这个程序提供一个比 threshold 短的 string 对象,则会产生下面 的错误信息: Error: wdebug.cc : line 21 Compiled on Jan 12 2005 at 19:44:40 Word read was "foo": Length too short 另一个常见的调试技术是使用 NDEBUG 预处理变量以及 assert 预处理宏。 assert 宏是在 cassert 头文件中定义的,所有使用 assert 的文件都必须包含 这个头文件。 预处理宏有点像函数调用。assert 宏需要一个表达式作为它的条件: assert(expr) 只要 NDEBUG 未定义,assert 宏就求解条件表达式 expr,如果结果为 false,assert 输出信息并且终止程序的执行。如果该表达式有一个非零(例如, true)值,则 assert 不做任何操作。 与异常不同(异常用于处理程序执行时预期要发生的错误),程序员使用 assert 来测试“不可能发生”的条件。例如,对于处理输入文本的程序,可以 预测全部给出的单词都比指定的阈值长。那么程序可以包含这样一个语句: assert(word.size() > threshold); 在测试过程中,assert 等效于检验数据是否总是具有预期的大小。一旦开 发和测试工作完成,程序就已经建立好,并且定义了 NDEBUG。在成品代码中, assert 语句不做任何工作,因此也没有任何运行时代价。当然,也不会引起任 何运行时检查。assert 仅用于检查确实不可能的条件,这只对程序的调试有帮 助,但不能用来代替运行时的逻辑检查,也不能代替对程序可能产生的错误的检 测。 300 Exercises Section 6.14 Exercise 6.25: 修改第 6.11 节习题所编写的程序,使其可以有条件地输 出运行时的信息。例如,可以输出每一个读入的单词,用 来判断循环是否正确地找到第一个连续出现的以大写字 母开头的单词。分别在打开和关闭调试器的情况下编译和 运行这个程序。 Exercise 6.26: 下面循环会导致什么现象的发生: string s; while (cin >> s) { assert(cin); // process s } 解释这种用法是否是 assert 宏的一种恰当应用。 Exercise 6.27: 解释下面的循环: string s; while (cin >> s && s != sought) { } // empty body assert(cin); // process s 小结 C++ 提供了种类相当有限的语句,其中大多数都会影响程序的控制流: while、for 以及 do while 语句,实现反复循环; if 和 switch,提供条件分支结构; continue,终止当次循环; break,退出一个循环或 switch 语句; goto,将控制跳转到某个标号语句; 301 try、catch 语句,实现 try 块的定义,该语句包含一个可能抛出异常的语 句序列,catch 子句则用来处理在 try 块里抛出的异常; throw 表达式,用于退出代码块的执行,将控制转移给相关的 catch 子句。 当然还有将在第七章介绍的 return 语句。 此外,C++ 还提供表达式语句和声明语句。表达式语句用于求解表达式。变 量的声明和定义则已在第二章讲述过了。 术语 assert 一种预处理宏,使用单个表达式作为断言条件。如果预处理变量 NDEBUG 没有定义,则 assert 将求解它的条件表达式。若条件为 false,assert 输出信息并终止程序的执行。 block(块) 包含在一对花括号里的语句序列。在语法上,块就是单语句,可出现在任 何单语句可以出现的地方。 break statement(break 语句) 一种语句,能够终止最近的循环或者 switch 语句的执行,将控制权交给 被终止的循环或者 switch 后的第一条语句。 case label(case 标号) switch 语句中跟在关键字 case 后的整型常量值。在同一个 switch 结 构中不能有任何两个标号拥有相同的常量值。如果 switch 条件表达式的 值与其中某个标号的值相等,则控制权转移到匹配标号后面的第一条语 句,从这种语句开始依次继续各个语句,直到遇到 break 或者到达 switch 结尾为止。 catch clause(catch 子句) 一种语句,包括关键字 catch、圆括号内的异常说明符以及一个块语句。 catch 子句中的代码实现某种异常的处理,该异常的处理,该异常由圆括 号内的异常说明符定义。 compound statement(复合语句) 块的同义词。 302 continue statement(continue 语句) 一种语句,能够结束最近的循环结构的当次循环迭代,将控制流转移到 while 或 do 的循环条件表达式,或者 for 语句头中第三个表达式。 dangling else(悬垂 else) 一个通俗术语,指出如何处理嵌套 if 语句中 if 多于 else 时发生的二 义性问题。C++ 中,else 总是与最近的未匹配的 if 配对。注意使用花 括号能有效地隐藏内层 if,使程序员可以控制给定的 else 与哪个 if 相匹配。 declaration statement(声明语句) 定义或者声明变量的语句。声明已在 第二章中介绍。 default label(default 标号) switch 语句中的一种标号,当计算 switch 条件所得的值与所有 case 标号的值都不匹配时,则执行 default 标号关联的语句。 exception classes(异常类) 标准库定义的一组描述程序错误的类。表 6.1 列出了常见的异常。 exception handler(异常处理代码) 一段代码,用于处理程序某个部分引起的异常。是 catch 子句的同义词。 exception specifier(异常说明符) 对象或类型的声明,用于指出当前的 catch 能处理的异常类型。 expression statement(表达式语句) 一种语句,由后接分号的表达式构成。表达式语句用于表达式的求解。 flow of control(控制流) 程序的执行路径。 goto statement(goto 语句) 一种语句,能够使程序控制流程无条件跳转到指定标号语句。goto 扰乱 了程序内部的控制流,应尽可能避免使用。 303 if else statement(if else 语句) 一种语句,有条件地执行 if 或 else 后的代码,如何执行取决于条件表 达式的真值。 if statement(if 语句) 基于指定条件值的条件分支语句。如果条件为真,则执行 if 语句体;否 则,控制流转到 if 后面的语句。 labeled statement(带标号的语句) 以标号开头的语句。标号是后面带一个冒号的标识符。 null statement(空语句) 空白的语句。其语法形式为单个分号。 preprocessor macro(预处理宏) 与预处理器定义的设施相似的函数。assert 是一个宏。现代 C++ 程序很 少使用预处理宏。 raise(引发) 常用作 throw 的同义词。C++ 程序员所说的“抛出(throwing)”或者 “引发(raising)”异常表示一样的含义。 switch statement(switch 语句) 从计算关键字 switch 后面的表达式开始执行的条件分支语句。程序的控 制流转跳到与表达式值匹配的 case 标号所标记的标号语句。如果没有匹 配的标号,则执行 default 标号标记的分支,如果没有提供 default 分 支则结束 switch 语句的执行。 terminate 异常未被捕获时调用的标准库函数。通常会终止程序的执行。 throw expression(throw 表达式) 中断当前执行路径的表达式。每个 throw 都会抛出一个对象,并将控制 转换到最近的可处理该类型异常的 catch 子句。 try block(try 块) 304 跟在关键字 try 后面的块,以及一个或多个 catch 子句。如果 try 块 中的代码产生了异常,而且该异常类型与其中某个 catch 子句匹配,则 执行这个 catch 子句的语句处理这个异常。否则,异常将由外围 try 块 处理,或者终止程序。 while loop(while 循环) 当指定条件为 true 时,执行目标代码的控制语句。根据条件的真值,目 标代码可能执行零次或多次。 305 第七章 函数 本章将介绍函数的定义和声明。其中讨论了如何给函数传递参数以及如何从 函数返回值。然后具体分析三类特殊的函数:内联(inline)函数、类成员函数 和重载函数。最后以一个更高级的话题“函数指针”来结束全章。 函数可以看作程序员定义的操作。与内置操作符相同的是,每个函数都会实 现一系列的计算,然后(大多数时候)生成一个计算结果。但与操作符不同的是, 函数有自己的函数名,而且操作数没有数量限制。与操作符一样,函数可以重载, 这意味着同样的函数名可以对应多个不同的函数。 7.1. 函数的定义 函数由函数名以及一组操作数类型唯一地表示。函数的操作数,也即形参,在一 对圆括号中声明,形参与形参之间以逗号分隔。函数执行的运算在一个称为函数 体的块语句中定义。每一个函数都有一个相关联的返回类型。 考虑下面的例子,这个函数用来求出两个 int 型数的最大公约数: // return the greatest common divisor int gcd(int v1, int v2) { while (v2) { int temp = v2; v2 = v1 % v2; v1 = temp; } return v1; } 这里,定义了一个名为 gcd 的函数,该函数返回一个 int 型值,并带有两个 int 型形参。调用 gcd 函数时,必须提供两个 int 型值传递给函数,然后将得到一 个 int 型的返回值。 函数的调用 C++ 语言使用调用操作符(即一对圆括号)实现函数的调用。正如其他操作符一 样,调用操作符需要操作数并产生一个结果。调用操作符的操作数是函数名和一 组(有可能是空的)由逗号分隔的实参。函数调用的结果类型就是函数返回值的 类型,该运算的结果本身就是函数的返回值: 306 // get values from standard input cout << "Enter two values: \n"; int i, j; cin >> i >> j; // call gcd on arguments i and j // and print their greatest common divisor cout << "gcd: " << gcd(i, j) << endl; 如果给定 15 和 123 作为程序的输入,程序将输出 3。 函数调用做了两件事情:用对应的实参初始化函数的形参,并将控制权转移给被 调用函数。主调函数的执行被挂起,被调函数开始执行。函数的运行以形参的(隐 式)定义和初始化开始。也就是说,当我们调用 gcd 时,第一件事就是创建名 为 v1 和 v2 的 int 型变量,并将这两个变量初始化为调用 gcd 时传递的实参 值。在上例中,v1 的初值为 i,而 v2 则初始化为 j 的值。 函数体是一个作用域 函数体是一个语句块,定义了函数的具体操作。通常,这个块语句包含在一对花 括号中,形成了一个新的作用域。和其他的块语句一样,在函数体中可以定义变 量。在函数体内定义的变量只在该函数中才可以访问。这种变量称为局部变量, 它们相对于定义它们的函数而言是“局部”的,其名字只能在该函数的作用域中 可见。这种变量只在函数运行时存在。第 7.5 节将详细讨论局部变量。 当执行到 return 语句时,函数调用结束。被调用的函数完成时,将产生一个在 return 语句中指定的结果值。执行 return 语句后,被挂起的主调函数在调用 处恢复执行,并将函数的返回值用作求解调用操作符的结果,继续处理在执行调 用的语句中所剩余的工作。 形参和实参 类似于局部变量,函数的形参为函数提供了已命名的局部存储空间。它们之间的 差别在于形参是在函数的形参表中定义的,并由调用函数时传递函数的实参初始 化。 实参则是一个表达式。它可以是变量或字面值常量,甚至是包含一个或几个操作 符的表达式。在调用函数时,所传递的实参个数必须与函数的形参个数完全相同。 与初始化式的类型必须与初始化对象的类型匹配一样,实参的类型也必须与其对 应形参的类型完全匹配:实参必须具有与形参类型相同、或者能隐式转换(第 5.12 节)为形参类型的数据类型。本章第 7.8.2 节将详细讨论实参与形参的匹 配。 307 7.1.1. 函数返回类型 函数的返回类型可以是内置类型(如 int 或者 double)、类类型或复合类型(如 int& 或 string*),还可以是 void 类型,表示该函数不返回任何值。下面的 例子列出了一些可能的函数返回类型: bool is_present(int *, int); int count(const string &, char); Date &calendar(const char*); void process(); value // returns bool // returns int // returns reference to Date // process does not return a 函数不能返回另一个函数或者内置数组类型,但可以返回指向函数的指针,或指 向数组元素的指针的指针: // ok: pointer to first element of the array int *foo_bar() { /* ... */ } 这个函数返回一个 int 型指针,该指针可以指向数组中的一个元素。 第 7.9 节将介绍有关函数指针的内容。 函数必须指定返回类型 在定义或声明函数时,没有显式指定返回类型是不合法的: // error: missing return type test(double v1, double v2) { /* ... */ } 早期的 C++ 版本可以接受这样的程序,将 test 函数的返回类型隐式地定义为 int 型。但在标准 C++ 中,上述程序则是错误的。 在 C++ 标准化之前,如果缺少显式返回类型,函数的返回值将 被假定为 int 型。早期未标准化的 C++ 编译器所编译的程序 可能依然含有隐式返回 int 型的函数。 308 7.1.2. 函数形参表 函数形参表可以为空,但不能省略。没有任何形参的函数可以用空形参表或含有 单个关键字 void 的形参表来表示。例如,下面关于 process 的声明是等价的: void process() { /* ... */ } // implicit void parameter list void process(void){ /* ... */ } // equivalent declaration 形参表由一系列用逗号分隔的参数类型和(可选的)参数名组成。如果两个参数 具有相同的类型,则其类型必须重复声明: int manip(int v1, v2) { /* ... */ } // error int manip(int v1, int v2) { /* ... */ } // ok 参数表中不能出现同名的参数。类似地,局部于函数的变量也不能使用与函数的 任意参数相同的名字。 参数名是可选的,但在函数定义中,通常所有参数都要命名。参数必须在命名后 才能使用。 参数类型检查 C++ 是一种静态强类型语句(第 2.3 节),对于每一次的函数 调用,编译时都会检查其实参。 调用函数时,对于每一个实参,其类型都必须与对应的形参类型相同,或具有可 被转换(第 5.12 节)为该形参类型的类型。函数的形参表为编译器提供了检查 实参需要的类型信息。例如,第 7.1 节定义的 gcd 函数有两个 int 型的形参: gcd("hello", "world"); // error: wrong argument types gcd(24312); // error: too few arguments gcd(42, 10, 0); // error: too many arguments 以上所有的调用都会导致编译时的错误。在第一个调用中,实参的类型都是 const char*,这种类型无法转换为 int 型,因此该调用不合法。而第二和第三 309 个调用传递的实参数量有误。在调用该函数时必须提供两个实参,实参数太多或 太少都是不合法的。 如果两个实参都是 double 类型,又会怎样呢?调用是否合法? gcd(3.14, 6.29); // ok: arguments are converted to int 在 C++中,答案是肯定的:该调用合法!正如第 5.12.1 节所示,double 型的 值可以转换为 int 型的值。本例中的函数调用正涉及了这种转换——用 double 型的值来初始化 int 型对象。因此,把该调用标记为不合法未免过于严格。更 确切地说,(通过截断)double 型实参被隐式地转换为 int 型。由于这样的转 换可能会导致精度损失,大多数编译器都会给出警告。对于本例,该调用实际上 变为: gcd(3, 6); 返回值是 3。 调用函数时传递过多的实参、忽略某个实参或者传递错误类型的实参,几乎肯定 会导致严重的运行时错误!对于大程序,在编译时检查出这些所谓的接口错误 (interface error),将会大大地缩短“编译-调试-测试”的周期。 310 Exercises Section 7.1.2 Exercise 形参和实参有什么区别? 7.1: Exercise 7.2: 下列哪些函数是错误的?为什么?请给出修改意见。 (a) int f() { string s; // ... return s; } (b) f2(int i) { /* ... */ } (c) int calc(int v1, int v1) /* ... */ } (d) double square(double x) return x * x; Exercise 编写一个带有两个 int 型形参的函数,产生第一个参数 7.3: 的第二个参数次幂的值。编写程序传递两个 int 数值调 用该函数,请检验其结果。 Exercise 编写一个函数,返回其形参的绝对值。 7.4: 7.2. 参数传递 每次调用函数时,都会重新创建该函数所有的形参,此时所传递的实参将会 初始化对应的形参。 形参的初始化与变量的初始化一样:如果形参具有非引用类型, 则复制实参的值,如果形参为引用类型(第 2.5 节),则它只 是实参的别名。 311 7.2.1. 非引用形参 普通的非引用类型的参数通过复制对应的实参实现初始化。当用实参副本初 始化形参时,函数并没有访问调用所传递的实参本身,因此不会修改实参的值。 下面再次观察 gcd 这个函数的定义: // return the greatest common divisor int gcd(int v1, int v2) { while (v2) { int temp = v2; v2 = v1 % v2; v1 = temp; } return v1; } while 循环体虽然修改了 v1 与 v2 的值,但这些变化仅限于局部参数,而 对调用 gcd 函数使用的实参没有任何影响。于是,如果有函数调用 gcd(i, j) 则 i 与 j 的值不受 gcd 内执行的赋值操作的影响。 非引用形参表示对应实参的局部副本。对这类形参的修改仅仅 改变了局部副本的值。一旦函数执行结束,这些局部变量的值 也就没有了。 指针形参 函数的形参可以是指针(第 4.2 节),此时将复制实参指针。与其他非引 用类型的形参一样,该类形参的任何改变也仅作用于局部副本。如果函数将新指 针赋给形参,主调函数使用的实参指针的值没有改变。 回顾第 4.2.3 节的讨论,事实上被复制的指针只影响对指针的赋值。如果 函数形参是非 const 类型的指针,则函数可通过指针实现赋值,修改指针所指 向对象的值: void reset(int *ip) 312 { *ip = 0; // changes the value of the object to which ip points ip = 0; // changes only the local value of ip; the argument is unchanged } 调用 reset 后,实参依然保持原来的值,但它所指向的对象的值将变为 0: int i = 42; int *p = &i; cout << "i: " << *p << '\n'; reset(p); cout << "i: " << *p << endl; // prints i: 42 // changes *p but not p // ok: prints i: 0 如果保护指针指向的值,则形参需定义为指向 const 对象的指针: void use_ptr(const int *p) { // use_ptr may read but not write to *p } 指针形参是指向 const 类型还是非 const 类型,将影响函数调用所使用的 实参。我们既可以用 int* 也可以用 const int* 类型的实参调用 use_ptr 函 数;但仅能将 int* 类型的实参传递给 reset 函数。这个差别来源于指针的初 始化规则(第 4.2.5 节)。可以将指向 const 对象的指针初始化为指向非 const 对象,但不可以让指向非 const 对象的指针向 const 对象。 const 形参 在调用函数时,如果该函数使用非引用的非 const 形参,则既可给该函数 传递 const 实参也可传递非 const 的实参。例如,可以传递两个 int 型 const 对象调用 gcd: const int i = 3, j = 6; int k = rgcd(3, 6); // ok: k initialized to 3 这种行为源于 const 对象的标准初始化规则(第 2.4 节)。因为初始化复 制了初始化式的值,所以可用 const 对象初始化非 const 对象,反之亦然。 如果将形参定义为非引用的 const 类型: 313 void fcn(const int i) { /* fcn can read but not write to i */ } 则在函数中,不可以改变实参的局部副本。由于实参仍然是以副本的形式传 递,因此传递给 fcn 的既可以是 const 对象也可以是非 const 对象。 令人吃惊的是,尽管函数的形参是 const,但是编译器却将 fcn 的定义视 为其形码被声明为普通的 int 型: void fcn(const int i) { /* fcn can read but not write to i */ } void fcn(int i) { /* ... */ } // error: redefines fcn(int) 这种用法是为了支持对 C 语言的兼容,因为在 C 语言中,具有 const 形 参或非 const 形参的函数并无区别。 复制实参的局限性 复制实参并不是在所有的情况下都适合,不适宜复制实参的情况包括: • 当需要在函数中修改实参的值时。 • 当需要以大型对象作为实参传递时。对实际的应用而言,复制对象所付出 的时间和存储空间代价往往过在。 • 当没有办法实现对象的复制时。 对于上述几种情况,有效的解决办法是将形参定义为引用或指针类型。 Exercises Section 7.2.1 Exercise 编写一个函数,该函数具有两个形参,分别为 int 型和 7.5: 指向 int 型的指针,并返回这两个 int 值之中较大的数 值。考虑应将其指针形参定义为什么类型? Exercise 编写函数交换两个 int 型指针所指向的值,调用并检验 7.6: 该函数,输出交换后的值。 7.2.2. 引用形参 考虑下面不适宜复制实参的例子,该函数希望交换两个实参的值: 314 // incorrect version of swap: The arguments are not changed! void swap(int v1, int v2) { int tmp = v2; v2 = v1; // assigns new value to local copy of the argument v1 = tmp; } // local objects v1 and v2 no longer exist 这个例子期望改变实参本身的值。但对于上述的函数定义,swap 无法影响 实参本身。执行 swap 时,只交换了其实参的局部副本,而传递 swap 的实参并 没有修改: int main() { int i = 10; int j = 20; cout << "Before swap():\ti: " << i << "\tj: " << j << endl; swap(i, j); cout << "After swap():\ti: " << i << "\tj: " << j << endl; return 0; } 编译并执行程序,产生如下输出结果: Before swap(): i: 10 j: 20 After swap(): i: 10 j: 20 为了使 swap 函数以期望的方式工作,交换实参的值,需要将形参定义为引 用类型: // ok: swap acts on references to its arguments void swap(int &v1, int &v2) { int tmp = v2; v2 = v1; v1 = tmp; } 315 与所有引用一样,引用形参直接关联到其所绑定的圣贤,而并非这些对象的 副本。定义引用时,必须用与该引用绑定的对象初始化该引用。引用形参完全以 相同的方式工作。每次调用函数,引用形参被创建并与相应实参关联。此时,当 调用 swap swap(i, j); 形参 v1 只是对象 i 的另一个名字,而 v2 则是对象 j 的另一个名字。对 v1 的任何修改实际上也是对 i 的修改。同样地,v2 上的任何修改实际上也是 对 j 的修改。重新编译使用 swap 的这个修订版本的 main 函数后,可以看到 输出结果是正确的: Before swap(): i: 10 j: 20 After swap(): i: 20 j: 10 从 C 语言背景转到 C++ 的程序员习惯通过传递指针来实 现对实参的访问。在 C++ 中,使用引用形参则更安全和更 自然。 使用引用形参返回额外的信息 通过对 swap 这个例子的讨论,了解了如何利用引用形参让函数修改实参的 值。引用形参的另一种用法是向主调函数返回额外的结果。 函数只能返回单个值,但有些时候,函数有不止一个的内容需要返回。例如, 定义一个 find_val 函数。在一个整型 vector 对象的元素中搜索某个特定值。 如果找到满足要求的元素,则返回指向该元素的迭代器;否则返回一个迭代器, 指向该 vector 对象的 end 操作返回的元素。此外,如果该值出现了不止一次, 我们还希望函数可以返回其出现的次数。在这种情况下,返回的迭代器应该指向 具有要寻找的值的第一个元素。 如何定义既返回一个迭代器又返回出现次数的函数?我们可以定义一种包 含一个迭代器和一个计数器的新类型。而更简便的解决方案是给 find_val 传递 一个额外的引用实参,用于返回出现次数的统计结果: // returns an iterator that refers to the first occurrence of value // the reference parameter occurs contains a second return value vector::const_iterator find_val( vector::const_iterator beg, // first element 316 vector::const_iterator end, // one past last element int value, // the value we want vector::size_type &occurs) // number of times it occurs { // res_iter will hold first occurrence, if any vector::const_iterator res_iter = end; occurs = 0; // set occurrence count parameter for ( ; beg != end; ++beg) if (*beg == value) { // remember first occurrence of value if (res_iter == end) res_iter = beg; ++occurs; // increment occurrence count } return res_iter; // count returned implicitly in occurs } 调用 find_val 时,需传递四个实参:一对标志 vector 对象中要搜索的元 素范围(第 9.2.1 节)的迭代器,所查找的值,以及用于存储出现次数的 size_type 类型(第 3.2.3 节)对象。假设 ivec 是 vector, it 类型的 对象,it 是一个适当类型的迭代器,而 ctr 则是 size_type 类型的变量,则 可如此调用该函数: it = find_val(ivec.begin(), ivec.end(), 42, ctr); 调用后,ctr 的值将是 42 出现的次数,如果 42 在 ivec 中出现了,则 it 将指向其第一次出现的位置;否则,it 的值为 ivec.end(),而 ctr 则为 0。 利用 const 引用避免复制 在向函数传递大型对象时,需要使用引用形参,这是引用形参适用的另一种 情况。虽然复制实参对于内置数据类型的对象或者规模较小的类类型对象来说没 有什么问题,但是对于大部分的类类型或者大型数组,它的效率(通常)太低了; 此外,我们将在第十三章学习到,某些类类型是无法复制的。使用引用形参,函 数可以直接访问实参对象,而无须复制它。 编写一个比较两个 string 对象长度的函数作为例子。这个函数需要访问每 个 string 对象的 size,但不必修改这些对象。由于 string 对象可能相当长, 所以我们希望避免复制操作。使用 const 引用就可避免复制: 317 // compare the length of two strings bool isShorter(const string &s1, const string &s2) { return s1.size() < s2.size(); } 其每一个形参都是 const string 类型的引用。因为形参是引用,所以不复 制实参。又因为形参是 const 引用,所以 isShorter 函数不能使用该引用来修 改实参。 如果使用引用形参的唯一目的是避免复制实参,则应将形参定 义为 const 引用。 更灵活的指向 const 的引用 如果函数具有普通的非 const 引用形参,则显然不能通过 const 对象进行 调用。毕竟,此时函数可以修改传递进来的对象,这样就违背了实参的 const 特 性。 但比较容易忽略的是,调用这样的函数时,传递一个右值(第 2.3.1 节) 或具有需要转换的类型的对象同样是不允许的: // function takes a non-const reference parameter int incr(int &val) { return ++val; } int main() { short v1 = 0; const int v2 = 42; int v3 = incr(v1); // error: v1 is not an int v3 = incr(v2); // error: v2 is const v3 = incr(0); // error: literals are not lvalues v3 = incr(v1 + v2); // error: addition doesn't yield an lvalue int v4 = incr(v3); // ok: v3 is a non const object type int } 318 问题的关键是非 const 引用形参(第 2.5 节)只能与完全同类型的非 const 对 象关联。 应该将不修改相应实参的形参定义为 const 引用。如果将这样的形参定义 为非 const 引用,则毫无必要地限制了该函数的使用。例如,可编写下面的程 序在一个 string 对象中查找一个指定的字符: // returns index of first occurrence of c in s or s.size() if c isn't in s // Note: s doesn't change, so it should be a reference to const string::size_type find_char(string &s, char c) { string::size_type i = 0; while (i != s.size() && s[i] != c) ++i; // not found, look at next character return i; } 这个函数将其 string 类型的实参当作普通(非 const)的引用,尽管函数 并没有修改这个形参的值。这样的定义带来的问题是不能通过字符串字面值来调 用这个函数: if (find_char("Hello World", 'o')) // ... 虽然字符串字面值可以转换为 string 对象,但上述调用仍然会导致编译失败。 继续将这个问题延伸下去会发现,即使程序本身没有 const 对象,而且只 使用 string 对象(而并非字符串字面值或产生 string 对象的表达式)调用 find_char 函数,编译阶段的问题依然会出现。例如,可能有另一个函数 is_sentence 调用 find_char 来判断一个 string 对象是否是句子: bool is_sentence (const string &s) { // if there's a period and it's the last character in s // then s is a sentence return (find_char(s, '.') == s.size() - 1); } 如上代码,函数 is_sentence 中 find_char 的调用是一个编译错误。传递 进 is_sentence 的形参是指向 const string 对象的引用,不能将这种类型的 参数传递给 find_char,因为后者期待得到一个指向非 const string 对象的引 用。 319 应该将不需要修改的引用形参定义为 const 引用。普 通的非 const 引用形参在使用时不太灵活。这样的形 参既不能用 const 对象初始化,也不能用字面值或产 生右值的表达式实参初始化。 传递指向指针的引用 假设我们想编写一个与前面交换两个整数的 swap 类似的函数,实现两个指 针的交换。已知需用 * 定义指针,用 & 定义引用。现在,问题在于如何将这两 个操作符结合起来以获得指向指针的引用。这里给出一个例子: // swap values of two pointers to int void ptrswap(int *&v1, int *&v2) { int *tmp = v2; v2 = v1; v1 = tmp; } 形参 int *&v1 的定义应从右至左理解:v1 是一个引用,与指向 int 型对象的指针相关联。也 就是说,v1 只是传递进 ptrswap 函数的任意指针的别名。 重写第 7.2.2 节的 main 函数,调用 ptrswap 交换分别指向值 10 和 20 的指 针: int main() { int i = 10; int j = 20; int *pi = &i; // pi points to i int *pj = &j; // pj points to j cout << "Before ptrswap():\t*pi: " << *pi << "\t*pj: " << *pj << endl; ptrswap(pi, pj); // now pi points to j; pj points to i cout << "After ptrswap():\t*pi: " 320 << *pi << "\t*pj: " << *pj << endl; return 0; } 编译并执行后,该程序产生如下结果: Before ptrswap(): *pi: 10 *pj: 20 After ptrswap(): *pi: 20 *pj: 10 即指针的值被交换了。在调用 ptrswap 时,pi 指向 i,而 pj 则指向 j。 在 ptrswap 函数中,指针被交换,使得调用 ptrswap 结束后,pi 指向了原来 pj 所指向的对象。换句话说,现在 pi 指向 j,而 pj 则指向了 i。 Exercises Section 7.2.2 Exercise 7.7: 解释下面两个形参声明的不同之处: void f(T); void f(T&); Exercise 举一个例子说明什么时候应该将形参定义为引用类型。再 7.8: 举一个例子说明什么时候不应该将形参定义为引用。 Exercise 将第 7.2.2 节定义的 find_val 函数的形参表中 7.9: occurs 的声明修改为非引用参数类型,并重新执行这个 程序,该函数的行为发生了什么改变? Exercise 下面的程序虽然是合法的,但可用性还不够好,指出并改 7.10: 正该程序的局限: bool test(string& s) { return s.empty(); } Exercise 何时应将引用形参定义为 const 对象?如果在需要 7.11: const 引用时,将形参定义为普通引用,则会出现什么问 题? 321 7.2.3. vector 和其他容器类型的形参 通常,函数不应该有 vector 或其他标准库容器类型的 形参。调用含有普通的非引用 vector 形参的函数将会 复制 vector 的每一个元素。 从避免复制 vector 的角度出发,应考虑将形参声明为引用类型。然而,看 过第十一章后我们会知道,事实上,C++ 程序员倾向于通过传递指向容器中需要 处理的元素的迭代器来传递容器: // pass iterators to the first and one past the last element to print void print(vector::const_iterator beg, vector::const_iterator end) { while (beg != end) { cout << *beg++; if (beg != end) cout << " "; // no space after last element } cout << endl; } 这个函数将输出从 beg 指向的元素开始到 end 指向的元素(不含)为止的 范围内所有的元素。除了最后一个元素外,每个元素后面都输出一个空格。 7.2.4. 数组形参 数组有两个特殊的性质,影响我们定义和使用作用在数组上的函数:一是不 能复制数组(第 4.1.1 节);二是使用数组名字时,数组名会自动转化为指向 其第一个元素的指针(第 4.2.4 节)。因为数组不能复制,所以无法编写使用 数组类型形参的函数。因为数组会被自动转化为指针,所以处理数组的函数通常 通过操纵指向数组指向数组中的元素的指针来处理数组。 数组形参的定义 如果要编写一个函数,输出 int 型数组的内容,可用下面三种方式指定数 组形参: 322 // three equivalent definitions of printValues void printValues(int*) { /* ... */ } void printValues(int[]) { /* ... */ } void printValues(int[10]) { /* ... */ } 虽然不能直接传递数组,但是函数的形参可以写成数组的形式。虽然形参表 示方式不同,但可将使用数组语法定义的形参看作指向数组元素类型的指针。上 面的三种定义是等价的,形参类型都是 int*。 通常,将数组形参直接定义为指针要比使用数组语法定 义更好。这样就明确地表示,函数操纵的是指向数组元 素的指针,而不是数组本身。由于忽略了数组长度,形 参定义中如果包含了数组长度则特别容易引起误解。 形参的长度会引起误解 编译器忽略为任何数组形参指定的长度。根据数组长度(权且这样说),可 将函数 printValues 编写为: // parameter treated as const int*, size of array is ignored void printValues(const int ia[10]) { // this code assumes array has 10 elements; // disaster if argument has fewer than 10 elements! for (size_t i = 0; i != 10; ++i) { cout << ia[i] << endl; } } 尽管上述代码假定所传递的数组至少含有 10 个元素,但 C++ 语言没有任 何机制强制实现这个假设。下面的调用都是合法的: int main() { int i = 0, j[2] = {0, 1}; printValues(&i); // ok: &i is int*; probable run-time error printValues(j); // ok: j is converted to pointer to 0th 323 return 0; } // element; argument has type int*; // probable run-time error 虽然编译没有问题,但是这两个调用都是错误的,可能导致运行失败。在这 两个调用中,由于函数 printValues 假设传递进来的数组至少含有 10 个元素, 因此造成数组内在的越界访问。程序的执行可能产生错误的输出,也可能崩溃, 这取决于越界访问的内存中恰好存储的数值是什么。 当编译器检查数组形参关联的实参时,它只会检查实参是不是 指针、指针的类型和数组元素的类型时是否匹配,而不会检查 数组的长度。 数组实参 和其他类型一样,数组形参可定义为引用或非引用类型。大部分情况下,数 组以普通的非引用类型传递,此时数组会悄悄地转换为指针。一般来说,非引用 类型的形参会初始化为其相应实参的副本。而在传递数组时,实参是指向数组第 一个元素的指针,形参复制的是这个指针的值,而不是数组元素本身。函数操纵 的是指针的副本,因此不会修改实参指针的值。然而,函数可通过该指针改变它 所指向的数组元素的值。通过指针形参做的任何改变都在修改数组元素本身。 不需要修改数组形参的元素时,函数应该将形参定义为 指向 const 对象的指针: // f won't change the elements in the array void f(const int*) { /* ... */ } 通过引用传递数组 和其他类型一样,数组形参可声明为数组的引用。如果形参是数组的引用, 编译器不会将数组实参转化为指针,而是传递数组的引用本身。在这种情况下, 数组大小成为形参和实参类型的一部分。编译器检查数组的实参的大小与形参的 大小是否匹配: 324 // ok: parameter is a reference to an array; size of array is fixed void printValues(int (&arr)[10]) { /* ... */ } int main() { int i = 0, j[2] = {0, 1}; int k[10] = {0,1,2,3,4,5,6,7,8,9}; printValues(&i); // error: argument is not an array of 10 ints printValues(j); // error: argument is not an array of 10 ints printValues(k); // ok: argument is an array of 10 ints return 0; } 这个版本的 printValues 函数只严格地接受含有 10 个 int 型数值的数 组,这限制了哪些数组可以传递。然而,由于形参是引用,在函数体中依赖数组 的大小是安全的: // ok: parameter is a reference to an array; size of array is fixed void printValues(int (&arr)[10]) { for (size_t i = 0; i != 10; ++i) { cout << arr[i] << endl; } } &arr 两边的圆括号是必需的,因为下标操作符具有更高的优先 级: f(int &arr[10]) // error: arr is an array of references f(int (&arr)[10]) // ok: arr is a reference to an array of 10 ints 在第 16.1.5 节将会介绍如何重新编写此函数,允许传递指向任意大小的数 组的引用形参。 多维数组的传递 回顾前面我们说过在 C++ 中没有多维数组(第 4.4 节)。所谓多维数组实 际是指数组的数组。 325 和其他数组一样,多维数组以指向 0 号元素的指针方式传递。多维数组的 元素本身就是数组。除了第一维以外的所有维的长度都是元素类型的一部分,必 须明确指定: // first parameter is an array whose elements are arrays of 10 ints void printValues(int (matrix*)[10], int rowSize); 上面的语句将 matrix 声明为指向含有 10 个 int 型元素的数组的指针。 再次强调,*matrix 两边的圆括号是必需的: int *matrix[10]; // array of 10 pointers int (*matrix)[10]; // pointer to an array of 10 ints 我们也可以用数组语法定义多维数组。与一维数组一样,编译器忽略第一维 的长度,所以最好不要把它包括在形参表内: // first parameter is an array whose elements are arrays of 10 ints void printValues(int matrix[][10], int rowSize); 这条语句把 matrix 声明为二维数组的形式。实际上,形参是一个指针,指 向数组的数组中的元素。数组中的每个元素本身就是含有 10 个 int 型对象的 数组。 7.2.5. 传递给函数的数组的处理 就如刚才所见的,非引用数组形参的类型检查只是确保实参是和数组元素具 有同样类型的指针,而不会检查实参实际上是否指向指定大小的数组。 任何处理数组的程序都要确保程序停留在数组的边界内。 326 有三种常见的编程技巧确保函数的操作不超出数组实参的边界。第一种方法 是在数组本身放置一个标记来检测数组的结束。C 风格字符串就是采用这种方法 的一个例子,它是一种字符数组,并且以空字符 null 作为结束的标记。处理 C 风格字符串的程序就是使用这个标记停止数组元素的处理。 使用标准库规范 第二种方法是传递指向数组第一个和最后一个元素的下一个位置的指针。这 种编程风格由标准库所使用的技术启发而得,在第二部分将会进一步介绍这种编 程风格。 使用这种方法重写函数 printValues 并调用该函数,如下所示: void printValues(const int *beg, const int *end) { while (beg != end) { cout << *beg++ << endl; } } int main() { int j[2] = {0, 1}; // ok: j is converted to pointer to 0th element in j // j + 2 refers one past the end of j printValues(j, j + 2); return 0; } printValues 中的循环很像用 vector 迭代器编写的程序。每次循环都使 beg 指针指向下一个元素,从而实现数组的遍历。当 beg 指针等于结束标记时, 循环结束。结束标记就是传递给函数的第二个形参。 调用这个版本的函数需要传递两个指针:一个指向要输出的第一个元素,另 一个则指向最后一个元素的下一个位置。只要正确计算指针,使它们标记一段有 效的元素范围,程序就会安全。 显式传递表示数组大小的形参 第三种方法是将第二个形参定义为表示数组的大小,这种用法在 C 程序和 标准化之前的 C++ 程序中十分普遍。 用这种方法再次重写函数 printValues,新版本及其调用如下所示: 327 // const int ia[] is equivalent to const int* ia // size is passed explicitly and used to control access to elements of ia void printValues(const int ia[], size_t size) { for (size_t i = 0; i != size; ++i) { cout << ia[i] << endl; } } int main() { int j[] = { 0, 1 }; // int array of size 2 printValues(j, sizeof(j)/sizeof(*j)); return 0; } 这个版本使用了形参 size 来确定要输出的元素的个数。调用 printValues 时,要额外传递一个形参。只要传递给函数的 size 值不超过数组的实际大小, 程序就能安全运行。 Exercises Section 7.2.5 Exercise 什么时候应使用指针形参?什么时候就使用引用形参? 7.12: 解释两者的优点和缺点。 Exercise 编写程序计算数组元素之和。要求编写函数三次,每次以 7.13: 不同的方法处理数组边界。 Exercise 编写程序求 vector 对象中所有元素之和。 7.14: 7.2.6. main: 处理命令行选项 主函数 main 是演示 C 程序如何将数组传递给函数的好例子。直到现在, 我们所定义的主函数都只有空的形参表: int main() { ... } 328 但是,我们通常需要给 main 传递实参。传统上,主函数的实参是可选的, 用来确定程序要执行的操作。比如,假设我们的主函数 main 位于名为 prog 的 可执行文件中,可如下将实参选项传递给程序: prog -d -o ofile data0 这种用法的处理方法实际上是在主函数 main 中定义了两个形参: int main(int argc, char *argv[]) { ... } 第二个形参 argv 是一个 C 风格字符串数组。第一个形参 argc 则用于传 递该数组中字符串的个数。由于第二个参数是一个数组,主函数 main 也可以这 样定义: int main(int argc, char **argv) { ... } 表示 argv 是指向 char* 的指针。 当将实参传递给主函数 main 时,argv 中的第一个字符串(如果有的话) 通常是程序的名字。接下来的元素将额外的可选字符串传递给主函数 main。以 前面的命令行为例,argc 应设为 5,argv 会保存下面几个 C 风格字符串: argv[0] = "prog"; argv[1] = "-d"; argv[2] = "-o"; argv[3] = "ofile"; argv[4] = "data0"; Exercises Section 7.2.6 Exercise 编写一个主函数 main,使用两个值作为实参,并输出它 7.15: 们的和。 Exercise 编写程序使之可以接受本节介绍的命令行选项,并输出传 7.16: 递给 main 的实参值。 329 7.2.7. 含有可变形参的函数 C++ 中的省略符形参是为了编译使用了 varargs 的 C 语言程 序。关于如何使用 varargs,请查阅所用 C 语言编译器的文档。 对于 C++ 程序,只能将简单数据类型传递给含有省略符形参的 函数。实际上,当需要传递给省略符形参时,大多数类类型对 象都不能正确地复制。 在无法列举出传递给函数的所有实参的类型和数目时,可以使用省略符形 参。省略符暂停了类型检查机制。它们的出现告知编译器,当调用函数时,可以 有 0 或多个实参,而实参的类型未知。省略符形参有下列两种形式: void foo(parm_list, ...); void foo(...); 第一种形式为特定数目的形参提供了声明。在这种情况下,当函数被调用时, 对于与显示声明的形参相对应的实参进行类型检查,而对于与省略符对应的实参 则暂停类型检查。在第一种形式中,形参声明后面的逗号是可选的。 大部分带有省略符形参的函数都利用显式声明的参数中的一些信息,来获取 函数调用中提供的其他可选实参的类型和数目。因此带有省略符的第一种形式的 函数声明是最常用的。 7.3. return 语句 return 语句用于结束当前正在执行的函数,并将控制权返回给调用此函数 的函数。return 语句有两种形式: return; return expression; 7.3.1. 没有返回值的函数 不带返回值的 return 语句只能用于返回类型为 void 的函数。在返回类型 为 void 的函数中,return 返回语句不是必需的,隐式的 return 发生在函数 的最后一个语句完成时。 330 一般情况下,返回类型是 void 的函数使用 return 语句是为了引起函数的 强制结束,这种 return 的用法类似于循环结构中的 break 语句(第 6.10 节) 的作用。例如,可如下重写 swap 程序,使之在输入的两个数值相同时不执行任 何工作: // ok: swap acts on references to its arguments void swap(int &v1, int &v2) { // if values already the same, no need to swap, just return if (v1 == v2) return; // ok, have work to do int tmp = v2; v2 = v1; v1 = tmp; // no explicit return necessary } 这个函数首先检查两个值是否相等,如果相等则退出函数;如果不相等,则 交换这两个值,隐式的 return 发生在最后一个赋值语句后。 返回类型为 void 的函数通常不能使用第二种形式的 return 语句,但是, 它可以返回另一个返回类型同样是 void 的函数的调用结果: void do_swap(int &v1, int &v2) { int tmp = v2; v2 = v1; v1 = tmp; // ok: void function doesn't need an explicit return } void swap(int &v1, int &v2) { if (v1 == v2) return false; // error: void function cannot return a value return do_swap(v1, v2); // ok: returns call to a void function } 返回任何其他表达式的尝试都会导致编译时的错误。 331 7.3.2. 具有返回值的函数 return 语句的第二种形式提供了函数的结果。任何返回类型不是 void 的 函数必须返回一个值,而且这个返回值的类型必须和函数的返回类型相同,或者 能隐式转化为函数的返回类型。 尽管 C++ 不能确保结果的正确性,但能保证函数每一次 return 都返回适 当类型的结果。例如,下面的程序就不能通过编译: // Determine whether two strings are equal. // If they differ in size, determine whether the smaller // one holds the same characters as the larger one bool str_subrange(const string &str1, const string &str2) { // same sizes: return normal equality test if (str1.size() == str2.size()) return str1 == str2; // ok, == returns bool // find size of smaller string string::size_type size = (str1.size() < str2.size()) ? str1.size() : str2.size(); string::size_type i = 0; // look at each element up to size of smaller string while (i != size) { if (str1[i] != str2[i]) return; // error: no return value } // error: control might flow off the end of the function without a return // the compiler is unlikely to detect this error } while 循环中的 return 语句是错误的,因为它没有返回任何值,编译器将 检查出这个错误。 第二个错误源于函数没有在 while 循环后提供 return 语句。调用这个函 数时,如果一个 string 是另一个 string 的子集,执行会退出 while 循环。 这里应该有一个 return 语句来处理这种情况。编译器有可能检查出也有可能检 查不出这种错误。执行程序时,不确定在运行阶段会出现什么问题。 332 在含有 return 语句的循环后没有提供 return 语句是很危险 的,因为大部分的编译器不能检测出这个漏洞,运行时会出现 什么问题是不确定的。 主函数 main 的返回值 返回类型不是 void 的函数必须返回一个值,但此规则有一个例外情况:允 许主函数 main 没有返回值就可结束。如果程序控制执行到主函数 main 的最后 一个语句都还没有返回,那么编译器会隐式地插入返回 0 的语句。 关于主函数 main 返回的另一个特别之处在于如何处理它的返回值。在第 1.1 节已知,可将主函数 main 返回的值视为状态指示器。返回 0 表示程序运 行成功,其他大部分返回值则表示失败。非 0 返回值的意义因机器不同而不同, 为了使返回值独立于机器,cstdlib 头文件定义了两个预处理变量(第 2.9.2 节),分别用于表示程序运行成功和失败: #include int main() { if (some_failure) return EXIT_FAILURE; else return EXIT_SUCCESS; } 我们的代码不再需要使用那些依赖于机器的精确返回值。相应地,这些值都 在 cstdlib 库中定义,我们的代码不需要做任何修改。 返回非引用类型 函数的返回值用于初始化在调用函数处创建的临时对象。在求解表达式时, 如果需要一个地方储存其运算结果,编译器会创建一个没有命名的对象,这就是 临时对象。在英语中,C++ 程序员通常用 temporary 这个术语来代替 temporary object。 333 用函数返回值初始化临时对象与用实参初始化形参的方法是一样的。如果返 回类型不是引用,在调用函数的地方会将函数返回值复制给临时对象。当函数返 回非引用类型时,其返回值既可以是局部对象,也可以是求解表达式的结果。 例如,下面的程序提供了一个计数器、一个单词 word 和单词结束字符串 ending,当计数器的值大于 1 时,返回该单词的复数版本: // return plural version of word if ctr isn't 1 string make_plural(size_t ctr, const string &word, const string &ending) { return (ctr == 1) ? word : word + ending; } 我们可以使用这样的函数来输出单词的单数或复数形式。 这个函数要么返回其形参 word 的副本,要么返回一个未命名的临时 string 对象,这个临时对象是由字符串 word 和 ending 的相加而产生的。这 两种情况下,return 都在调用该函数的地方复制了返回的 string 对象。 返回引用 当函数返回引用类型时,没有复制返回值。相反,返回的是对象本身。例如, 考虑下面的函数,此函数返回两个 string 类型形参中较短的那个字符串的引 用: // find longer of two strings const string &shorterString(const string &s1, const string &s2) { return s1.size() < s2.size() ? s1 : s2; } 形参和返回类型都是指向 const string 对象的引用,调用函数和返回结果 时,都没有复制这些 string 对象。 334 千万不要返回局部对象的引用 理解返回引用至关重要的是:千万不能返回局部变量的引用。 当函数执行完毕时,将释放分配给局部对象的存储空间。此时,对局部对象 的引用就会指向不确定的内存。考虑下面的程序: // Disaster: Function returns a reference to a local object const string &manip(const string& s) { string ret = s; // transform ret in some way return ret; // Wrong: Returning reference to a local object! } 这个函数会在运行时出错,因为它返回了局部对象的引用。当函数执行完毕, 字符串 ret 占用的储存空间被释放,函数返回值指向了对于这个程序来说不再 有效的内存空间。 确保返回引用安全的一个好方法是:请自问,这个引用指向 哪个在此之前存在的对象? 引用返回左值 返回引用的函数返回一个左值。因此,这样的函数可用于任何要求使用左值 的地方: char &get_val(string &str, string::size_type ix) { return str[ix]; } int main() 335 { string s("a value"); cout << s << endl; // prints a value get_val(s, 0) = 'A'; // changes s[0] to A cout << s << endl; // prints A value return 0; } 给函数返回值赋值可能让人惊讶,由于函数返回的是一个引用,因此这是正 确的,该引用是被返回元素的同义词。 如果不希望引用返回值被修改,返回值应该声明为 const: const char &get_val(... 千万不要返回指向局部对象的指针 函数的返回类型可以是大多数类型。特别地,函数也可以返回指针类型。和 返回局部对象的引用一样,返回指向局部对象的指针也是错误的。一旦函数结束, 局部对象被释放,返回的指针就变成了指向不再存在的对象的悬垂指针(第 5.11 节)。 336 Exercises Section 7.3.2 Exercise 什么时候返回引用是正确的?而什么时候返回 const 引 7.17: 用是正确的? Exercise 7.18: 下面函数存在什么潜在的运行时问题? string &processText() { string text; while (cin >> text) { /* ... */ } // .... return text; } Exercise 判断下面程序是否合法;如果合法,解释其功能;如果不 7.19: 合法,更正它并解释原因。 int &get(int *arry, int index) { return arry[index]; } int main() { int ia[10]; for (int i = 0; i != 10; ++i) get(ia, i) = 0; } 7.3.3. 递归 直接或间接调用自己的函数称为递归函数。一个简单的递归函数例子是阶乘 的计算。数 n 阶乘是从 1 到 n 的乘积。例如,5 的阶乘就是 120。 1 * 2 * 3 * 4 * 5 = 120 解决这个问题的自然方法就是递归: // calculate val!, which is 1*2 *3 ... * val int factorial(int val) { 337 if (val > 1) return factorial(val-1) * val; return 1; } 递归函数必须定义一个终止条件;否则,函数就会“永远”递归下去,这意 味着函数会一直调用自身直到程序栈耗尽。有时候,这种现象称为“无限递归错 误”。对于函数 factorial,val 为 1 是终止条件。 另一个例子是求最大公约数的递归函数: // recursive version greatest common divisor program int rgcd(int v1, int v2) { if (v2 != 0) // we're done once v2 gets to zero return rgcd(v2, v1%v2); // recurse, reducing v2 on each call return v1; } 这个例子中,终止条件是余数为 0。如果用实参 (15, 123) 来调用 rgcd 函 数,结果为 3。表 7.1 跟踪了它的执行过程。 表 7.1. rgcd(15, 123) 的跟踪过程 v1 v2 Return 15 123 rgcd(123, 15) 123 15 rgcd(15, 3) 15 3 rgcd(3, 0) 3 0 3 最后一次调用: rgcd(3,0) 满足了终止条件,它返回最大公约数 3。该值依次成为前面每个调用的返回 值。这个过程称为此值向上回渗(percolate),直到执行返回到第一次调用 rgcd 的函数。 338 主函数 main 不能调用自身。 Exercises Section 7.3.3 Exercise 将函数 factorial 重写为迭代函数(即非递归函数)。 7.20: Exercise 如是函数 factorial 的终止条件为: 7.21: if (val != 0) 会出现什么问题? 7.4. 函数声明 正如变量必须先声明后使用一样,函数也必须在被调用之前先声明。与变量的定 义(第 2.3.5 节)类似,函数的声明也可以和函数的定义分离;一个函数只能 定义一次,但是可声明多次。 函数声明由函数返回类型、函数名和形参列表组成。形参列表必须包括形参类型, 但是不必对形参命名。这三个元素被称为函数原型,函数原型描述了函数的接口。 函数原型为定义函数的程序员和使用函数的程序员之间提供了 接口。在使用函数时,程序员只对函数原型编程即可。 函数声明中的形参名会被忽略,如果在声明中给出了形参的名字,它应该用作辅 助文档: void print(int *array, int size); 339 在头文件中提供函数声明 回顾前面章节,变量可在头文件中声明(第 2.9 节),而在源文件中定义。同 理,函数也应当在头文件中声明,并在源文件中定义。 把函数声明直接放到每个使用该函数的源文件中,这可能是大家希望的方式,而 且也是合法的。但问题在于这种用法比较呆板而且容易出错。解决的方法是把函 数声明放在头文件中,这样可以确保对于指定函数其所有声明保持一致。如果函 数接口发生变化,则只要修改其唯一的声明即可。 定义函数的源文件应包含声明该函数的头文件。 将提供函数声明头文件包含在定义该函数的源文件中,可使编译器能检查该函数 的定义和声明时是否一致。特别地,如果函数定义和函数声明的形参列表一致, 但返回类型不一致,编译器会发出警告或出错信息来指出这种差异。 340 Exercises Section 7.4 Exercise 7.22: 编写下面函数的原型: a. 函数名为 compare,有两个形参,都是名为 matrix 的类的引用,返回 bool 类型的值。 b. 函数名为 change_val,返回 vector 类型的 迭代器,有两个形参:一个是 int 型形参,另一 个是 vector 类型的迭代器。 提示:写函数原型时,函数名应当暗示函数的功能。考虑 这个提示会如何影响你用的类型? Exercise 给出下面函数,判断哪些调用是合法的,哪些是不合法的。 7.23: 对于那些不合法的调用,解释原因。 double calc(double); int count(const string &, char); int sum(vector::iterator, vector::iterator, int); vector vec(10); (a) calc(23.4, 55.1); (b) count("abcda", 'a'); (c) calc(66); (d) sum(vec.begin(), vec.end(), 3.8); 7.4.1. 默认实参 默认实参是一种虽然并不普遍、但在多数情况下仍然适用的实参值。调用函数时, 可以省略有默认值的实参。编译器会为我们省略的实参提供默认值。 默认实参是通过给形参表中的形参提供明确的初始值来指定的。程序员可为一个 或多个形参定义默认值。但是,如果有一个形参具有默认实参,那么,它后面所 有的形参都必须有默认实参。 例如,下面的函数创建并初始化了一个 string 对象,用于模拟窗口屏幕。此函 数为窗口屏幕的高、宽和背景字符提供了默认实参: 341 string screenInit(string::size_type height = 24, string::size_type width = 80, char background = ' ' ); 调用包含默认实参的函数时,可以为该形参提供实参,也可以不提供。如果提供 了实参,则它将覆盖默认的实参值;否则,函数将使用默认实参值。下面的函数 screenInit 的调用都是正确的: string screen; screen = screenInit(); // equivalent to screenInit (24,80,' ') screen = screenInit(66); // equivalent to screenInit (66,80,' ') screen = screenInit(66, 256); // screenInit(66,256,' ') screen = screenInit(66, 256, '#'); 函数调用的实参按位置解析,默认实参只能用来替换函数调用缺少的尾部实参。 例如,如果要给 background 提供实参,那么也必须给 height 和 width 提供 实参: screen = screenInit(, , '?'); // error, can omit only trailing arguments screen = screenInit( '?'); // calls screenInit('?',80,' ') 注意第二个调用,只传递了一个字符值,虽然这是合法的,但是却并不是程序员 的原意。因为 '?' 是一个 char,char 可提升为最左边形参的类型,所以这个 调用是合法的。最左边的形参具有 string::size_type 类型,这是 unsigned 整 型。在这个调用中,char 实参隐式地提升为 string::size_type 类型,并作为 实参传递给形参 height。 因为 char 是整型(第 2.1.1 节),因此把一个 char 值传递 给 int 型形参是合法的,反之亦然。这个事实会导致很多误解。 例如,如果函数同时含有 char 型和 int 型形参,则调用者很 容易以错误的顺序传递实参。如果使用默认实参,则这个问题 会变得更加复杂。 设计带有默认实参的函数,其中部分工作就是排列形参,使最少使用默认实参的 形参排在最前,最可能使用默认实参的形参排在最后。 342 默认实参的初始化式 默认实参可以是任何适当类型的表达式: string::size_type screenHeight(); string::size_type screenWidth(string::size_type); char screenDefault(char = ' '); string screenInit( string::size_type height = screenHeight(), string::size_type width = screenWidth(screenHeight()), char background = screenDefault()); 如果默认实参是一个表达式,而且默认值用作实参,则在调用函数时求解该表达 式。例如,每次不带第三个实参调用函数 screenInit 时,编译器都会调用函数 screenDefault 为 background 获得一个值。 指定默认实参的约束 既可以在函数声明也可以在函数定义中指定默认实参。但是,在一个文件中,只 能为一个形参指定默认实参一次。下面的例子是错误的: // ff.h int ff(int = 0); // ff.cc #include "ff.h" int ff(int i = 0) { /* ... */ } // error 通常,应在函数声明中指定默认实参,并将该声明放在合适的 头文件中。 如果在函数定义的形参表中提供默认实参,那么只有在包含该函数定义的源文件 中调用该函数时,默认实参才是有效的。 343 Exercises Section 7.4.1 Exercise 7.24: 如果有的话,指出下面哪些函数声明是错误的?为什么? (a) int ff(int a, int b = 0, int c = 0); (b) char *init(int ht = 24, int wd, char bckgrnd); Exercise 假设有如下函数声明和调用,指出哪些调用是不合法的? 7.25: 为什么?哪些是合法的但可能不符合程序员的原意?为 什么? // declarations char *init(int ht, int wd = 80, char bckgrnd = ' '); (a) init(); (b) init(24,10); (c) init(14, '*'); Exercise 用字符 's' 作为默认实参重写函数 make_plural。利用 7.26: 这个版本的函数输出单词“success”和“failure”的 单数和复数形式。 7.5. 局部对象 在 C++ 语言中,每个名字都有作用域,而每个对象都有生命期。要弄清楚 函数是怎么运行的,理解这两个概念十分重要。名字的作用域指的是知道该名字 的程序文本区。对象的生命期则是在程序执行过程中对象存在的时间。 在函数中定义的形参和变量的名字只位于函数的作用域中:这些名字只在函 数体中可见。通常,变量名从声明或定义的地方开始到包围它的作用域结束处都 是可用的。 344 7.5.1. 自动对象 默认情况下,局部变量的生命期局限于所在函数的每次执行期间。只有当定 义它的函数被调用时才存在的对象称为自动对象。自动对象在每次调用函数时创 建和撤销。 局部变量所对应的自动对象在函数控制经过变量定义语句时创建。如果在定 义时提供了初始化式,那么每次创建对象时,对象都会被赋予指定的初值。对于 未初始化的内置类型局部变量,其初值不确定。当函数调用结束时,自动对象就 会撤销。 形参也是自动对象。形参所占用的存储空间在调用函数时创建,而在函数结 束时撤销。 自动对象,包括形参,都在定义它们的块语句结束时撤销。形参在函数块中 定义,因此当函数的执行结束时撤销。当函数结束时,会释放它的局部存储空间。 在函数结束后,自动对象和形参的值都不能再访问了。 7.5.2. 静态局部对象 一个变量如果位于函数的作用域内,但生命期跨越了这个函数的多次调用, 这种变量往往很有用。则应该将这样的对象定义为 static(静态的)。 static 局部对象确保不迟于在程序执行流程第一次经过该对象的定义语句 时进行初始化。这种对象一旦被创建,在程序结束前都不会撤销。当定义静态局 部对象的函数结束时,静态局部对象不会撤销。在该函数被多次调用的过程中, 静态局部对象会持续存在并保持它的值。考虑下面的小例子,这个函数计算了自 己被调用的次数: size_t count_calls() { static size_t ctr = 0; // value will persist across calls return ++ctr; } int main() { for (size_t i = 0; i != 10; ++i) cout << count_calls() << endl; return 0; } 这个程序会依次输出 1 到 10(包含 10)的整数。 345 在第一次调用函数 count_calls 之前,ctr 就已创建并赋予初值 0。每次 函数调用都使加 1,并且返回其当前值。在执行函数 count_calls 时,变量 ctr 就已经存在并且保留上次调用该函数时的值。因此,第二次调用时,ctr 的值为 1,第三次为 2,依此类推。 Exercises Section 7.5.2 Exercise 解释形参、局部变量和静态局部变量的差别。并给出一个 7.27: 有效使用了这三种变量的程序例子。 Exercise 编写函数,使其在第一次调用时返回 0,然后再次调用时 7.28: 按顺序产生正整数(即返回其当前的调用次数)。 7.6. 内联函数 回顾在第 7.3.2 节编写的那个返回两个 string 形参中较短的字符串的函 数: // find longer of two strings const string &shorterString(const string &s1, const string &s2) { return s1.size() < s2.size() ? s1 : s2; } 为这样的小操作定义一个函数的好处是: • 阅读和理解函数 shorterString 的调用,要比读一条用等价的条件表达 式取代函数调用表达式并解释它的含义要容易得多。 • 如果需要做任何修改,修改函数要比找出并修改每一处等价表达式容易得 多。 • 使用函数可以确保统一的行为,每个测试都保证以相同的方式实现。 • 函数可以重用,不必为其他应用重写代码。 但是,将 shorterString 写成函数有一个潜在的缺点:调用函数比求解等价 表达式要慢得多。在大多数的机器上,调用函数都要做很多工作;调用前要先保 存寄存器,并在返回时恢复;复制实参;程序还必须转向一个新位置执行。 346 inline 函数避免函数调用的开销 将函数指定为 inline 函数,(通常)就是将它在程序中每个调用点上“内联地” 展开。假设我们将 shorterString 定义为内联函数,则调用: cout << shorterString(s1, s2) << endl; 在编译时将展开为: cout << (s1.size() < s2.size() ? s1 : s2) << endl; 从而消除了把 shorterString 写成函数的额外执行开销。 从而消除了把 shorterString 写成函数的额外执行开销。 // inline version: find longer of two strings inline const string & shorterString(const string &s1, const string &s2) { return s1.size() < s2.size() ? s1 : s2; } inline 说明对于编译器来说只是一个建议,编译器可以选择忽 略这个。 一般来说,内联机制适用于优化小的、只有几行的而且经常被调用的函数。 大多数的编译器都不支持递归函数的内联。一个 1200 行的函数也不太可能在调 用点内联展开。 把 inline 函数放入头文件 内联函数应该在头文件中定义,这一点不同于其他函数。 347 inline 函数的定义对编译器而言必须是可见的,以便编译器能够在调用点 内联展开该函数的代码。此时,仅有函数原型是不够的。 inline 函数可能要在程序中定义不止一次,只要 inline 函数的定义在某 个源文件中只出现一次,而且在所有源文件中,其定义必须是完全相同的。把 inline 函数的定义放在头文件中,可以确保在调用函数时所使用的定义是相同 的,并且保证在调用点该函数的定义对编译器可见。 在头文件中加入或修改 inline 函数时,使用了该头文件的所 有源文件都必须重新编译。 Exercises Section 7.6 Exercise 对于下面的声明和定义,你会将哪个放在头文件,哪个放 7.29: 在程序文本文件呢?为什么? (a) inline bool eq(const BigInt&, const BigInt&) {...} (b) void putValues(int *arr, int size); Exercise 第 7.2.2 节的函数 is Shorter 改写为 inline 函数。 7.30: 7.7. 类的成员函数 第 2.8 节开始定义类 Sales_item,用于解决第一章的书店问题。至此,我们已 经了解了如何定义普通函数,现在来定义类的成员函数,以继续完善这个类。 成员函数的定义与普通函数的定义类似。和任何函数一样,成员函数也包含下面 四个部分: • 函数返回类型。 • 函数名。 • 用逗号隔开的形参表(也可能是空的)。 • 包含在一对花括号里面的函数体。 348 正如我们知道的,前面三部分组成函数原型。函数原型定义了所有和函数相关的 类型信息:函数返回类型是什么、函数的名字、应该给这个函数传递什么类型的 实参。函数原型必须在类中定义。但是,函数体则既可以在类中也可以在类外定 义。 知道这些后,观察下面扩展的类定义,我们为这个类增加了两个新成员:成员函 数 avg_price 和 same_isbn。其中 avg_price 函数的形参表是空的,返回 double 类型的值。而 same_isbn 函数则返回 bool 对象,有一个 const Sales_item 类型的引用形参。 class Sales_item { public: // operations on Sales_item objects double avg_price() const; bool same_isbn(const Sales_item &rhs) const { return isbn == rhs.isbn; } // private members as before private: std::string isbn; unsigned units_sold; double revenue; }; 在解释跟在形参表后面的 const 之前,必须先说明成员函数是如何定义的。 7.7.1. 定义成员函数的函数体 类的所有成员都必须在类定义的花括号里面声明,此后,就不能再为类增加任何 成员。类的成员函数必须如声明的一般定义。类的成员函数既可以在类的定义内 也可以在类的定义外定义。在类 Sales_item 中,这两种情况各有一例说明:函 数 same_isbn 在类内定义,而函数 avg_price 则在类内声明,在类外定义。 编译器隐式地将在类内定义的成员函数当作内联函数(第 7.6 节)。 再详细观察函数 same_isbn 的定义: bool same_isbn(const Sales_item &rhs) const { return isbn == rhs.isbn; } 与任何函数一样,该函数的函数体也是一个块。在这个函数中,块中只有一个语 句,比较两个 Sales_item 对象的数据成员 isbn 的值,并返回比较结果。 349 首先要注意的是:成员 isbn 是 private 的。尽管如此,上述语句却没有任何 错误。 类的成员函数可以访问该类的 private 成员。 更有意思的是,函数从哪个 Sales_item 类对象得到这个用于比较的值?函数涉 及到 isbn 和 rhs.isbn。很明显,rhs.isbn 使用的是传递给此函数的实参的 isbn 成员。没有前缀的 isbn 的用法更加有意思。正如我们所看见的,这个没 有前缀的 isbn 指的是用于调用函数的对象的 isbn 成员。 成员函数含有额外的、隐含的形参 调用成员函数时,实际上是使用对象来调用的。例如,调用 第 1.6 节书店程序 中的函数 same_isbn,是通过名为 total 的对象来执行 same_isbn 函数的: if (total.same_isbn(trans)) 在这个调用中,传递了对象 trans。作为执行调用的一部分,使用对象 trans 初 始化形参 rhs。于是,rhs.isbn 是 trans.isbn 的引用。 而没有前缀的 isbn 使用了相同的实参绑定过程,使之与名为 total 的对象绑 定起来。每个成员函数都有一个额外的、隐含的形参将该成员函数与调用该函数 的类对象捆绑在一起。当调用名为 total 的对象的 same_isbn 时,这个对象也 传递给了函数。而 same_isbn 函数使用 isbn 时,就隐式地使用了调用该函数 的对象的 isbn 成员。这个函数调用的效果是比较 total.isbn 和 trans.isbn 两个值。 this 指针的引入 每个成员函数(除了在第 12.6 节介绍的 static 成员函数外)都有一个额外的、 隐含的形参 this。在调用成员函数时,形参 this 初始化为调用函数的对象的 地址。为了理解成员函数的调用,可考虑下面的语句: total.same_isbn(trans); 就如编译器这样重写这个函数调用: 350 // pseudo-code illustration of how a call to a member function is translated Sales_item::same_isbn(&total, trans); 在这个调用中,函数 same_isbn 中的数据成员 isbn 属于对象 total。 const 成员函数的引入 现在,可以理解跟在 Sales_item 成员函数声明的形参表后面的 const 所起的 作用了:const 改变了隐含的 this 形参的类型。在调用 total.same_isbn(trans) 时,隐含的 this 形参将是一个指向 total 对象的 const Sales_Item* 类型的指针。就像如下编写 same_isbn 的函数体一样: // pseudo-code illustration of how the implicit this pointer is used // This code is illegal: We may not explicitly define the this pointer ourselves // Note that this is a pointer to const because same_isbn is a const member bool Sales_item::same_isbn(const Sales_item *const this, const Sales_item &rhs) const { return (this->isbn == rhs.isbn); } 用这种方式使用 const 的函数称为常量成员函数。由于 this 是指向 const 对 象的指针,const 成员函数不能修改调用该函数的对象。因此,函数 avg_price 和函数 same_isbn 只能读取而不能修改调用它们的对象的数据成员。 const 对象、指向 const 对象的指针或引用只能用于调用其 const 成员函数,如果尝试用它们来调用非 const 成员函数, 则是错误的。 this 指针的使用 在成员函数中,不必显式地使用 this 指针来访问被调用函数所属对象的成员。 对这个类的成员的任何没有前缀的引用,都被假定为通过指针 this 实现的引 用: bool same_isbn(const Sales_item &rhs) const { return isbn == rhs.isbn; } 351 在这个函数中 isbn 的用法与 this->units_sold 或 this->revenue 的用法一 样。 由于 this 指针是隐式定义的,因此不需要在函数的形参表中包含 this 指针, 实际上,这样做也是非法的。但是,在函数体中可以显式地使用 this 指针。如 下定义函数 same_isbn 尽管没有必要,但是却是合法的: bool same_isbn(const Sales_item &rhs) const { return this->isbn == rhs.isbn; } 7.7.2. 在类外定义成员函数 在类的定义外面定义成员函数必须指明它们是类的成员: double Sales_item::avg_price() const { if (units_sold) return revenue/units_sold; else return 0; } 上述定义和其他函数一样:该函数返回类型为 double,在函数名后面的圆括号 起了一个空的形参表。新的内容则包括跟在形参表后面的 const 和函数名的形 式。函数名: Sales_item::avg_price 使用作用域操作符(第 1.2.2 节)指明函数 avg_price 是在类 Sales_item 的 作用域范围内定义的。 形参表后面的 const 则反映了在类 Sales_item 中声明成员函数的形式。在任 何函数定义中,返回类型和形参表必须和函数声明(如果有的话)一致。对于成 员函数,函数声明必须与其定义一致。如果函数被声明为 const 成员函数,那 么函数定义时形参表后面也必须有 const。 现在可以完全理解第一行代码了:这行代码说明现在正在定义类 Sales_item 的 函数 avg_price,而且这是一个 const 成员函数,这个函数没有(显式的)形 参,返回 double 类型的值。 352 函数体更加容易理解:检查 units_sold 是否为 0,如果不为 0,返回 revenue 除以 units_sold 的结果;如果 units_sold 是 0,不能安全地进行除法运算 ——除以 0 是未定义的行为。此时程序返回 0,表示没有任何销售时平均售价 为 0。根据异常错误处理策略,也可以抛出异常来代替刚才的处理(第 6.13 节)。 7.7.3. 编写 Sales_item 类的构造函数 还必须编写一个成员,那就是构造函数。正如在第 2.8 节所学习的,在定义类 时没有初始化它的数据成员,而是通过构造函数来初始化其数据成员。 构造函数是特殊的成员函数 构造函数是特殊的成员函数,与其他成员函数不同,构造函数和类同名,而且没 有返回类型。而与其他成员函数相同的是,构造函数也有形参表(可能为空)和 函数体。一个类可以有多个构造函数,每个构造函数必须有与其他构造函数不同 数目或类型的形参。 构造函数的形参指定了创建类类型对象时使用的初始化式。通常,这些初始化式 会用于初始化新创建对象的数据成员。构造函数通常应确保其每个数据成员都完 成了初始化。 Sales_item 类只需要显式定义一个构造函数:没有形参的默认构造函数。默认 构造函数说明当定义对象却没有为它提供(显式的)初始化式时应该怎么办: vector vi; string s; Sales_item item; // default constructor: empty vector // default constructor: empty string // default constructor: ??? 我们知道 string 和 vector 类默认构造函数的行为:这些构造函数会将对象初 始化为合理的默认状态。string 的默认构造函数会产生空字符串上,相当于 ""。 vector 的默认构造函数则生成一个没有元素的 vector 向量对象。 同样地,我们希望类 Sales_items 的默认构造函数为它生成一个空的 Sales_item 对象。这里的“空”意味着对象中的 isbn 是空字符串,units_sold 和 revenue 则初始化为 0。 构造函数的定义 和其他成员函数一样,构造函数也必须在类中声明,但是可以在类中或类外定义。 由于我们的构造函数很简单,因此在类中定义它: 353 class Sales_item { public: // operations on Sales_item objects double avg_price() const; bool same_isbn(const Sales_item &rhs) const { return isbn == rhs.isbn; } // default constructor needed to initialize members of built-in type Sales_item(): units_sold(0), revenue(0.0) { } // private members as before private: std::string isbn; unsigned units_sold; double revenue; }; 在解释任何构造函数的定义之前,注意到构造函数是放在类的 public 部分的。 通常构造函数会作为类的接口的一部分,这个例子也是这样。毕竟,我们希望使 用类 Sales_item 的代码可以定义和初始化类 Sales_item 的对象。如果将构造 函数定义为 private 的,则不能定义类 Sales_item 的对象,这样的话,这个 类就没有什么用了。 对于定义本身: // default constructor needed to initialize members of built-in type Sales_item(): units_sold(0), revenue(0.0) { } 上述语句说明现在正在定义类 Sales_item 的构造函数,这个构造函数的形参表 和函数体都为空。令人感兴趣的是冒号和冒号与定义(空)函数体的花括号之间 的代码。 构造函数和初始化列表 在冒号和花括号之间的代码称为构造函数的初始化列表。构造函数的初始化列表 为类的一个或多个数据成员指定初值。它跟在构造函数的形参表之后,以冒号开 关。构造函数的初始化式是一系列成员名,每个成员后面是括在圆括号中的初始 值。多个成员的初始化用逗号分隔。 上述例题的初始化列表表明 units_sold 和 revenue 成员都应初始化为 0。每 当创建 Sales_item 对象时,它的这两个成员都以初值 0 出现。而 isbn 成员 可以不必准确指明其初值。除非在初始化列表中有其他表述,否则具有类类型的 成员皆被其默认构造函数自动初始化。于是,isbn 由 string 类的默认构造函 354 数初始化为空串。当然,如果有必要的话,也可以在初始化列表中指明 isbn 的 默认初值。 解释了初始化列表后,就可以深入地了解这个构造函数了:它的形参表和函数体 都为空。形参表为空是因为正在定义的构造函数是默认调用的,无需提供任何初 值。函数体为空是因为除了初始化 units_sold 和 revenue 成员外没有其他工 作可做了。初始化列表显式地将 units_sold 和 revenue 初始化为 0,并隐式 地将 isbn 初始化为空串。当创建新 Sales_item 对象时,数据成员将以这些值 出现。 合成的默认构造函数 If we do not explicitly define any constructors, then the compiler will generate the default constructor for us. 如果没有为一个类显式定义任何构造函数,编译器将自动为这 个类生成默认构造函数。 由编译器创建的默认构造函数通常称为默认构造函数,它将依据如同变量初始化 (第 2.3.4 节)的规则初始化类中所有成员。对于具有类类型的成员,如 isbn, 则会调用该成员所属类自身的默认构造函数实现初始化。内置类型成员的初值依 赖于对象如何定义。如果对象在全局作用域中定义(即不在任何函数中)或定义 为静态局部对象,则这些成员将被初始化为 0。如果对象在局部作用域中定义, 则这些成员没有初始化。除了给它们赋值之外,出于其他任何目的对未初始化成 员的使用都没有定义。 合成的默认构造函数一般适用于仅包含类类型成员的类。而对 于含有内置类型或复合类型成员的类,则通常应该定义他们自 己的默认构造函数初始化这些成员。 由于合成的默认构造函数不会自动初始化内置类型的成员,所以必须明确定义 Sales_item 类的默认构造函数。 355 7.7.4. 类代码文件的组织 正如在第 2.9 节提及的,通常将类的声明放置在头文件中。大多数情况下,在 类外定义的成员函数则置于源文件中。C++ 程序员习惯使用一些简单的规则给头 文件及其关联的类定义代码命名。类定义应置于名为 type.h 或 type.H 的文件 中,type 指在该文件中定义的类的名字。成员函数的定义则一般存储在与类同 名的源文件中。依照这些规则,我们将类 Sales_item 放在名为 Sales_item.h 的文件中定义。任何需使用这个类的程序,都必须包含这个头文件。而 Sales_item 的成员函数的定义则应该放在名为 Sales_item.cc 的文件中。这个 文件同样也必须包含 Sales_item.h 头文件。 Exercises Section 7.7.4 Exercise 7.31: 编写你自己的 Sales_item 类,添加两个公用(public) 成员用于读和写 Sales_item 对象。这两个成员函数的功 能应类似于第一章介绍的输入输出操作符。交易也应类似 于那一章所定义的。利用这个类读入并输出一组交易。 Exercise 编写一个头文件,包含你自己的 Sales_item 类。使用通 7.32: 用的 C++ 规则给这个头文件以及任何相关的文件命名, 这些文件用于存储在类外定义的非内联函数。 Exercise 在 Sales_item 类中加入一个成员,用于添加两个 7.33: Sales_item 对象。使用修改后的类重新解决第一章给出 的平均价格问题。 7.8. 重载函数 出现在相同作用域中的两个函数,如果具有相同的名字而形参表不同,则称为重 载函数。 使用某种程序设计语言编写过算术表达式的程序员都肯定使用过重载函数。表达 式 1+3 调用了针对整型操作数加法操作符,而表达式 1.0 + 3.0 356 调用了另外一个专门处理浮点操作数的不同的加法操作。根据操作数的类型来区 分不同的操作,并应用适当的操作,是编译器的责任,而不是程序员的事情。 类似地,程序员可以定义一组函数,它们执行同样的一般性动作,但是应用在不 同的形参类型上,调用这些函数时,无需担心调用的是哪个函数,就像我们不必 操心执行的是整数算术操作还是浮点数自述操作就可以实现 int 型加法或 double 型加法一样。 通过省去为函数起名并记住函数名字的麻烦,函数重载简化了程序的实现,使程 序更容易理解。函数名只是为了帮助编译器判断调用的是哪个函数而已。例如, 一个数据库应用可能需要提供多个 lookup 函数,分别实现基于姓名、电话号码 或账号之类的查询功能。函数重载使我们可以定义一系列的函数,它们的名字都 是 lookup,不同之处在于用于查询的值不相同。如此可传递几种类型中的任一 种值调用 lookup 函数: Record lookup(const Account&); Record lookup(const Phone&); Record lookup(const Name&); Record r1, r2; r1 = lookup(acct); Account r2 = lookup(phone); Phone // find by Account // find by Phone // find by Name // call version that takes an // call version that takes a 这里的三个函数共享同一个函数名,但却是三个不同的函数。编译器将根据所传 递的实参类型来判断调用的是哪个函数。 要理解函数重载,必须理解如何定义一组重载函数和编译器如何决定对某一调用 使用哪个函数。本节的其余部分将会回顾这些主题。 任何程序都仅有一个 main 函数的实例。main 函数不能重载。 357 函数重载和重复声明的区别 如果两个函数声明的返回类型和形参表完全匹配,则将第二个函数声明视为第一 个的重复声明。如果两个函数的形参表完全相同,但返回类型不同,则第二个声 明是错误的: Record lookup(const Account&); bool lookup(const Account&); // error: only return type is different 函数不能仅仅基于不同的返回类型而实现重载。 有些看起来不相同的形参表本质上是相同的: // each pair declares the same function Record lookup(const Account &acct); Record lookup(const Account&); // parameter names are ignored typedef Phone Telno; Record lookup(const Phone&); Record lookup(const Telno&); // Telno and Phone are the same type Record lookup(const Phone&, const Name&); // default argument doesn't change the number of parameters Record lookup(const Phone&, const Name& = ""); // const is irrelevent for nonreference parameters Record lookup(Phone); Record lookup(const Phone); // redeclaration 在第一对函数声明中,第一个声明给它的形参命了名。形参名只是帮助文档,并 没有修改形参表。 在第二对函数声明中,看似形参类型不同,但注意到 Telno 其实并不是新类型, 只是 Phone 类型的同义词。typedef 给已存在的数据类型提供别名,但并没有 创建新的数据类型。所以,如果两个形参的差别只是一个使用 typedef 定义的 类型名,而另一个使用 typedef 对应的原类型名,则这两个形参并无不同。 在第三对中,形参列表只有默认实参不同。默认实参并没有改变形参的个数。无 论实参是由用户还是由编译器提供的,这个函数都带有两个实参。 最后一对的区别仅在于是否将形参定义为 const。这种差异并不影响传递至函数 的对象;第二个函数声明被视为第一个的重复声明。其原因在于实参传递的方式。 复制形参时并不考虑形参是否为 const——函数操纵的只是副本。函数的无法修 改实参。结果,既可将 const 对象传递给 const 形参,也可传递给非 const 形 参,这两种形参并无本质区别。 358 值得注意的是,形参与 const 形参的等价性仅适用于非引用形参。有 const 引 用形参的函数与有非 const 引用形参的函数是不同的。类似地,如果函数带有 指向 const 类型的指针形参,则与带有指向相同类型的非 const 对象的指针形 参的函数不相同。 建议:何时不重载函数名 虽然,对于通常的操作,重载函数能避免不必要的函数命名(和名字记 忆),但很容易就会过分使用重载。在一些情况下,使用不同的函数名 能提供较多的信息,使程序易于理解。考虑下面 Screen 类的一组用于 移动屏幕光标的成员函数: Screen& moveHome(); Screen& moveAbs(int, int); Screen& moveRel(int, int, char *direction); 乍看上去,似乎把这组函数重载为名为 move 的函数更好一些: Screen& move(); Screen& move(int, int); Screen& move(int, int, *direction); 其实不然,重载过后的函数失去了原来函数名所包含的信息,如此一来, 程序变得晦涩难懂了。 虽则这几个函数共享的一般性动作都是光标移动,但特殊的移动性质却 互不相同。例如,moveHome 表示的是光标移动的一个特殊实例。对于程 序的读者,下面两种调用中,哪种更易于理解?而对于使用 Screen 类 的程序员,哪一个调用又更容易记忆呢? // which is easier to understand? myScreen.home(); // we think this one! myScreen.move(); 359 7.8.1. 重载与作用域 第 2.3.6 节的程序演示了 C++ 作用域的嵌套。在函数中局部声明的名字将屏蔽 在全局作用域(第 2.3.6 节)内声明的同名名字。这个关于变量名字的性质对 于函数名同样成立: /* Program for illustration purposes only: * It is bad style for a function to define a local variable * with the same name as a global name it wants to use */ string init(); // the name init has global scope void fcn() { int init = 0; // init is local and hides global init string s = init(); // error: global init is hidden } 一般的作用域规则同样适用于重载函数名。如果局部地声明一个函数,则该函数 将屏蔽而不是重载在外层作用域中声明的同名函数。由此推论,每一个版本的重 载函数都应在同一个作用域中声明。 一般来说,局部地声明函数是一种不明智的选择。函数的声明 应放在头文件中。 但为了解释作用域与重载的相互作用,我们将违反上述规则而使用局部函数声 明。 作为例子,考虑下面的程序: void print(const string &); void print(double); // overloads the print function void fooBar(int ival) { void print(int); // new scope: hides previous instances of print print("Value: "); // error: print(const string &) is hidden print(ival); // ok: print(int) is visible print(3.14); // ok: calls print(int); print(double) is hidden } 360 函数 fooBar 中的 print(int) 声明将屏蔽 print 的其他声明,就像只有一个 有效的 print 函数一样:该函数仅带有一个 int 型形参。在这个作用域或嵌套 在这个作用域里的其他作用域中,名字 print 的任何使用都将解释为这个 print 函数实例。 调用 print 时,编译器首先检索这个名字的声明,找到只有一个 int 型形参的 print 函数的局部声明。一旦找到这个名字,编译器将不再继续检查这个名字是 否在外层作用域中存在,即编译器将认同找到的这个声明即是程序需要调用的函 数,余下的工作只是检查该名字的使用是否有效。 第一个函数调用传递了一个字符串字面值,但是函数的形参却是 int 型的。字 符串字面值无法隐式地转换为 int 型,因而该调用是错误的。print(const string&) 函数与这个函数调用匹配,但已被屏蔽,因此不在解释该调用时考虑。 当传递一个 double 数据调用 print 函数时,编译器重复了同样的匹配过程: 首先检索到 print(int) 局部声明,然后将 double 型的实参隐式转换为 int 型。因此,该调用合法。 在 C++ 中,名字查找发生在类型检查之前。 另一种情况是,在与其他 print 函数相同的作用域中声明 print(int),这样, 它就成为 print 函数的另一个重载版本。此时,所有的调用将以不同的方式解 释: void print(const string &); void print(double); // overloads print function void print(int); // another overloaded instance void fooBar2(int ival) { print("Value: "); // ok: calls print(const string &) print(ival); // ok: print(int) print(3.14); // ok: calls print (double) } 现在,编译器在检索名字 print 时,将找到这个名字的三个函数。每一个调用 都将选择与其传递的实参相匹配的 print 版本。 361 Exercises Section 7.8.1 Exercise 定义一组名为 error 的重载函数,使之与下面的调用匹 7.34: 配: int index, upperBound; char selectVal; // ... error("Subscript out of bounds: ", index, upperBound); error("Division by zero"); error("Invalid selection", selectVal); Exercise 下面提供了三组函数声明,解释每组中第二个声明的效 7.35: 果,并指出哪些(如果有的话)是不合法的。 (a) int calc(int, int); int calc(const int, const int); (b) int get(); double get(); (c) int *reset(int *); double *reset(double *); 7.8.2. 函数匹配与实参转换 函数重载确定,即函数匹配是将函数调用与重载函数集合中的一个函数相关联的 过程。通过自动提取函数调用中实际使用的实参与重载集合中各个函数提供的形 参做比较,编译器实现该调用与函数的匹配。匹配结果有三种可能: 1. 编译器找到与实参最佳匹配的函数,并生成调用该函数的代码。 2. 找不到形参与函数调用的实参匹配的函数,在这种情况下,编译器将给出 编译错误信息。 3. 存在多个与实参匹配的函数,但没有一个是明显的最佳选择。这种情况也 是,该调用具有二义性。 大多数情况下,编译器都可以直接明确地判断一个实际的调用是否合法,如果合 法,则应该调用哪一个函数。重载集合中的函数通常有不同个数的参数或无关联 的参数类型。当多个函数的形参具有可通过隐式转换(第 5.12 节)关联起来的 362 类型,则函数匹配将相当灵活。在这种情况下,需要程序员充分地掌握函数匹配 的过程。 7.8.3. 重载确定的三个步骤 考虑下面的这组函数和函数调用: void f(); void f(int); void f(int, int); void f(double, double = 3.14); f(5.6); // calls void f(double, double) 候选函数 函数重载确定的第一步是确定该调用所考虑的重载函数集合,该集合中的函数称 为候选函数。候选函数是与被调函数同名的函数,并且在调用点上,它的声明可 见。在这个例子中,有四个名为 f 的候选函数。 选择可行函数 第二步是从候选函数中选择一个或多个函数,它们能够用该调用中指定的实参来 调用。因此,选出来的函数称为可行函数。可行函数必须满足两个条件:第一, 函数的形参个数与该调用的实参个数相同;第二,每一个实参的类型必须与对应 形参的类型匹配,或者可被隐式转换为对应的形参类型。 如果函数具有默认实参(第 7.4.1 节),则调用该函数时,所 用的实参可能比实际需要的少。默认实参也是实参,在函数匹 配过程中,它的处理方式与其他实参一样。 对于函数调用 f(5.6),可首先排除两个实参个数不匹配的候选函数。没有形参 的 f 函数和有两个 int 型形参的 f 函数对于这个函数调用来说都不可行。例 中的调用只有一个实参,而这些函数分别带有零个和两个形参。 另一方面,有两个 double 型参数的 f 函数可能是可行的。调用带有默认实参 (第 7.4.1 节)的函数时可忽略这个实参。编译器自动将默认实参的值提供给 被忽略的实参。因此,某个调用拥有的实参可能比显式给出的多。 363 根据实参个数选出潜在的可行函数后,必须检查实参的类型是否与对应的形参类 型匹配。与任意函数调用一样,实参必须与它的形参匹配,它们的类型要么精确 匹配,要么实参类型能够转换为形参类型。在这个例子中,余下的两个函数都是 是可行的。 • f(int) 是一个可行函数,因为通过隐式转换可将函数调用中的 double 型实参转换为该函数唯一的 int 型形参。 • f(double, double) 也是一个可行函数,因为该函数为其第二个形参提供 了默认实参,而且第一个形参是 double 类型,与实参类型精确匹配。 如果没有找到可行函数,则该调用错误。 寻找最佳匹配(如果有的话) 函数重载确定的第三步是确定与函数调用中使用的实际参数匹配最佳的可行函 数。这个过程考虑函数调用中的每一个实参,选择对应形参与之最匹配的一个或 多个可行函数。这里所谓“最佳”的细节将在下一节中解释,其原则是实参类型 与形参类型越接近则匹配越佳。因此,实参类型与形参类型之间的精确类型匹配 比需要转换的匹配好。 在上述例子中,只需考虑一个 double 类型的显式实参。如果调用 f(int),实 参需从 double 型转换为 int 型。而另一个可行函数 f(double, double) 则与 该实参精确匹配。由于精确匹配优于需要类型转换的匹配,因此编译器将会把函 数调用 f(5.6) 解释为对带有两个 double 形参的 f 函数的调用。 含有多个形参的重载确定 如果函数调用使用了两个或两个以上的显式实参,则函数匹配会更加复杂。假设 有两样的名为 f 的函数,分析下面的函数调用: f(42, 2.56); 可行函数将以同样的方式选出。编译器将选出形参个数和类型都与实参匹配的函 数。在本例中,可行函数是 f(int, int) 和 f(double, double)。接下来,编 译器通过依次检查每一个实参来决定哪个或哪些函数匹配最佳。如果有且仅有一 个函数满足下列条件,则匹配成功: 1. 其每个实参的匹配都不劣于其他可行函数需要的匹配。 2. 至少有一个实参的匹配优于其他可行函数提供的匹配。 364 如果在检查了所有实参后,仍找不到唯一最佳匹配函数,则该调用错误。编译器 将提示该调用具有二义性。 在本例子的调用中,首先分析第一个实参,发现函数 f(int, int) 匹配精确。 如果使之与第二个函数匹配,就必须将 int 型实参 42 转换为 double 型的值。 通过内置转换的匹配“劣于”精确匹配。所以,如果只考虑这个形参,带有两个 int 型形参的函数比带有两个 double 型形参的函数匹配更佳。 但是,当分析第二个实参时,有两个 double 型形参的函数为实参 2.56 提供了 精确匹配。而调用两个 int 型形参的 f 函数版本则需要把 2.56 从 double 型 转换为 int 型。所以只考虑第二个形参的话,函数 f(double, double) 匹配更 佳。 因此,这个调用有二义性:每个可行函数都对函数调用的一个实参实现更好的匹 配。编译器将产生错误。解决这样的二义性,可通过显式的强制类型转换强制函 数匹配: f(static_cast(42), 2.56); // calls f(double, double) f(42, static_cast(2.56)); // calls f(int, int) 在实际应用中,调用重载函数时应尽量避免对实参做强 制类型转换:需要使用强制类型转换意味着所设计的形 参集合不合理。 365 Exercises Section 7.8.3 Exercise 什么是候选函数?什么是可行函数? 7.36: Exercise 7.37: 已知本节所列出的 f 函数的声明,判断下面哪些函数调 用是合法的。如果有的话,列出每个函数调用的可行函 数。如果调用非法,指出是没有函数匹配还是该调用存 在二义性。如果调用合法,指出哪个函数是最佳匹配。 (a) f(2.56, 42); (b) f(42); (c) f(42, 0); (d) f(2.56, 3.14); 7.8.4. 实参类型转换 为了确定最佳匹配,编译器将实参类型到相应形参类型转换划分等级。转换等级 以降序排列如下: 1. 精确匹配。实参与形参类型相同。 2. 通过类型提升实现的匹配(第 5.12.2 节)。 3. 通过标准转换实现的匹配(第 5.12.3 节)。 4. 通过类类型转换实现的匹配(第 14.9 节将介绍这类转换)。 内置类型的提升和转换可能会使函数匹配产生意想不到的结 果。但幸运的是,设计良好的系统很少会包含与下面例子类似 的形参类型如此接近的函数。 通过这些例子,学习并加深了解特殊的函数匹配和内置类型之间的一般关系。 需要类型提升或转换的匹配 类型提升或转换适用于实参类型可通过某种标准转换提升或转换为适当的形参 类型情况。 必须注意的一个重点是较小的整型提升为 int 型。假设有两个函数,一个的形 参为 int 型,另一个的形参则是 short 型。对于任意整型的实参值,int 型版 366 本都是优于 short 型版本的较佳匹配,即使从形式上看 short 型版本的匹配较 佳: void ff(int); void ff(short); ff('a'); // char promotes to int, so matches f(int) 字符字面值是 char 类型,char 类型可提升为 int 型。提升后的类型与函数 ff(int) 的形参类型匹配。char 类型同样也可转换为 short 型,但需要类型转 换的匹配“劣于”需要类型提升的匹配。结果应将该调用解释为对 ff (int) 的 调用。 通过类型提升实现的转换优于其他标准转换。例如,对于 char 型实参来说,有 int 型形参的函数是优于有 double 型形参的函数的较佳匹配。其他的标准转换 也以相同的规则处理。例如,从 char 型到 unsigned char 型的转换的优先级 不比从 char 型到 double 型的转换高。再举一个具体的例子,考虑: extern void manip(long); extern void manip(float); manip(3.14); // error: ambiguous call 字面值常量 3.14 的类型为 double。这种类型既可转为 long 型也可转为 float 型。由于两者都是可行的标准转换,因此该调用具有二义性。没有哪个标 准转换比其他标准转换具有更高的优先级。 参数匹配和枚举类型 回顾枚举类型 enum,我们知道这种类型的对象只能用同一枚举类型的另一个对 象或一个枚举成员进行初始化(第 2.7 节)。整数对象即使具有与枚举元素相 同的值也不能用于调用期望获得枚举类型实参的函数。 enum Tokens {INLINE = 128, VIRTUAL = 129}; void ff(Tokens); void ff(int); int main() { Tokens curTok = INLINE; ff(128); // exactly matches ff(int) ff(INLINE); // exactly matches ff(Tokens) ff(curTok); // exactly matches ff(Tokens) return 0; } 传递字面值常量 128 的函数调用与有一个 int 型参数的 ff 版本匹配。 367 虽然无法将整型值传递给枚举类型的形参,但可以将枚举值传递给整型形参。此 时,枚举值被提升为 int 型或更大的整型。具体的提升类型取决于枚举成员的 值。如果是重载函数,枚举值提升后的类型将决定调用哪个函数: void newf(unsigned char); void newf(int); unsigned char uc = 129; newf(VIRTUAL); // calls newf(int) newf(uc); // calls newf(unsigned char) 枚举类型 Tokens 只有两个枚举成员,最大的值为 129。这个值可以用 unsigned char 类型表示,很多编译器会将这个枚举类型存储为 unsigned char 类型。然 而,枚举成员 VIRTUAL 却并不是 unsigned char 类型。就算枚举成员的值能存 储在 unsigned char 类型中,枚举成员和枚举类型的值也不会提升为 unsigned char 类型。 在使用有枚举类型形参的重载函数时,请记住:由于不同枚举 类型的枚举常量值不相同,在函数重载确定过程中,不同的枚 举类型会具有完全不同的行为。其枚举成员决定了它们提升的 类型,而所提升的类型依赖于机器。 重载和 const 形参 仅当形参是引用或指针时,形参是否为 const 才有影响。 可基于函数的引用形参是指向 const 对象还是指向非 const 对象,实现函数重 载。将引用形参定义为 const 来重载函数是合法的,因为编译器可以根据实参 是否为 const 确定调用哪一个函数: Record lookup(Account&); Record lookup(const Account&); // new function const Account a(0); Account b; lookup(a); // calls lookup(const Account&) lookup(b); // calls lookup(Account&) 如果形参是普通的引用,则不能将 const 对象传递给这个形参。如果传递了 const 对象,则只有带 const 引用形参的版本才是该调用的可行函数。 368 如果传递的是非 const 对象,则上述任意一种函数皆可行。非 const 对象既可 用于初始化 const 引用,也可用于初始化非 const 引用。但是,将 const 引 用初始化为非 const 对象,需通过转换来实现,而非 const 形参的初始化则是 精确匹配。 对指针形参的相关处理如出一辙。可将 const 对象的地址值只传递给带有指向 const 对象的指针形参的函数。也可将指向非 const 对象的指针传递给函数的 const 或非 const 类型的指针形参。如果两个函数仅在指针形参时是否指向 const 对象上不同,则指向非 const 对象的指针形参对于指向非 const 对象的 指针(实参)来说是更佳的匹配。重复强调,编译器可以判断:如果实参是 const 对象,则调用带有 const* 类型形参的函数;否则,如果实参不是 const 对象, 将调用带有普通指针形参的函数。 注意不能基于指针本身是否为 const 来实现函数的重载: f(int *); f(int *const); // redeclaration 此时,const 用于修改指针本身,而不是修饰指针所指向的类型。在上述两种情 况中,都复制了指针,指针本身是否为 const 并没有带来区别。正如前面第 7.8 节所提到的,当形参以副本传递时,不能基于形参是否为 const 来实现重载。 369 Exercises Section 7.8.4 Exercise 7.38: 给出如下声明: void manip(int, int); double dobj; 对于下面两组函数调用,请指出实参上每个转换的优先级 等级(第 7.8.4 节)? (a) manip('a', 'z'); (b) manip(55.4, dobj); Exercise 解释以下每组声明中的第二个函数声明所造成的影响,并 7.39: 指出哪些不合法(如果有的话)。 (a) int calc(int, int); int calc(const int&, const int&); (b) int calc(char*, char*); int calc(const char*, const char*); (c) int calc(char*, char*); int calc(char* const, char* const); Exercise 7.40: 下面的函数调用是否合法?如果不合法,请解释原因。 enum Stat { Fail, Pass }; void test(Stat); test(0); 7.9. 指向函数的指针 函数指针是指指向函数而非指向对象的指针。像其他指针一样,函数指针也 指向某个特定的类型。函数类型由其返回类型以及形参表确定,而与函数名无关: // pf points to function returning bool that takes two const string references bool (*pf)(const string &, const string &); 这个语句将 pf 声明为指向函数的指针,它所指向的函数带有两个 const string& 类型的形参和 bool 类型的返回值。 370 *pf 两侧的圆括号是必需的: // declares a function named pf that returns a bool* bool *pf(const string &, const string &); 用 typedef 简化函数指针的定义 函数指针类型相当地冗长。使用 typedef 为指针类型定义同义词,可将函 数指针的使用大大简化:(第 2.6 节): typedef bool (*cmpFcn)(const string &, const string &); 该定义表示 cmpFcn 是一种指向函数的指针类型的名字。该指针类型为“指 向返回 bool 类型并带有两个 const string 引用形参的函数的指针”。在要使 用这种函数指针类型时,只需直接使用 cmpFcn 即可,不必每次都把整个类型声 明全部写出来。 指向函数的指针的初始化和赋值 在引用函数名但又没有调用该函数时,函数名将被自动解释为指向函数的指 针。假设有函数: // compares lengths of two strings bool lengthCompare(const string &, const string &); 除了用作函数调用的左操作数以外,对 lengthCompare 的任何使用都被解 释为如下类型的指针: bool (*)(const string &, const string &); 可使用函数名对函数指针做初始化或赋值: cmpFcn pf1 = 0; // ok: unbound pointer to function cmpFcn pf2 = lengthCompare; // ok: pointer type matches function's type pf1 = lengthCompare; // ok: pointer type matches function's type pf2 = pf1; // ok: pointer types match 371 此时,直接引用函数名等效于在函数名上应用取地址操作符: cmpFcn pf1 = lengthCompare; cmpFcn pf2 = &lengthCompare; 函数指针只能通过同类型的函数或函数指针或 0 值常量表达 式进行初始化或赋值。 将函数指针初始化为 0,表示该指针不指向任何函数。 指向不同函数类型的指针之间不存在转换: string::size_type sumLength(const string&, const string&); bool cstringCompare(char*, char*); // pointer to function returning bool taking two const string& cmpFcn pf; pf = sumLength; // error: return type differs pf = cstringCompare; // error: parameter types differ pf = lengthCompare; // ok: function and pointer types match exactly 通过指针调用函数 指向函数的指针可用于调用它所指向的函数。可以不需要使用解引用操作 符,直接通过指针调用函数: cmpFcn pf = lengthCompare; lengthCompare("hi", "bye"); // direct call pf("hi", "bye"); // equivalent call: pf1 implicitly dereferenced (*pf)("hi", "bye"); // equivalent call: pf1 explicitly dereferenced 如果指向函数的指针没有初始化,或者具有 0 值,则该指针不 能在函数调用中使用。只有当指针已经初始化,或被赋值为指 向某个函数,方能安全地用来调用函数。 函数指针形参 函数的形参可以是指向函数的指针。这种形参可以用以下两种形式编写: 372 [View full width] /* useBigger function's third parameter is a pointer to function * that function returns a bool and takes two const string references * two ways to specify that parameter: */ // third parameter is a function type and is automatically treated as a pointer to function void useBigger(const string &, const string &, bool(const string &, const string &)); // equivalent declaration: explicitly define the parameter as a pointer to function void useBigger(const string &, const string &, bool (*)(const string &, const string &)); 返回指向函数的指针 函数可以返回指向函数的指针,但是,正确写出这种返回类型相当不容易: // ff is a function taking an int and returning a function pointer // the function pointed to returns an int and takes an int* and an int int (*ff(int))(int*, int); 阅读函数指针声明的最佳方法是从声明的名字开始由里而 外理解。 要理解该声明的含义,首先观察: ff(int) 将 ff 声明为一个函数,它带有一个 int 型的形参。该函数返回 int (*)(int*, int); 它是一个指向函数的指针,所指向的函数返回 int 型并带有两个分别是 int* 型和 int 型的形参。 使用 typedef 可使该定义更简明易懂: // PF is a pointer to a function returning an int, taking an int* and an int typedef int (*PF)(int*, int); 373 PF ff(int); // ff returns a pointer to function 允许将形参定义为函数类型,但函数的返回类型则必须是指向 函数的指针,而不能是函数。 具有函数类型的形参所对应的实参将被自动转换为指向相应函数类型的指 针。但是,当返回的是函数时,同样的转换操作则无法实现: // func is a function type, not a pointer to function! typedef int func(int*, int); void f1(func); // ok: f1 has a parameter of function type func f2(int); // error: f2 has a return type of function type func *f3(int); // ok: f3 returns a pointer to function type 指向重载函数的指针 C++ 语言允许使用函数指针指向重载的函数: extern void ff(vector); extern void ff(unsigned int); // which function does pf1 refer to? void (*pf1)(unsigned int) = &ff; // ff(unsigned) 指针的类型必须与重载函数的一个版本精确匹配。如果没有精确匹配的函 数,则对该指针的初始化或赋值都将导致编译错误: // error: no match: invalid parameter list void (*pf2)(int) = &ff; // error: no match: invalid return type double (*pf3)(vector); pf3 = &ff; 小结 函数是有名字的计算单元,对程序(就算是小程序)的结构化至关重要。函 数的定义由返回类型、函数名、形参表(可能为空)以及函数体组成。函数体是 调用函数时执行的语句块。在调用函数时,传递给函数的实参必须与相应的形参 类型兼容。 给函数传递实参遵循变量初始化的规则。非引用类型的形参以相应实参的副 本初始化。对(非引用)形参的任何修改仅作用于局部副本,并不影响实参本身。 374 复制庞大而复杂的值有昂贵的开销。为了避免传递副本的开销,可将形参指 定为引用类型。对引用形参的任何修改会直接影响实参本身。应将不需要修改相 应实参的引用形参定义为 const 引用。 在 C++ 中,函数可以重载。只要函数中形参的个数或类型不同,则同一个 函数名可用于定义不同的函数。编译器将根据函数调用时的实参确定调用哪一个 函数。在重载函数集合中选择适合的函数的过程称为函数匹配。 C++ 提供了两种特殊的函数:内联函数和成员函数。将函数指定为内联是建 议编译器在调用点直接把函数代码展开。内联函数避免了调用函数的代价。成员 函数则是身为类成员的函数。本章介绍了简单的成员函数,在第十二章将会更详 细地介绍成员函数。 术语 ambiguous call(有二义性的调用) 一种编译错误,当调用重载函数,找不到唯一的最佳匹配时产生。 arguments(实参) 调用函数时提供的值。这些值用于初始化相应的形参,其方式类似于初始 化同类型变量的方法。 automatic objects(自动对象) 局部于函数的对象。自动对象会在每一次函数调用时重新创建和初始化, 并在定义它的函数块结束时撤销。一旦函数执行完毕,这些对象就不再存 在了。 best match(最佳匹配) 在重载函数集合里找到的与给定调用的实参达到最佳匹配的唯一函数。 call operator(调用操作符) 使函数执行的操作符。该操作符是一对圆括号,并且有两个操作数:被调 用函数的名字,以及由逗号分隔的(也可能为空)形参表。 candidate functions(候选函数) 在解析函数调用时考虑的函数集合。候选函数包括了所有在该调用发生的 作用域中声明的、具有该调用所使用的名字的函数。 const member function(常量成员函数) 375 类的成员函数,并可以由该类类型的常量对象调用。常量成员函数不能修 改所操纵的对象的数据成员。 constructor(构造函数) 与所属类同名的类成员函数。构造函数说明如何初始化本类的对象。构造 函数没有返回类型,而且可以重载。 constructor initializer list(构造函数初始化列表) 在构造函数中用于为数据成员指定初值的表。初始化列表出现在构造函数 的定义中,位于构造函数体与形参表之间。该表由冒号和冒号后面的一组 用逗号分隔的成员名组成,每一个成员名后面跟着用圆括号括起来的该成 员的初值。 default constructor(默认构造函数) 在没有显式提供初始化式时调用的构造函数。如果类中没有定义任何构造 函数,编译器会自动为这个类合成默认构造函数。 function(函数) 可调用的计算单元。 function body(函数体) 定义函数动作的语句块。 function matching(函数匹配) 确定重载函数调用的编译器过程。调用时使用的实参将与每个重载函数的 形参表作比较。 function prototype(函数原型) 函数声明的同义词。包括了函数的名字、返回类型和形参类型。调用函数 时,必须在调用点之前声明函数原型。 inline function(内联函数) 如果可能的话,将在调用点展开的函数。内联函数直接以函数代码替代了 函数调用点展开的函数。内联函数直接以函数代码替代了函数调用语句, 从而避免了一般函数调用的开销。 local static objects(局部静态对象) 376 在函数第一次调用前就已经创建和初始化的局部对象,其值在函数的调用 之间保持有效。 local variables(局部变量) 在函数内定义的变量,仅能在函数体内访问。 object lifetime(对象生命期) 每个对象皆有与之关联的生命期。在块中定义的对象从定义时开始存在, 直到它的定义所在的语句块结束为止。静态局部对象和函数外定义的全局 变量则在程序开始执行时创建,当 main 函数结束时撤销。动态创建的对 象由 new 表达式创建,从此开始存在,直到由相应的 delete 表达式释 放所占据的内存空间为止。 overload resolution(重载确定) 函数匹配的同义词。 overloaded function(重载函数) 和至少一个其他函数同名的函数。重载函数必须在形参的个数或类型上有 所不同。 parameters(形参) 函数的局部变量,其初值由函数调用提供。 recursive function(递归函数) 直接或间接调用自己的函数。 return type(返回类型) 函数返回值的类型。 synthesized default constructor(合成默认构造函数) 如果类没有定义任何构造函数,则编译器会为这个类创建(合成)一个默 认构造函数。该函数以默认的方式初始化类中的所有数据成员。 temporary object(临时对象) 在求解表达式的过程中由编译器自动创建的没有名字的对象。“临时对 象”这个术语通常简称为“临时”。临时对象一直存在直到最大表达式结 377 束为止,最大表达式指的是包含创建该临时对象的表达式的最大范围内的 表达式。 this pointer(this 指针) 成员函数的隐式形参。this 指针指向调用该函数的对象,是指向类类型 的指针。在 const 成员函数中,该指针也指向 const 对象。 viable functions(可行函数) 重载函数中可与指定的函数调用匹配的子集。可行函数的形参个数必须与 该函数调用的实参个数相同,而且每个实参类型都可潜在地转换为相应形 参的类型。 378 第八章 标准 IO 库 C++ 的输入/输出(input/output)由标准库提供。标准库定义了一族类型, 支持对文件和控制窗口等设备的读写(IO)。还定义了其他一些类型,使 string 对象能够像文件一样操作,从而使我们无须 IO 就能实现数据与字符之间的转 换。这些 IO 类型都定义了如何读写内置数据类型的值。此外,一般来说,类的 设计者还可以很方便地使用 IO 标准库设施读写自定义类的对象。类类型通常使 用 IO 标准库为内置类型定义的操作符和规则来进行读写。 本章将介绍 IO 标准库的基础知识,而更多的内容会在后续章节中介绍:第 十四章考虑如何编写自己的输入输出操作符:附录 A 则介绍格式控制以及文件 的随机访问。 前面的程序已经使用了多种 IO 标准库提供的工具: • istream(输入流)类型,提供输入操作。 • ostream(输出流)类型,提供输出操作。 • cin(发音为 see-in):读入标准输入的 istream 对象。 • cout(发音为 see-out):写到标准输出的 ostream 对象。 • cerr(发音为 see-err):输出标准错误的 ostream 对象。cerr 常用于 程序错误信息。 • >> 操作符,用于从 istream 对象中读入输入。 • << 操作符,用于把输出写到 ostream 对象中。 • getline 函数,需要分别取 istream 类型和 string 类型的两个引用形 参,其功能是从 istream 对象读取一个单词,然后写入 string 对象中。 本章简要地介绍一些附加的 IO 操作,并讨论文件对象和 string 对象的读 写。附录 A 会介绍如何控制 IO 操作的格式、文件的随机访问以及无格式的 IO。 本书是初级读本,因此不会详细讨论完整的 iostream 标准库——特别是,我们 不但没有涉及系统特定的实现细则,也不讨论标准库管理输入输出缓冲区的机 制,以及如何编写自定义的缓冲区类。这些话题已超出了本书的范畴。相对而言, 本书把重点放在 IO 标准库对普通程序最有用的部分。 8.1. 面向对象的标准库 迄今为止,我们已经使用 IO 类型和对象读写数据流,它们常用于与用户控 制窗口的交互。当然,实际的程序不能仅限于对控制窗口的 IO,通常还需要读 或写已命名的文件。此外,程序还应该能方便地使用 IO 操作格式化内存中的数 据,从而避免读写磁盘或其他设备的复杂性和运行代价。应用程序还需要支持宽 字符(wide-character)语言的读写。 从概念上看,无论是设备的类型还是字符的大小,都不影响需要执行的 IO 操作。例如,不管我们是从控制窗口、磁盘文件或内存中的字符串读入数据,都 379 可使用 >> 操作符。相似地,无论我们读的是 char 类型的字符还是 wchar_t (第 2.1.1 节)的字符,也都可以使用该操作符。 乍看起来,要同时支持或使用不同类型设备以及不同大小的字符流,其复杂 程度似乎相当可怕。为了管理这样的复杂性,标准库使用了继承(inheritance) 来定义一组面向对象(object-oriented)类。在本书的第四部分将会更详细地 讨论继承和面向对象程序设计,不过,一般而言,通过继承关联起来的类型都共 享共同的接口。当一个类继承另一个类时,这两个类通常可以使用相同的操作。 更确切地说,如果两种类型存在继承关系,则可以说一个类“继承”了其父类的 行为——接口。C++ 中所提及的父类称为基类(base class),而继承而来的类 则称为派生类(derived class)。 IO 类型在三个独立的头文件中定义:iostream 定义读写控制窗口的类型, fstream 定义读写已命名文件的类型,而 sstream 所定义的类型则用于读写存 储在内存中的 string 对象。在 fstream 和 sstream 里定义的每种类型都是从 iostream 头文件中定义的相关类型派生而来。表 8.1 列出了 C++ 的 IO 类, 而图 8.1 则阐明这些类型之间的继承关系。继承关系通常可以用类似于家庭树 的图解说明。最顶端的圆圈代表基类(或称“父类”),基类和派生类(或称“子 类”)之间用线段连接。因此,图 8.1 所示,istream 是 ifstream 和 istringstream 的基类,同时也是 iostream 的基类,而 iostream 则是 stringstream 和 fstream 的基类。 表 8.1. IO 标准库类型和头文件 Header Type iostream istream 从流中读取 ostream 写到流中去 iostream 对流进行读写;从 istream 和 ostream 派生而来 fstream ifstream 从文件中读取;由 istream 派生而来 ofstream 写到文件中去;由 ostream 派生而来 fstream 读写文件;由 iostream 派生而来 sstream istringstream 从 string 对象中读取;由 istream 派生而来 ostringstream 写到 string 对象中去;由 ostream 派生而来 stringstream 对 string 对象进行读写;由 iostream 派生而来 380 图 8.1. 简单的 iostream 继承层次 由于 ifstream 和 istringstream 类型继承了 istream 类,因此已知这两 种类型的大量用法。我们曾经编写过的读 istream 对象的程序也可用于读文件 (使用 ifstream 类型)或者 string 对象(使用 istringstream 类型)。类 似地,提供输出功能的程序同样可用 ofstream 或 ostringstream 取代 ostream 类型实现。除了 istream 和 ostream 类型之外,iostream 头文件还 定义了 iostream 类型。尽管我们的程序还没用过这种类型,但事实上可以多了 解一些关于 iostream 的用法。iostream 类型由 istream 和 ostream 两者派 生而来。这意味着 iostream 对象共享了它的两个父类的接口。也就是说,可使 用 iostream 类型在同一个流上实现输入和输出操作。标准库还定义了另外两个 继承 iostream 的类型。这些类型可用于读写文件或 string 对象。 对 IO 类型使用继承还有另外一个重要的含义:正如在第十五章可以看到 的,如果函数有基类类型的引用形参时,可以给函数传递其派生类型的对象。这 就意味着:对 istream& 进行操作的函数,也可使用 ifstream 或者 istringstream 对象来调用。类似地,形参为 ostream& 类型的函数也可用 ofstream 或者 ostringstream 对象调用。因为 IO 类型通过继承关联,所以可 以只编写一个函数,而将它应用到三种类型的流上:控制台、磁盘文件或者字符 串流(string streams)。 国际字符的支持 迄今为止,所描述的流类(stream class)读写的是由 char 类型组成的流。 此外,标准库还定义了一组相关的类型,支持 wchar_t 类型。每个类都加上“w” 381 前缀,以此与 char 类型的版本区分开来。于是,wostream、wistream 和 wiostream 类型从控制窗口读写 wchar_t 数据。相应的文件输入输出类是 wifstream、wofstream 和 wfstream。而 wchar_t 版本的 string 输入/输出 流则是 wistringstream、wostringstream 和 wstringstream。标准库还定义了 从标准输入输出读写宽字符的对象。这些对象加上“w”前缀,以此与 char 类 型版本区分:wchar_t 类型的标准输入对象是 wcin;标准输出是 wcout;而标 准错误则是 wcerr。 每一个 IO 头文件都定义了 char 和 wchar_t 类型的类和标准输入/输出 对象。基于流的 wchar_t 类型的类和对象在 iostream 中定义,宽字符文件流 类型在 fstream 中定义,而宽字符 stringstream 则在 sstream 头文件中定 义。 IO 对象不可复制或赋值 出于某些原因,标准库类型不允许做复制或赋值操作。其原因将在后面第三 部分和第四部分学习类和继承时阐明。 ofstream out1, out2; out1 = out2; // error: cannot assign stream objects // print function: parameter is copied ofstream print(ofstream); out2 = print(out2); // error: cannot copy stream objects 这个要求有两层特别重要的含义。正如在第九章看到的,只有支持复制的元 素类型可以存储在 vector 或其他容器类型里。由于流对象不能复制,因此不能 存储在 vector(或其他)容器中(即不存在存储流对象的 vector 或其他容器)。 第二个含义是:形参或返回类型也不能为流类型。如果需要传递或返回 IO 对象,则必须传递或返回指向该对象的指针或引用: ofstream &print(ofstream&); // ok: takes a reference, no copy while (print(out2)) { /* ... */ } // ok: pass reference to out2 一般情况下,如果要传递 IO 对象以便对它进行读写,可用非 const 引用 的方式传递这个流对象。对 IO 对象的读写会改变它的状态,因此引用必须是非 const 的。 382 Exercises Section 8.1 Exercise 假设 os 是一个 ofstream 对象,下面程序做了什么? 8.1: os << "Goodbye!" << endl; 如果 os 是 ostringstream 对象呢?或者,os 是 ifstream 呢? Exercise 下面的声明是错误的,指出其错误并改正之: 8.2: ostream print(ostream os); 8.2. 条件状态 在展开讨论 fstream 和 sstream 头文件中定义的类型之前,需要了解更多 IO 标准库如何管理其缓冲区及其流状态的相关内容。谨记本节和下一节所介绍 的内容同样适用于普通流、文件流以及 string 流。 实现 IO 的继承正是错误发生的根源。一些错误是可恢复的;一些错误则发 生在系统底层,位于程序可修正的范围之外。IO 标准库管理一系列条件状态 (condition state)成员,用来标记给定的 IO 对象是否处于可用状态,或者 碰到了哪种特定的错误。表 8.2 列出了标准库定义的一组函数和标记,提供访 问和操纵流状态的手段。 表 8.2. IO 标准库的条件状态 strm::iostate strm::badbit strm::failbit strm::eofbit s.eof() s.fail() 机器相关的整型名,由各个 iostream 类定义,用于定义条 件状态 strm::iostate 类型的值,用于指出被破坏的流 strm::iostate 类型的值,用于指出失败的 IO 操作 strm::iostate 类型的值,用于指出流已经到达文件结束符 如果设置了流 s 的 eofbit 值,则该函数返回 true 如果设置了流 s 的 failbit 值,则该函数返回 true 383 s.bad() 如果设置了流 s 的 badbit 值,则该函数返回 true s.good() 如果流 s 处于有效状态,则该函数返回 true s.clear() 将流 s 中的所有状态值都重设为有效状态 s.clear(flag) 将流 s 中的某个指定条件状态设置为有效。flag 的类型是 strm::iostate s.setstate(flag) 给流 s 添加指定条件。flag 的类型是 strm::iostate s.rdstate() 返回流 s 的当前条件,返回值类型为 strm::iostate 考虑下面 IO 错误的例子: int ival; cin >> ival; 如果在标准输入设备输入 Borges,则 cin 在尝试将输入的字符串读为 int 型数据失败后,会生成一个错误状态。类似地,如果输入文件结束符 (end-of-file),cin 也会进入错误状态。而如果输入 1024,则成功读取,cin 将处于正确的无错误状态。 流必须处于无错误状态,才能用于输入或输出。检测流是否用的最简单的方 法是检查其真值: if (cin) // ok to use cin, it is in a valid state while (cin >> word) // ok: read operation successful ... if 语句直接检查流的状态,而 while 语句则检测条件表达式返回的流,从 而间接地检查了流的状态。如果成功输入,则条件检测为 true。 条件状态 许多程序只需知道是否有效。而某些程序则需要更详细地访问或控制流的状 态,此时,除了知道流处于错误状态外,还必须了解它遇到了哪种类型的错误。 例如,程序员也许希望弄清是到达了文件的结尾,还是遇到了 IO 设备上的错误。 所有流对象都包含一个条件状态成员,该成员由 setstate 和 clear 操作 管理。这个状态成员为 iostate 类型,这是由各个 iostream 类分别定义的机 384 器相关的整型。该状态成员以二进制位(bit)的形式使用,类似于第 5.3.1 节 的例子中用于记录测验成绩的 int_quiz1 变量。 每个 IO 类还定义了三个 iostate 类型的常量值,分别表示特定的位模式。 这些常量值用于指出特定类型的 IO 条件,可与位操作符(第 5.3 节)一起使 用,以便在一次操作中检查或设置多个标志。 badbit 标志着系统级的故障,如无法恢复的读写错误。如果出现了这类错 误,则该流通常就不能再继续使用了。如果出现的是可恢复的错误,如在希望获 得数值型数据时输入了字符,此时则设置 failbit 标志,这种导致设置 failbit 的问题通常是可以修正的。eofbit 是在遇到文件结束符时设置的,此时同时还 设置了 failbit。 流的状态由 bad、fail、eof 和 good 操作提示。如果 bad、fail 或者 eof 中的任意一个为 true,则检查流本身将显示该流处于错误状态。类似地,如果 这三个条件没有一个为 true,则 good 操作将返回 true。 clear 和 setstate 操作用于改变条件成员的状态。clear 操作将条件重设 为有效状态。在流的使用出现了问题并做出补救后,如果我们希望把流重设为有 效状态,则可以调用 clear 操作。使用 setstate 操作可打开某个指定的条件, 用于表示某个问题的发生。除了添加的标记状态,setstate 将保留其他已存在 的状态变量不变。 流状态的查询和控制 可以如下管理输入操作 int ival; // read cin and test only for EOF; loop is executed even if there are other IO failures while (cin >> ival, !cin.eof()) { if (cin.bad()) // input stream is corrupted; bail out throw runtime_error("IO stream corrupted"); if (cin.fail()) { // bad input cerr<< "bad data, try again"; // warn the user cin.clear(istream::failbit); // reset the stream continue; // get next input } // ok to process ival } 这个循环不断读入 cin,直到到达文件结束符或者发生不可恢复的读取错误 为止。循环条件使用了逗号操作符(第 5.9 节)。回顾逗号操作符的求解过程: 385 首先计算它的每一个操作数,然后返回最右边操作数作为整个操作的结果。因此, 循环条件只读入 cin 而忽略了其结果。该条件的结果是 !cin.eof() 的值。如 果 cin 到达文件结束符,条件则为假,退出循环。如果 cin 没有到达文件结束 符,则不管在读取时是否发生了其他可能遇到的错误,都进入循环。 在循环中,首先检查流是否已破坏。如果是的放,抛出异常并退出循环。如 果输入无效,则输出警告并清除 failbit 状态。在本例中,执行 continue 语 句(第 6.11 节)回到 while 的开头,读入另一个值 ival。如果没有出现任何 错误,那么循环体中余下的部分则可以很安全地使用 ival。 条件状态的访问 rdstate 成员函数返回一个 iostate 类型值,该值对应于流当前的整个条件状 态: // remember current state of cin istream::iostate old_state = cin.rdstate(); cin.clear(); process_input(); // use cin cin.clear(old_state); // now reset cin to old state 多种状态的处理 常常会出现需要设置或清除多个状态二进制位的情况。此时,可以通过多次 调用 setstate 或者 clear 函数实现。另外一种方法则是使用按位或(OR)操 作符(第 5.3 节)在一次调用中生成“传递两个或更多状态位”的值。按位或 操作使用其操作数的二进制位模式产生一个整型数值。对于结果中的每一个二进 制位,如果其值为 1,则该操作的两个操作数中至少有一个的对应二进制位是 1。 例如: // sets both the badbit and the failbit is.setstate(ifstream::badbit | ifstream::failbit); 将对象 is 的 failbit 和 badbit 位同时打开。实参: is.badbit | is.failbit 生成了一个值,其对应于 badbit 和 failbit 的位都打开了,也就是将这 两个位都设置为 1,该值的其他位则都为 0。在调用 setstate 时,使用这个值 来开启流条件状态成员中对应的 badbit 和 failbit 位。 386 Exercises Section 8.2 Exercise 8.3: 编写一个函数,其唯一的形参和返回值都是 istream& 类 型。该个函数应一直读取流直到到达文件结束符为止,还 应将读到的内容输出到标准输出中。最后,重设流使其有 效,并返回该流。 Exercise 通过以 cin 为实参实现调用来测试上题编写的函数。 8.4: Exercise 导致下面的 while 终止的原因是什么? 8.5: while (cin >> i) /* . . . */ 8.3. 输出缓冲区的管理 每个 IO 对象管理一个缓冲区,用于存储程序读写的数据。如有下面语句: os << "please enter a value: "; 系统将字符串字面值存储在与流 os 关联的缓冲区中。下面几种情况将导致 缓冲区的内容被刷新,即写入到真实的输出设备或者文件: 1. 程序正常结束。作为 main 返回工作的一部分,将清空所有输出缓冲区。 2. 在一些不确定的时候,缓冲区可能已经满了,在这种情况下,缓冲区将会 在写下一个值之前刷新。 3. 用操纵符(第 1.2.2 节)显式地刷新缓冲区,例如行结束符 endl。 4. 在每次输出操作执行完后,用 unitbuf 操作符设置流的内部状态,从而 清空缓冲区。 5. 可将输出流与输入流关联(tie)起来。在这种情况下,在读输入流时将 刷新其关联的输出缓冲区。 输出缓冲区的刷新 我们的程序已经使用过 endl 操纵符,用于输出一个换行符并刷新缓冲区。 除此之外,C++ 语言还提供了另外两个类似的操纵符。第一个经常使用的 flush, 用于刷新流,但不在输出中添加任何字符。第二个则是比较少用的 ends,这个 操纵符在缓冲区中插入空字符 null,然后后刷新它: 387 cout << "hi!" << flush; cout << "hi!" << ends; buffer cout << "hi!" << endl; buffer // flushes the buffer; adds no data // inserts a null, then flushes the // inserts a newline, then flushes the unitbuf 操纵符 如果需要刷新所有输出,最好使用 unitbuf 操纵符。这个操纵符在每次执 行完写操作后都刷新流: cout << unitbuf << "first" << " second" << nounitbuf; 等价于: cout << "first" << flush << " second" << flush; nounitbuf 操纵符将流恢复为使用正常的、由系统管理的缓冲区刷新方式。 警告:如果程序崩溃了,则不会刷新缓冲区 如果程序不正常结束,输出缓冲区将不会刷新。在尝试调试已崩溃的程 序时,通常会根据最后的输出找出程序发生错误的区域。如果崩溃出现 在某个特定的输出语句后面,则可知是在程序的这个位置之后出错。 调试程序时,必须保证期待写入的每个输出都确实被刷新了。因为系统 不会在程序崩溃时自动刷新缓冲区,这就可能出现这样的情况:程序做 了写输出的工作,但写的内容并没有显示在标准输出上,仍然存储在输 出缓冲区中等待输出。 如果需要使用最后的输出给程序错误定位,则必须确定所有要输出的都 已经输出。为了确保用户看到程序实际上处理的所有输出,最好的方法 是保证所有的输出操作都显式地调用了 flush 或 endl。 如果仅因为缓冲区没有刷新,程序员将浪费大量的时间跟踪调试并没有 执行的代码。基于这个原因,输出时应多使用 endl 而非 '\n'。使用 endl 则不必担心程序崩溃时输出是否悬而未决(即还留在缓冲区,未输 出到设备中)。 388 将输入和输出绑在一起 当输入流与输出流绑在一起时,任何读输入流的尝试都将首先刷新其输出流 关联的缓冲区。标准库将 cout 与 cin 绑在一起,因此语句: cin >> ival; 导致 cout 关联的缓冲区被刷新。 交互式系统通常应确保它们的输入和输出流是绑在一起的。这 样做意味着可以保证任何输出,包括给用户的提示,都在试图 读之前输出。 tie 函数可用 istream 或 ostream 对象调用,使用一个指向 ostream 对 象的指针形参。调用 tie 函数时,将实参流绑在调用该函数的对象上。如果一 个流调用 tie 函数将其本身绑在传递给 tie 的 ostream 实参对象上,则该流 上的任何 IO 操作都会刷新实参所关联的缓冲区。 cin.tie(&cout); // illustration only: the library ties cin and cout for us ostream *old_tie = cin.tie(); cin.tie(0); // break tie to cout, cout no longer flushed when cin is read cin.tie(&cerr); // ties cin and cerr, not necessarily a good idea! // ... cin.tie(0); // break tie between cin and cerr cin.tie(old_tie); // restablish normal tie between cin and cout 一个 ostream 对象每次只能与一个 istream 对象绑在一起。如果在调用 tie 函数时传递实参 0,则打破该流上已存在的捆绑。 8.4. 文件的输入和输出 fstream 头文件定义了三种支持文件 IO 的类型: 1. ifstream,由 istream 派生而来,提供读文件的功能。 2. ofstream,由 ostream 派生而来,提供写文件的功能。 3. fstream,由 iostream 派生而来,提供读写同一个文件的功能。 389 这些类型都由相应的 iostream 类型派生而来,这个事实意味着我们已经知 道使用 fstream 类型需要了解的大部分内容了。特别是,可使用 IO 操作符(<< 和 >> )在文件上实现格式化的 IO,而且在前面章节介绍的条件状态也同样适 用于 fstream 对象。 fstream 类型除了继承下来的行为外,还定义了两个自己的新操作—— open 和 close,以及形参为要打开的文件名的构造函数。fstream、ifstream 或 ofstream 对象可调用这些操作,而其他的 IO 类型则不能调用。 8.4.1. 文件流对象的使用 迄今为止,我们的程序已经使用过标准库定义的对象:cin、cout 和 cerr。 需要读写文件时,则必须定义自己的对象,并将它们绑定在需要的文件上。假设 ifile 和 ofile 是存储希望读写的文件名的 strings 对象,可如下编写代码: // construct an ifstream and bind it to the file named ifile ifstream infile(ifile.c_str()); // ofstream output file object to write file named ofile ofstream outfile(ofile.c_str()); 上述代码定义并打开了一对 fstream 对象。infile 是读的流,而 outfile 则是写的流。为 ifstream 或者 ofstream 对象提供文件名作为初始化式,就相 当于打开了特定的文件。 ifstream infile; // unbound input file stream ofstream outfile; // unbound output file stream 上述语句将 infile 定义为读文件的流对象,将 outfile 定义为写文件的 对象。这两个对象都没有捆绑具体的文件。在使用 fstream 对象之前,还必须 使这些对象捆绑要读写的文件: infile.open("in"); // open file named "in" in the current directory outfile.open("out"); // open file named "out" in the current directory 调用 open 成员函数将已存在的 fstream 对象与特定文件绑定。为了实现 读写,需要将指定的文件打开并定位,open 函数完成系统指定的所有需要的操 作。 390 警告:C++ 中的文件名 由于历史原因,IO 标准库使用 C 风格字符串(第 4.3 节)而不是 C++ strings 类型的字符串作为文件名。在创建 fstream 对象时,如果调用 open 或使用文件名作初始化式,需要传递的实参应为 C 风格字符串, 而不是标准库 strings 对象。程序常常从标准输入获得文件名。通常, 比较好的方法是将文件名读入 string 对象,而不是 C 风格字符数组。 假设要使用的文件名保存在 string 对象中,则可调用 c_str 成员(第 4.3.2 节)获取 C 风格字符串。 检查文件打开是否成功 打开文件后,通常要检验打开是否成功,这是一个好习惯: // check that the open succeeded if (!infile) { cerr << "error: unable to open input file: " << ifile << endl; return -1; } 这个条件与之前测试 cin 是否到达文件尾或遇到某些其他错误的条件类 似。检查流等效于检查对象是否“适合”输入或输出。如果打开(open)失败, 则说明 fstream 对象还没有为 IO 做好准备。当测试对象 if (outfile) // ok to use outfile? 返回 true 意味着文件已经可以使用。由于希望知道文件是否未准备好,则 对返回值取反来检查流: if (!outfile) // not ok to use outfile? 将文件流与新文件重新捆绑 fstream 对象一旦打开,就保持与指定的文件相关联。如果要把 fstream 对 象与另一个不同的文件关联,则必须先关闭(close)现在的文件,然后打开(open) 另一个文件:要点是在尝试打开新文件之前,必须先关闭当前的文件流。open 函 391 数会检查流是否已经打开。如果已经打开,则设置内部状态,以指出发生了错误。 接下来使用文件流的任何尝试都会失败。 ifstream infile("in"); infile.close(); infile.open("next"); // opens file named "in" for reading // closes "in" // opens file named "next" for reading 清除文件流的状态 考虑这样的程序,它有一个 vector 对象,包含一些要打开并读取的文件名, 程序要对每个文件中存储的单词做一些处理。假设该 vector 对象命名为 files,程序也许会有如下循环: // for each file in the vector while (it != files.end()) { ifstream input(it->c_str()); // open the file; // if the file is ok, read and "process" the input if (!input) break; // error: bail out! while(input >> s) // do the work on this file process(s); ++it; // increment iterator to get next file } 每一次循环都构造了名为 input 的 ifstream 对象,打开并读取指定的文 件。构造函数的初始化式使用了箭头操作符(第 5.6 节)对 it 进行解引用, 从而获取 it 当前表示的 string 对象的 c_str 成员。文件由构造函数打开, 并假设打开成功,读取文件直到到达文件结束符或者出现其他的错误条件为止。 在这个点上,input 处于错误状态。任何读 input 的尝试都会失败。因为 input 是 while 循环的局部变量,在每次迭代中创建。这就意味着它在每次循环中都 以干净的状态即 input.good() 为 true,开始使用。 如果希望避免在每次 while 循环过程中创建新流对象,可将 input 的定义 移到 while 之前。这点小小的改动意味着必须更仔细地管理流的状态。如果遇 到文件结束符或其他错误,将设置流的内部状态,以便之后不允许再对该流做读 写操作。关闭流并不能改变流对象的内部状态。如果最后的读写操作失败了,对 象的状态将保持为错误模式,直到执行 clear 操作重新恢复流的状态为止。调 用 clear 后,就像重新创建了该对象一样。 如果打算重用已存在的流对象,那么 while 循环必须在每次循环进记得关 闭(close)和清空(clear)文件流: 392 ifstream input; vector::const_iterator it = files.begin(); // for each file in the vector while (it != files.end()) { input.open(it->c_str()); // open the file // if the file is ok, read and "process" the input if (!input) break; // error: bail out! while(input >> s) // do the work on this file process(s); input.close(); // close file when we're done with it input.clear(); // reset state to ok ++it; // increment iterator to get next file } 如果忽略 clear 的调用,则循环只能读入第一个文件。要了解其原因,就 需要考虑在循环中发生了什么:首先打开指定的文件。假设打开成功,则读取文 件直到文件结束或者出现其他错误条件为止。在这个点上,input 处于错误状态。 如果在关闭(close)该流前没有调用 clear 清除流的状态,接着在 input 上 做的任何输入运算都会失败。一旦关闭该文件,再打开 下一个文件时,在内层 while 循环上读 input 仍然会失败——毕竟最后一次对流的读操作到达了文件 结束符,事实上该文件结束符对应的是另一个与本文件无关的其他文件。 如果程序员需要重用文件流读写多个文件,必须在读另一个文 件之前调用 clear 清除该流的状态。 393 Exercises Section 8.4.1 Exercise 由于 ifstream 继承了 istream,因此可将 ifstream 对 8.6: 象传递给形参为 istream 引用的函数。使用第 8.2 节第 一个习题编写的函数读取已命名的文件。 Exercise 8.7: 本节编写的两个程序,在打开 vector 容器中存放的任何 文件失败时,使用 break 跳出 while 循环。重写这两个 循环,如果文件无法打开,则输出警告信息,然后从 vector 中获取下一个文件名继续处理。 Exercise 上一个习题的程序可以不用 continue 语句实现。分别使 8.8: 用或不使用 continue 语句编写该程序。 Exercise 编写函数打开文件用于输入,将文件内容读入 string 类 8.9: 型的 vector 容器,每一行存储为该容器对象的一个元 素。 Exercise 重写上面的程序,把文件中的每个单词存储为容器的一个 8.10: 元素。 8.4.2. 文件模式 在打开文件时,无论是调用 open 还是以文件名作为流初始化的一部分,都 需指定文件模式(file mode)。每个 fstream 类都定义了一组表示不同模式的 值,用于指定流打开的不同模式。与条件状态标志一样,文件模式也是整型常量, 在打开指定文件时,可用位操作符(第 5.3 节)设置一个或多个模式。文件流 构造函数和 open 函数都提供了默认实参(第 7.4.1 节)设置文件模式。默认 值因流类型的不同而不同。此外,还可以显式地以模式打开文件。表 8.3 列出 了文件模式及其含义。 表 8.3 文件模式 in 打开文件做读操作 out 打开文件做写操作 app 在每次写之前找到文件尾 394 ate 打开文件后立即将文件定位在文件尾 trunc 打开文件时清空已存在的文件流 binary 以二进制模式进行 IO 操作 out、trunc 和 app 模式只能用于指定与 ofstream 或 fstream 对象关联 的文件;in 模式只能用于指定与 ifstream 或 fstream 对象关联的文件。所有 的文件都可以用 ate 或 binary 模式打开。ate 模式只在打开时有效:文件打 开后将定位在文件尾。以 binary 模式打开的流则将文件以字节序列的形式处 理,而不解释流中的字符。 默认时,与 ifstream 流对象关联的文件将以 in 模式打开,该模式允许文 件做读的操作:与 ofstream 关联的文件则以 out 模式打开,使文件可写。以 out 模式打开的文件会被清空:丢弃该文件存储的所有数据。 从效果来看,为 ofstream 对象指定 out 模式等效于同时指定 了 out 和 trunc 模式。 对于用 ofstream 打开的文件,要保存文件中存在的数据,唯一方法是显式 地指定 app 模式打开: // output mode by default; truncates file named "file1" ofstream outfile("file1"); // equivalent effect: "file1" is explicitly truncated ofstream outfile2("file1", ofstream::out | ofstream::trunc); // append mode; adds new data at end of existing file named "file2" ofstream appfile("file2", ofstream::app); outfile2 的定义使用了按位或操作符(第 5.3 节)将相应的文件同时以 out 和 trunc 模式打开。 对同一个文件作输入和输出运算 fstream 对象既可以读也可以写它所关联的文件。fstream 如何使用它的文 件取决于打开文件时指定的模式。 395 默认情况下,fstream 对象以 in 和 out 模式同时打开。当文件同时以 in 和 out 打开时不清空。如果打开 fstream 所关联的文件时,只使用 out 模式, 而不指定 in 模式,则文件会清空已存在的数据。如果打开文件时指定了 trunc 模式,则无论是否同时指定了 in 模式,文件同样会被清空。下面的定义将 copyOut 文件同时以输入和输出的模式打开: // open for input and output fstream inOut("copyOut", fstream::in | fstream::out); 对于同时以输入和输出的模式打开的文件,附录 A.3.8 将讨论其使用方法。 模式是文件的属性而不是流的属性 每次打开文件时都会设置模式 ofstream outfile; // output mode set to out, "scratchpad" truncated outfile.open("scratchpad", ofstream::out); outfile.close(); // close outfile so we can rebind it // appends to file named "precious" outfile.open("precious", ofstream::app); outfile.close(); // output mode set by default, "out" truncated outfile.open("out"); 第一次调用 open 函数时,指定的模式是 ofstream::out。当前目录中名为 “scratchpad”的文件以输出模式打开并清空。而名为“precious”的文件,则 要求以添加模式打开:保存文件里的原有数据,所有的新内容在文件尾部写入。 在打开“out”文件时,没有明确指明输出模式,该文件则以 out 模式打开,这 意味着当前存储在“out”文件中的任何数据都将被丢弃。 只要调用 open 函数,就要设置文件模式,其模式的设置可以 是显式的也可以是隐式的。如果没有指定文件模式,将使用默 认值。 396 打开模式的有效组合 并不是所有的打开模式都可以同时指定。有些模式组合是没有意义的,例如 同时以 in 和 trunc 模式打开文件,准备读取所生成的流,但却因为 trunc 操 作而导致无数据可读。表 8.4 列出了有效的模式组合及其含义。 表 8.4 文件模式的组合 out 打开文件做写操作,删除文件中已有的数据 out | app 打开文件做写操作,在文件尾写入 out | trunc 与 out 模式相同 in 打开文件做读操作 in | out 打开文件做读、写操作,并定位于文件开头处 in | out | trunc 打开文件做读、写操作,删除文件中已有的数据 上述所有的打开模式组合还可以添加 ate 模式。对这些模式添加 ate 只会 改变文件打开时的初始化定位,在第一次读或写之前,将文件定位于文件末尾处。 8.4.3. 一个打开并检查输入文件的程序 本书有好几个程序都要打开给定文件用输入。由于需要在多个程序里做这件 工作,我们编写一个名为 open_file 的函数实现这个功能。这个函数有两个引 用形参,分别是 ifstream 和 string 类型,其中 string 类型的引用形参存储 与指定 ifstream 对象关联的文件名: // opens in binding it to the given file ifstream& open_file(ifstream &in, const string &file) { in.close(); // close in case it was already open in.clear(); // clear any existing errors // if the open fails, the stream will be in an invalid state in.open(file.c_str()); // open the file we were given return in; // condition state is good if open succeeded } 397 由于不清楚流 in 的当前状态,因此首先调用 close 和 clear 将这个流设 置为有效状态。然后尝试打开给定的文件。如果打开失败,流的条件状态将标志 这个流是不可用的。最后返回流对象 in,此时,in 要么已经与指定文件绑定起 来了,要么处于错误条件状态。 Exercises Section 8.4.3 Exercise 对于 open_file 函数,请解释为什么在调用 open 前先 8.11: 调用 clear 函数。如果忽略这个函数调用,会出现什么 问题?如果在 open 后面调用 clear 函数,又会怎样? Exercise 对于 open_file 函数,请解释如果程序执行 close 函数 8.12: 失败,会产生什么结果? Exercise 编写类似 open_file 的程序打开文件用于输出。 8.13: Exercise 使用 open_file 函数以及第 8.2 节第一个习题编写的 8.14: 程序,打开给定的文件并读取其内容。 8.5. 字符串流 iostream 标准库支持内存中的输入/输出,只要将流与存储在程序内存中的 string 对象捆绑起来即可。此时,可使用 iostream 输入和输出操作符读写这 个 string 对象。标准库定义了三种类型的字符串流: • istringstream,由 istream 派生而来,提供读 string 的功能。 • ostringstream,由 ostream 派生而来,提供写 string 的功能。 • stringstream,由 iostream 派生而来,提供读写 string 的功能。 要使用上述类,必须包含 sstream 头文件。 与 fstream 类型一样,上述类型由 iostream 类型派生而来,这意味着 iostream 上所有的操作适用于 sstream 中的类型。sstream 类型除了继承的操 作外,还各自定义了一个有 string 形参的构造函数,这个构造函数将 string 类型的实参复制给 stringstream 对象。对 stringstream 的读写操作实际上读 写的就是该对象中的 string 对象。这些类还定义了名为 str 的成员,用来读 取或设置 stringstream 对象所操纵的 string 值。 398 注意到尽管 fstream 和 sstream 共享相同的基类,但它们没有其他相互关 系。特别是,stringstream 对象不使用 open 和 close 函数,而 fstream 对 象则不允许使用 str。 表 8.5. stringstream 特定的操作 stringstream strm; 创建自由的 stringstream 对象 stringstream strm(s); 创建存储 s 的副本的 stringstream 对象,其中 s 是 string 类型的对象 strm.str() 返回 strm 中存储的 string 类型对象 strm.str(s) 将 string 类型的 s 复制给 strm,返回 void stringstream 对象的和使用 前面已经见过以每次一个单词或每次一行的方式处理输入的程序。第一种程 序用 string 输入操作符,而第二种则使用 getline 函数。然而,有些程序需 要同时使用这两种方式:有些处理基于每行实现,而其他处理则要操纵每行中每 个单词。可用 stringstreams 对象实现: string line, word; // will hold a line and word from input, respectively while (getline(cin, line)) { // read a line from the input into line // do per-line processing istringstream stream(line); // bind to stream to the line we read while (stream >> word){ // read a word from line // do per-word processing } } 这里,使用 getline 函数从输入读取整行内容。然后为了获得每行中的单 词,将一个 istringstream 对象与所读取的行绑定起来,这样只需要使用普通 的 string 输入操作符即可读出每行中的单词。 399 stringstream 提供的转换和/或格式化 stringstream 对象的一个常见用法是,需要在多种数据类型之间实现自动 格式化时使用该类类型。例如,有一个数值型数据集合,要获取它们的 string 表 示形式,或反之。sstream 输入和输出操作可自动地把算术类型转化为相应的 string 表示形式,反过来也可以。 int val1 = 512, val2 = 1024; ostringstream format_message; // ok: converts values to a string representation format_message << "val1: " << val1 << "\n" << "val2: " << val2 << "\n"; 这里创建了一个名为 format_message 的 ostringstream 类型空对象,并 将指定的内容插入该对象。重点在于 int 型值自动转换为等价的可打印的字符 串。format_message 的内容是以下字符: val1: 512\nval2: 1024 相反,用 istringstream 读 string 对象,即可重新将数值型数据找回来。 读取 istringstream 对象自动地将数值型数据的字符表示方式转换为相应的算 术值。 // str member obtains the string associated with a stringstream istringstream input_istring(format_message.str()); string dump; // place to dump the labels from the formatted message // extracts the stored ascii values, converting back to arithmetic types input_istring >> dump >> val1 >> dump >> val2; cout << val1 << " " << val2 << endl; // prints 512 1024 这里使用 。str 成员获取与之前创建的 ostringstream 对象关联的 string 副本。再将 input_istring 与 string 绑定起来。在读 input_istring 时,相应的值恢复为它们原来的数值型表示形式 为了读取 input_string,必须把该 string 对象分解为若干个 部分。我们要的是数值型数据;为了得到它们,必须读取(和 忽略)处于所需数据周围的标号。 400 因为输入操作符读取的是有类型的值,因此读入的对象类型必须和由 stringstream 读入的值的类型一致。在本例中,input_istring 分成四个部分: string 类型的值 val1,接着是 512,然后是 string 类型的值 val2,最后是 1024。一般情况下,使用输入操作符读 string 时,空白符将会忽略。于是,在 读与 format_message 关联的 string 时,忽略其中的换行符。 Exercises Section 8.5 Exercise 使用第 8.2 节第一个习题编写的函数输出 8.15: istringstream 对象的内容。 Exercise 编写程序将文件中的每一行存储在 vector 容 8.16: 器对象中,然后使用 istringstream 从 vector 里以每 次读一个单词的形式读取存储的行。 小结 C++ 使用标准库类处理输入和输出: • iostream 类处理面向流的输入和输出。 • fstream 类处理已命名文件的 IO。 • stringstream 类处理内存中字符串的 IO。 所有的这些类都是通过继承相互关联的。输入类继承了 istream,而输出类 则继承了 ostream。因此,可在 istream 对象上执行的操作同样适用于 ifstream 或 istringstream 对象。而继承 ostream 的输出类也是类似的。 所有 IO 对象都有一组条件状态,用来指示是否可以通过该对象进行 IO 操 作。如果出现了错误(例如遇到文件结束符)对象的状态将标志无法再进行输入, 直到修正了错误为止。标准库提供了一组函数设置和检查这些状态。 术语 base class(基类) 是其他类的父类。基类定义了派生类所继承的接口。 condition state(条件状态) 401 流类用于指示给定的流是否用的标志以及相关函数。表 8.2 列出了流的 状态以及获取和设置这些状态的函数。 derived class(派生类) 与父类共享接口的类。 file mode(文件模式) 由 fstream 类定义的标志,在打开文件和控制文件如何使用时指定。表 8.3 列出了所有的文件模式。 fstream 用来读或写已命名文件的流对象。除了普通的 iostream 操作外,fstream 类还定义了 open 和 close 成员。open 成员函数有一个表示打开文件名 的 C 风格字符串参数和一个可选的打开模式参数。默认时,ifstream 对 象以 in 模式打开,ofstream 对象以 out 模式打开,而 fstream 对象 则同时以 in 和 out 模式打开。close 成员关闭流关联的文件,必须在 打开另一个文件前调用。 inheritance(继承) 有继承关系的类型共享相同的接口。派生类继承其基类的属性。第十五章 将继承。 object-oriented library(面向对象标准库) 有继承关系的类的集合。一般来说,面向对象标准库的基类定义了接口, 由继承这个 基类的各个派生类共享。在 IO 标准库中,istream 和 ostream 类是 fstream 和 sstream 头文件中定义的类型的基类。派生类 的对象可当做基类对象使用。例如,可在 ifstream 对象上使用 istream 定义的操作。 stringstream 读写字符串的流对象。除了普通的 iostream 操作外,它还定义了名为 str 的重载成员。无实参地调用 str 将返回 stringstream 所关联的 string 值。用 string 对象做实参调用它则将该 stringstream 对象与 实参副本相关联。 402 第二部分:容器和算法 C++ 提供了使用抽象进行高效率编程的方式。标准库就是一个很好的例子: 标准库定义了许多容器类以及一系列泛型算法,使程序员可以更简洁、抽象和有 效地编写程序。这样可以让标准库操心那些繁琐的细节,特别是内存管理,我们 的程序只需要关注要解决的实际问题就行了。 第三章介绍了 vector 容器类型。我们将会在第九章进一步探讨 vector 和 其他顺序容器类型,而且还会学习 string 类型提供的更多操作,这些容器类型 都是由标准库定义的。我们可将 string 视为仅包含字符的特殊容器,string 类 型提供大量(但并不是全部)的容器操作。 标准库还定义了几种关联容器。关联容器中的元素不是顺序排列,而是按键 (key)排序的。关联容器共享了许多顺序容器提供的操作,此外,还定义了自 己特殊的操作。我们将在第十章学习相关的内容。 第十一章介绍了泛型算法,这些算法通常作用于容器或序列中某一范围的元 素。算法库提供了各种各样经典算法的有效实现,像查找、排序及其他常见的算 法任务。例如,复制算法将一个序列中所有所元素复制到另一个序列中;查找算 法则用于寻找一个指定元素,等等。泛型算法中,所谓“泛型(generic)”指 的是两个方面:这些算法可作用于各种不同的容器类型,而这些容器又可以容纳 多种不同类型的元素。 为容器类型提供通用接口是设计库的目的。如果两种容器提供相似的操作, 则为它们定义的这个操作应该完全相同。例如,所有容器都有返回容器内元素个 数的操作,于是所有容器都将操作命名为 size,并将 size 返回值的类型都指 定为 size_type 类型。类似地,算法具有一致的接口。例如,大部分算法都作 用在由一对迭代器指定的元素范围上。 容器提供的操作和算法是一致定义的,这使得学习标准库更容易:只需理解 一个操作如何工作,就能将该操作应用于其他的容器。更重要的是,接口的一致 性使程序变得更灵活。通常不需要重新编写代码,就可以将一段使用某种容器类 型的程序修改为使用不同容器实现。正如我们所看到的,容器提供了不同的性能 折衷方案,可以改变容器类型对优化系统性能来说颇有价值。 403 第九章. 顺序容器 第三章介绍了最常用的顺序容器:vector 类型。本章将对第三章的内容进行扩 充和完善,继续讨论标准库提供的顺序容器类型。顺序容器内的元素按其位置存 储和访问。除顺序容器外,标准库还定义了几种关联容器,其元素按键(key) 排序。我们将在下一章讨论它们。 容器类共享公共的接口,这使标准库更容易学习,只要学会其中一种类型就能运 用另一种类型。每种容器类型提供一组不同的时间和功能折衷方案。通常不需要 修改代码,只需改变类型声明,用一种容器类型替代另一种容器类型,就可以优 化程序的性能。 容器容纳特定类型对象的集合。我们已经使用过一种容器类型:标准库 vector 类型,这是一种顺序容器(sequential container)。它将单一类型元素聚集起 来成为容器,然后根据位置来存储和访问这些元素,这就是顺序容器。顺序容器 的元素排列次序与元素值无关,而是由元素添加到容器里的次序决定。 标准库定义了三种顺序容器类型:vector、list 和 deque(是双端队列 “double-ended queue”的简写,发音为“deck”)。它们的差别在于访问元素 的方式,以及添加或删除元素相关操作的运行代价。标准库还提供了三种容器适 配器(adaptors)。实际上,适配器是根据原始的容器类型所提供的操作,通过 定义新的操作接口,来适应基础的容器类型。顺序容器适配器包括 stack、 queue 和 priority_queue 类型,见表 9-1。 容器只定义了少量操作。大多数额外操作则由算法库提供,我们将在第十一章学 习算法库。标准库为由容器类型定义的操作强加了公共的接口。这些容器类型的 差别在于它们提供哪些操作,但是如果两个容器提供了相同的操作,则它们的接 口(函数名字和参数个数)应该相同。容器类型的操作集合形成了以下层次结构: • 一些操作适用于所有容器类型。 • 另外一些操作则只适用于顺序或关联容器类型。 • 还有一些操作只适用于顺序或关联容器类型的一个子集。 在本章的后续部分,我们将详细描述顺序容器类型和它们所提供的操作。 表 9.1. 顺序容器类型 顺序容器 vector 支持快速随机访问 404 表 9.1. 顺序容器类型 顺序容器 list 支持快速插入/删除 deque 双端队列 顺序容器适配器 stack 后进先出(LIFO)堆栈 queue 先进先出(FIFO)队列 priority_queue 有优先级管理的队列 9.1. 顺序容器的定义 在第 3.3 节中,我们已经了解了一些使用顺序容器类型的知识。为了定义 一个容器类型的对象,必须先包含相关的头文件,即下列头文件之一: #include #include #include 所有的容器都是类模板(第 3.3 节)。要定义某种特殊的容器,必须在容 器名后加一对尖括号,尖括号里面提供容器中存放的元素的类型: vector svec; list ilist; deque items; // empty vector that can hold strings // empty list that can hold ints // empty deque that holds Sales_items 所有容器类型都定义了默认构造函数,用于创建指定类型的空容器对象。默 认构造函数不带参数。 为了使程序更清晰、简短,容器类型最常用的构造函数是默认 构造函数。在大多数的程序中,使用默认构造函数能达到最佳 运行时性能,并且使容器更容易使用。 405 9.1.1. 容器元素的初始化 除了默认构造函数,容器类型还提供其他的构造函数,使程序员可以指定元 素初值,见表 9.2。 表 9.2. 容器构造函数 C c; 创建一个名为 c 的空容器。C 是容器类型名,如 vector,T 是元素 类型,如 int 或 string 适用于所有容器。 C c(c2); 创建容器 c2 的副本 c;c 和 c2 必须具有相同的容器类型,并存放 相同类型的元素。适用于所有容器。 C c(b, 创建 c,其元素是迭代器 b 和 e 标示的范围内元素的副本。适用于 e); 所有容器。 C c(n, 用 n 个值为 t 的元素创建容器 c,其中值 t 必须是容器类型 C 的 t); 元素类型的值,或者是可转换为该类型的值。 只适用于顺序容器 C c(n); 创建有 n 个值初始化(第 3.3.1 节)(value-initialized)元素 的容器 c。 只适用于顺序容器 将一个容器初始化为另一个容器的副本 当不使用默认构造函数,而是用其他构造函数初始化顺序容器时,必须指出 该容器有多少个元素,并提供这些元素的初值。同时指定元素个数和初值的一个 方法是将新创建的容器初始化为一个同类型的已存在容器的副本: vector ivec; vector ivec2(ivec); // ok: ivec is vector list ilist(ivec); // error: ivec is not list vector dvec(ivec); // error: ivec holds int not double 406 将一个容器复制给另一个容器时,类型必须匹配:容器类型和 元素类型都必须相同。 初始化为一段元素的副本 尽管不能直接将一种容器内的元素复制给另一种容器,但系统允许通过传递 一对迭代器(第 3.4 节)间接实现该实现该功能。使用迭代器时,不要求容器 类型相同。容器内的元素类型也可以不相同,只要它们相互兼容,能够将要复制 的元素转换为所构建的新容器的元素类型,即可实现复制。 迭代器标记了要复制的元素范围,这些元素用于初始化新容器的元素。迭代 器标记出要复制的第一个元素和最后一个元素。采用这种初始化形式可复制不能 直接复制的容器。更重要的是,可以实现复制其他容器的一个子序列: // initialize slist with copy of each element of svec list slist(svec.begin(), svec.end()); // find midpoint in the vector vector::iterator mid = svec.begin() + svec.size()/2; // initialize front with first half of svec: The elements up to but not including *mid deque front(svec.begin(), mid); // initialize back with second half of svec: The elements *mid through end of svec deque back(mid, svec.end()); 回顾一下指针,我们知道指针就是迭代器,因此允许通过使用内置数组中的 一对指针初始化容器也就不奇怪了: char *words[] = {"stately", "plump", "buck", "mulligan"}; // calculate how many elements in words size_t words_size = sizeof(words)/sizeof(char *); // use entire array to initialize words2 list words2(words, words + words_size); 407 这里,使用 sizeof(5.8 节)计算数组的长度。将数组长度加到指向第一 个元素的指针上就可以得到指向超出数组末端的下一位置的指针。通过指向第一 个元素的指针 words 和指向数组中最后一个元素的下一位置的指针,实现了 words2 的初始化。其中第二个指针提供停止复制的条件,其所指向的位置上存 放的元素并没有复制。 分配和初始化指定数目的元素 创建顺序容器时,可显式指定容器大小和一个(可选的)元素初始化式。容 器大小可以是常量或非常量表达式,元素初始化则必须是可用于初始化其元素类 型的对象的值: const list::size_type list_size = 64; list slist(list_size, "eh?"); // 64 strings, each is eh? 这段代码表示 slist 含有 64 个元素,每个元素都被初始化为“eh?”字符串。 创建容器时,除了指定元素个数,还可选择是否提供元素初始化式。我们也 可以只指定容器大小: list ilist(list_size); // 64 elements, each initialized to 0 // svec has as many elements as the return value from get_word_count extern unsigned get_word_count(const string &file_name); vector svec(get_word_count("Chimera")); 不提供元素初始化式时,标准库将为该容器实现值初始化(3.3.1&nbps;节)。 采用这种类型的初始化,元素类型必须是内置或复合类型,或者是提供了默认构 造函数的类类型。如果元素类型没有默认构造函数,则必须显式指定其元素初始 化式。 接受容器大小做形参的构造函数只适用于顺序容器,而关联容 器不支持这种初始化。 408 Exercises Section 9.1.1 Exercise 9.1: 解释下列初始化,指出哪些是错误的,为什么? int ia[7] = { 0, 1, 1, 2, 3, 5, 8 }; string sa[6] = { "Fort Sumter", "Manassas", "Perryville", "Vicksburg", "Meridian", "Chancellorsville" }; (a) vector svec(sa, sa+6); (b) list ilist( ia+4, ia+6); (c) vector ivec(ia, ia+8); (d) list slist(sa+6, sa); Exercise 创建和初始化一个 vector 对象有 4 种方式,为每种方 9.2: 式提供一个例子,并解释每个例子生成的 vector 对象包 含什么值。 Exercise 解释复制容器对象的构造函数和使用两个迭代器的构造 9.3: 函数之间的差别。 9.1.2. 容器内元素的类型约束 C++ 语言中,大多数类型都可用作容器的元素类型。容器元素类型必须满足 以下两个约束: • 元素类型必须支持赋值运算。 • 元素类型的对象必须可以复制。 此外,关联容器的键类型还需满足其他的约束,我们将在第十章介绍相关内容。 大多数类型满足上述最低限度的元素类型要求。除了引用类型外,所有内置 或复合类型都可用做元素类型。引用不支持一般意义的赋值运算,因此没有元素 是引用类型的容器。 除输入输出(IO)标准库类型(以及第 17.1.9 节介绍的 auto_ptr 类型) 之外,所有其他标准库类型都是有效的容器元素类型。特别地,容器本身也满足 上述要求,因此,可以定义元素本身就是容器类型的容器。Sales_item 类型也 满足上述要求。 409 IO 库类型不支持复制或赋值。因此,不能创建存放 IO 类型对象的容器。 容器操作的特殊要求 支持复制和赋值功能是容器元素类型的最低要求。此外,一些容器操作对元 素类型还有特殊要求。如果元素类型不支持这些特殊要求,则相关的容器操作就 不能执行:我们可以定义该类型的容器,但不能使用某些特定的操作。 其中一种需外加类型要求的容器操作是指定容器大小并提供单个初始化式 的构造函数。如果容器存储类类型的对象,那么只有当其元素类型提供默认构造 函数时,容器才能使用这种构造函数。尽管有一些类没有提供默认构造函数,但 大多数类类型都会有。例如,假设类 Foo 没有默认构造函数,但提供了需要一 个 int 型形参的构造函数。现在,考虑下面的声明: vector empty; // ok: no need for element default constructor vector bad(10); // error: no default constructor for Foo vector ok(10, 1); // ok: each element initialized to 1 我们定义一个存放 Foo 类型对象的空容器,但是,只有在同时指定每个元 素的初始化式时,才能使用给定容器大小的构造函数来创建同类型的容器对象。 在描述容器操作时,我们应该留意(如果有的话)每个操作对元素类型的约束。 容器的容器 因为容器受容器元素类型的约束,所以可定义元素是容器类型的容器。例如, 可以定义 vector 类型的容器 lines,其元素为 string 类型的 vector 对象: // note spacing: use ">>" not ">>" when specifying a container element type vector< vector > lines; // vector of vectors 注意,在指定容器元素为容器类型时,必须如下使用空格: vector< vector > lines; // ok: space required between close > vector< vector> lines; // error: >> treated as shift operator 410 必须用空格隔开两个相邻的 > 符号,以示这是两个分开的符 号,否则,系统会认为 >> 是单个符号,为右移操作符,并导 致编译时错误。 Exercises Section 9.1.2 Exercise 定义一个 list 对象来存储 deque 对象里的元素,该 9.4: deque 对象存放 int 型元素。 Exercise 为什么我们不可以使用容器来存储 iostream 对象? 9.5: Exercise 假设有一个名为 Foo 的类,这个类没有定义默认构造函 9.6: 数,但提供了需要一个 int 型参数的构造函数,定义一 个存放 Foo 的 list 对象,该对象有 10 个元素。 9.2. 迭代器和迭代器范围 在整个标准库中,经常使用形参为一对迭代器的构造函数。在深入探讨容器 操作之前,先来了解一下迭代器和迭代器范围。 第 3.4 节首次介绍了 vector 类型的迭代器。每种容器类型都提供若干共 同工作的迭代器类型。与容器类型一样,所有迭代器具有相同的接口:如果某种 迭代器支持某种操作,那么支持这种操作的其他迭代器也会以相同的方式支持这 种操作。例如,所有容器迭代器都支持以解引用运算从容器中读入一个元素。类 似地,容器都提供自增和自减操作符来支持从一个元素到下一个元素的访问。表 9.3 列出迭代器为所有标准库容器类型所提供的运算。 表 9.3. 常用迭代器运算 *iter iter->mem 返回迭代器 iter 所指向的元素的引用 对 iter 进行解引用,获取指定元素中名为 mem 的成员。等效于 (*iter).mem 411 表 9.3. 常用迭代器运算 *iter ++iter iter++ --iter iter-- iter1 == iter2 iter1 != iter2 返回迭代器 iter 所指向的元素的引用 给 iter 加 1,使其指向容器里的下一个元素 给 iter 减 1,使其指向容器里的前一个元素 比较两个迭代器是否相等(或不等)。当两个迭代器指向同一个 容器中的同一个元素,或者当它们都指向同一个容器的超出末端 的下一位置时,两个迭代器相等 vector 和 deque 容器的迭代器提供额外的运算 C++ 定义的容器类型中,只有 vector 和 deque 容器提供下面两种重要的 运算集合:迭代器算术运算(第 3.4.1 节),以及使用除了 == 和 != 之外的 关系操作符来比较两个迭代器(== 和 != 这两种关系运算适用于所有容器)。 表 9.4 总结了这些相关的操作符。 表 9.4. vector 和 deque 类型迭代器支持的操作 iter + n 在迭代器上加(减)整数值 n,将产生指向容器中前面(后面)第 n iter - n 个元素的迭代器。新计算出来的迭代器必须指向容器中的元素或超出 容器末端的下一位置 iter1 += 这里迭代器加减法的复合赋值运算:将 iter1 加上或减去 iter2 的 iter2 运算结果赋给 iter1 iter1 -= iter2 iter1 - 两个迭代器的减法,其运算结果加上右边的迭代器即得左边的迭代 iter2 器。这两个迭代器必须指向同一个容器中的元素或超出容器末端的下 一位置 只适用于 vector 和 deque 容器 412 表 9.4. vector 和 deque 类型迭代器支持的操作 iter + n 在迭代器上加(减)整数值 n,将产生指向容器中前面(后面)第 n iter - n 个元素的迭代器。新计算出来的迭代器必须指向容器中的元素或超出 容器末端的下一位置 >, >=, <, <= 迭代器的关系操作符。当一个迭代器指向的元素在容器中位于另一个 迭代器指向的元素之前,则前一个迭代器小于后一个迭代器。关系操 作符的两个迭代器必须指向同一个容器中的元素或超出容器末端的 下一位置 只适用于 vector 和 deque 容器 关系操作符只适用于 vector 和 deque 容器,这是因为只有这种两种容器 为其元素提供快速、随机的访问。它们确保可根据元素位置直接有效地访问指定 的容器元素。这两种容器都支持通过元素位置实现的随机访问,因此它们的迭代 器可以有效地实现算术和关系运算。 例如,下面的语句用于计算 vector 对象的中点位置: vector::iterator iter = vec.begin() + vec.size()/2; 另一方面,代码: // copy elements from vec into ilist list ilist(vec.begin(), vec.end()); ilist.begin() + ilist.size()/2; // error: no addition on list iterators 是错误的。list 容器的迭代器既不支持算术运算(加法或减法),也不支 持关系运算(<=, <, >=, >),它只提供前置和后置的自增、自减运算以及相等 (不等)运算。 第十一章中,我们将会了解到迭代器提供运算是使用标准库算法的基础。 413 Exercises Section 9.2 Exercise 9.7: 下面的程序错在哪里?如何改正。 list lst1; list::iterator iter1 = lst1.begin(), iter2 = lst1.end(); while (iter1 < iter2) /* . . . */ Exercise 假设 vec_iter 与 vector 对象的一个元素捆绑在一起,该 9.8: vector 对象存放 string 类型的元素,请问下面的语句实现什么 功能? if (vec_iter->empty()) /* . . . */ Exercise 编写一个循环将 list 容器的元素逆序输出。 9.9: Exercise 9.10: 下列迭代器的用法哪些(如果有的话)是错误的? const vector< int > ivec(10); vector< string > svec(10); list< int > ilist(10); (a) vector::iterator it = ivec.begin(); (b) list::iterator it = ilist.begin()+2; (c) vector::iterator it = &svec[0]; (d) for (vector::iterator it = svec.begin(); it != 0; ++it) // ... 9.2.1. 迭代器范围 迭代器范围这个概念是标准库的基础。 414 C++ 语言使用一对迭代器标记迭代器范围(iterator range),这两个迭代 器分别指向同一个容器中的两个元素或超出末端的下一位置,通常将它们命名为 first 和 last,或 beg 和 end,用于标记容器中的一段元素范围。 尽管 last 和 end 这两个名字很常见,但是它们却容易引起误解。其实第 二个迭代器从来都不是指向元素范围的最后一个元素,而是指向最后一个元素的 下一位置。该范围内的元素包括迭代器 first 指向的元素,以及从 first 开始 一直到迭代器 last 指向的位置之前的所有元素。如果两个迭代器相等,则迭代 器范围为空。 此类元素范围称为左闭合区间(left-inclusive interval),其标准表示 方式为: // to be read as: includes first and each element up to but not including last [ first, last ) 表示范围从 first 开始,到 last 结束,但不包括 last。迭代器 last 可 以等于 first,或者指向 first 标记的元素后面的某个元素,但绝对不能指向 first 标记的元素前面的元素。 对形成迭代器范围的迭代器的要求 迭代器 first 和 last 如果满足以下条件,则可形成一个迭代器范围: • 它们指向同一个容器中的元素或超出末端的下一位置。 • 如果这两个迭代器不相等,则对 first 反复做自增运算必须能够 到达 last。换句话说,在容器中,last 绝对不能位于 first 之 前。 编译器自己不能保证上述要求。编译器无法知道迭代器 所关联的是哪个容器,也不知道容器内有多少个元素。 若不能满足上述要求,将导致运行时未定义的行为。 415 使用左闭合区间的编程意义 因为左闭合区间有两个方便使用的性质,所以标准库使用此烦区间。假设 first 和 last 标记了一个有效的迭代器范围,于是: 1. 当 first 与 last 相等时,迭代器范围为空; 2. 当 first 与不相等时,迭代器范围内至少有一个元素,而且 first 指向 该区间中的第一元素。此外,通过若干次自增运算可以使 first 的值不 断增大,直到 first == last 为止。 这两个性质意味着程序员可以安全地编写如下的循环,通过测试迭代器处理 一段元素: while (first != last) { // safe to use *first because we know there is at least one element ++first; } 假设 first 和 last 标记了一段有效的迭代器范围,于是我们知道要么 first == last,这是退出循环的情况;要么该区间非空,first 指向其第一个 元素。因为 while 循环条件处理了空区间情况,所以对此无须再特别处理。当 迭代器范围非空时,循环至少执行一次。由于循环体每次循环就给 first 加 1, 因此循环必定会终止。而且在循环内可确保 *first 是安全的:它必然指向 first 和 last 之间非空区间内的某个特定元素。 416 Exercises Section 9.2.1 Exercise 要标记出有效的迭代器范围,迭代器需要满足什么约束? 9.11: Exercise 编写一个函数,其形参是一对迭代器和一个 int 型数值, 9.12: 实现在迭代器标记的范围内寻找该 int 型数值的功能, 并返回一个 bool 结果,以指明是否找到指定数据。 Exercise 重写程序,查找元素的值,并返回指向找到的元素的迭代 9.13: 器。确保程序在要寻找的元素不存在时也能正确工作。 Exercise 使用迭代器编写程序,从标准输入设备读入若干 string 9.14: 对象,并将它们存储在一个 vector 对象中,然后输出该 vector 对象中的所有元素。 Exercise 用 list 容器类型重写习题 9.14 得到的程序,列出改变 9.15: 了容器类型后要做的修改。 9.2.2. 使迭代器失效的容器操作 在后面的几节里,我们将看到一些容器操作会修改容器的内在状态或移动容 器内的元素。这样的操作使所有指向被移动的元素的迭代器失效,也可能同时使 其他迭代器失效。使用无效迭代器是没有定义的,可能会导致与悬垂指针相同的 问题。 例如,每种容器都定义了一个或多个 erase 函数。这些函数提供了删除容 器元素的功能。任何指向已删除元素的迭代器都具有无效值,毕竟,该迭代器指 向了容器中不再存在的元素。 使用迭代器编写程序时,必须留意哪些操作会使迭代器失效。 使用无效迭代器将会导致严重的运行时错误。 417 无法检查迭代器是否有效,也无法通过测试来发现迭代器是否已经失效。任 何无效迭代器的使用都可能导致运行时错误,但程序不一定会崩溃,否则检查这 种错误也许会容易些。 使用迭代器时,通常可以编写程序使得要求迭代器有效的代 码范围相对较短。然后,在该范围内,严格检查每一条语句, 判断是否有元素添加或删除,从而相应地调整迭代器的值。 9.3. 每种顺序容器都提供了一组有用的类型定义以及以下操作: 每种顺序容器都提供了一组有用的类型定义以及以下操作: • 在容器中添加元素。 • 在容器中删除元素。 • 设置容器大小。 • (如果有的话)获取容器内的第一个和最后一个元素。 9.3.1. 容器定义的类型别名 在前面的章节里,我们已经使用过三种由容器定义的类型:size_type、 iterator 和 const_iterator。所有容器都提供这三种类型以及表 9.5 所列出 的其他类型。 表 9.5. 容器定义的类型别名 size_type 无符号整型,足以存储此容器类型的最大可能容器长 度 iterator 此容器类型的迭代器类型 const_iterator 元素的只读迭代器类型 reverse_iterator 按逆序寻址元素的迭代器 const_reverse_iterator 元素的只读(不能写)逆序迭代器 difference_type 足够存储两个迭代器差值的有符号整型,可为负数 value_type 元素类型 reference 元素的左值类型,是 value_type& 的同义词 const_reference 元素的常量左值类型,等效于 const value_type& 418 我们将在第 11.3.3 节中详细介绍逆序迭代器。简单地说,逆序迭代器从后 向前遍历容器,并反转了某些相关的迭代器操作:例如,在逆序迭代器上做 ++ 运 算将指向容器中的前一个元素。 表 9.5 的最后三种类型使程序员无须直接知道容器元素的真正类型,就能 使用它。需要使用元素类型时,只要用 value_type 即可。如果要引用该类型, 则通过 reference 和 const_reference 类型实现。在程序员编写自己的泛型程 序(第十六章)时,这些元素相关类型的定义非常有用。 使用容器定义类型的表达式看上去非常复杂: // iter is the iterator type defined by list list::iterator iter; // cnt is the difference_type type defined by vector vector::difference_type cnt; iter 所声明使用了作用域操作符,以表明此时所使用的符号 :: 右边的类 型名字是在符号 iter 左边指定容器的作用域内定义的。其效果是将 iter 声明 为 iterator 类型,而 iterator 是存放 string 类型元素的 list 类的成员。 Exercises Section 9.3.1 Exercise int 型的 vector 容器应该使用什么类型的索引? 9.16: Exercise 读取存放 string 对象的 list 容器时,应该使用什么类 9.17: 型? 9.3.2. begin 和 end 成员 begin 和 end 操作产生指向容器内第一个元素和最后一个元素的下一位置 的迭代器,如表 9.6 所示。这两个迭代器通常用于标记包含容器中所有元素的 迭代器范围。 419 表 9.6. 容器的 begin 和 end 操作 c.begin() 返回一个迭代器,它指向容器 c 的第一个元素 c.end() 返回一个迭代器,它指向容器 c 的最后一个元素的下一位置 c.rbegin() 返回一个逆序迭代器,它指向容器 c 的最后一个元素 c.rend() 返回一个逆序迭代器,它指向容器 c 的第一个元素前面的位置 上述每个操作都有两个不同版本:一个是 const 成员(第 7.7.1 节),另 一个是非 const 成员。这些操作返回什么类型取决于容器是否为 const。如果 容器不是 const,则这些操作返回 iterator 或 reverse_iterator 类型。如果 容器是 const,则其返回类型要加上 const_ 前缀,也就是 const_iterator 和 const_reverse_iterator 类型。我们将在第 11.3.3 节中详细介绍逆序迭代器。 9.3.3. 在顺序容器中添加元素 第 3.3.2 节介绍了添加元素的一种方法:push_back。所有顺序容器都支持 push_back 操作(表 9.7),提供在容器尾部插入一个元素的功能。下面的循环 每次读入一个 string 类型的值,并存放在 text_word: 对象中: // read from standard input putting each word onto the end of container string text_word; while (cin >> text_word) container.push_back(text_word); 调用 push_back 函数会在容器 container 尾部创建一个新元素,并使容器 的长度加 1。新元素的值为 text_word 对象的副本,而 container 的类型则可 能是 list、vector 或 deque。 除了 push_back 运算,list 和 deque 容器类型还提供了类似的操作: push_front。这个操作实现在容器首部插入新元素的功能。例如: list ilist; // add elements at the end of ilist for (size_t ix = 0; ix != 4; ++ix) ilist.push_back(ix); 使用 push_back 操作在容器 ilist 尾部依次添加元素 0、1、2、3。 420 然后,我们选择用 push_front 操作再次在 ilist 中添加元素: // add elements to the start of ilist for (size_t ix = 0; ix != 4; ++ix) ilist.push_front(ix); 此时,元素 0、1、2、3 则被依次添加在 ilist 的开始位置。由于每个元 素都在 list 的新起点插入,因此它们在容器中以逆序排列,循环结束后,ilist 内的元素序列为:3、2、1、0、0、1、2、3。 关键概念:容器元素都是副本 在容器中添加元素时,系统是将元素值复制到容器里。类似地,使用一 段元素初始化新容器时,新容器存放的是原始元素的副本。被复制的原 始值与新容器中的元素各不相关,此后,容器内元素值发生变化时,被 复制的原值不会受到影响,反之亦然。 表 9.7 在顺序容器中添加元素的操作 c.push_back(t) 在容器 c 的尾部添加值为 t 的元素。返回 void 类型 c.push_front(t) 在容器 c 的前端添加值为 t 的元素。返回 void 类型 只适用于 list 和 deque 容器类型. c.insert(p,t) 在迭代器 p 所指向的元素前面插入值为 t 的新元素。返回 指向新添加元素的迭代器 c.insert(p,n,t) 在迭代器 p 所指向的元素前面插入 n 个值为 t 的新元素。 返回 void 类型 c.insert(p,b,e) 在迭代器 p 所指向的元素前面插入由迭代器 b 和 e 标记 的范围内的元素。返回 void 类型 在容器中指定位置添加元素 使用 push_back 和 push_front 操作可以非常方便地在顺序容器的尾部或 首部添加单个元素。而 insert 操作则提供了一组更通用的插入方法,实现在容 器的任意指定位置插入新元素。insert 操作有三个版本(表 9.7)。第一个版 421 本需要一个迭代器和一个元素值参数,迭代器指向插入新元素的位置。下面的程 序就是使用了这个版本的 insert 函数在容器首部插入新元素: vector svec; list slist; string spouse("Beth"); // equivalent to calling slist.push_front (spouse); slist.insert(slist.begin(), spouse); // no push_front on vector but we can insert before begin() // warning: inserting anywhere but at the end of a vector is an expensive operation svec.insert(svec.begin(), spouse); 新元素是插入在迭代器指向的位置之前。迭代器可以指向容器的任意位置, 包括超出末端的下一位置。由于迭代器可能指向超出容器末端的下一位置,这是 一个不存在的元素,因此 insert 函数是在其指向位置之前而非其后插入元素。 代码 slist.insert(iter, spouse); // insert spouse just before iter 就在 iter 指向的元素前面插入 spouse 的副本。 这个版本的 insert 函数返回指向新插入元素的迭代器。可使用该返回值在 容器中的指定位置重复插入元素: list lst; list::iterator iter = lst.begin(); while (cin >> word) iter = lst.insert(iter, word); // same as calling push_front 要彻底地理解上述循环是如何执行的,这一点非常重要——特 别是要明白我们为什么说上述循环等效于调用 push_front 函 数。 循环前,将 iter 初始化为 lst.begin()。此时,由于该 list 对象是空的, 因此 lst.begin() 与 lst.end() 相等,于是 iter 指向该(空)容器的超出末 端的下一位置。第一次调用 insert 函数时,将刚读入的元素插入到 iter 所指 向位置的前面,容器 lst 得到第一个也是唯一的元素。然后 insert 函数返回 422 指向这个新元素的迭代器,并赋给 iter,接着重复 while 循环,读入下一个单 词。只要有单词要插入,每次 while 循环都将新元素插入到 iter 前面,然后 重置 iter 指向新插入元素。新插入的元素总是容器中的第一个元素,因此,每 次迭代器都将元素插入在该 list 对象的第一元素前面。 插入一段元素 insert 函数的第二个版本提供在指定位置插入指定数量的相同元素的功能: svec.insert(svec.end(), 10, "Anna"); 上述代码在容器 svec 的尾部插入 10 个元素,每个新元素都初始化为 "Anna"。 insert 函数的最后一个版本实现在容器中插入由一对迭代器标记的一段范 围内的元素。例如,给出以下 string 类型的数组: string sarray[4] = {"quasi", "simba", "frollo", "scar"}; 可将该数组中所有的或其中一部分元素插入到 string 类型的 list 容器中: // insert all the elements in sarray at end of slist slist.insert(slist.end(), sarray, sarray+4); list::iterator slist_iter = slist.begin(); // insert last two elements of sarray before slist_iter slist.insert(slist_iter, sarray+2, sarray+4); 添加元素可能会使迭代器失效 正如我们在第 9.4 节中了解的一样,在 vector 容器中添加元素可能会导 致整个容器的重新加载,这样的话,该容器涉及的所有迭代器都会失效。即使需 要重新加载整个容器,指向新插入元素后面的那个元素的迭代器也会失效。 任何 insert 或 push 操作都可能导致迭代器失效。当编写循 环将元素插入到 vector 或 deque 容器中时,程序必须确保迭 代器在每次循环后都得到更新。 423 避免存储 end 操作返回的迭代器 在 vector 或 deque 容器中添加元素时,可能会导致某些或全部迭代器失 效。假设所有迭代器失效是最安全的做法。这个建议特别适用于由 end 操作返 回的迭代器。在容器的任何位置插入任何元素都会使该迭代器失效。 例如,考虑一个读取容器中每个元素的循环,对读出元素做完处理后,在原 始元素后面插入一个新元素。我们希望该循环可以处理每个原始元素,然后使用 insert 函数插入新元素,并返回指向刚插入元素的迭代器。在每次插入操作完 成后,给返回的迭代器自增 1,以使循环定位在下一个要处理的原始元素。如果 我们尝试通过存储 end() 操作返回的迭代器来“优化”该循环,将导致灾难性 错误: vector::iterator first = v.begin(), last = v.end(); // cache end iterator // diaster: behavior of this loop is undefined while (first != last) { // do some processing // insert new value and reassign first, which otherwise would be invalid first = v.insert(first, 42); ++first; // advance first just past the element we added } 上述代码的行为未定义。在很多实现中,该段代码将导致死循环。问题在于 这个程序将 end 操作返回的迭代器值存储在名为 last 的局部变量中。循环体 中实现了元素的添加运算,添加元素会使得存储在 last 中的迭代器失效。该迭 代器既没有指向容器 v 的元素,也不再指向 v 的超出末端的下一位置。 不要存储 end 操作返回的迭代器。添加或删除 deque 或 vector 容器内的元素都会导致存储的迭代器失效。 为了避免存储 end 迭代器,可以在每次做完插入运算后重新计算 end 迭代器 值: // safer: recalculate end on each trip whenever the loop adds/erases elements while (first != v.end()) { // do some processing 424 first = v.insert(first, 42); // insert new value ++first; // advance first just past the element we added } Exercises Section 9.3.3 Exercise 9.18: 编写程序将 int 型的 list 容器的所有元素复制到两个 deque 容器中。list 容器的元素如果为偶数,则复制到 一个 deque 容器中;如果为奇数,则复制到另一个 deque 容器里。 Exercise 假设 iv 是一个 int 型的 vector 容器,下列程序存在 9.19: 什么错误?如何改正之。 vector::iterator mid = iv.begin() + iv.size()/2; while (vector::iterator iter != mid) if (iter == some_val) iv.insert(iter, 2 * some_val); 9.3.4. 关系操作符 所有的容器类型都支持用关系操作符(第 5.2 节)来实现两个容器的比较。 显比较的容器必须具有相同的容器类型,而且其元素类型也必须相同。例如, vector 容器只能与 vector 容器比较,而不能与 list 或 vector 容器比较,而不能与 list 或 vector 类型的容器 比较。 容器的比较是基于容器内元素的比较。容器的比较使用了元素类型定义的同 一个关系操作符:两个容器做 != 比较使用了其元素类型定义的 != 操作符。如 果容器的元素类型不支持某种操作符,则该容器就不能做这种比较运算。 下面的操作类似于 string 类型的关系运算(第 3.2.3 节): • 如果两个容器具有相同的长度而且所有元素都相等,那么这两个容器就相 等;否则,它们就不相等。 • 如果两个容器的长度不相同,但较短的容器中所有元素都等于较长容器中 对应的元素,则称较短的容器小于另一个容器。 425 • 如果两个容器都不是对文的初始子序列,则它们的比较结果取决于所比较 的第一个不相等的元素。 理解上述操作的最简单方法是研究例程: /* ivec1: 1 3 5 7 9 12 ivec2: 0 2 4 6 8 10 12 ivec3: 1 3 9 ivec4: 1 3 5 7 ivec5: 1 3 5 7 9 12 */ // ivec1 and ivec2 differ at element[0]: ivec1 greater than ivec2 ivec1 < ivec2 // false ivec2 < ivec1 // true // ivec1 and ivec3 differ at element[2]: ivec1 less than ivec3 ivec1 < ivec3 // true // all elements equal, but ivec4 has fewer elements, so ivec1 is greater than ivec4 ivec1 < ivec4 // false ivec1 == ivec5 // true; each element equal and same number of elements ivec1 == ivec4 // false; ivec4 has fewer elements than ivec1 ivec1 != ivec4 // true; ivec4 has fewer elements than ivec1 使用元素提供的关系操作符实现容器的关系运算 C++ 语言只允许两个容器做其元素类型定义的关系运算。 所有容器都通过比较其元素对来实现关系运算: ivec1 < ivec2 426 假设 ivec1 和 ivec2 都是 vector 类型的容器,则上述比较使用了 内置 int 型定义的小于操作符。如果这两个 vector 容器存储的是 strings 对 象,则使用 string 类型的小于操作符。 如果上述 vector 容器存储 第 1.5 节定义的 Sales_item 类型的对象,则 该比较运算不合法。因为 Sales_item 类型没有定义关系运算,所以不能比较存 放 Sales_items 对象的容器: vector storeA; vector storeB; if (storeA < storeB) // error: Sales_item has no less-than operator Exercises Section 9.3.4 Exercise 编写程序判断一个 vector 容器所包含的元素是否 9.20: 与一个 list 容器的完全相同。 Exercise 假设 c1 和 c2 都是容器,下列用法给 c1 和 c2 的元素 9.21: 类型带来什么约束? if (c1 < c2) (如果有的话)对 c1 和 c2 的约束又是什么? 9.3.5. 容器大小的操作 所有容器类型都提供四种与容器大小相关的操作(表 9.8)第 3.2.3 节已 经使用了 size 和 empty 函数:size 操作返回容器内元素的个数:empty 操作 则返回一个布尔值,当容器的大小为 0 时,返回值为 true,否则为 false。 表 9.8. 顺序容器的大小操作 c.size() 返回容器 c 中的元素个数。返回类型为 c::size_type c.max_size() 返回容器 c 可容纳的最多元素个数,返回类型为 c::size_type 427 c.empty() 返回标记容器大小是否为 0 的布尔值 c.resize(n) 调整容器 c 的长度大小,使其能容纳 n 个元素,如果 n < c.size(),则删除多出来的元素;否则,添加采用值初始化的 新元素 c.resize(n,t) 调整容器 c 的长度大小,使其能容纳 n 个元素。所有新添加 的元素值都为 t 容器类型提供 resize 操作来改变容器所包含的元素个数。如果当前的容器 长度大于新的长度值,则该容器后部的元素会被删除;如果当前的容器长度小于 新的长度值,则系统会在该容器后部添加新元素: list ilist(10, 42); // 10 ints: each has value 42 ilist.resize(15); ilist // adds 5 elements of value 0 to back of ilist.resize(25, -1); of ilist // adds 10 elements of value -1 to back ilist.resize(5); ilist // erases 20 elements from the back of resize 操作可带有一个可选的元素值形参。如果在调用该函数时提供了这 个参数,则所有新添加的元素都初始化为这个值。如果没有这个参数,则新添加 的元素采用值初始化(第 3.3.1 节)。 resize 操作可能会使迭代器失效。在 vector 或 deque 容器 上做 resize 操作有可能会使其所有的迭代器都失效。 对于所有的容器类型,如果 resize 操作压缩了容器,则指向已删除的元素迭代 器失效。 428 Exercises Section 9.3.5 Exercise 已知容器 vec 存放了 25 个元素,那么 9.22: vec.resize(100) 操作实现了什么功能?若再做操作 vec.resize(10),实现的又是什么功能? Exercise 使用只带有一个长度参数的 resize 操作对元素类型有 9.23: 什么要求(如果有的话)? 9.3.6. 访问元素 如果容器非空,那么容器类型的 front 和 back 成员(表 9.9)将返回容 器内第一个或最后一个元素的引用: // check that there are elements before dereferencing an iterator // or calling front or back if (!ilist.empty()) { // val and val2 refer to the same element list::reference val = *ilist.begin(); list::reference val2 = ilist.front(); // last and last2 refer to the same element list::reference last = *--ilist.end(); list::reference last2 = ilist.back(); } 表 9.9. 访问顺序容器内元素的操作 c.back() 返回容器 c 的最后一个元素的引用。如果 c 为空,则该操作未定 义 c.front() 返回容器 c 的第一个元素的引用。如果 c 为空,则该操作未定义 c[n] 返回下标为 n 的元素的引用 如果 n <0 或 n >= c.size(),则该操作未定义 只适用于 vector 和 deque 容器 429 c.at(n) 返回下标为 n 的元素的引用。如果下标越界,则该操作未定义 只适用于 vector 和 deque 容器 这段程序使用了两种不同的方法获取时 ilist 中的第一个和最后一个元素 的引用。直接的方法是调用 front 或 back 函数。间接的方法是,通过对 begin 操作返回的迭代器进行解引用,或对 end 操作返回的迭代器的前一个元素位置 进行解引用,来获取对同一元素的引用。在这段程序中,有两个地方值得注意: end 迭代器指向容器的超出末端的下一位置,因此必须先对其减 1 才能获取最 后一个元素;另一点是,在调用 front 或 back 函数之前,或者在对 begin 或 end 返回的迭代器进行解引用运算之前,必须保证 ilist 容器非空。如果该 list 容器为空,则 if 语句内所有的操作都没有定义。 第 3.3.2 节介绍了下标运算,我们注意到程序员必须保证在指定下标位置 上的元素确实存在。下标操作符本身不会做相关的检查。使用 front 或 back 运 算时,必须注意同样的问题。如果容器为空,那么这些操作将产生未定义的结果。 如果容器内只有一个元素,则 front 和 back 操作都返回对该元素的引用。 使用越界的下标,或调用空容器的 front 或 back 函数,都会 导致程序出现严重的错误。 使用下标运算的另一个可选方案是 at 成员函数(表 9.9)。这个函数的行 为和下标运算相似,但是如果给出的下标无效,at 函数将会抛出 out_of_range 异常(第 6.13 节): vector svec; cout << svec[0]; in svec! cout << svec.at(0); // empty vector // run-time error: There are no elements // throws out_of_range exception 430 Exercises Section 9.3.6 Exercise 编写程序获取 vector 容器的第一个元素。分别使用下标 9.24: 操作符、front 函数以及 begin 函数实现该功能,并提 供空的 vector 容器测试你的程序。 9.3.7. 删除元素 回顾前面的章节,我们知道容器类型提供了通用的 insert 操作在容器的任 何位置插入元素,并支持特定的 push_front 和 push_back 操作在容器首部或 尾部插入新元素。类似地,容器类型提供了通用的 erase 操作和特定的 pop_front 和 pop_back 操作来删除容器内的元素(表 9.10)。 表 9.10. 删除顺序容器内元素的操作 c.erase(p) 删除迭代器 p 所指向的元素 返回一个迭代器,它指向被删除元素后面的元素。如果 p 指向 容器内的最后一个元素,则返回的迭代器指向容器的超出末端 的下一位置。如果 p 本身就是指向超出末端的下一位置的迭代 器,则该函数未定义 c.erase(b,e) 删除迭代器 b 和 e 所标记的范围内所有的元素 返回一个迭代器,它指向被删除元素段后面的元素。如果 e 本 身就是指向超出末端的下一位置的迭代器,则返回的迭代器也 指向容器的超出末端的下一位置 c.clear() 删除容器 c 内的所有元素。返回 void c.pop_back() 删除容器 c 的最后一个元素。返回 void。如果 c 为空容器, 则该函数未定义 c.pop_front() 删除容器 c 的第一个元素。返回 void。如果 c 为空容器,则 该函数未定义 只适用于 list 或 deque 容器 431 删除第一个或最后一个元素 pop_front 和 pop_back 函数用于删除容器内的第一个和最后一个元素。但 vector 容器类型不支持 pop_front 操作。这些操作删除指定的元素并返回 void。 pop_front 操作通常与 front 操作配套使用,实现以栈的方式处理容器: while (!ilist.empty()) { process(ilist.front()); // do something with the current top of ilist ilist.pop_front(); // done; remove first element } 这个循环非常简单:使用 front 操作获取要处理的元素,然后调用 pop_front 函数从容器 list 中删除该元素。 pop_front 和 pop_back 函数的返回值并不是删除的元素值, 而是 void。要获取删除的元素值,则必须在删除元素之前调用 notfront 或 back 函数。 删除容器内的一个元素 删除一个或一段元素更通用的方法是 erase 操作。该操作有两个版本:删 除由一个迭代器指向的单个元素,或删除由一对迭代器标记的一段元素。erase 的这两种形式都返回一个迭代器,它指向被删除元素或元素段后面的元素。也就 是说,如果元素 j 恰好紧跟在元素 i 后面,则将元素 i 从容器中删除后,删 除操作返回指向 j 的迭代器。 如同其他操作一样,erase 操作也不会检查它的参数。程序员 必须确保用作参数的迭代器或迭代器范围是有效的。 通常,程序员必须在容器中找出要删除的元素后,才使用 erase 操作。寻 找一个指定元素的最简单方法是使用标准库的 find 算法。我们将在第 11.1 节 中进一步讨论 find 算法。为了使用 find 函数或其他泛型算法,在编程时,必 须将 algorithm 头文件包含进来。find 函数需要一对标记查找范围的迭代器以 432 及一个在该范围内查找的值作参数。查找完成后,该函数返回一个迭代器,它指 向具有指定值的第一个元素,或超出末端的下一位置。 string searchValue("Quasimodo"); list::iterator iter = find(slist.begin(), slist.end(), searchValue); if (iter != slist.end()) slist.erase(iter); 注意,在删除元素之前,必须确保迭代器是不是 end 迭代器。使用 erase 操 作删除单个必须确保元素确实存在——如果删除指向超出末端的下一位置的迭 代器,那么 erase 操作的行为未定义。 删除容器内所有元素 要删除容器内所有的元素,可以调用 clear 函数,或将 begin 和 end 迭 代器传递给 erase 函数。 slist.clear(); // delete all the elements within the container slist.erase(slist.begin(), slist.end()); // equivalent erase 函数的迭代器对版本提供了删除一部分元素的功能: // delete range of elements between two values list::iterator elem1, elem2; // elem1 refers to val1 elem1 = find(slist.begin(), slist.end(), val1); // elem2 refers to the first occurrence of val2 after val1 elem2 = find(elem1, slist.end(), val2); // erase range from val1 up to but not including val2 slist.erase(elem1, elem2); 这段代码首先调用了 find 函数两次,以获得指向特定元素的两个迭代器。 迭代器 elem1 指向第一个具有 val1 值的元素,如果容器 list 中不存在值为 val1 的元素,则该迭代器指向超出末端的下一位置。如果在 val1 元素后面存 在值为 val2 的元素,那么迭代器 elem2 就指向这段范围内第一个具有 val2 值的元素,否则,elem2 就是一个超出末端的迭代器。最后,调用 erase 函数 433 删除从迭代器 elem1 开始一直到 elem2 之间的所有元素,但不包括 elem2 指 向的元素。 erase、pop_front 和 pop_back 函数使指向被删除元素的所有 迭代器失效。对于 vector 容器,指向删除点后面的元素的迭 代器通常也会失效。而对于 deque 容器,如果删除时不包含第 一个元素或最后一个元素,那么该 deque 容器相关的所有迭代 器都会失效。 Exercises Section 9.3.7 Exercise 需要删除一段元素时,如果 val1 与 val2 相等,那么程 9.25: 序会发生什么事情?如果 val1 和 val2 中的一个不存 在,或两个都不存在,程序又会怎么样? Exercise 9.26: 假设有如下 ia 的定义,将 ia 复制到一个 vector 容器 和一个 list 容器中。使用单个迭代器参数版本的 erase 函数将 list 容器中的奇数值元素删除掉,然后将 vector 容器中的偶数值元素删除掉。 int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 55, 89 }; Exercise 编写程序处理一个 string 类型的 list 容器。在该容器 9.27: 中寻找一个特殊值,如果找到,则将它删除掉。用 deque 容器重写上述程序。 9.3.8. 赋值与 swap 与赋值相关的操作符都作用于整个容器。除 swap 操作外,其他操作都可以 用 erase 和 insert 操作实现(表 9.11)。赋值操作符首先 erases 其左操作 数容器中的所有元素,然后将右操作数容器的所有元素 inserts 到左边容器中: c1 = c2; // replace contents of c1 with a copy of elements in c2 // equivalent operation using erase and insert c1.erase(c1.begin(), c1.end()); // delete all elements in c1 c1.insert(c1.begin(), c2.begin(), c2.end()); // insert c2 434 赋值后,左右两边的容器相等:尽管赋值前两个容器的长度可能不相等,但赋值 后两个容器都具有右操作数的长度。 赋值和 assign 操作使左操作数容器的所有迭代器失效。swap 操作则不会使迭代器失效。完成 swap 操作后,尽管被交换的 元素已经存放在另一容器中,但迭代器仍然指向相同的元素。 表 9.11. 顺序容器的赋值操作 c1 = c2 删除容器 c1 的所有元素,然后将 c2 的元素复制给 c1。c1 和 c2 的类型(包括容器类型和元素类型)必须相同 c1.swap(c2) 交换内容:调用完该函数后,c1 中存放的是 c2 原来的元素, c2 中存放的则是 c1 原来的元素。c1 和 c2 的类型必须相同。 该函数的执行速度通常要比将 c2 复制到 c1 的操作快 c.assign(b,e) 重新设置 c 的元素:将迭代器 b 和 e 标记的范围内所有的元 素复制到 c 中。b 和 e 必须不是指向 c 中元素的迭代器 c.assign(n,t) 将容器 c 重新设置为存储 n 个值为 t 的元素 使用 assign assign 操作首先删除容器中所有的元素,然后将其参数所指定的新元素插 入到该容器中。与复制容器元素的构造函数一样,如果两个容器类型相同,其元 素类型也相同,就可以使用赋值操作符(=)将一个容器赋值给另一个容器。如 果在不同(或相同)类型的容器内,元素类型不相同但是相互兼容,则其赋值运 算必须使用 assign 函数。例如,可通过 assign 操作实现将 vector 容器中一 段 char* 类型的元素赋给 string 类型 list 容器。 由于 assign 操作首先删除容器中原来存储的所有元素,因此, 传递给 assign 函数的迭代器不能指向调用该函数的容器内的 元素。 435 assign 函数的参数决定了要插入多少个元素以及新元素的值是什么。语句 // equivalent to slist1 = slist2 slist1.assign(slist2.begin(), slist2.end()); 使用了带一对迭代器参数的 assign 函数版本。在删除 slist1 的元素后, 该函数将 slist2 容器内一段指定的元素复制到 slist2 中。于是,这段代码行 等效于将 slist1 赋给 slist1。 带有一对迭代器参数的 assign 操作允许我们将一个容器的元 素赋给另一个不同类型的容器。 assign 运算的第二个版本需要一个整型数值和一个元素值做参数,它将容 器重置为存储指定数量的元素,并且每个元素的值都为指定值: // equivalent to: slist1.clear(); // followed by slist1.insert(slist1.begin(), 10, "Hiya!"); slist1.assign(10, "Hiya!"); // 10 elements; each one is Hiya! 执行了上述语句后,容器 slist1 有 10 个元素,每个元素的值都是 Hiya!。 使用 swap 操作以节省删除元素的成本 swap 操作实现交换两个容器内所有元素的功能。要交换的容器的类型必须 匹配:操作数必须是相同类型的容器,而且所存储的元素类型也必须相同。调用 了 swap 函数后,右操作数原来存储的元素被存放在左操作数中,反之亦然。 vector svec1(10); // vector with 10 elements vector svec2(24); // vector with 24 elements svec1.swap(svec2); 执行 swap 后,容器 svec1 中存储 24 个 string 类型的元素,而 svec2 则存 储 10 个元素。 436 关于 swap 的一个重要问题在于:该操作不会删除或插入任何 元素,而且保证在常量时间内实现交换。由于容器内没有移动 任何元素,因此迭代器不会失效。 没有移动元素这个事实意味着迭代器不会失效。它们指向同一元素,就像没 作 swap 运算之前一样。虽然,在 swap 运算后,这些元素已经被存储在不同的 容器之中了。例如,在做 swap 运算之前,有一个迭代器 iter 指向 svec1[3] 字 符串;实现 swap 运算后,该迭代器则指向 svec2[3] 字符串(这是同一个字符 串,只是存储在不同的容器之中而已)。 Exercises Section 9.3.8 Exercise 9.28: 编写程序将一个 list 容器的所有元素赋值给一个 vector 容器,其中 list 容器中存储的是指向 C 风格字 符串的 char* 指针,而 vector 容器的元素则是 string 类型。 9.4. vector 容器的自增长 在容器对象中 insert 或压入一个元素时,该对象的大小增加 1。类似地, 如果 resize 容器以扩充其容量,则必须在容器中添加额外的元素。标准库处理 存储这些新元素的内存分配问题。 一般来说,我们不应该关心标准库类型是如何实现的:我们只需要关心如何 使用这些标准库类型就可以了。然而,对于 vector 容器,有一些实现也与其接 口相关。为了支持快速的随机访问,vector 容器的元素以连续的方式存放—— 每一个元素都紧挨着前一个元素存储。 已知元素是连续存储的,当我们在容器内添加一个元素时,想想会发生什么 事情:如果容器中已经没有空间容纳新的元素,此时,由于元素必须连续存储以 便索引访问,所以不能在内存中随便找个地方存储这个新元素。于是,vector 必 须重新分配存储空间,用来存放原来的元素以及新添加的元素:存放在旧存储空 间中的元素被复制到新存储空间里,接着插入新元素,最后撤销旧的存储空间。 如果 vector 容器在在每次添加新元素时,都要这么分配和撤销内存空间,其性 能将会非常慢,简直无法接受。 437 对于不连续存储元素的容器,不存在这样的内存分配问题。例如,在 list 容 器中添加一个元素,标准库只需创建一个新元素,然后将该新元素连接在已存在 的链表中,不需要重新分配存储空间,也不必复制任何已存在的元素。 由此可以推论:一般而言,使用 list 容器优于 vector 容器。但是,通常 出现的反而是以下情况:对于大部分应用,使用 vector 容器是最好的。原因在 于,标准库的实现者使用这样内存分配策略:以最小的代价连续存储元素。由此 而带来的访问元素的便利弥补了其存储代价。 为了使 vector 容器实现快速的内存分配,其实际分配的容量要比当前所需 的空间多一些。vector 容器预留了这些额外的存储区,用于存放新添加的元素。 于是,不必为每个新元素重新分配容器。所分配的额外内存容量的确切数目因库 的实现不同而不同。比起每添加一个新元素就必须重新分配一次容器,这个分配 策略带来显著的效率。事实上,其性能非常好,因此在实际应用中,比起 list 和 deque 容器,vector 的增长效率通常会更高。 9.4.1. capacity 和 reserve 成员 vector 容器处理内存分配的细节是其实现的一部分。然而,该实现部分是 由 vector 的接口支持的。vector 类提供了两个成员函数:capacity 和 reserve 使程序员可与 vector 容器内存分配的实现部分交互工作。capacity 操作获取在容器需要分配更多的存储空间之前能够存储的元素总数,而 reserve 操作则告诉 vector 容器应该预留多少个元素的存储空间。 弄清楚容器的 capacity(容量)与 size(长度)的区别非常 重要。size 指容器当前拥有的元素个数;而 capacity 则指容 器在必须分配新存储空间之前可以存储的元素总数。 为了说明 size 和 capacity 的交互作用,考虑下面的程序: vector ivec; // size should be zero; capacity is implementation defined cout << "ivec: size: " << ivec.size() << " capacity: " << ivec.capacity() << endl; // give ivec 24 elements for (vector::size_type ix = 0; ix != 24; ++ix) ivec.push_back(ix); 438 // size should be 24; capacity will be >= 24 and is implementation defined cout << "ivec: size: " << ivec.size() << " capacity: " << ivec.capacity() << endl; 在我们的系统上运行该程序时,得到以下输出结果: ivec: size: 0 capacity: 0 ivec: size: 24 capacity: 32 由此可见,空 vector 容器的 size 是 0,而标准库显然将其 capacity 也 设置为 0。当程序员在 vector 中插入元素时,容器的 size 就是所添加的元素 个数,而其 capacity 则必须至少等于 size,但通常比 size 值更大。在上述 程序中,一次添加一个元素,共添加了 24 个元素,结果其 capacity 为 32。 容 器的当前状态如下图所示: 现在,可如下预留额外的存储空间: ivec.reserve(50); // sets capacity to at least 50; might be more // size should be 24; capacity will be >= 50 and is implementation defined cout << "ivec: size: " << ivec.size() << " capacity: " << ivec.capacity() << endl; 正如下面的输出结果所示,该操作保改变了容器的 capacity,而其 size 不变: ivec: size: 24 capacity: 50 下面的程序将预留的容量用完: // add elements to use up the excess capacity while (ivec.size() != ivec.capacity()) ivec.push_back(0); // size should be 50; capacity should be unchanged cout << "ivec: size: " << ivec.size() 439 << " capacity: " << ivec.capacity() << endl; 由于在该程序中,只使用了预留的容量,因此 vector 不必做任何的内存分 配工作。事实上,只要有剩余的容量,vector 就不必为其元素重新分配存储空 间。 其输出结果表明:此时我们已经耗尽了预留的容量,该容器的 size 和 capacity 值相等: ivec: size: 50 capacity: 50 此时,如果要添加新的元素,vector 必须为自己重新分配存储空间: ivec.push_back(42); // add one more element // size should be 51; capacity will be >= 51 and is implementation defined cout << "ivec: size: " << ivec.size() << " capacity: " << ivec.capacity() << endl; 这段程序的输出: ivec: size: 51 capacity: 100 表明:每当 vector 容器不得不分配新的存储空间时,以加倍当前容量的分配策 略实现重新分配。 vector 的每种实现都可自由地选择自己的内存分配策略。然 而,它们都必须提供 vector 和 capacity 函数,而且必须是 到必要时才分配新的内存空间。分配多少内存取决于其实现方 式。不同的库采用不同的策略实现。 此外,每种实现都要求遵循以下原则:确保 push_back 操作高效地在 vector 中添加元素。从技术上来说,在原来为空的 vector 容器上 n 次调用 push_back 函数,从而创建拥有 n 个元素的 vector 容器,其执行时间永远不 能超过 n 的常量倍。 440 Exercises Section 9.4.1 Exercise 解释 vector 的容量和长度之间的区别。为什么在连续存 9.29: 储元素的容器中需要支持“容量”的概念?而非连续的 容器,如 list,则不需要。 Exercise 编写程序研究标准库为 vector 对象提供的内存分配策 9.30: 略。 Exercise 容器的容量可以比其长度小吗?在初始时或插入元素后, 9.31: 容量是否恰好等于所需要的长度?为什么? Exercise 9.32: 解释下面程序实现的功能: vector svec; svec.reserve(1024); string text_word; while (cin >> text_word) svec.push_back(text_word); svec.resize(svec.size()+svec.size()/2); 如果该程序读入了 256 个单词,在调整大小后,该容器 可能是多少?如果读入 512,或 1000,或 1048 个单词 呢? 9.5. 容器的选用 在前面的章节中可见,分配连续存储元素的内存空间会影响内存分配策略和 容器对象的开销。通过巧妙的实现技巧,标准库的实现者已经最小化了内存分配 的开销。元素是否连续存储还会显著地影响: • 在容器的中间位置添加或删除元素的代价。 • 执行容器元素的随机访问的代价。 程序使用这些操作的程序将决定应该选择哪种类型的容器。vector 和 deque 容器提供了对元素的快速随机访问,但付出的代价是,在容器的任意位置插入或 删除元素,比在容器尾部插入和删除的开销更大。list 类型在任何位置都能快 速插入和删除,但付出的代价是元素的随机访问开销较大。 441 插入操作如何影响容器的选择 list 容器表示不连续的内存区域,允许向前和向后逐个遍历元素。在任何 位置都可高效地 insert 或 erase 一个元素。插入或删除 list 容器中的一个 元素不需要移动任何其他元素。另一方面,list 容器不支持随机访问,访问某 个元素要求遍历涉及的其他元素。 对于 vector 容器,除了容器尾部外,其他任何位置上的插入(或删除)操 作都要求移动被插入(或删除)元素右边所有的元素。例如,假设有一个拥有 50 个元素的 vector 容器,我们希望删除其中的第 23 号元素,则 23 号元素后面 的所有元素都必须向前移动一个位置。否则, vector 容器上将会留下一个空位 (hole),而 vector 容器的元素就不再是连续存放的了。 deque 容器拥有更加复杂的数据结构。从 deque 队列的两端插入和删除元素 都非常快。在容器中间插入或删除付出的代价将更高。 deque 容器同时提供了 list 和 vector 的一些性质: • 与 vector 容器一样,在 deque 容器的中间 insert 或 erase 元素效率 比较低。 • 不同于 vector 容器,deque 容器提供高效地在其首部实现 insert 和 erase 的操作,就像在容器尾部的一样。 • 与 vector 容器一样而不同于 list 容器的是, deque 容器支持对所有 元素的随机访问。 • 在 deque 容器首部或尾部插入元素不会使任何迭代器失效,而首部或尾 部删除元素则只会使指向被删除元素的迭代器失效。在 deque 容器的任 何其他位置的插入和删除操作将使指向该容器元素的所有迭代器都失效。 元素的访问如何影响容器的选择 vector 和 deque 容器都支持对其元素实现高效的随机访问。也就是说,我 们可以高效地先访问 5 号元素,然后访问 15 号元素,接着访问 7 号元素,等 等。 由于 vector 容器的每次访问都是距离其起点的固定偏移,因此其随机访 问非常有效率。在 list 容器中,上述跳跃访问会变得慢很多。在 list 容器的 元素之间移动的唯一方法是顺序跟随指针。从 5 号元素移动到 15 号元素必须 遍历它们之间所有的元素。 通常来说,除非找到选择使用其他容器的更好理由,否则 vector 容器都是最佳选择。 442 选择容器的提示 下面列举了一些选择容器类型的法则: 1. 如果程序要求随机访问元素,则应使用 vector 或 deque 容器。 2. 如果程序必须在容器的中间位置插入或删除元素,则应采用 list 容器。 3. 如果程序不是在容器的中间位置,而是在容器首部或尾部插入或删除元 素,则应采用 deque 容器。 4. 如果只需在读取输入时在容器的中间位置插入元素,然后需要随机访问元 素,则可考虑在输入时将元素读入到一个 list 容器,接着对此容器重新 排序,使其适合顺序访问,然后将排序后的 list 容器复制到一个 vector 容器。 如果程序既需要随机访问又必须在容器的中间位置插入或删除元素,那应该 怎么办呢? 此时,选择何种容器取决于下面两种操作付出的相对代价:随机访问 list 容 器元素的代价,以及在 vector 或 deque 容器中插入/删除元素时复制元素的 代价。通常来说,应用中占优势的操作(程序中更多使用的是访问操作还是插入 /删除操作)将决定应该什么类型的容器。 决定使用哪种容器可能要求剖析各种容器类型完成应用所要求的各类操作的 性能。 如果无法确定某种应用应该采用哪种容器,则编写代码时尝试 只使用 vector 和 lists 容器都提供的操作:使用迭代器,而 不是下标,并且避免随机访问元素。这样编写,在必要时,可 很方便地将程序从使用 vector 容器修改为使用 list 的容 器。 443 Exercises Section 9.5 Exercise 9.33: 对于下列程序任务,采用哪种容器(vector、deque 还是 list)实现最合适?解释选择的理由。如果无法说明采用 某种容器比另一种容器更好的原因,请解释为什么无法说 明? a. 从一个文件中讲稿未知数目的单词,以生成英文句 子。 b. 读入固定数目的单词,在输入时将它们按字母顺序 插入到容器中。下一章将更适合处理此类问题的关 联容器。 c. 读入未知数目的单词。总是在容器尾部插入新单 词,从容器首部删除下一个值。 d. 从一个文件中讲稿未知数目的整数。对这些整数排 序,然后把它们输出到标准输出设备。 9.6. 再谈 string 类型 第 3.2 节介绍了 string 类型,表 9.12 扼要重述了在该节中介绍的 string 操作。 表 9.12 第 3.2 节介绍的 string 操作 string s; 定义一个新的空 string 对象,命名为 s string s(cp); 定义一个新的 string 对象,用 cp 所指向的(以空字符 null 结束的)C 风格字符串初始化该对象 string s(s2); 定义一个新的 string 对象,并将它初始化为 s2 的副本 is >> s; 从输入流 is 中读取一个以空白字符分隔的字符串,写入 s os << s; 将 s 写到输出流 os 中 getline(is, s) 从输入流 is 中读取一行字符,写入 s s1 + s2 把 s1 和 s2 串接起来,产生一个新的 string 对象 s1 += s2 将 s2 拼接在 s1 后面 Relational 相等运算(== 和 !=)以及关系运算(<、<=、> 和 >=)都 444 Operators 关系操作符 可用于 string 对象的比较,等效于(区分大小写的)字典 次序的比较 除了已经使用过的操作外,string 类型还支持大多数顺序容器操作。在某 些方面,可将 string 类型视为字符容器。除了一些特殊操作,string 类型提 供与 vector 容器相同的操作。string 类型与 vector 容器不同的是,它不支 持以栈方式操纵容器:在 string 类型中不能使用 front、back 和 pop_back 操 作。 string 支持的容器操作有: • 表 9.5 列出的 typedef,包括迭代器类型。 • 表 9.2 列出的容器构造函数,但是不包括只需要一个长度参数的构造函 数。 • 表 9.7 列出的 vector 容器所提供的添加元素的操作。注意:无论 vector 容器还是 string 类型都不支持 push_front 操作。 • 表 9.8 列出的长度操作。 • 表 9.9 列出的下标和 at 操作;但 string 类型不提供该表列出的 back 和 front 操作。 • 表 9.6 列出的 begin 和 end 操作。 • 表 9.10 列出的 erase 和 clear 操作;但是 string 类型不入提供 pop_back 或 pop_front 操作。 • 表 9.11 列出的赋值操作。 • 与 vector 容器的元素一样,string 的字符也是连续存储的。因此, string 类型支持第 9.4 节描述的 capacity 和 reserve 操作。 string 类型提供容器操作意味着可将操纵 vector 对象的程序改写为操纵 string 对象。例如,以下程序使用迭代器将一个 string 对象的字符以每次一 行的方式输出到标准输出设备: string s("Hiya!"); string::iterator iter = s.begin(); while (iter != s.end()) cout << *iter++ << endl; // postfix increment: print old value 不要奇怪,这段代码看上去几乎与第 5.5 节中输出 vector 容器元素的程 序一模一样。 除了共享容器的操作外,string 类型还支持其他本类型特有的操作。在本 节剩下的篇幅吕,我们将回顾这些 string 类型特有的操作,包括与其他容器相 445 关操作的补充版本,以及全新的函数。string 类型的补充函数将在第 9.6.2 节 介绍。 string 类型为某些容器操作提供补充版本,以支持 string 特有的、不为 其他容器共享的属性。例如,好几种操作允许指定指向字符数组的指针参数。无 论字符串是否以空字符结束,这些操作都支持标准库 string 对象与字符数组之 间的紧密交互作用。其他版本则使用程序员只能使用下标而不能使用迭代器。这 些版本只能通过位置操纵元素:指定起始位置,在某些情况下还需指定一个计数 器,由此指定要操纵的某个元素或一段元素。 Exercises Section 9.6 Exercise 使用迭代器将 string 对象中的字符都改为大写字母。 9.34: Exercise 使用迭代器寻找和删除 string 对象中所有的大写字 9.35: 符。 Exercise 编写程序用 vector 容器初始化 string 对象。 9.36: Exercise 假设希望一次读取一个字符并写入 string 对象,而且 9.37: 已知需要读入至少 100 个字符,考虑应该如何提高程序 的性能? string 库定义了大量使用重复模式的函数。由于该类型支 持的函数非常多,初次阅读本节是会觉得精神疲累。 读者可跳过第 9.6 节剩下的内容。一旦知道了有哪些操作可以使用,就可 以在编写需要使用这种操作的程序时,才回来阅读其细节。 9.6.1. 构造 string 对象的其他方法 string 类支持表 9.2 所列出的几乎所有构造函数,只有一个例外:string 不支持带有单个容器长度作为参数的构造函数。创建 string 对象时:不提供任 何参数,则得到空的 string 对象;也可将新对象初始化为另一个 string 对象 的副本;或用一对迭代器初始化:或者使用一个计数器和一个字符初始化: 446 string s1; // s1 is the empty string string s2(5, 'a'); // s2 == "aaaaa" string s3(s2); // s3 is a copy of s2 string s4(s3.begin(), s3.begin() + s3.size() / 2); // s4 == "aa" 除了上述构造函数之外,string 类型还提供了三种其他的方式创建类对象 (表 9.13)。在前面的章节中,已经使用过只有一个指针参数的构造函数,该 指针指向以空字符结束的字符数组中的第一个元素。另一种构造函数需要一个指 向字符数组元素的指针和一个标记要复制多少个字符的计数器作参数。由于该构 造函数带有一个计数器,因此数组不必以空字符结束: char *cp = "Hiya"; // null-terminated array char c_array[] = "World!!!!"; // null-terminated char no_null[] = {'H', 'i'}; // not null-terminated string s1(cp); // s1 == "Hiya" string s2(c_array, 5); // s2 == "World" string s3(c_array + 5, 4); // s3 == "!!!!" string s4(no_null); // runtime error: no_null not null-terminated string s5(no_null, 2); // ok: s5 == "Hi" 使用只有一个指针参数的构造函数定义 s1,该指针指向以空字符结束的数 组中的第一字符。这个数组的所有字符,但不包括结束符 null,都被复制到新 创建的 string 对象中。 而 s2 的初始化式则通过第二种构造函数实现,它的参数包括一个指针和一 个计数器。在这个例子中,从参数指针指向的那个字符开始,连续复制第二个参 数指定数目的字符。因此,s2 是 c_array 数组前 5 个字符的副本。记住,将 数组作为参数传递时,数组将自动转换为指向其第一个元素的指针。当然,并没 有限制非得传递指向数组起点的指针不可。通过给 s3 的构造函数传递指向 c_array 数组中第一个感叹号字符的指针,s3 被初始化为存储 4 个感叹号字符 的 string 对象。 s4 和 s5 的初始化式并不是 C 风格字符串。其中,s4 的定义是错误的。 调用这种形式的初始化,其参数必须是以空字符结束的数组。将不包含 null 的 数组传递给构造函数将导致编译器无法检测的严重错误(第 4.3 节),此类错 误在运行时将支发生什么状况并未定义。 s5 的初始化则是正确的:初始化式包含了一个计数器,以说明要复制多少 个字符用于初始化。该计数器的值必须小于数组的长度,此时,无论数组是否以 空字符结束,都没什么关系。 447 9.13. 构造 string 对象的其他方法 string 创建一个 string 对象,它被初始化为 cp 所指向数组的前 n 个元 s(cp, n) 素的副本 string s(s2, pos2) 创建一个 string 对象,它被初始化为一个已存在的 string 对象 s2 中从下标 pos2 开始的字符的副本 string s(s2, pos2, len2) 创建一个 string 对象,它被初始化为 s2 中从下标 pos2 开始的 len2 个字符的副本。如果 pos2 > s2.size(),则该操作未定义, 无论 len2 的值是多少,最多只能复制 s2.size() - pos2 个字符 注意:n、len2 和 pos2 都是 unsigned 值 用子串做初始化式 另一对构造函数使用程序员可以在创建 string 对象时将其初始化为另一 个 string 对象的子串。 string s6(s1, 2); // s6 == "ya" string s7(s1, 0, 2); // s7 == "Hi" string s8(s1, 0, 8); // s8 == "Hiya" 第一个语句的两个参数指定了要复制的 string 对象及其复制的起点。在两 个参数的构造函数版本中,复制 string 对象实参中从指定位置到其末尾的所有 字符,用于初始化新创建的 string 对象。还可以为此类构造函数提供第三个参 数,用于指定复制字符的个数。在本例中,我们从指定位置开始复制指定数目(最 多为 string 对象的长度)的字符数。例如,创建 s7 时,从 s1 中下标为 0 的 位置开始复制两个字符;而创建 s8 时,只复制了 4 个字符,而并不是要求的 8 个字符。无论要求复制多少个字符,标准库最多只能复制数目与 string 对象长 度相等的字符。 9.6.2. 修改 string 对象的其他方法 string 类型支持的许多容器操作在操作时都以迭代器为基础。例如,erase 操作需要一个迭代器或一段迭代器范围作其参数,用于指定从容器中删除的元 素。类似地,所有版本的 insert 函数的第一参数都是一个指向插入位置之后的 迭代器,而新插入的元素值则由其他参数指定。尽管 string 类型支持这些基于 迭代器的操作,它同样也提供以下标为基础的操作。下标用于指定 erase 操作 的起始元素,或在其前面 insert 适当值的元素。表 9.14 列出了 string 类型 和容器类型共有的操作;而表 9.15 则列出了 string 类型特有的操作。 448 表 9.14 与容器共有的 string 操作 s.insert(p, t) 在迭代器 p 指向的元素之前插入一个值为 t 的新元素。返回 指向新插入元素的迭代器 s.insert(p, n, 在迭代器 p 指向的元素之前插入 n 个值为 t 的新元素。返 t) 回 void s.insert(p, b, 在迭代器 p 指向的元素之前插入迭代器 b 和 e 标记范围内 e) 所有的元素。返回 void s.assign(b, e) 在迭代器 b 和 e 标记范围内的元素替换 s。对于 string 类 型,该操作返回 s;对于容器类型,则返回 void s.assign(n, t) 用值为 t 的 n 个副本替换 s。对于 string 类型,该操作返 回 s;对于容器类型,则返回 void s.erase(p) 删除迭代器 p 指向的元素。返回一个迭代器,指向被删除元 素后面的元素 s.erase(b, e) 删除迭代器 b 和 e 标记范围内所有的元素。返回一个迭代 器,指向被删除元素段后面的第一个元素 表 9.15 string 类型特有的版本 s.insert(pos, n, c) 在下标为 pos 的元素之前插入 n 个字符 c s.insert(pos, s2) 在下标为 pos 的元素之前插入 string 对象 s2 的副 本 s.insert(pos, s2, pos2, len) 在下标为 pos 的元素之前插入 s2 中从下标 pos2 开 始的 len 个字符 s.insert(pos, cp, len) 在下标为 pos 打元素之前插入 cp 所指向数组的前 len 个字符 s.insert(pos, cp) 在下标为 pos 的元素之前插入 cp 所指向的以空字符 结束的字符串副本 s.assign(s2) 用 s2 的副本替换 s s.assign(s2, pos2, 用 s2 中从下标 pos2 开始的 len 个字符副本替换 s len) s.assign(cp, len) 用 cp 所指向数组的前 len 个字符副本替换 s s.assign(cp) 用 cp 所指向的以空字符结束的字符串副本替换 s 449 s.erase(pos, len) 删除从下标 pos 开始的 len 个字符 除非特殊声明,上述所有操作都返回 s 的引用 基于位置的实参 string 类型为这些操作提供本类型特有的版本,它们接受的实参类似于在 前一节介绍的补充构造函数。程序员可通过这些操作基于位置处理 string 对 象,并/或使用指向字符数组的指针而不是 string 对象作实参。 例如,所有容器都允许程序员指定一对迭代器,用于标记删除(erase)的 元素范围。对于 string 类型,还允许通过为 erase 函数传递一个起点位置和 删除元素的数目,来指定删除的范围。假设 s 至少有 5 个元素,下面的语句用 于删除 s 的最后 5 个字符: s.erase(s.size() - 5, 5); // erase last five characters from s 类似地,对于容器类型,可在迭代器指向的元素之前插入(insert)指定数 目的新值。而对于 string 类型,系统还允许使用下标而不是迭代器指定插入位 置: 指定新的内容 在 string 对象中 insert 或 assign 的字符可来自于字符数组或另一个 string 对象。例如,以空字符结束的字符数组可以用作 insert 或 assign 到 string 对象的内容: char *cp = "Stately plump Buck"; string s; s.assign(cp, 7); // s == "Stately" s.insert(s.size(), cp + 7); // s == "Stately plump Buck" 类似地,可如下所示将一个 string 对象的副本插入到另一个 string 对象中: s = "some string"; s2 = "some other string"; // 3 equivalent ways to insert all the characters from s2 at beginning of s // insert iterator range before s.begin() s.insert(s.begin(), s2.begin(), s2.end()); // insert copy of s2 before position 0 in s s.insert(0, s2); 450 // insert s2.size() characters from s2 starting at s2[0] before s[0] s.insert(0, s2, 0, s2.size()); 9.6.3. 只适用于 string 类型的操作 string 类型提供了容器类型不支持其他几种操作,如表 9.16 所示: • substr 函数,返回当前 string 对象的子串。 • append 和 replace 函数,用于修改 string 对象。 • 一系列 find 函数,用于查找 string 对象。 substr 操作 使用 substr 操作可在指定 string 对象中检索需要的子串。我们可以给 substr 函数传递查找的起点和一个计数器。该函数将生成一个新的 string 对 象,包含原目标 string 对象从指定位置开始的若干个字符(字符数目由计数器 决定,但最多只能到原 string 对象的最后一个字符): string s("hello world"); // return substring of 5 characters starting at position 6 string s2 = s.substr(6, 5); // s2 = world 可选择另一种方法实现相同的功能: // return substring from position 6 to the end of s string s3 = s.substr(6); // s3 = world 表 9.16 子串操作 s.substr(pos, n) s.substr(pos) s.substr() 返回一个 string 类型的字符串,它包含 s 中从下标 pos 开始的 n 个字符 返回一个 string 类型的字符串,它包含从下标 pos 开始到 s 末尾的所有字符 返回 s 的副本 append 和 replace 函数 string 类型提供了 6 个 append 重载函数版本和 10 个 replace 版本 (见表 9.17)。append 和 replace 函数使用了相同的参数集合实现重载。这 些参数如表 9.18 所示,用于指定在 string 对象中添加的字符。对于 append 451 操作,字符将添加在 string 对象的末尾。而 replace 函数则将这些字符插入 到指定位置,从而替换 string 对象中一段已存在的字符。 string s("C++ Primer"); // initialize s to "C++ Primer" s.append(" 3rd Ed."); // s == "C++ Primer 3rd Ed." // equivalent to s.append(" 3rd Ed.") s.insert(s.size(), " 3rd Ed."); string 类型为 replace 操作提供了 10 个不同版本,其差别在于以不同的 方式指定要删除的字符和要插入的新字符。前两个参数应指定删除的元素范围, 可用迭代器对实现,也可用一个下标和一个计数器实现。其他的参数则用于指定 插入的新字符。 可将 replace 视为删除一些字符然后在同一位置插入其他内容的捷径: 表 9.17 修改 string 对象的操作(args 在表 9.18 中定义) s.append( args) s.replace(pos, len, args) 将 args 串接在 s 后面。返回 s 引用 删除 s 中从下标 pos 开始的 len 个字符,用 args 指定的字符替换之。返回 s 的引用 在这个版本中,args 不能为 b2,e2 s.replace(b, e, args) 删除迭代器 b 和 e 标记范围内所有的字符,用 args 替换之。返回 s 的引用 在这个版本中,args 不能为 s2,pos2,len2 // starting at position 11, erase 3 characters and then insert "4th" s.replace(11, 3, "4th"); // s == "C++ Primer 4th Ed." // equivalent way to replace "3rd" by "4th" s.erase(11, 3); // s == "C++ Primer Ed." s.insert(11, "4th"); // s == "C++ Primer 4th Ed." append 操作提供了在字符串尾部插入的捷径: replace 操作用于删除一段指定范围的字符,然后在删除位置插入一组新字符, 等效于调用 erase 和 insert 函数。 452 s.replace(11, 3, "Fourth"); // s == "C++ Primer Fourth Ed." 在这个例子中,删除了 3 个字符,但在同一个位置却插入了 6 个字符。 表 9.18 append 和 replace 操作的参数:args s2 string 类型的字符串 s2 s2, pos2, len2 字符串 s2 中从下标 pos2 开始的 len2 个字符 cp 指针 cp 指向的以空字符结束的数组 cp, len2 cp 指向的以空字符结束的数组中前 len2 个字符 n, c 字符 c 的 n 个副本 b2, e2 迭代器 b2 和 e2 标记的范围内所有字符 9.6.4. string 类型的查找操作 string 类提供了 6 种查找函数(表 9.19),每种函数以不同形式的 find 命名。这些操作全都返回 string::size_type 类型的值,以下标形式标记查找 匹配所发生的位置;或者返回一个名为 string::npos 的特殊值,说明查找没有 匹配。string 类将 npos 定义为保证大于任何有效下标的值。 每种查找操作都有 4 个重载版本,每个版本使用不同的参数集合。表 9.20 列出了查找操作使用的不同参数形式。基本上,这些操作的不同之处在于查找的 到底是单个字符、另一个 string 字符串、C 风格的以空字符结束的字符串,还 是用字符数组给出的特定数目的字符集合。 表 9.19. string 查找操作符 s.find( args) 在 s 中查找 args 的第一次出现 s.rfind( args) 在 s 中查找 args 的最后一次出现 s.find_first_of( args) 在 s 中查找 args 的任意字符的第一次出现 s.find_last_of( args) 在 s 中查找 args 的任意字符的最后一次出现 s.find_first_not_of( args) 在 s 中查找第一个不属于 args 的字符 s.find_last_not_of( args) 在 s 中查找最后一个不属于 args 的字符 string 类型提供的 find 操作的参数 453 c, pos 在 s 中,从下标 pos 标记的位置开始,查找字符 c。pos 的默认值 为0 s2, pos 在 s 中,从下标 pos 标记的位置开始,查找 string 对象 s2。pos 的 默认值为 0 cp, pos 在 s 中,从下标 pos 标记的位置形参,查找指针 cp 所指向的 C 风 格的以空字符结束的字符串。pos 的默认值为 0 cp, 在 s 中,从下标 pos 标记的位置开始,查找指针 cp 所指向数组的 pos, n 前 n 个字符。pos 和 n 都没有默认值 精确匹配的查找 最简单的查找操作是 find 函数,用于寻找实参指定的内容。如果找到的话, 则返回第一次匹配的下标值;如果找不到,则返回 npos: string name("AnnaBelle"); string::size_type pos1 = name.find("Anna"); // pos1 == 0 返回 0,这是子串“Anna”位于字符串“AnnaBelle”中的下标。 默认情况下,find 操作(以及其他处理字符的 string 操作) 使用内置操作符比较 string 字符串中的字符。因此,这些操 作(以及其他 string 操作)都区分字母的大小写。 以下程序寻找 string 对象中的某个值,字母的大小写影响了程序结果: string lowercase("annabelle"); pos1 = lowercase.find("Anna"); // pos1 == npos 这段代码使 pos1 的值为 npos ——字符串 Anna 与 anna 不匹配 find 操作的返回类型是 string::size_type,请使用 该类型的对象存储 find 的返回值。 454 查找任意字符 如果在查找字符串时希望匹配任意指定的字符,则实现起来稍微复杂一点。 例如,下面的程序要在 name 中寻找并定位第一个数字: string numerics("0123456789"); string name("r2d2"); string::size_type pos = name.find_first_of(numerics); cout << "found number at index: " << pos << " element is " << name[pos] << endl; 在这个例子中,pos 的值被设置为 1(记住,string 对象的元素下标从 0 开 始计数)。 指定查找的起点 程序员可以给 find 操作传递一个可选的起点位置实参,用于指定开始查找 的下标位置,该位置实参的默认值为 0。通常的编程模式是使用这个可选的实参 循环查找 string 对象中所有的匹配。下面的程序重写了查找“r2d2”的程序, 以便找出 name 字符串中出现的所有数字: string::size_type pos = 0; // each trip reset pos to the next instance in name while ((pos = name.find_first_of(numerics, pos)) != string::npos) { cout << "found number at index: " << pos << " element is " << name[pos] << endl; ++pos; // move to the next character } 在这个例子中,首先将 pos 初始化为 0,使第一次循环从 0 号元素开始查 找 name。while 的循环条件实现两个功能:从当前 pos 位置开始查找,并将找 到的第一个数字出现的下标值赋给 pos。当 find_first_of 函数返回有效的下 标值时,输出此次查找的结果,并且让 pos 加 1。 如果漏掉了循环体末尾让 pos 加 1 的语句,那么循环永远都不会结束。考 虑没有该操作时,会发生什么情况?第二次循环时,从 pos 标记的位置开始查 找,而此时 pos 标记的就是一个数字,于是 find_first_of 函数将(不断重复 地)返回同一个 pos 值。 寻找不匹配点 455 除了寻找匹配的位置外,还可以调用 find_first_not_of 函数查找第一个 与实参不匹配的位置。例如,如果要在 string 对象中寻找第一个非数字字符, 可以如下编写程序: string numbers("0123456789"); string dept("03714p3"); // returns 5, which is the index to the character 'p' string::size_type pos = dept.find_first_not_of(numbers); 反向查找 迄今为止,我们使用的所有 find 操作都是从左向右查找的。除此之外,标 准库还提供了一组类似的从右向左查找 string 对象的操作。rfind 成员函数用 于寻找最后一个——也就是是最右边的——指定子串出现的位置: string river("Mississippi"); string::size_type first_pos = river.find("is"); // returns 1 string::size_type last_pos = river.rfind("is"); // returns 4 find 函数返回下标 1,标记 river 字符串中第一个“is”的出现位置;而 rfind 函数返回最后一个匹配的位置,而并不是第一个。 • find_last_of 函数查找与目标字符串的任意字符匹配的最后一个字符。 • find_last_not_of 函数查找最后一个不能跟目标字符串的任何字符匹配 的字符。 这两个操作都提供第二个参数,这个参数是可选的,用于指定在 string 对 象中开始查找的位置。 9.6.5. 字符串比较 正如在第 3.2.3 节所看到的,string 类型定义了所有关系操作符,使程序 员可以比较两个 string 对象是否相等(==)、不等(!=),以及实现小于或大 于(<、<=、>、>=)运算。string 对象采用字典顺序比较,也就是说,string 对 象的比较与大小写敏感的字典顺序比较相同: string cobol_program_crash("abend"); string cplus_program_crash("abort"); Exercises Section 9.6.4 456 Exercise 已知有如下 string 对象: 9.38: "ab2c3d7R4E6" 编写程序寻找该字符串中所有的数字字符,然后再寻找 所有的字母字符。以两种版本编写该程序:第一个版本 使用 find_first_of 函数,而第二个版本则使用 find_first_not_of 函数。 Exercise 9.39: 已知有如下 string 对象: string line1 = "We were her pride of 10 she named us:"; string line2 = "Benjamin, Phoenix, the Prodigal" string line3 = "and perspicacious pacific Suzanne"; string sentence = line1 + ' ' + line2 + ' ' + line3; 编写程序计算 sentence 中有多少个单词,并指出其中 最长和最短的单词。如果有多个最长或最短的单词,则 将它们全部输出。 操作符逐个字符地进行比较,直到比较到某个位置上,两个 string 对象对 应的字符不相同为止。string 对象的整个比较依赖于不相同字符之间的比较。 在本例中,第一个不相等的字符是‘e’和‘o’。由于在英文字母表中,‘e’ 出现得比‘o’早(即‘e’小于‘o’),于是“abend”小于“abort”。如果 要比较的两个 string 对象长度不相同,而且一个 string 对象是另一个 string 对象的子串,则较短的 string 对象小于较长的 string 对象。 compare 函数 除了关系操作符,string 类型还提供了一组 compare 操作(表 9.21), 用于实现字典顺序的比较。这些操作的结果类似于 C 语言中的库函数 strcmp (第 4.3 节)。假设有语句: s1.compare (args); 457 compare 函数返回下面列出的三种可能值之一: 1. 正数,此时 s1 大于 args 所代表的 string 对象。 2. 负数,此时 s1 小于 args 所代表的 string 对象。 3. 0,此时 s1 恰好等于 args 所代表的 string 对象。 例如: // returns a negative value cobol_program_crash.compare(cplus_program_crash); // returns a positive value cplus_program_crash.compare(cobol_program_crash); 表 9.21 string 类型 compare 操作 s.compare(s2) 比较 s 和 s2 s.compare(pos1, n1, s2) 让 s 中从 pos 下标位置开始的 n1 个字符与 s2 做比较 s.compare(pos1, n1, s2, pos2, n2) 让 s 中从 pos1 下标位置开始的 n1 个字符与 s2 中从 pos2 下标位置开始的 n2 个字符做比较 s.compare(cp) 比较 s 和 cp 所指向的以空字符结束的字符串 s.compare(pos1, n1, cp) 让 s 中从 pos1 下标位置开始的 n1 个字符与 cp 所指向的 字符串做比较 s.compare(pos1, n1, cp, n2) 让 s 中从 pos1 下标位置开始的 n1 个字符与 cp 所指向的 字符串的前 n2 个字符做比较 compare 操作提供了 6 种重载函数版本,以方便程序员实现一个或两个 string 对象的子串的比较,以及 string 对象与字符数组或其中某一部分的比 较: char second_ed[] = "C++ Primer, 2nd Edition"; string third_ed("C++ Primer, 3rd Edition"); string fourth_ed("C++ Primer, 4th Edition"); 458 // compares C++ library string to C-style string fourth_ed.compare(second_ed); // ok, second_ed is null-terminated // compare substrings of fourth_ed and third_ed fourth_ed.compare(fourth_ed.find("4th"), 3, third_ed, third_ed.find("3rd"), 3); 我们对第二个 compare 函数的调用更感兴趣。这个调用使用了具有 5 个参 数的 compare 函数版本。先调用 find 函数找到子串“4th”的起点。让从此起 点开始的 3 个字符与 third_ed 的子串做比较。而 third_ed 的子串起始于 find 函数找到的“3rd”的起点,同样取其随后的 3 个字符参加比较。可见这 个语句本质上比较的是“4th”和“3rd”。 9.7. 容器适配器 除了顺序容器,标准库还提供了三种顺序容器适配器:queue、 priority_queue 和 stack。适配器(adaptor)是标准库中通用的概念,包括容 器适配器、迭代器适配器和函数适配器。本质上,适配器是使一事物的行为类似 于另一事物的行为的一种机制。容器适配器让一种已存在的容器类型采用另一种 不同的抽象类型的工作方式实现。例如,stack(栈)适配器可使任何一种顺序 容器以栈的方式工作。表 9.22 列出了所有容器适配器通用的操作和类型。 表 9.22. 适配器通用的操作和类型 size_type 一种类型,足以存储此适配器类型最大对象的长度 value_type 元素类型 container_type 基础容器的类型,适配器在此容器类型上实现 A a; 创建一个新空适配器,命名为 a A a(c); 创建一个名为 a 的新适配器,初始化为容器 c 的副本 关系操作符 所有适配器都支持全部关系操作符:==、 !=、 <、 <=、 >、 >= 使用适配器时,必须包含相关的头文件: #include #include // stack adaptor // both queue and priority_queue adaptors 适配器的初始化 459 所有适配器都定义了两个构造函数:默认构造函数用于创建空对象,而带一 个容器参数的构造函数将参数容器的副本作为其基础值。例如,假设 deq 是 deque 类型的容器,则可用 deq 初始化一个新的栈,如下所示: stack stk(deq); // copies elements from deq into stk 覆盖基础容器类型 默认的 stack 和 queue 都基于 deque 容器实现,而 priority_queue 则 在 vector 容器上实现。在创建适配器时,通过将一个顺序容器指定为适配器的 第二个类型实参,可覆盖其关联的基础容器类型: // empty stack implemented on top of vector stack< string, vector > str_stk; // str_stk2 is implemented on top of vector and holds a copy of svec stack > str_stk2(svec); 对于给定的适配器,其关联的容器必须满足一定的约束条件。stack 适配器 所关联的基础容器可以是任意一种顺序容器类型。因此,stack 栈可以建立在 vector、list 或者 deque 容器之上。而 queue 适配器要求其关联的基础容器 必须提供 push_front 运算,因此只能建立在 list 容器上,而不能建立在 vector 容器上。priority_queue 适配器要求提供随机访问功能,因此可建立在 vector 或 deque 容器上,但不能建立在 list 容器上。 适配器的关系运算 两个相同类型的适配器可以做相等、不等、小于、大于、小于等于以及等于 关系比较,只要基础元素类型支持等于和小于操作符既可。这些关系运算由元素 依次比较来实现。第一对不相等的元素将决定两者之间的小于或大于关系。 9.7.1. 栈适配器 表 9.23 列出了栈提供的所有操作。 表 9.23. 栈容器适配器支持的操作 s.empty() s.size() s.pop() s.top() 如果栈为空,则返回 true,否则返回 stack 返回栈中元素的个数 删除栈顶元素的值,但不返回其值 返回栈顶元素的值,但不删除该元素 460 s.push(item) 在栈顶压入新元素 // number of elements we'll put in our stack const stack::size_type stk_size = 10; stack intStack; // empty stack // fill up the stack int ix = 0; while (intStack.size() != stk_size) // use postfix increment; want to push old value onto intStack intStack.push(ix++); // intStack holds 0...9 inclusive int error_cnt = 0; // look at each value and pop it off the stack while (intStack.empty() == false) { int value = intStack.top(); // read the top element of the stack if (value != --ix) { cerr << "oops! expected " << ix << " received " << value << endl; ++error_cnt; } intStack.pop(); // pop the top element, and repeat } cout << "Our program ran with " << error_cnt << " errors!" << endl; 声明语句: stack intStack; // empty stack 将 intStack 定义为一个存储整型元素的空栈。第一个 while 循环在该栈 中添加了 stk_size 个元素,元素初值是从 0 开始依次递增 1 的整数。第二个 while 循环迭代遍历整个栈,检查其栈顶(top)的元素值,然后栈顶元素出栈, 直到栈变空为止。 所有容器适配器都根据其基础容器类型所支持的操作来定义自己的操作。默 认情况下,栈适配器建立在 deque 容器上,因此采用 deque 提供的操作来实现 栈功能。例如,执行下面的语句: // use postfix increment; want to push old value onto intStack intStack.push(ix++); // intStack holds 0...9 inclusive 461 这个操作通过调用 push_back 操作实现,而该 intStack 所基于的 deque 对象提供。尽管栈是以 deque 容器为基础实现的,但是程序员不能直接访问 deque 所提供的操作。例如,不能在栈上调用 push_back 函数,而是必须使用 栈所提供的名为 push 的操作。 9.7.2. 队列和优先级队列 标准库队列使用了先进先出(FIFO)的存储和检索策略。进入队列的对象被 放置在尾部,下一个被取出的元素则取自队列的首部。标准库提供了两种风格的 队列:FIFO 队列(FIFO queue,简称 queue),以及优先级队列(priority queue)。 priority_queue 允许用户为队列中存储的元素设置优先级。这种队列不是 直接将新元素放置在队列尾部,而是放在比它优先级低的元素前面。标准库默认 使用元素类型的 < 操作符来确定它们之间的优先级关系。 优先级队列的一个实例是机场行李检查队列。30 分钟后即将离港的航班的 乘客通常会被移到队列前面,以便他们能在飞机起飞前完成检查过程。使用优先 级队列的程序示例是操作系统的调试表,它决定在大量等待进程中下一个要执行 的进程。 要使用这两种队列,必须包含 queue 头文件。表 9.24 列出了队列和优先 级队列所提供的所有操作。 表 9.24. 队列和优先级队列支持的操作 q.empty() q.size() q.pop() q.front() 如果队列为空,则返回 true,否则返回 false 返回队列中元素的个数 删除队首元素,但不返回其值 返回队首元素的值,但不删除该元素 q.back() 该操作只适用于队列 返回队尾元素的值,但不删除该元素 q.top() 该操作只适用于队列 返回具有最高优先级的元素值,但不删除该元素 该操作只适用于优先级队列 q.push(item) 对于 queue,在队尾压入一个新元素,对于 priority_quue,在 基于优先级的适当位置插入新元素 462 Exercises Section 9.7.2 Exercise 编写程序读入一系列单词,并将它们存储在 stack 对象 9.42: 中。 Exercise 9.43: 使用 stack 对象处理带圆括号的表达式。遇到左圆括号 时,将其标记下来。然后在遇到右加括号时,弹出 stack 对象中这两边括号之间的相关元素(包括左圆括号)。 接着在 stack 对象中压入一个值,用以表明这个用一对 圆括号括起来的表达式已经被替换。 小结 C++ 标准库定义了一系列顺序容器类型。容器是用于存储某种给定类型对象 的模板类型。在顺序容器中,所有元素根据其位置排列和访问。顺序容器共享一 组通用的已标准化的接口:如果两种顺序容器都提供某一操作,那么该操作具有 相同的接口和含义。所有容器都提供(有效的)动态内存管理。程序员在容器中 添加元素时,不必操心元素存放在哪里。容器自己实现其存储管理。 最经常使用的容器类型是 vector,它支持对元素的快速随机访问。可高效 地在 vector 容器尾部添加和删除元素,而在其他任何位置上的插入或删除运算 则要付出比较昂贵的代价。deque 类与 vector 相似,但它还支持在 deque 首 部的快速插入和删除运算。list 类只支持元素的顺序访问,但在 list 内部任 何位置插入和删除元素都非常快速。 容器定义的操作非常少,只定义了构造函数、添加或删除元素的操作、设置 容器长度的操作以及返回指向特殊元素的迭代器的操作。其他一些有用的操作, 如排序、查找,则不是由容器类型定义,而是由第十一章介绍的标准算法定义。 在容器中添加或删除元素可能会使已存在的迭代器失效。当混合使用迭代器 操作和容器操作时,必须时刻留意给定的容器操作是否会使迭代器失效。许多使 一个迭代器失效的操作,例如 insert 或 erase,将返回一个新的迭代器,让程 序员保留容器中的一个位置。使用改变容器长度的容器操作的循环必须非常小心 其迭代器的使用。 术语 adaptor(适配器) 463 一种标准库类型、函数或迭代器,使某种标准库类型、函数或迭代器的行 为类似于另外一种标准库类型、函数或迭代器。系统提供了三种顺序容器 适配器:stack(栈)、queue(队列)以及 priority_queue(优先级队 列)。所有的适配器都会在其基础顺序容器上定义一个新接口。 begin(begin 操作) 一种容器操作。如果容器中有元素,该操作返回指向容器中第一个元素的 迭代器;如果容器为空,则返回超出末端迭代器。 container(容器) 一种存储给定类型对象集合的类型。所有标准库容器类型都是模板类型。 定义容器时,必须指定在该容器中存储的元素是什么类型。标准库容器具 有可变的长度。 deque(双端队列) 一种顺序容器。deque 中存储的元素通过其下标位置访问。该容器类型在 很多方面与 vector 一样,唯一的不同是 deque 类型支持在容器首部快 速地插入新元素,就像在尾部插入一样,而且无论在容器的哪一端插入或 删除都不会引起元素的重新定位。 end(end 操作) 一种容器操作,返回指向容器的超出末端的下一位置的迭代器。 invalidated iterator(无效迭代器) 指向不再存在的元素的迭代器。无效迭代器的使用未定义,可能会导致严 重的运行时错误。 iterator(迭代器) 一种类型,其操作支持遍历和检查容器元素的操作。所有标准库容器都定 义了 表 9.5 列出的 4 种迭代器,与之共同工作。标准库迭代器都支持 解引用(*)操作符和箭头(->)操作符,用于检查迭代器指向的元素值。 它们还支持前置和后置的自增(++)、自减操作符(--),以及相等(==) 和不等(!=)操作符。 iterator range(迭代器范围) 由一对迭代器标记的一段元素范围。第一个迭代器指向序列中的第一个元 素,而第二个迭代器则指向该范围中的最后一个元素的下一位置。如果这 段范围为空,则这两个迭代器相等(反之亦然——如果这两个迭代器相等, 则它们标记一个空范围)。如果这段范围非空,则对第一个空范围)。如 464 果这段范围非空,则对第一个迭代器重复做自增运算,必然能达第二个迭 代器。通过这个对迭代器进行自增的过程,即可处理该序列中所有的元素。 left-inclusive interval(左闭合区间) 一段包含第一个元素但不包含最后一个元素的范围。一般表示为 [i, j), 意味着该序列从 i 开始(包括 i)一直到 j,但不包含 j。 list(列表) 一种顺序容器。list 中的元素只能顺序访问——从给定元素开始,要获 取另一个元素,则必须通过自增或自减迭代器的操作遍历这两个元素之间 的所有元素。list 容器支持在容器的任何位置实现快速插入(或删除) 运算。新元素的插入不会影响 list 中的其他元素。插入元素时,迭代器 保持有效;删除元素时,只有指向该元素的迭代器失效。 priority_queue(优先级队列) 一种顺序容器适配器。在这种队列中,新元素不是在队列尾部插入,而是 根据指定的优先级级别插入。默认情况下,元素的优先级由元素类型的小 于操作符决定。 queue(队列) 一种顺序容器适配器。在这种队列中,保证只在队尾插入新元素,而且只 在队首删除元素。 sequential container(顺序容器) 以有序集合的方式存储单一类型对象的类型。顺序容器中的元素可通过下 标访问。 stack(栈) 一种顺序容器适配器,这种类型只能在一端插入和删除元素。 vector(向量) 一种顺序容器。vector 中的元素通过其位置下标访问。可通过调用 push_back 或 insert 函数在 vector 中添加元素。在 vector 中添加元 素可能会导致重新为容器分配内存空间,也可能会使所有的迭代器失效。 在 vector 容器中间添加(或删除)元素将使所有指向插入(或删除)点 后面的元素的迭代器失效。 465 第十章 关联容器 本章将继续介绍标准库容器类型的另一项内容——关联容器。关联容器和顺 序容器的本质差别在于:关联容器通过键(key)存储和读取元素,而顺序容器 则通过元素在容器中的位置顺序存储和访问元素。 虽然关联容器的大部分行为与顺序容器相同,但其独特之处在于支持键的使 用。本章涵盖了关联容器的相关内容,并完善和扩展了一个使用顺序容器和关联 容器的例子。 关联容器(Associative containers)支持通过键来高效地查找和读取元素。 两个基本的关联容器类型是 map set。map 的元素以键-值(key-value)对的 形式组织:键用作元素在 map 中的索引,而值则表示所存储和读取的数据。set 仅包含一个键,并有效地支持关于某个键是否存在的查询。 一般来说,如果希望有效地存储不同值的集合,那么使用 set 容器比较合 适,而 map 容器则更适用于需要存储(乃至修改)每个键所关联的值的情况。 在做某种文本处理时,可使用 set 保存要忽略的单词。而字典则是 map 的一种 很好的应用:单词本身是键,而它的解释说明则是值。 set 和 map 类型的对象所包含的元素都具有不同的键,不允许为同一个键 添加第二个元素。如果一个键必须对应多个实例,则需使用 multimap 或 multi set,这两种类型允许多个元素拥有相同的键。 关联容器支持很多顺序容器也提供的相同操作,此外,还提供管理或使用键 的特殊操作。下面的小节将详细讨论关联容器类型及其操作,最后以一个用容器 实现的小型文本查询程序结束本章。 表 10.1. 关联容器类型 map 关联数组:元素通过键来存储和读取 set 大小可变的集合,支持通过键实现的快速读取 multimap 支持同一个键多次出现的 map 类型 multiset 支持同一个键多次出现的 set 类型 10.1. 引言:pair 类型 在开始介绍关联容器之前,必须先了解一种与之相关的简单的标准库类型 ——pair(表 10.2),该类型在 utility 头文件中定义。 表 10.2 pairs 类型提供的操作 466 pair 创建一个空的 pair 对象,它的两个元素分别是 T1 和 T2 p1; 类型,采用值初始化(第 3.3.1 节) pair p1(v1, v2); 创建一个 pair 对象,它的两个元素分别是 T1 和 T2 ,其 中 first 成员初始化为 v1,而 second 成员初始化为 v2 make_pair(v1, 以 v1 和 v2 值创建一个新 pair 对象,其元素类型分别是 v2) v1 和 v2 的类型 p1 < p2 两个 pair 对象之间的小于运算,其定义遵循字典次序:如 果 p1.first < p2.first 或者 !(p2.first < p1.first) && p1.second < p2.second,则返回 true p1 == p2 如果两个 pair 对象的 first 和 second 成员依次相等, 则这两个对象相等。该运算使用其元素的 == 操作符 p.first 返回 p 中名为 first 的(公有)数据成员 p.second 返回 p 的名为 second 的(公有)数据成员 pair 的创建和初始化 pair 包含两个数据值。与容器一样,pair 也是一种模板类型。但又与之前 介绍的容器不同,在创建 pair 对象时,必须提供两个类型名:pair 对象所包 含的两个数据成员各自对应的类型名字,这两个类型必相同。 pair anon; // holds two strings pair word_count; // holds a string and an int pair > line; // holds string and vector 如果在创建 pair 对象时不提供初始化式,则调用默认构造函数对其成员采 用值初始化。于是,anon 是包含两空 string 类型成员的 pair 对象,line 则 存储一个空的 string 类型对象和一个空的 vector 类型对象。word_count 中 的 int 成员获得 0 值,而 string 成员则初始化为空 string 对象。 当然,也可在定义时为每个成员提供初始化式: pair author("James", "Joyce"); 创建一个名为 author 的 pair 对象,它的两个成员都是 string 类型,分 别初始化为字符串 "James" 和 "Joyce"。 467 pair 类型的使用相当繁琐,因此,如果需要定义多个相同的 pair 类型对 象,可考虑利用 typedef 简化其声明: typedef pair Author; Author proust("Marcel", "Proust"); Author joyce("James", "Joyce"); pairs 对象的操作 与其他标准库类型不同,对于 pair 类,可以直接访问其数据成员:其成员 都是仅有的,分别命名为 first 和 second。只需使用普通的点操作符——成员 访问标志即可访问其成员: string firstBook; // access and test the data members of the pair if (author.first == "James" && author.second == "Joyce") firstBook = "Stephen Hero"; 标准库只为 pair 类型定义了表 10.2 所列出的数量有限的操作。 生成新的 pair 对象 除了构造函数,标准库还定义了一个 make_pair 函数,由传递给它的两个 实参生成一个新的 pair 对象。可如下使用该函数创建新的 pair 对象,并赋给 已存在的 pair 对象: pair next_auth; string first, last; while (cin >> first >> last) { // generate a pair from first and last next_auth = make_pair(first, last); // process next_auth... } 这个循环处理一系列的作者信息:在 while 循环条件中读入的作者名字作 为实参,调用 make_pair 函数生成一个新的 pair 对象。此操作等价于下面更 复杂的操作: // use pair constructor to make first and last into a pair next_auth = pair(first, last); 468 由于 pair 的数据成员是公有的,因而可如下直接地读取输入: pair next_auth; // read directly into the members of next_auth while (cin >> next_auth.first >> next_auth.second) { // process next_auth... } Exercises Section 10.1 Exercise 编写程序读入一系列 string 和 int 型数据,将每一组 10.1: 存储在一个 pair 对象中,然后将这些 pair 对象存储 在 vector 容器里。 Exercise 在前一题中,至少可使用三种方法创建 pair 对象。编 10.2: 写三个版本的程序,分别采用不同的方法来创建 pair 对象。你认为哪一种方法更易于编写和理解,为什么? 10.2. 关联容器 关联容器共享大部分——但并非全部——的顺序容器操作。关联容器不提供 front、 push_front、 pop_front、back、push_back 以及 pop_back 操作。 顺序容器和关联容器公共的操作包括下面的几种: • 表 9.2 描述的前三种构造函数: C c; // creates an empty container // c2 must be same type as c1 C c1(c2); // copies elements from c2 into c1 // b and e are iterators denoting a sequence C c(b, e); // copies elements from the sequence into c 关联容器不能通过容器大小来定义,因为这样的话就无法知道键所对应的 值是什么。 • 第 9.3.4 节中描述的关系运算。 • 表 9.6 列出的 begin、end、rbegin 和 rend 操作。 469 • 表 9.5 列出的类型别名(typedef)。注意,对于 map 容器,value_type 并非元素的类型,而是描述键及其关联值类型的 pair 类型。第 10.3.2 节 将详细解释 map 中的类型别名。 • 表 9.11 中描述的 swap 和赋值操作。但关联容器不提供 assign 函数。 • 表 9.10 列出的 clear 和 erase 操作,但关联容器的 erase 运算返回 void 类型。 • 表 9.8 列出的关于容器大小的操作。但 resize 函数不能用于关联容器。 根据键排列元素 除了上述列出的操作之外,关联容器还提供了其他的操作。而对于顺序容器 也提供的相同操作,关联容器也重新定义了这些操作的含义或返回类型,其中的 差别在于关联容器中使用了键。 “容器元素根据键的次序排列”这一事实就是一个重要的结 论:在迭代遍历关联容器时,我们可确保按键的顺序的访问元 素,而与元素在容器中的存放位置完全无关。 Exercises Section 10.2 Exercise 描述关联容器和顺序容器的差别。 10.3: Exercise 举例说明 list、vector、deque、map 以及 set 类型分 10.4: 别适用的情况。 10.3. map 类型 map 是键-值对的集合。map 类型通常可理解为关联数组(associative array):可使用键作为下标来获取一个值,正如内置数组类型一样。而关联的 本质在于元素的值与某个特定的键相关联,而并非通过元素在数组中的位置来获 取。 10.3.1. map 对象的定义 要使用 map 对象,则必须包含 map 头文件。在定义 map 对象时,必须分 别指明键和值的类型(value type)(表 10.4): // count number of times each word occurs in the input 470 map word_count; // empty map from string to int 这个语句定义了一个名为 word_count 的 map 对象,由 string 类型的键 索引,关联的值则 int 型。 表 10.3. map 的构造函数 map m; 创建一个名为 m 的空 map 对象,其键和值的类型分别为 k 和 v map m(m2); 创建 m2 的副本 m,m 与 m2 必须有相同的键类型和值类型 map m(b, e); 创建 map 类型的对象 m,存储迭代器 b 和 e 标记的范围内所有 元素的副本。元素的类型必须能转换为 pair 键类型的约束 在使用关联容器时,它的键不但有一个类型,而且还有一个相关的比较函数。 默认情况下,标准库使用键类型定义的 < 操作符来实现键(key type)的比较。 第 15.8.3 节将介绍如何重写默认的操作符,并提供自定义的操作符函数。 所用的比较函数必须在键类型上定义严格弱排序(strict weak ordering)。 所谓的严格弱排序可理解为键类型数据上的“小于”关系,虽然实际上可以选择 将比较函数设计得更复杂。但无论这样的比较函数如何定义,当用于一个键与自 身的比较时,肯定会导致 false 结果。此外,在比较两个键时,不能出现相互 “小于”的情况,而且,如果 k1“小于”k2,k2“小于”k3,则 k1 必然“小 于”k3。对于两个键,如果它们相互之间都不存在“小于”关系,则容器将之视 为相同的键。用做 map 对象的键时,可使用任意一个键值来访问相应的元素。 在实际应用中,键类型必须定义 < 操作符,而且该操作符应能 “正确地工作”,这一点很重要。 例如,在书店问题中,可增加一个名为 ISBN 的类型,封装与国际标准图书 编号(ISBN)相关的规则。在我们的实现中,国际标准图书编号是 string 类型, 可做比较运算以确定编号之间的大小关系。因此,ISBN 类型可以支持 < 运算。 假设我们已经定义了这样的类型,则可定义一个 map 容器对象,以便高效地查 找书店中存放的某本书。 map bookstore; 471 该语句定义了一个名为 bookstore 的 map 对象,以 ISBN 类型的对象为索 引,其所有元素都存储了一个关联的 Sales_item 类类型实例。 对于键类型,唯一的约束就是必须支持 < 操作符,至于是否支 持其他的关系或相等运算,则不作要求。 Exercises Section 10.3.1 Exercise 定义一个 map 对象,将单词与一个 list 对象关联起 10.5: 来,该 list 对象存储对应的单词可能的行号。 Exercise 可否定义一个 map 对象以 vector::iterator 为 10.6: 键关联 int 型对象?如果以 list::iterator 关 联 int?对于每种情况,如果不允许,请解释其原因。 10.3.2. map 定义的类型 map 对象的元素是键-值对,也即每个元素包含两个部分:键以及由键关联 的值。map 的 value_type 就反映了这个事实。该类型比前面介绍的容器所使用 的元素类型要复杂得多:value_type 是存储元素的键以及值的 pair 类型,而 且键为 const。例如,word_count 数组的 value_type 为 pair 类型。 表 10.4. map 类定义的类型 map::key_type map::mapped_type map::value_type 在 map 容器中,用做索引的键的类型 在 map 容器中,键所关联的值的类型 一个 pair 类型,它的 first 元素具有 const map::key_type 类型,而 second 元素则为 map::mapped_type 类型 在学习 map 的接口时,需谨记 value_type 是 pair 类型,它 的值成员可以修改,但键成员不能修改。 472 map 迭代器进行解引用将产生 pair 类型的对象 对迭代器进行解引用时,将获得一个引用,指向容器中一个 value_type 类 型的值。对于 map 容器,其 value_type 是 pair 类型: // get an iterator to an element in word_count map::iterator map_it = word_count.begin(); // *map_it is a reference to a pair object cout << map_it->first; // prints the key for this element cout << " " << map_it->second; // prints the value of the element map_it->first = "new key"; // error: key is const ++map_it->second; // ok: we can change value through an iterator 对迭代器进行解引用将获得一个 pair 对象,它的 first 成员存放键,为 const,而 second 成员则存放值。 map 容器额外定义的类型别名(typedef) map 类额外定义了两种类型:key_type 和 mapped_type,以获得键或值的 类型。对于 word_count,其 key_type 是 string 类型,而 mapped_type 则是 int 型。如同顺序容器(第 9.3.1 节)一样,可使用作用域操作符(scope operator)来获取类型成员,如 map::key_type。 Exercises Section 10.3.2 Exercise 对于以 int 型对象为索引关联 vector 型对象的 10.7: map 容器,它的 mapped_type、key_type 和 value_type 分别是什么? Exercise 编写一个表达式,使用 map 的迭代器给其元素赋值。 10.8: 10.3.3. 给 map 添加元素 473 定义了 map 容器后,下一步工作就是在容器中添加键-值元素对。该项工 作可使用 insert 成员实现;或者,先用下标操作符获取元素,然后给获取的元 素赋值。在这两种情况下,一个给定的键只能对应于一个元素这一事实影响了这 些操作的行为。 10.3.4. 使用下标访问 map 对象 如下编写程序时: map word_count; // empty map // insert default initialzed element with key Anna; then assign 1 to its value word_count["Anna"] = 1; 将发生以下事情: 1. 在 word_count 中查找键为 Anna 的元素,没有找到。 2. 将一个新的键-值对插入到 word_count 中。它的键是 const string 类型 的对象,保存 Anna。而它的值则采用值初始化,这就意味着在本例中值为 0。 3. 将这个新的键-值对插入到 word_count 中。 4. 读取新插入的元素,并将它的值赋为 1。 使用下标访问 map 与使用下标访问数组或 vector 的行为截 然不同:用下标访问不存在的元素将导致在 map 容器中添加一 个新元素,它的键即为该下标值。 如同其他下标操作符一样,map 的下标也使用索引(其实就是键)来获取该 键所关联的值。如果该键已在容器中,则 map 的下标运算与 vector 的下标运 算行为相同:返回该键所关联的值。只有在所查找的键不存在时,map 容器才为 该键创建一个新的元素,并将它插入到此 map 对象中。此时,所关联的值采用 值初始化:类类型的元素用默认构造函数初始化,而内置类型的元素初始化为 0。 下标操作符返回值的使用 通常来说,下标操作符返回左值。它返回的左值是特定键所关联的值。可如 下读或写元素: cout << word_count["Anna"]; // fetch element indexed by Anna; prints 1 ++word_count["Anna"]; // fetch the element and add one to it 474 cout << word_count["Anna"]; // fetch the element and print it; prints 2 有别于 vector 或 string 类型,map 下标操作符返回的类型 与对 map 迭代器进行解引用获得的类型不相同。 显然,map 迭代器返回 value_type 类型的值——包含 const key_type 和 mapped_type 类型成员的 pair 对象;下标操作符则返回一个 mapped_type 类 型的值。 下标行为的编程意义 对于 map 容器,如果下标所表示的键在容器中不存在,则添加新元素,这 一特性可使程序惊人地简练: // count number of times each word occurs in the input map word_count; // empty map from string to int string word; while (cin >> word) ++word_count[word]; 这段程序创建一个 map 对象,用来记录每个单词出现的次数。while 循环 每次从标准输入读取一个单词。如果这是一个新的单词,则在 word_count 中添 加以该单词为索引的新元素。如果读入的单词已在 map 对象中,则将它所对应 的值加 1。 其中最有趣的是,在单词第一次出现时,会在 word_count 中创建并插入一 个以该单词为索引的新元素,同时将它的值初始化为 0。然后其值立即加 1,所 以每次在 map 中添加新元素时,所统计的出现次数正好从 1 开始。 Exercises Section 10.3.4 Exercise 编写程序统计并输出所读入的单词出现的次数。 10.9: Exercise 解释下面的程序的功能: 10.10: map m; 475 m[0] = 1; 比较上一程序和下面程序的行为 vector v; v[0] = 1; Exercise 10.11: 哪些类型可用做 map 容器对象的下标?下标操作符返 回的又是什么类型?给出一个具体例子说明,即定义一 个 map 对象,指出哪些类型可用作其下标,以及下标操 作符返回的类型。 10.3.5. map::insert 的使用 map 容器的 insert 成员与顺序容器的类似,但有一点要注意:必须考虑键 的作用。键影响了实参的类型:插入单个元素的 insert 版本使用键-值 pair 类型的参数。类似地,对于参数为一对迭代器的版本,迭代器必须指向键-值 pair 类型的元素。另一个差别则是:map 容器的接受单个值的 insert 版本的 返回类型。本节的后续部分将详细阐述这一特性。 表 10.5. map 容器提供的 insert 操作 m.insert(e) e 是一个用在 m 上的 value_type 类型的值。如果键 (e.first)不在 m 中,则插入一个值为 e.second 的新元素; 如果该键在 m 中已存在,则保持 m 不变。该函数返回一个 pair 类型对象,包含指向键为 e.first 的元素的 map 迭代 器,以及一个 bool 类型的对象,表示是否插入了该元素 m.insert(beg, end) beg 和 end 是标记元素范围的迭代器,其中的元素必须为 m.value_type 类型的键-值对。对于该范围内的所有元素, 如果它的键在 m 中不存在,则将该键及其关联的值插入到 m。 返回 void 类型 m.insert(iter, e) e 是一个用在 m 上的 value_type 类型的值。如果键 (e.first)不在 m 中,则创建新元素,并以迭代器 iter 为 起点搜索新元素存储的位置。返回一个迭代器,指向 m 中具 有给定键的元素 以 insert 代替下标运算 476 使用下标给 map 容器添加新元素时,元素的值部分将采用值初始化。通常, 我们会立即为其赋值,其实就是对同一个对象进行初始化并赋值。而插入元素的 另一个方法是:直接使用 insert 成员,其语法更紧凑: // if Anna not already in word_count, inserts new element with value 1 word_count.insert(map::value_type("Anna", 1)); 这个 insert 函数版本的实参: map::value_type(anna, 1) 是一个新创建的 pair 对象,将直接插入到 map 容器中。谨记 value_type 是 pair 类型的同义词,K 为键类型,而 V 是键所关联的值的类 型。insert 的实参创建了一个适当的 pair 类型新对象,该对象将插入到 map 容器。在添加新 map 元素时,使用 insert 成员可避免使用下标操作符所带来 的副作用:不必要的初始化。 传递给 insert 的实参相当笨拙。可用两种方法简化:使用 make_pair: word_count.insert(make_pair("Anna", 1)); 或使用 typedef typedef map::value_type valType; word_count.insert(valType("Anna", 1)); 这两种方法都使用调用变得简单,提高了程序的可读性。 检测 insert 的返回值 map 对象中一个给定键只对应一个元素。如果试图插入的元素所对应的键已 在容器中,则 insert 将不做任何操作。含有一个或一对迭代器形参的 insert 函数版本并不说明是否有或有多少个元素插入到容器中。 但是,带有一个键-值 pair 形参的 insert 版本将返回一个值:包含一个 迭代器和一个 bool 值的 pair 对象,其中迭代器指向 map 中具有相应键的元 素,而 bool 值则表示是否插入了该元素。如果该键已在容器中,则其关联的值 保持不变,返回的 bool 值为 true。在这两种情况下,迭代器都将指向具有给 定键的元素。下面是使用 insert 重写的单词统计程序: // count number of times each word occurs in the input 477 map word_count; // empty map from string to int string word; while (cin >> word) { // inserts element with key equal to word and value 1; // if word already in word_count, insert does nothing pair::iterator, bool> ret = word_count.insert(make_pair(word, 1)); if (!ret.second) // word already in word_count ++ret.first->second; // increment counter } 对于每个单词,都尝试 insert 它,并将它的值赋 1。if 语句检测 insert 函数返回值中的 bool 值。如果该值为 false,则表示没有做插入操作,按 word 索引的元素已在 word_count 中存在。此时,将该元素所关联的值加 1。 语法展开 ret 的定义和自增运算可能比较难解释: pair::iterator, bool> ret = word_count.insert(make_pair(word, 1)); 首先,应该很容易看出我们定义的是一个 pair 对象,它的 second 成员为 bool 类型。而它的 first 成员则比较难理解,这是 map 容器所 定义的迭代器类型。 根据操作符的优先级次序(第 5.10.1 节),可如下从添加圆括号开始理解自增 操作: ++((ret.first)->second); // equivalent expression 下面对这个表达式一步步地展开解释: • ret 存储 insert 函数返回的 pair 对象。该 pair 的 first 成员是一 个 map 迭代器,指向插入的键。 • ret.first 从 insert 返回的 pair 对象中获取 map 迭代器。 • ret.first->second 对该迭代器进行解引用,获得一个 value_type 类型 的对象。这个对象同样是 pair 类型的,它的 second 成员即为我们所添 加的元素的值部分。 • ++ret.first->second 实现该值的自增运算。 478 归结起来,这个自增语句获取指向按 word 索引的元素的迭代器,并将该元素的 值加 1。 Exercises Section 10.3.5 Exercise 重写第 10.3.4 节习题的单词统计程序,要求使用 10.12: insert 函数代替下标运算。你认为哪个程序更容易编写 和阅读?请解释原因。 Exercise 假设有 map > 类型,指出在该容 10.13: 器中插入一个元素的 insert 函数应具有的参数类型和 返回值类型。 10.3.6. 查找并读取 map 中的元素 下标操作符给出了读取一个值的最简单方法: map word_count; int occurs = word_count["foobar"]; 但是,使用下标存在一个很危险的副作用:如果该键不在 map 容器中,那 么下标操作会插入一个具有该键的新元素。 这样的行为是否正确取决于程序员的意愿。在这个例子中,如果“foobar” 不存在,则在 map 中插入具有该键的新元素,其关联的值为 0。在这种情况下, occurs 获得 0 值。 我们的单词统计程序的确是要通过下标引用一个不存在的元素来实现新元 素的插入,并将其关联的值初始化为 0。然而,大多数情况下,我们只想知道某 元素是否存在,而当该元素不存在时,并不想做做插入运算。对于这种应用,则 不能使用下标操作符来判断元素是否存在。 map 容器提供了两个操作:count 和 find,用于检查某个键是否存在而不 会插入该键。 表 10.6. 不修改 map 对象的查询操作 m.count(k) 返回 m 中 k 的出现次数 m.find(k) 如果 m 容器中存在按 k 索引的元素,则返回指向该元素的迭代 479 器。如果不存在,则返回超出末端迭代器(第 3.4 节) 使用 count 检查 map 对象中某键是否存在 对于 map 对象,count 成员的返回值只能是 0 或 1。map 容器只允许一个 键对应一个实例,所以 count 可有效地表明一个键是否存在。而对于 multimaps 容器,count 的返回值将有更多的用途,相关内容将会在第 10.5 节中介绍。如 果返回值非 0,则可以使用下标操作符来获取该键所关联的值,而不必担心这样 做会在 map 中插入新元素: int occurs = 0; if (word_count.count("foobar")) occurs = word_count["foobar"]; 当然,在执行 count 后再使用下标操作符,实际上是对元素作了两次查找。 如果希望当元素存在时就使用它,则应该用 find 操作。 读取元素而不插入该元素 find 操作返回指向元素的迭代器,如果元素不存在,则返回 end 迭代器: int occurs = 0; map::iterator it = word_count.find("foobar"); if (it != word_count.end()) occurs = it->second; 如果希望当具有指定键的元素存在时,就获取该元素的引用,否则就不在容器中 创建新元素,那么应该使用 find。 Exercises Section 10.3.6 Exercise map 容器的 count 和 find 运算有何区别? 10.14: Exercise 你认为 count 适合用于解决哪一类问题?而 find 10.15: 呢? 480 Exercise 定义并初始化一个变量,用来存储调用键为 string、值 10.16: 为 vector 的 map 对象的 find 函数的返回结 果。 10.3.7. 从 map 对象中删除元素 从 map 容器中删除元素的 erase 操作有三种变化形式(表 10.7)。与顺 序容器一样,可向 erase 传递一个或一对迭代器,来删除单个元素或一段范围 内的元素。其删除功能类似于顺序容器,但有一点不同:map 容器的 erase 操 作返回 void,而顺序容器的 erase 操作则返回一个迭代器,指向被删除元素后 面的元素。 除此之外,map 类型还提供了一种额外的 erase 操作,其参数是 key_type 类型的值,如果拥有该键的元素存在,则删除该元素。对于单词统计程序,可使 用这个版本的 erase 函数来删除 word_count 中指定的单词,然后输出被删除 的单词: // erase of a key returns number of elements removed if (word_count.erase(removal_word)) cout << "ok: " << removal_word << " removed\n"; else cout << "oops: " << removal_word << " not found!\n"; erase 函数返回被删除元素的个数。对于 map 容器,该值必然是 0 或 1。 如果返回 0,则表示欲删除的元素在 map 不存在。 表 10.7. 从 map 对象中删除元素 m.erase(k) 删除 m 中键为 k 的元素。返回 size_type 类型的值,表示删除 的元素个数 m.erase(p) 从 m 中删除迭代器 p 所指向的元素。p 必须指向 m 中确实存在 的元素,而且不能等于 m.end()。返回 void m.erase(b, e) 从 m 中删除一段范围内的元素,该范围由迭代器对 b 和 e 标记。 b 和 e 必须标记 m 中的一段有效范围:即 b 和 e 都必须指向 m 中的元素或最后一个元素的下一个位置。而且,b 和 e 要么相等 (此时删除的范围为空),要么 b 所指向的元素必须出现在 e 所 指向的元素之前。返回 void 类型 10.3.8. map 对象的迭代遍历 481 与其他容器一样,map 同样提供 begin 和 end 运算,以生成用于遍历整个 容器的迭代器。例如,可如下将 map 容器 word_count 的内容输出: // get iterator positioned on the first element map::const_iterator map_it = word_count.begin(); // for each element in the map while (map_it != word_count.end()) { // print the element key, value pairs cout << map_it->first << " occurs " << map_it->second << " times" << endl; ++map_it; // increment iterator to denote the next element } while 循环的条件判断以及循环体中迭代器的自增都与输出 vector 或 string 容器内容的程序非常相像。首先,初始化 map_it 迭代器,使之指向 word_count 的第一元素。只要该迭代器不等于 end 的值,就输出当前元素并给 迭代器加 1。这段程序的循环体要比前面类似的程序更加复杂,原因在于对于 map 的每个元素都必须分别输出它的键和值。 这个单词统计程序依据字典顺序输出单词。在使用迭代器遍历 map 容器时,迭代器指向的元素按键的升序排列。 10.3.9. “单词转换” map 对象 下面的程序说明如何创建、查找和迭代遍历一个 map 对象,我们将以此结 束本节内容。这个程序求解的问题是:给出一个 string 对象,把它转换为另一 个 string 对象。本程序的输入是两个文件。第一个文件包括了若干单词对,每 对的第一个单词将出现在输入的字符串中,而第二个单词则是用于输出。本质上, 这个文件提供的是单词转换的集合——在遇到第一个单词时,应该将之替换为第 二个单词。第二个文件则提供了需要转换的文本。如果单词转换文件的内容是: 'em them cuz because gratz grateful i I nah no 482 pos supposed sez said tanx thanks wuz was 而要转换的文本是: nah i sez tanx cuz i wuz pos to not cuz i wuz gratz 则程序将产生如下输出结果: no I said thanks because I was supposed to not because I was grateful 单词转换程序 下面给出的解决方案是将单词转换文件的内容存储在一个 map 容器中,将 被替换的单词作为键,而用作替换的单词则作为其相应的值。接着读取输入,查 找输入的每个单词是否对应有转换。若有,则实现转换,然后输出其转换后的单 词,否则,直接输出原词。 该程序的 main 函数需要两个实参(第 7.2.6 节):单词转换文件的名字 以及需要转换的文件名。程序执行时,首先检查实参的个数。第一个实参 argv[0] 是命令名,而执行该程序所需要的两个文件名参数则分别存储在 argv[1] 及 argv[2] 中。 如果 argv[1] 的值合法,则调用 open_file(第 8.4.3 节)打开单词转换 文件。假设 open 操作成功,则读入“单词转换对”。以“转换对”中的第一个 单词为键,第二个为值,调用 insert 函数在容器中插入新元素。while 循环结 束后,trans_map 容器对象包含了转换输入文本所需的数据。而如果该实参有问 题,则抛出异常(第 6.13 节)并结束程序的运行。 接下来,调用 open_file 打开要转换的文件。第二个 while 循环使用 getline 函数逐行读入文件。因为程序每次读入一行,从而可在输出文件的相同 位置进行换行。然后在内嵌的 while 循环中使用 istringstream 将每一行中的 单词提取出来。这部分程序与第 8.5 节的程序框架类似。 内层的 while 循环检查每个单词,判断它是否在转换的 map 中出现。如果 在,则从该 map 对象中取出对应的值替代此单词。最后,无论是否做了转换, 483 都输出该单词。同时,程序使用 bool 值 firstword 判断是否需要输出空格。 如果当前处理的是这一行的第一个单词,则无须输出空格。 /* * A program to transform words. * Takes two arguments: The first is name of the word transformation file * The second is name of the input to transform */ int main(int argc, char **argv) { // map to hold the word transformation pairs: // key is the word to look for in the input; value is word to use in the output map trans_map; string key, value; if (argc != 3) throw runtime_error("wrong number of arguments"); // open transformation file and check that open succeeded ifstream map_file; if (!open_file(map_file, argv[1])) throw runtime_error("no transformation file"); // read the transformation map and build the map while (map_file >> key >> value) trans_map.insert(make_pair(key, value)); // ok, now we're ready to do the transformations // open the input file and check that the open succeeded ifstream input; if (!open_file(input, argv[2])) throw runtime_error("no input file"); string line; // hold each line from the input // read the text to transform it a line at a time while (getline(input, line)) { istringstream stream(line); // read the line a word at a time string word; bool firstword = true; // controls whether a space is printed while (stream >> word) { // ok: the actual mapwork, this part is the heart of the program map::const_iterator map_it = trans_map.find(word); // if this word is in the transformation map 484 if (map_it != trans_map.end()) // replace it by the transformation value in the map word = map_it->second; if (firstword) firstword = false; else cout << " "; // print space between words cout << word; } cout << endl; // done with this line of input } return 0; } Exercises Section 10.3.9 Exercise 10.17: 上述转换程序使用了 find 函数来查找单词: map::const_iterator map_it = trans_map.find(word); 你认为这个程序为什么要使用 find 函数?如果使用下 标操作符又会怎么样? Exercise 10.18: 定义一个 map 对象,其元素的键是家庭姓氏,而值则是 存储该家庭孩子名字的 vector 对象。为这个 map 容器 输入至少六个条目。通过基于家庭姓氏的查询检测你的 程序,查询应输出该家庭所有孩子的名字。 Exercise 10.19: 把上一题的 map 对象再扩展一下,使其 vector 对象存 储 pair 类型的对象,记录每个孩子的名字和生日。相 应地修改程序,测试修改后测试程序以检查所编写的 map 是否正确。 Exercise 列出至少三种可以使用 map 类型的应用。为每种应用定 10.20: 义 map 对象,并指出如何插入和读取元素。 10.4. set 类型 485 map 容器是键-值对的集合,好比以人名为键的地址和电话号码。相反地, set 容器只是单纯的键的集合。例如,某公司可能定义了一个名为 bad_checks 的 set 容器,用于记录曾经给本公司发空头支票的客户。当只想知道一个值是 否存在时,使用 set 容器是最适合的。例如,在接收一张支票前,该公司可能 想查询 bad_checks 对象,看看该客户的名字是否存在。 除了两种例外情况,set 容器支持大部分的 map 操作,包括下面几种: • 第 10.2 节列出的所有通用的容器操作。 • 表 10.3 描述的构造函数。 • 表 10.5 描述的 insert 操作。 • 表 10.6 描述的 count 和 find 操作。 • 表 10.7 描述的 erase 操作。 两种例外包括:set 不支持下标操作符,而且没有定义 mapped_type 类型。 在 set 容器中,value_type 不是 pair 类型,而是与 key_type 相同的类型。 它们指的都是 set 中存储的元素类型。这一差别也体现了 set 存储的元素仅仅 是键,而没有所关联的值。与 map 一样,set 容器存储的键也必须唯一,而且 不能修改。 Exercises Section 10.4 Exercise 解释 map 和 set 容器的差别,以及它们各自适用的情 10.21: 况。 Exercise 解释 set 和 list 容器的差别,以及它们各自适用的情 10.22: 况。 10.4.1. set 容器的定义和使用 为了使用 set 容器,必须包含 set 头文件。set 支持的操作基本上与 map 提供的相同。 与 map 容器一样,set 容器的每个键都只能对应一个元素。以一段范围的 元素初始化 set 对象,或在 set 对象中插入一组元素时,对于每个键,事实上 都只添加了一个元素: // define a vector with 20 elements, holding two copies of each number from 0 to 9 vector ivec; for (vector::size_type i = 0; i != 10; ++i) { 486 ivec.push_back(i); ivec.push_back(i); // duplicate copies of each number } // iset holds unique elements from ivec set iset(ivec.begin(), ivec.end()); cout << ivec.size() << endl; // prints 20 cout << iset.size() << endl; // prints 10 首先创建了一个名为 ivec 的 int 型 vector 容器,存储 20 个元素:0-9 (包括 9)中每个整数都出现了两次。然后用 ivec 中所有的元素初始化一个 int 型的 set 容器。则这个 set 容器仅有 10 个元素:ivec 中不相同的各个 元素。 在 set 中添加元素 可使用 insert 操作在 set 中添加元素: set set1; set1.insert("the"); set1.insert("and"); // empty set // set1 now has one element // set1 now has two elements 另一种用法是,调用 insert 函数时,提供一对迭代器实参,插入其标记范 围内所有的元素。该版本的 insert 函数类似于形参为一对迭代器的构造函数 ——对于一个键,仅插入一个元素: set iset2; // empty set iset2.insert(ivec.begin(), ivec.end()); elements // iset2 has 10 与 map 容器的操作一样,带有一个键参数的 insert 版本返回 pair 类型 对象,包含一个迭代器和一个 bool 值,迭代器指向拥有该键的元素,而 bool 值 表明是否添加了元素。使用迭代器对的 insert 版本返回 void 类型。 从 set 中获取元素 set 容器不提供下标操作符。为了通过键从 set 中获取元素,可使用 find 运算。如果只需简单地判断某个元素是否存在,同样可以使用 count 运算,返 回 set 中该键对应的元素个数。当然,对于 set 容器,count 的返回值只能是 1(该元素存在)或 0(该元素不存在): iset.find(1) key == 1 // returns iterator that refers to the element with 487 iset.find(11) // returns iterator == iset.end() iset.count(1) // returns 1 iset.count(11) // returns 0 正如不能修改 map 中元素的键部分一样,set 中的键也为 const。在获得 指向 set 中某元素的迭代器后,只能对其做读操作,而不能做写操作: // set_it refers to the element with key == 1 set::iterator set_it = iset.find(1); *set_it = 11; // error: keys in a set are read-only cout << *set_it << endl; // ok: can read the key 10.4.2. 创建“单词排除”集 第 10.3.7 节的程序从 map 对象 word_count 中删除一个指定的单词。可 将这个操作扩展为删除指定文件中所有的单词(即该文件记录的是排除集)。也 即,我们的单词统计程序只对那些不在排除集中的单词进行统计。使用 set 和 map 容器,可以简单而直接地实现该功能: void restricted_wc(ifstream &remove_file, map &word_count) { set excluded; // set to hold words we'll ignore string remove_word; while (remove_file >> remove_word) excluded.insert(remove_word); // read input and keep a count for words that aren't in the exclusion set string word; while (cin >> word) // increment counter only if the word is not in excluded if (!excluded.count(word)) ++word_count[word]; } 这个程序类似第 10.3.4 节的单词统计程序。其差别在于不需要费力地统计 常见的单词。 该函数首先读取传递进来的文件,该文件列出了所有被排除的单词。读入这 些单词并存储在一个名为 excluded 的 set 容器中。第一个 while 循环完成 捍,该 set 对象包含了输入文件中的所有单词。 488 接下来的程序类似原来的单词统计程序。关键的区别在于:在统计每个单词 之前,先检查该单词是否出现在排除集中。第二个 while 循环里的 if 语句实 现了该功能: // increment counter only if the word is not in excluded if (!excluded.count(word)) 如果该单词出现在排除集 excluded 中,则调用 count 将返回 1,否则返 回 0。对 count 的返回值做“非”运算,则当该 word 不在 excluded 中时, 条件测试成功,此时修改该单词在 map 中对应的值。 与单词统计程序原来的版本一样,需要使用下标操作符的性质:如果某键尚未在 map 容器中出现,则将该元素插入容器。所以语句 ++word_count[word]; 的效果是:如果 word 还没出现过,则将它插入到 word_count 中,并在插入元 素后,将它关联的值初始化为 0。然后不管是否插入了新元素,相应元素的值都 加 1。 Exercises Section 10.4.2 Exercise 编写程序将被排除的单词存储在 vector 对象中,而不 10.23: 是存储在 set 对象中。请指出使用 set 的好处。 Exercise 10.24: 编写程序通过删除单词尾部的‘s’生成该单词的非复 数版本。同时,建立一个单词排除集,用于识别以‘s’ 结尾、但这个结尾的‘s’又不能删除的单词。例如,放 在该排除集中的单词可能有 success 和 class。使用这 个排除集编写程序,删除输入单词的复数后缀,而如果 输入的是排除集中的单词,则保持该单词不变。 Exercise 10.25: 定义一个 vector 的容器,存储你在未来六个月里要阅 读的书,再定义一个 set,用于记录你已经看过的书名。 编写程序从 vector 中为你选择一本没有读过而现在要 读的书。当它为你返回选中的书名后,应该将该书名放 入记录已读书目的 set 中。如果实际上你把这本书放在 一边没有看,则本程序应该支持从已读书目的 set 中删 除该书的记录。在虚拟的六个月后,输出已读书目和还 没有读的书目。 489 10.5. multimap 和 multiset 类型 map 和 set 容器中,一个键只能对应一个实例。而 multiset 和 multimap 类型则允许一个键对应多个实例。例如,在电话簿中,每个人可能有单独的电话 号码列表。在作者的文章集中,每位作者可能有单独的文章标题列表。multimap 和 multiset 类型与相应的单元素版本具有相同的头文件定义:分别是 map 和 set 头文件。 multimap 和 multiset 所支持的操作分别与 map 和 set 的操作相同,只 有一个例外:multimap 不支持下标运算。不能对 multimap 对象使用下标操作, 因为在这类容器中,某个键可能对应多个值。为了顺应一个键可以对应多个值这 一性质,map 和 multimap,或 set 和 multiset 中相同的操作都以不同的方式 做出了一定的修改。在使用 multimap 或 multiset 时,对于某个键,必须做好 处理多个值的准备,而非只有单一的值。 10.5.1. 元素的添加和删除 表 10.5 描述的 insert 操作和表 10.7 描述的 erase 操作同样适用于 multimap 以及 multiset 容器,实现元素的添加和删除。 由于键不要求是唯一的,因此每次调用 insert 总会添加一个元素。例如, 可如下定义一个 multimap 容器对象将作者映射到他们所写的书的书名上。这样 的映射可为一个作者存储多个条目: // adds first element with key Barth authors.insert(make_pair( string("Barth, John"), string("Sot-Weed Factor"))); // ok: adds second element with key Barth authors.insert(make_pair( string("Barth, John"), string("Lost in the Funhouse"))); 带有一个键参数的 erase 版本将删除拥有该键的所有元素,并返回删除元 素的个数。而带有一个或一对迭代器参数的版本只删除指定的元素,并返回 void 类型: multimap authors; string search_item("Kazuo Ishiguro"); // erase all elements with this key; returns number of elements removed 490 multimap::size_type cnt = authors.erase(search_item); 10.5.2. 在 multimap 和 multiset 中查找元素 注意到,关联容器 map 和 set 的元素是按顺序存储的。而 multimap 和 multset 也一样。因此,在 multimap 和 multiset 容器中,如果某个键对应多 个实例,则这些实例在容器中将相邻存放。 迭代遍历 multimap 或 multiset 容器时,可保证依次返回特 定键所关联的所有元素。 在 map 或 set 容器中查找一个元素很简单——该元素要么在要么不在容 器中。但对于 multimap 或 multiset,该过程就复杂多了:某键对应的元素可 能出现多次。例如,假设有作者与书名的映射,我们可能希望找到并输出某个作 者写的所有书的书名。 事实证明,上述问题可用三种策略解决。而且三种策略都基于一个事实—— 在 multimap 中,同一个键所关联的元素必然相邻存放。 首先介绍第一种策略:仅使用前面介绍过的函数。但这种方法要编写比较多的代 码,所以我们将继续探索更简洁的方法。 使用 find 和 count 操作 使用 find 和 count 可有效地解决刚才的问题。count 函数求出某键出现 的次数,而 find 操作则返回一个迭代器,指向第一个拥有正在查找的键的实例: // author we'll look for string search_item("Alain de Botton"); // how many entries are there for this author typedef multimap::size_type sz_type; sz_type entries = authors.count(search_item); // get iterator to the first entry for this author multimap::iterator iter = authors.find(search_item); // loop through the number of entries there are for this author 491 for (sz_type cnt = 0; cnt != entries; ++cnt, ++iter) cout << iter->second << endl; // print each title 首先,调用 count 确定某作者所写的书籍数目,然后调用 find 获得指向 第一个该键所关联的元素的迭代器。for 循环迭代的次数依赖于 count 返回的 值。在特殊情况下,如果 count 返回 0 值,则该循环永不执行。 与众不同的面向迭代器的解决方案 另一个更优雅简洁的方法是使用两个未曾见过的关联容器的操作: lower_bound 和 upper_bound。表 10.8 列出的这些操作适用于所有的关联容 器,也可用于普通的 map 和 set 容器,但更常用于 multimap 和 multiset。 所有这些操作都需要传递一个键,并返回一个迭代器。 表 10.8. 返回迭代器的关联容器操作 m.lower_bound(k) 返回一个迭代器,指向键不小于 k 的第一个元素 m.upper_bound(k) 返回一个迭代器,指向键大于 k 的第一个元素 m.equal_range(k) 返回一个迭代器的 pair 对象 它的 first 成员等价于 m.lower_bound(k)。而 second 成 员则等价于 m.upper_bound(k) 在同一个键上调用 lower_bound 和 upper_bound,将产生一个迭代器范围 (第 9.2.1 节),指示出该键所关联的所有元素。如果该键在容器中存在,则 会获得两个不同的迭代器:lower_bound 返回的迭代器指向该键关联的第一个实 例,而 upper_bound 返回的迭代器则指向最后一个实例的下一位置。如果该键 不在 multimap 中,这两个操作将返回同一个迭代器,指向依据元素的排列顺序 该键应该插入的位置。 当然,这些操作返回的也可能是容器自身的超出末端迭代器。如果所查找的 元素拥有 multimap 容器中最大的键,那么的该键上调用 upper_bound 将返回 超出末端迭代器。如果所查找的键不存在,而且比 multimap 容器中所有的键都 大,则 low_bound 也将返回超出末端迭代器。 lower_bound 返回的迭代器不一定指向拥有特定键的元素。如 果该键不在容器中,则 lower_bound 返回在保持容器元素顺序 的前提下该键应被插入的第一个位置。 492 使用这些操作,可如下重写程序: // definitions of authors and search_item as above // beg and end denote range of elements for this author typedef multimap::iterator authors_it; authors_it beg = authors.lower_bound(search_item), end = authors.upper_bound(search_item); // loop through the number of entries there are for this author while (beg != end) { cout << beg->second << endl; // print each title ++beg; } 这个程序实现的功能与前面使用 count 和 find 的程序相同,但任务的实 现更直接。调用 lower_bound 定位 beg 迭代器,如果键 search_item 在容器 中存在,则使 beg 指向第一个与之匹配的元素。如果容器中没有这样的元素, 那么 beg 将指向第一个键比 search_item 大的元素。调用 upper_bound 设置 end 迭代器,使之指向拥有该键的最后一个元素的下一位置。 这两个操作不会说明键是否存在,其关键之处在于返回值给出 了迭代器范围。 若该键没有关联的元素,则 lower_bound 和 upper_bound 返回相同的迭代 器:都指向同一个元素或同时指向 multimap 的超出末端位置。它们都指向在保 持容器元素顺序的前提下该键应被插入的位置。 如果该键所关联的元素存在,那么 beg 将指向满足条件的元素中的第一个。 可对 beg 做自增运算遍历拥有该键的所有元素。当迭代器累加至 end 标志时, 表示已遍历了所有这些元素。当 beg 等于 end 时,表示已访问所有与该键关联 的元素。 假设这些迭代器标记某个范围,可使用同样的 while 循环遍历该范围。该 循环执行 0 次或多次,输出指定作者所写的所有书的书名(如果有的话)。如 果没有相关的元素,那么 beg 和 end 相等,循环永不执行。否则,不断累加 beg 将最终到达 end,在这个过程中可输出该作者所关联的记录。 enual_range 函数 493 事实上,解决上述问题更直接的方法是:调用 equal_range 函数来取代调 用 upper_bound 和 lower_bound 函数。equal_range 函数返回存储一对迭代器 的 pair 对象。如果该值存在,则 pair 对象中的第一个迭代器指向该键关联的 第一个实例,第二个迭代器指向该键关联的最后一个实例的下一位置。如果找不 到匹配的元素,则 pair 对象中的两个迭代器都将指向此键应该插入的位置。 使用 equal_range 函数再次修改程序: // definitions of authors and search_item as above // pos holds iterators that denote range of elements for this key pair pos = authors.equal_range(search_item); // loop through the number of entries there are for this author while (pos.first != pos.second) { cout << pos.first->second << endl; // print each title ++pos.first; } 这个程序段与前面使用 upper_bound 和 lower_bound 的程序基本上是相 同的。本程序不用局部变量 beg 和 end 来记录迭代器范围,而是直接使用 equal_range 返回的 pair 对象。该 pair 对象的 first 成员存储 lower_bound 函数返回的迭代器,而 second 成员则记录 upper_bound 函数返 回的迭代器。 因此,本程序的 pos.first 等价于前一方法中的 beg,而 pos.second 等价于 end。 Exercises Section 10.5.2 Exercise 10.26: 编写程序建立作者及其作品的 multimap 容器。使用 find 函数在 multimap 中查找元素,并调用 erase 将 其删除。当所寻找的元素不存在时,确保你的程序依然 能正确执行。 Exercise 重复上一题所编写的程序,但这一次要求使用 10.27: equal_range 函数获取迭代器,然后删除一段范围内的 元素。 Exercise 沿用上题中的 multimap 容器,编写程序以下面的格式 494 10.28: 按姓名首字母的顺序输出作者名字: Author Names Beginning with 'A': Author, book, book, ... ... Author Names Beginning with 'B': ... Exercise 解释本节最后一个程序的输出表达式使用操作数 10.29: pos.first->second 的含义。 10.6. 容器的综合应用:文本查询程序 我们将实现一个简单的文本查询程序来结束本章。 我们的程序将读取用户指定的任意文本文件,然后允许用户从该文件中查找 单词。查询的结果是该单词出现的次数,并列出每次出现所在的行。如果某单词 在同一行中多次出现,程序将只显示该行一次。行号按升序显示,即第 7 行应 该在第 9 行之前输出,依此类推。 例如,以本章的内容作为文件输入,然后查找单词“element”。输出的前 几行应为: element occurs 125 times (line 62) element with a given key. (line 64) second element with the same key. (line 153) element |==| operator. (line 250) the element type. (line 398) corresponding element. 后面省略了大约 120 行。 10.6.1. 查询程序的设计 设计程序的一个良好习惯是首先将程序所涉及的操作列出来。明确需要提供 的操作有助于建立需要的数据结构和实现这些行为。从需求出发,我们的程序需 要支持如下任务: 1. 它必须允许用户指明要处理的文件名字。程序将存储该文件的内容,以便 输出每个单词所在的原始行。 495 2. 它必须将每一行分解为各个单词,并记录每个单词所在的所有行。在输出 行号时,应保证以升序输出,并且不重复。 3. 对特定单词的查询将返回出现该单词的所有行的行号。 4. 输出某单词所在的行文本时,程序必须能根据给定的行号从输入文件中获 取相应的行。 数据结构 我们将用一个简单的类 TextQuery 实现这个程序。再加几种容器的配合使 用,就可相当巧妙地满足上述要求。 1. 使用一个 vector 类型的对象存储整个输入文件的副本。输入文 件的每一行是该 vector 对象的一个元素。因而,在希望输出某一行时, 只需以行号为下标获取该行所在的元素即可。 2. 将每个单词所在的行号存储在一个 set 容器对象中。使用 set 就可确保 每行只有一个条目,而且行号将自动按升序排列。 3. 使用一个 map 容器将每个单词与一个 set 容器对象关联起来,该 set 容器对象记录此单词所在的行号。 综上所述,我们定义的 TextQuery 类将有两个数据成员:储存输入文件的 vector 对象,以及一个 map 容器对象,该对象关联每个输入的单词以及记录该 单词所在行号的 set 容器对象。 操作 对于类还要求有良好的接口。然而,一个重要的设计策略首先要确定:查询 函数需返回存储一组行号的 set 对象。这个返回类型应该如何设计呢? 事实上,查询的过程相当简单:使用下标访问 map 对象获取关联的 set 对 象即可。唯一的问题是如何返回所找到的 set 对象。安全的设计方案是返回该 set 对象的副本。但如此一来,就意味着要复制 set 中的每个元素。如果处理 的是一个相当庞大的文件,则复制 set 对象的代价会非常昂贵。其他可行的方 法包括:返回一个 pair 对象,存储一对指向 set 中元素的迭代器;或者返回 set 对象的 const 引用。为简单起见,我们在这里采用返回副本的方法,但注 意:如果在实际应用中复制代价太大,需要新考虑其实现方法。 第一、第三和第四个任务是使用这个类的程序员将执行的动作。第二个任务 则是类的内部任务。将这四任务映射为类的成员函数,则类的接口需提供下列三 个 public 函数: • read_file 成员函数,其形参为一个 ifstream& 类型对象。该函数每次 从文件中读入一行,并将它保存在 vector 容器中。输入完毕后, read_file 将创建关联每个单词及其所在行号的 map 容器。 • run_query 成员函数,其形参为一个 string 类型对象,返回一个 set 对 象,该 set 对象包含出现该 string 对象的所有行的行号。 496 • text_line 成员函数,其形参为一个行号,返回输入文本中该行号对应的 文本行。 无论 run_query 还是 text_line 都不会修改调用此函数的对象,因此,可 将这两个操作定义为 const 成员函数(第 7.7.1 节)。 为实现 read_file 功能,还需定义两个 private 函数来读取输入文本和创 建 map 容器: • store_file 函数读入文件,并将文件内容存储在 vector 容器对象中。 • build_map 函数将每一行分解为各个单词,创建 map 容器对象,同时记 录每个单词出现行号。 10.6.2. TextQuery 类 经过前面的设计后,现在可以编写 TextQuery 类了: class TextQuery { public: // typedef to make declarations easier typedef std::vector::size_type line_no; /* interface: * read_file builds internal data structures for the given file * run_query finds the given word and returns set of lines on which it appears * text_line returns a requested line from the input file */ void read_file(std::ifstream &is) { store_file(is); build_map(); } std::set run_query(const std::string&) const; std::string text_line(line_no) const; private: // utility functions used by read_file void store_file(std::ifstream&); // store input file void build_map(); // associated each word with a set of line numbers // remember the whole input file std::vector lines_of_text; // map word to set of the lines on which it occurs std::map< std::string, std::set > word_map; }; 这个类直接反映了我们的设计策略。唯一提及的是使用 typedef 为 vector 的 size_type 定义了一个别名。 497 基于第 3.1 节所提及的原因,这个类的定义在引用标准库内容 时都必须完整地使用 std:: 限定符。 read_file 函数在类的内部定义。该函数首先调用 store_file 读取并保存 输入文件,然后调用 build_map 创建关联单词与行号的 map 容器。该类的其他 函数将在第 10.6.4 节定义。首先,我们编写一个程序,使用这个类来解决文本 查询问题。 Exercises Section 10.6.2 Exercise 10.30: TextQuery 类的成员函数仅使用了前面介绍过的内容。 先别查看后面章节,请自己编写这些成员函数。提示: 唯一棘手的是 run_query 函数在行号集合 set 为空时 应返回什么值?解决方法是构造并返回一个新的(临时) set 对象。 10.6.3. TextQuery 类的使用 下面的主程序 main 使用 TextQuery 对象实现简单的用户查询会话。这段 程序的主要工作是实现与用户的互动:提示输入下一个要查询的单词,然后调用 print_results 函数(将在下面定义)输出结果。 // program takes single argument specifying the file to query int main(int argc, char **argv) { // open the file from which user will query words ifstream infile; if (argc < 2 || !open_file(infile, argv[1])) { cerr << "No input file!" << endl; return EXIT_FAILURE; } TextQuery tq; tq.read_file(infile); // builds query map // iterate with the user: prompt for a word to find and print results // loop indefinitely; the loop exit is inside the while while (true) { 498 cout << "enter word to look for, or q to quit: "; string s; cin >> s; // stop if hit eof on input or a 'q'is entered if (!cin || s == "q") break; // get the set of line numbers on which this word appears set locs = tq.run_query(s); // print count and all occurrences, if any print_results(locs, s, tq); } return 0; } 引子 程序首先检查 argv[1] 是否合法,然后调用 open_file 函数(第 8.4.3 节)打开以 main 函数实参形式给出的文件。检查流以判断输入文件是否正确。 如果不正确,就给出适当的提示信息结束程序的运行,返回 EXIT_FAILURE(第 7.3.2 节)说明发生了错误。 一旦文件成功打开,建立支持查询的 map 容器就相当简单。定义一个局部 变量 tq 来保存该输入文件和所关联的数据结构: TextQuery tq; tq.read_file(infile); builds query map tq 调用 read_file 操作,并将由 open_file 打开的文件传递给此函数。 read_file 完成后,tq 存储了两个数据结构:保存输入文件的 vector 对 象,以及关联单词和行号的 map 容器对象。map 容器为输入文件中的每个单词 建立唯一的元素,由每个单词关联的 set 容器记录了该单词出现的行号。 实现查询 为了使用户在每次会话时都能查询多个单词,我们将提示语句也置于 while 循环中: // iterate with the user: prompt for a word to find and print results // loop indefinitely; the loop exit is inside the while while (true) { cout << "enter word to look for, or q to quit: "; string s; cin >> s; 499 // stop if hit eof on input or a 'q' is entered if (!cin || s == "q") break; // get the set of line numbers on which this word appears set locs = tq.run_query(s); // print count and all occurrences, if any print_results(locs, s, tq); } while 循环条件为布尔字面值 true,这就意味着循环条件总是成立。在检 查 cin 和读入 s 值后,由紧跟的 break 语句跳出循环。具体说来,当 cin 遇 到错误或文件结束,或者用户输入 q 时,循环结束。 每次要查找一个单词时,访问 tq 获取记录该单词出现的行号的 set 对象。 将 set 对象、要查找的单词和 TextQuery 对象作为参数传递给 print_results 函数,该函数输出查询结果。 输出结果 现在只剩下 print_results 函数的定义: void print_results(const set& locs, const string& sought, const TextQuery &file) { // if the word was found, then print count and all occurrences typedef set line_nums; line_nums::size_type size = locs.size(); cout << "\n" << sought << " occurs " << size << " " << make_plural(size, "time", "s") << endl; // print each line in which the word appeared line_nums::const_iterator it = locs.begin(); for ( ; it != locs.end(); ++it) { cout << "\t(line " // don't confound user with text lines starting at 0 << (*it) + 1 << ") " << file.text_line(*it) << endl; } } 函数首先使用 typedef 简化记录行号的 set 容器对象的使用。输出时,首 先给出查询到的匹配个数,即 set 对象的大小。然后调用 make_plural(第 7.3.2 节),根据 size 是否为 1 输出“time”或“times”。 500 这段程序最复杂的部分是处理 locs 对象的 for 循环,用于输出找到该单 词的行号。其唯一的微妙之处是记得将行号修改为更友好的形式输出。为了与 C++ 的容器和数组下标编号匹配,在储存文本时,我们以行号 0 存储第一行。 但考虑到很多用户会默认第一行的行号为 1,所以输出行号时,相应地所存储的 行号上加 1 使之转换为更通用的形式。 Exercises Section 10.6.3 Exercise 如果没有找到要查询的单词,main 函数输出什么? 10.31: 10.6.4. 编写成员函数 现在给没有在类内定义的成员函数编写定义。 存储输入文件 第一个任务是读入需要查询的文件。使用 string 和 vector 容器提供的操 作,可以很简便地实现这个任务: // read input file: store each line as element in lines_of_text void TextQuery::store_file(ifstream &is) { string textline; while (getline(is, textline)) lines_of_text.push_back(textline); } 由于我们希望每次存储文件的一行内容,因此使用 getline 读取输入,每 读入一行就将它添加到名为 lines_of_text 的 vector 对象中。 建立单词 map 容器 vector 容器中的每个元素就是一行文本。要建立一个从单词关联到行号的 map 容器,必须将每行分解为各个单词。再次使用第 8.5 节描述的 istringstream: // finds whitespace-separated words in the input vector // and puts the word in word_map along with the line number void TextQuery::build_map() 501 { // process each line from the input vector for (line_no line_num = 0; line_num != lines_of_text.size(); ++line_num) { //we'll use line to read the text a word at a time istringstream line(lines_of_text[line_num]); string word; while (line >> word) // add this line number to the set; // subscript will add word to the map if it's not already there word_map[word].insert(line_num); } } for 循环以每次一行的迭代过程遍历 lines_of_text。首先将 istringstream 对象 line 与当前行绑定起来,然后使用 istringstream 的输 入操作符读入该行中的每个单词。回顾此类输入操作符,与其他 istream 操作 符一样,将忽略空白符号。因此,while 循环将 line 中以空白符分隔的单词读 取出来。 这个函数的结尾部分类似前面的单词统计程序。将 word 用做 map 容器的 下标。如果 word 在 word_map 容器对象中不存在,那么下标操作符将该 word 添加到此容器中,将将其关联的值初始化为空的 set。不管是否添加了 word, 下标运算都返回一个 set 对象,然后调用 insert 函数在该 set 对象中添加当 前行号。如果某个单词在同一行中重复出现,那么 insert 函数的调用将不做任 何操作。 支持查询 run_query 函数实现真正的查询功能: set TextQuery::run_query(const string &query_word) const { //Note: must use find and not subscript the map directly //to avoid adding words to word_map! map >::const_iterator loc = word_map.find(query_word); if (loc == word_map.end()) return set(); // not found, return empty set else 502 // fetch and return set of line numbers for this word return loc->second; } run_query 函数带有指向 const string 类型对象的引用参数,并以这个参 数作为下标来访问 word_map 对象。假设成功找到这个 string,那么该函数返 回关联此 string 的 set 对象,否则返回一个空的 set 对象。 run_query 返回值的使用 运行 run_query 函数后,将获得一组所查找的单词出现的行号。除了输出 该单词的出现次数之外,还需要输出出现该单词的每一行。这就是 text_line 函 数实现的功能: string TextQuery::text_line(line_no line) const { if (line < lines_of_text.size()) return lines_of_text[line]; throw std::out_of_range("line number out of range"); } 该函数带有一个行号参数,返回该行号所对应的输入文本行。由于上述代码 使用了 TextQuery 类,因此不能直接输出(因为 lines_of_text 是私有的), 应该首先检查我们要查询的行是否们于合法范围内。如果是,则返回相应的行, 否则,抛出 out_of_range 异常。 Exercises Section 10.6.4 Exercise 10.32: 重新实现文本查询程序,使用 vector 容器代替 set 对 象来存储行号。注意,由于行以升序出现,因此只有在 当前行号不是 vector 容器对象中的最后一个元素时, 才能将新行号添加到 vector 中。这两种实现方法的性 能特点和设计特点分别是什么?你觉得哪一种解决方法 更好?为什么? Exercise TextQuery::text_line 函数为什么不检查它的参数是 10.33: 否为负数? 小结 503 关联容器的元素按键排序和访问。关联容器支持通过键高效地查找和读取元 素。键的使用,使关联容器区别于顺序容器,顺序容器的元素是根据位置访问的。 map 和 multimap 类型存储的元素是键-值对。它们使用在 utility 头文 件中定义的标准库 pair 类,来表示这些键-值对元素。对 map 或 multimap 迭 代器进行解引用将获得 pair 类型的值。pair 对象的 first 成员是一个 const 键,而 second 成员则是该键所关联的值。set 和 multiset 类型则专门用于存 储键。在 map 和 set 类型中,一个键只能关联一个元素。而 multimap 和 multiset 类型则允许多个元素拥有相同的键。 关联容器共享了顺序容器的许多操作。除此之外,关联容器还定义一些新操 作,并对某些顺序容器同样提供的操作重新定义了其含义或返回类型,这些操作 的差别体现了关联容器中键的使用。 关联容器的元素可用迭代器访问。标准库保证迭代器按照键的次序访问元 素。begin 操作将获得拥有最小键的元素,对此迭代器作自增运算则可以按非降 序依次访问各个元素。 术语 associative array(关联数组) 由键而不是位置来索引元素的数组。通常描述为:此类数组将键映射到其 关联的值上。 associative container(关联容器) 存储对象集合的类型,支持通过键的高效查询。 key_type 关联容器定义的类型,表示该容器在存储或读取值时所使用的键的类型。 对于 map 容器,key_type 是用于索引该容器的类型。对于 set 容器, key_type 与 value_type 相同。 map 定义关联数组的关联容器类型。与 vector 容器一样,map 也是类模板。 但是,map 容器定义了两种类型:键类型及其关联的值类型。在 map 中, 每个键只能出现一次,并关联某一具体的值。对 map 容器的迭代器进行 解引用将获得一个 pair 对象,该对象存储了一个 const 键和它所关联 的值。 mapped_type 504 map 或 multimap 容器定义的类型,表示在 map 容器中存储的值的类型。 multimap 类似 map 的关联容器。在 multimap 容器中,一个键可以出现多次。 multiset 只存储键的关联容器类型。在 multiset 容器中,一个键可以出现多次。 pair 一种类型,有两个 public 数据成员,分别名为 first 和 second。pair 类型是带有两个类型形参的模板类型,它的类型形参用作数据成员的类 型。 set 只存储键的关联容器。在 set 容器中,一个键只能出现一次。 strict weak ordering(严格弱排序) 关联容器所使用的键之间的比较关系。在这种关系下,任意两个元素都可 比较,并能确定两者之间谁比谁小。如果两个值都不比对方小,则这两个 值相等,详见第 10.3.1 节。 value_type 存储在容器中的元素的类型。对于 set 和 multiset 容器,value_type 与 key_type 相同。而对于 map 和 multimap 容器,该类型为 pair 类 型,它的 first 成员是 const key_type 类型,second 成员则是 mapped_type 类型。 * operator(解引用操作符) 用于 map、set、multimap 或 multiset 迭代器时,解引用操作符将生成 一个 value_type 类型的值。注意,对于 map 和 multimap 容器, value_type 是 pair 类型。 [] operator(下标操作符) 下标操作符。对 map 容器使用下标操作符时,[] 中的索引必须是 key_type 类型(或者是可以转换为 key_type 的类型)的值;该运算生 成 mapped_type 类型的值。 505 第十一章 泛型算法 标准库容器定义的操作非常少。标准库没有给容器添加大量的功能函数,而 是选择提供一组算法,这些算法大都不依赖特定的容器类型,是“泛型”的,可 作用在不同类型的容器和不同类型的元素上。 泛型算法以及对迭代器更详尽的描述,组成了本章的主题。 标准容器(the standard container)定义了很少的操作。大部分容器都支 持添加和删除元素;访问第一个和最后一个元素;获取容器的大小,并在某些情 况下重设容器的大小;以及获取指向第一个元素和最后一个元素的下一位位置的 迭代器。 可以想像,用户可能还希望对容器元素进行更多其他有用的操作:也许需要 给顺序容器排序,或者查找某个特定的元素,或者查找最大或最小的元素,等等。 标准库并没有为每种容器类型都定义实现这些操作的成员函数,而是定义了一组 泛型算法:因为它们实现共同的操作,所以称之为“算法”;而“泛型”指的是 它们可以操作在多种容器类型上——不但可作用于 vector 或 list 这些标准 库类型,还可用在内置数组类型、甚至其他类型的序列上,这些我们将在本章的 后续内容中了解。自定义的容器类型只要与标准库兼容,同样可以使用这些泛型 算法。 大多数算法是通过遍历由两个迭代器标记的一段元素来实现其功能。典型情 况下,算法在遍历一段元素范围时,操纵其中的每一个元素。算法通过迭代器访 问元素,这些迭代器标记了要遍历的元素范围。 11.1. 概述 假设有一个 int 的 vector 对象,名为 vec,我们想知道其中包含某个特 定值。解决这个问题最简单的方法是使用标准库提供的 find 运算: // value we'll look for int search_value = 42; // call find to see if that value is present vector::const_iterator result = find(vec.begin(), vec.end(), search_value); // report the result cout << "The value " << search_value << (result == vec.end() ? " is not present" : " is present") << endl; 506 使用两个迭代器和一个值调用 find 函数,检查两个迭代器实参标记范围内 的每一个元素。只要找到与给定值相等的元素,find 就会返回指向该元素的迭 代器。如果没有匹配的元素,find 就返回它的第二个迭代器实参,表示查找失 败。于是,只要检查该函数的返回值是否与它的第二个实参相等,就可得知元素 是否找到了。我们在输出语句中使用条件操作符(第 5.7 节)实现这个检查并 报告是否找到了给定值。 由于 find 运算是基于迭代器的,因此可在任意容器中使用相同的 find 函 数查找值。例如,可在一个名为 lst 的 int 型 list 对象上,使用 find 函数 查找一个值: // call find to look through elements in a list list::const_iterator result = find(lst.begin(), lst.end(), search_value); cout << "The value " << search_value << (result == lst.end() ? " is not present" : " is present") << endl; 除了 result 的类型和传递给 find 的迭代器类型之外,这段代码与使 用 find 在 vector 对象中查找元素的程序完全相同。 类似地,由于指针的行为与作用在内置数组上的迭代器一样,因此也可以使 用 find 来搜索数组: int ia[6] = {27, 210, 12, 47, 109, 83}; int search_value = 83; int *result = find(ia, ia + 6, search_value); cout << "The value " << search_value << (result == ia + 6 ? " is not present" : " is present") << endl; 这里给 find 函数传递了两个指针:指向 ia 数组中第一个元素的指针,以 及指向 ia 数组起始位置之后第 6 个元素的指针(即 ia 的最后一个元素的下 一位置)。如果返回的指针等于 ia + 6,那么搜索不成功;否则,返回的指针 指向找到值。 507 如果需要传递一个子区间,则传递指向这个子区间的第一个元素以及最后一 个元素的下一位置的迭代器(或指针)。例如,在下面对 find 函数的调用中, 只搜索了 ia[1] 和 ia[2]: // only search elements ia[1] and ia[2] int *result = find(ia + 1, ia + 3, search_value); 算法如何工作 每个泛型算法的实现都独立于单独的容器。这些算法还是大而不全的,并且 不依赖于容器存储的元素类型。为了知道算法如何工作,让我们深入了 解 find 操作。该操作的任务是在一个未排序的元素集合中查找特定的元素。从 概念上看,find 必须包含以下步骤: 1. 顺序检查每个元素。 2. 如果当前元素等于要查找的值,那么返回指向该元素的迭代器。 3. 否则,检查下一个元素,重复步骤 2,直到找到这个值,或者检查完所有的 元素为止。 4. 如果已经到达集合末尾,而且还未找到该值,则返回某个值,指明要查找的 值在这个集合中不存在。 标准算法固有地独立于类型 这种算法,正如我们所指出的,与容器的类型无关:在前面的描述中,没有 任何内容依赖于容器类型。这种算法只在一点上隐式地依赖元素类型:必须能够 对元素做比较运算。该算法的明确要求如下: 1. 需要某种遍历集合的方式:能够从一个元素向前移到下一个元素。 2. 必须能够知道是否到达了集合的末尾。 3. 必须能够对容器中的每一个元素与被查找的元素进行比较。 4. 需要一个类型指出元素在容器中的位置,或者表示找不到该元素。 迭代器将算法和容器绑定起来 泛型算法用迭代器来解决第一个要求:遍历容器。所有迭代器都支持自增操 作符,从一个元素定位下一个元素,并提供解引用操作符访问元素的值。除了 第 11.3.5 节将介绍的一个例外情况之外,迭代器还支持相等和不等操作符,用于 判断两个迭代是否相等。 508 大多数情况下,每个算法都需要使用(至少)两个迭代器指出该算法操纵的 元素范围。第一个迭代器指向第一个元素,而第二个迭代器则指向最后一个元素 的下一位置。第二个迭代器所指向的元素[有时被称为超出末端迭代器]本身不 是要操作的元素,而被用作终止遍历的哨兵(sentinel)。 使用超出末端迭代器还可以很方便地处理第四个要求,只要以此迭代器为返 回值,即可表示没有找到要查找的元素。如果要查找的值未找到,则返回超出末 端迭代器;否则,返回的迭代器指向匹配的元素。 第三个要求——元素值的比较,有两种解决方法。默认情况下,find 操作 要元素类型定义了相等(==)操作符,算法使用这个操作符比较元素。如果元素 类型不支持相等(==)操作符,或者打算用不同的测试方法来比较元素,则可使 用第二个版本的 find 函数。这个版本需要一个额外的参数:实现元素比较的函 数名字。 这些算法从不使用容器操作,因而其实现与类型无关,元素的所有访问和遍 历都通过迭代器实现。实际的容器类型未知(甚至所处理的元素是否存储在容器 中也是未知的)。 标准库提供了超过 100 种算法。与容器一样,算法有着一致的结构。比起 死记全部一百多种算法,了解算法的设计可使我们更容易学习和使用它们。本章 除了举例说明这些算法的使用之外,还将描述标准库算法的统一原理。附录 A 根据操作分类列出了所有的算法。 Exercises Section 11.1 Exercise 11.1: algorithm 头文件定义了一个名为 count 的函数,其功 能类似于 find。这个函数使用一对迭代器和一个值做参 数,返回这个值出现次数的统计结果。编写程序读取一 系列 int 型数据,并将它们存储到 vector 对象中,然 后统计某个指定的值出现了多少次。 Exercise 重复前面的程序,但是,将读入的值存储到一 11.2: 个 string 类型的 list 对象中。 关键概念:算法永不执行容器提供的操作 泛型算法本身从不执行容器操作,只是单独依赖迭代器和迭代器操作实 现。算法基于迭代器及其操作实现,而并非基于容器操作。这个事实也 许比较意外,但本质上暗示了:使用“普通”的迭代器时,算法从不修 509 改基础容器的大小。正如我们所看到的,算法也许会改变存储在容器中 的元素的值,也许会在容器内移动元素,但是,算法从不直接添加或删 除元素。 第 11.3.1 节将介绍标准库提供的另一种特殊的迭代器类:插入器 (inserter),除了用于遍历其所绑定的序列之外,还可实现更多的功 能。在给这类迭代器赋值时,在基础容器上将执行插入运算。如果算法 操纵这类迭代器,迭代器将可能导致在容器中添加元素。但是,算法本 身从不这么做。 11.2. 初窥算法 在研究算法标准库的结构之前,先看一些例子。上一节已经介绍了 find 函 数的用法;本节将要使用其他的一些算法。使用泛型算法必须包含 algorithm 头 文件: #include 标准库还定义了一组泛化的算术算法(generalized numeric algorithm), 其命名习惯与泛型算法相同。使用这些算法则必须包含 numeric 头文件: #include 除了少数例外情况,所有算法都在一段范围内的元素上操作,我们将这段范 围称为“输出范围(input range)”。带有输入范围参数的算法总是使用头两 个形参标记该范围。这两个形参是分别指向要处理的第一个元素和最后一个元素 的下一位置的迭代器。 尽管大多数算法对算法对输入范围的操作是类似的,但在该范围内如何操纵 元素却有所不同。理解算法的最基本方法是了解该算法是否读元素、写元素或者 对元素进行重新排序。在本节的余下内容中,将会观察到每种算法的例子。 11.2.1. 只读算法 许多算法只会读取其输入范围内的元素,而不会写这些元素。find 就是一 个这样的算法。另一个简单的只读算法是 accumulate,该算法在 numeric 头文 件中定义。假设 vec 是一个 int 型的 vector 对象,下面的代码: // sum the elements in vec starting the summation with the value 42 int sum = accumulate(vec.begin(), vec.end(), 42); 将 sum 设置为 vec 的元素之和再加上 42。accumulate 带有三个形参。头 两个形参指定要累加的元素范围。第三个形参则是累加的初值。accumulate 函 510 数将它的一个内部变量设置为指定的初值,然后在此初值上累加输入范 围 accumulate 用于指定累加起始值的第三个实参是必要的,因 为 accumulate 对将要累加的元素类型一无所知,因此,除此 之外,没有别的办法创建合适的起始值或者关联的类型。 accumulate 对要累加的元素类型一无所知,这个事实有两层含义。首先,调用 该函数时必须传递一个起始值,否则,accumulate 将不知道使用什么起始值。 其次,容器内的元素类型必须与第三个实参的类型匹配,或者可转换为第三个实 参的类型。在 accumulate 内部,第三个实参用作累加的起点;容器内的元素按 顺序连续累加到总和之中。因此,必须能够将元素类型加到总和类型上。 考虑下面的例子,可以使用 accumulate 把 string 型的 vector 容器中的 元素连接起来: // concatenate elements from v and store in sum string sum = accumulate(v.begin(), v.end(), string("")); 这个函数调用的效果是:从空字符串开始,把 vec 里的每个元素连接成一 个字符串。注意:程序显式地创建了一个 string 对象,用该函数调用的第三个 实参。传递一个字符串字面值,将会导致编译时错误。因为此时,累加和的类型 将是 const char*,而 string 的加法操作符(第 3.2.3 节)所使用的操作数 则分别是 string 和 const char* 类型,加法的结果将产生一个 string 对象, 而不是 const char* 指针。 find_first_of 的使用 除了 find 之外,标准库还定义了其他一些更复杂的查找算法。当中的一部 分类似 string 类的 find 操作(第 9.6.4 节),其中一个是 find_first_of 函 数。这个算法带有两对迭代器参数来标记两段元素范围,在第一段范围内查找与 第二段范围中任意元素匹配的元素,然后返回一个迭代器,指向第一个匹配的元 素。如果找不到元素,则返回第一个范围的 end 迭代器。假 设 roster1 和 roster2 是两个存放名字的 list 对象,可使 用 find_first_of 统计有多少个名字同时出现在这两个列表中: // program for illustration purposes only: // there are much faster ways to solve this problem size_t cnt = 0; list::iterator it = roster1.begin(); // look in roster1 for any name also in roster2 while ((it = find_first_of(it, roster1.end(), roster2.begin(), roster2.end())) != roster1.end()) { 511 ++cnt; // we got a match, increment it to look in the rest of roster1 ++it; } cout << "Found " << cnt << " names on both rosters" << endl; 调用 find_first_of 查找 roster2 中的每个元素是否与第一个范围内的 元素匹配,也就是在 it 到 roster1.end() 范围内查找一个元素。该函数返回 此范围内第一个同时存在于第二个范围中的元素。在 while 的第一次循环中, 遍历整个 roster1 范围。第二次以及后续的循环迭代则只考虑 roster1 中尚未 匹配的部分。 循环条件检查 find_first_of 的返回值,判断是否找到匹配的名字。如果 找到一个匹配,则使计数器加 1,同时给 it 加 1,使它指向 roster1 中的下一 个元素。很明显可知,当不再有任何匹配时,find_first_of 返 回 roster1.end(),完成统计。 关键概念:迭代器实参类型 通常,泛型算法都是在标记容器(或其他序列)内的元素范围的迭代器 上操作的。标记范围的两个实参类型必须精确匹配,而迭代器本身必须 标记一个范围:它们必须指向同一个容器中的元素(或者超出容器末端 的下一位置),并且如果两者不相等,则第一个迭代器通过不断地自增, 必须可以到达第二个迭代器。 有些算法,例如 find_first_of,带有两对迭代器参数。每对迭代器中, 两个实参的类型必须精确匹配,但不要求两对之间的类型匹配。特别是, 元素可存储在不同类型序列中,只要这两序列的元素可以比较即可。 在上述程序中,roster1 和 roster2 的类型不必精确匹配:roster1 可 以是 list 对象,而 roster2 则可以是 vector 对象、 deque 对象或 者是其他后面要学到的序列。只要这两个序列的元素可使用相等(==) 操作符进行比较即可。如果 roster1 是 list 对象, 则 roster2 可以是 vector 对象,因为 string 标准库 为 string 对象与 char* 对象定义了相等(==)操作符。 Exercises Section 11.2.1 Exercise 用 accumulate 统计 vector 容器对象中的元素 11.3: 之和 512 Exercise 假定 v 是 vector 类型的对象,则调 11.4: 用 accumulate vec; // empty vector // disaster: attempts to write to 10 (nonexistent) elements in vec fill_n(vec.begin(), 10, 0); 513 这个 fill_n 函数的调用将带来灾难性的后果。我们指定要写入 10 个元 素,但这些元素却不存在——vec 是空的。其结果未定义,很可能导致严重的运 行时错误。 对指定数目的元素做写入运算,或者写到目标迭代器的算法, 都不检查目标的大小是否足以存储要写入的元素。 引入 back_inserter 确保算法有足够的元素存储输出数据的一种方法是使用插入迭代器。插入迭 代器是可以给基础容器添加元素的迭代器。通常,用迭代器给容器元素赋值时, 被赋值的是迭代器所指向的元素。而使用插入迭代器赋值时,则会在容器中添加 一个新元素,其值等于赋值运算的右操作数的值。 第 11.3.1 节将会讨论更多关于插入迭代器的内容。然而,为了说明如何安 全使用写容器的算法,下面将使用 back_inserter. 使用 back_inserter 的程 序必须包含 iterator 头文件。 back_inserter 函数是迭代器适配器。与容器适配器(第 9.7 节)一样, 迭代器适配器使用一个对象作为实参,并生成一个适应其实参行为的新对象。在 本例中,传递给 back_inserter 的实参是一个容器的引用。back_inserter 生 成一个绑定在该容器上的插入迭代器。在试图通过这个迭代器给元素赋值时,赋 值运算将调用 push_back 在容器中添加一个具有指定值的元素。使 用 back_inserter 可以生成一个指向 fill_n 写入目标的迭代器: vector vec; // empty vector // ok: back_inserter creates an insert iterator that adds elements to vec fill_n (back_inserter(vec), 10, 0); // appends 10 elements to vec 现在,fill_n 函数每写入一个值,都会通过 back_inserter 生成的插入迭 代器实现。效果相当于在 vec 上调用 push_back,在 vec 末尾添加 10 个元素, 每个元素的值都是 0。 写入到目标迭代器的算法 第三类算法向目标迭代器写入未知个数的元素。正如 fill_n 函数一样,目 标迭代器指向存放输出数据的序列中第一个元素。这类算法中最简单的 是 copy 函数。copy 带有三个迭代器参数:头两个指定输入范围,第三个则指 向目标序列的一个元素。传递给 copy 的目标序列必须至少要与输入范围一样 514 大。假设 ilst 是一个存放 int 型数据的 list 对象,可如下将它 copy 给一 个 vector 对象: vector ivec; // empty vector // copy elements from ilst into ivec copy (ilst.begin(), ilst.end(), back_inserter(ivec)); copy 从输入范围中读取元素,然后将它们复制给目标 ivec。 当然,这个例子的效率比较差:通常,如果要以一个已存在的容器为副本创 建新容器,更好的方法是直接用输入范围作为新构造容器的初始化式: // better way to copy elements from ilst vector ivec(ilst.begin(), ilst.end()); 算法的 _copy 版本 有些算法提供所谓的“复制(copying)”版本。这些算法对输入序列的元 素做出处理,但不修改原来的元素,而是创建一个新序列存储元素的处理结果。 但不修改原来的元素,而是创建一个新序列存储元素的处理结果。 replace 算法就是一个很好的例子。该算法对输入序列做读写操作,将序列 中特定的值替换为新的值。该算法带有四个形参:一对指定输入范围的迭代器和 两个值。每一个等于第一值的元素替换成第二个值。 // replace any element with value of 0 by 42 replace(ilst.begin(), ilst.end(), 0, 42); 这个调用将所有值为 0 的实例替换成 42。如果不想改变原来的序列,则调 用 replace_copy。这个算法接受第三个迭代器实参,指定保存调整后序列的目 标位置。 // create empty vector to hold the replacement vector ivec; // use back_inserter to grow destination as needed replace_copy (ilst.begin(), ilst.end(), back_inserter(ivec), 0, 42); 调用该函数后,ilst 没有改变,ivec 存储 ilst 一份副本,而 ilst 内所 有的 0 在 ivec 中都变成了 42。 Exercises Section 11.2.2 515 Exercise 使用 fill_n 编写程序,将一个 int 序列的值设为 0。 11.6: Exercise 11.7: 判断下面的程序是否有错,如果有,请改正之: (a) vector vec; list lst; int i; while (cin >> i) lst.push_back(i); copy(lst.begin(), lst.end(), vec.begin()); (b) vector vec; vec.reserve(10); fill_n(vec.begin(), 10, 0); Exercise 前面说过,算法不改变它所操纵的容器的大小,为什么 11.8: 使用 back_inserter 也不能突破这个限制? 11.2.3. 对容器元素重新排序的算法 假设我们要分析一组儿童故事中所使用的单词。例如,可能想知道它们使用 了多少个由六个或以上字母组成的单词。每个单词只统计一次,不考虑它出现的 次数,也不考虑它是否在多个故事中出现。要求以长度的大小输出这些单词,对 于同样长的单词,则以字典顺序输出。 假定每本书的文本已经读入并保存在一个 string 类型的 vector 对象中, 它的名字是 words。现在,应该怎么解决包括统计单词出现次数这个问题呢?为 了解此问题,要做下面几项操作: 1. 去掉所有重复的单词。 2. 按单词的长度排序。 3. 统计长度等于或超过 6 个字符的单词个数。 上述每一步都可使用泛型算法实现。 为了说清楚,使用下面这个简单的故事作为我们的输入: the quick red fox jumps over the slow red turtle 对于这个输入,我们的程序应该产生如下输出: 1 word 6 characters or longer 去除重复 516 假设我们的输入存储在一个名为 words 的 vector 对象中,第一个子问题 是将 words 中重复出现的单词去除掉: // sort words alphabetically so we can find the duplicates sort(words.begin(), words.end()); /* eliminate duplicate words: * unique reorders words so that each word appears once in the * front portion of words and returns an iterator one past the unique range; * erase uses a vector operation to remove the nonunique elements */ vector::iterator end_unique = unique(words.begin(), words.end()); words.erase(end_unique, words.end()); vector 对象包含每个故事中使用的所有单词。首先对此 vector 对象排序。 sort 算法带有两个迭代器实参,指出要排序的元素范围。这个算法使用小于(<) 操作符比较元素。在本次调用中,要求对整个 vector 对象排序。 调用 sort 后,此 vector 对象的元素按次序排列: fox jumps over quick red red slow the the turtle 注意,单词 red 和 the 重复出现了。 unique 的使用 单词按次序排列后,现在的问题是:让故事中所用到的每个单词都只保留一 个副本。unique 算法很适合用于解决这个问题,它带有两个指定元素范围的迭 代器参数。该算法删除相邻的重复元素,然后重新排列输入范围内的元素,并且 返回一个迭代器,表示无重复的值范围的结束。 调用 unique 后,vector 中存储内容是: 注意,words 的大小并没有改变,依然保存着 10 个元素;只是这些元素的 顺序改变了。调用 unique“删除”了相邻的重复值。给“删除”加上引号是因 为 unique 实际上并没有删除任何元素,而是将无重复的元素复制到序列的前 端,从而覆盖相邻的重复元素。unique 返回的迭代器指向超出无重复的元素范 围末端的下一位置。 517 使用容器操作删除元素 如果要删除重复的项,必须使用容器操作,在本例中调用 erase 实现该功 能。这个函数调用从 end_unique 指向的元素开始删除,直到 words 的最后一 个元素也删除掉为止。调用之后,words 存储输入的 8 个不相同的元素。 算法不直接修改容器的大小。如果需要添加或删除元素,则必 须使用容器操作。 值得注意的是,对没有重复元素的 vector 对象,调用 erase 也是安全的。 如果不存在重复的元素,unique 就会返回 words.end(),此时,调用 erase 的 两个实参值相同,都是 words.end()。两个迭代器相等这个事实意味着 erase 函 数要删除的范围是空的。删除一段空的范围没有任何作用,所以即使输入中没有 重复的元素,我们的程序仍然正确。 定义需要的实用函数 下一个子问题统计长度不小于 6 的单词个数。为了解决这个问题,需要用 到另外两个泛型算法:stable_sort 和 count_if。使用这些算法,还需要一个 配套的实用函数,称为谓词。谓词是做某些检测的函数,返回用于条件判断的类 型,指出条件是否成立。 我们需要的第一个谓词将用在基于大小的元素排序中。为了实现排序,必须 定义一个谓词函数来实现两个 string 对象的比较,并返回一个 bool 值,指出 第一个字符串是否比第二个短: // comparison function to be used to sort by word length bool isShorter(const string &s1, const string &s2) { return s1.size() < s2.size(); } 另一个所需的谓词函数将判断给出的 string 对象的长度是否不小于 6: // determine whether a length of a given word is 6 or more bool GT6(const string &s) { return s.size() >= 6; } 尽管这个函数能解决问题,但存在不必要限制——函数内部硬性规定了对长 度大小的要求。如果要统计其他长度的单词个数,则必须编写另一个函数。其实 很容易写出更通用的比较函数,使它带有两个形参,分别是 string 对象和一个 长度大小值即可。但是,传递给 count_if 算法的函数只能带有一个实参,因此 518 本程序不能使用上述更通用的方法。第 14.8.1 节将为这个问题提供更好的解决 方案。 排序算法 标准库定义了四种不同的排序算法,上面只使用了最简单的 sort 算法, 使 words 按字典次序排列。除了 sort 之外,标准库还定义了 stable_sort 算 法,stable_sort 保留相等元素的原始相对位置。通常,对于已排序的序列,我 们并不关心其相等元素的相对位置,毕竟,这些元素是相等的。但是,在这个应 用中,我们将“相等”定义为“相同的长度”,有着相同长度的元素还能以字典 次序的不同而区分。调用 stable_sort 后,对于长度相同的元素,将保留其字 典顺序。 sort 和 stable_sort 都是重载函数。其中一个版本使用元素类型提供的小 于(<)操作符实现比较。在查找重复元素之前,我们就是用这个 sort 版本对 元素排序。第二个重载版本带有第三个形参:比较元素所使用的谓词函数的名字。 这个谓词函数必须接受两个实参,实参的类型必须与元素类型相同,并返回一个 可用作条件检测的值。下面将比较元素的 isShorter 函数作为实参,调用第二 个版本的排序函数: // sort words by size, but maintain alphabetic order for words of the same size stable_sort(words.begin(), words.end(), isShorter); 调用后,words 中的元素按长度大小排序,而长度相同的单词则仍然保持字典顺 序: 统计长度不小于 6 的单词 现在此 vector 对象已经按单词长度排序,剩下的问题就是统计长度不小 于 6 的单词个数。使用 count_if 算法处理这个问题: vector::size_type wc = count_if(words.begin(), words.end(), GT6); 执行 count_if 时,首先读取它的头两个实参所标记的范围内的元素。每读 出一个元素,就将它传递给第三个实参表示的谓词函数。此谓词函数。此谓词函 数需要单个元素类型的实参,并返回一个可用作条件检测的值。count_if 算法 返回使谓词函数返回条件成立的元素个数。在这个程序中,count_if 将每个单 词传递给 GT6,而 GT6 返回一个 bool 值,如果单词长度不小于 6,则 该 bool 值为 true。 519 将全部程序段放在一起 了解程序的细节之后,下面是完整的程序: // comparison function to be used to sort by word length bool isShorter(const string &s1, const string &s2) { return s1.size() < s2.size(); } // determine whether a length of a given word is 6 or more bool GT6(const string &s) { return s.size() >= 6; } int main() { vector words; // copy contents of each book into a single vector string next_word; while (cin >> next_word) { // insert next book's contents at end of words words.push_back(next_word); } // sort words alphabetically so we can find the duplicates sort (words.begin(), words.end()); /* eliminate duplicate words: * unique reorders words so that each word appears once in the * front portion of words and returns an iterator one past the unique range; * erase uses a vector operation to remove the nonunique elements */ vector::iterator end_unique = unique(words.begin(), words.end()); words.erase(end_unique, words.end()); // sort words by size, but maintain alphabetic order for words of the same size stable_sort(words.begin(), words.end(), isShorter); vector::size_type wc = count_if (words.begin(), words.end(), GT6); cout << wc << " " << make_plural(wc, "word", "s") << " 6 characters or longer" << endl; return 0; } 最后,我们留下按长度顺序输出单词这个问题作为习题。 520 Exercises Section 11.2.3 Exercise 编写程序统计长度不小于 4 的单词,并输出输入序列中 11.9: 不重复的单词。在程序源文件上运行和测试你自己编写 的程序。 Exercise 11.10: 标准库定义了一个 find_if 函数。与 find 一样, find_if 函数带有一对迭代器形参,指定其操作的范围。 与 count_if 一样,该函数还带有第三个形参,表明用 于检查范围内每个元素的谓词函数。find_if 返回一个 迭代器,指向第一个谓词函数返回非零值的元素。如果 这样的元素不存在,则返回第二个迭代器实参。使 用 find_if 函数重写上述例题中统计长度大于 6 的单 词个数的程序部分。 Exercise 你认为为什么算法不改变容器的大小? 11.11: Exercise 为什么必须使用 erase,而不是定义一个泛型算法来删 11.12: 除容器中的元素? 11.3. 再谈迭代器 第 11.2.2 节已强调标准库所定义的迭代器不依赖于特定的容器。事实上, C++ 语言还提供了另外三种迭代器: • 插入迭代器:这类迭代器与容器绑定在一起,实现在容器中插入元素的功 能。 • iostream 迭代器:这类迭代器可与输入或输出流绑定在一起,用于迭代 遍历所关联的 IO 流。 • 反向迭代器:这类迭代器实现向后遍历,而不是向前遍历。所有容器类型 都定义了自己的 reverse_iterator 类型,由 rbegin 和 rend 成员函数 返回。 上述迭代器类型都在 iterator 头文件中定义。 本节将详细分析上述每种迭代器,并介绍在泛型算法中如何使用这些迭代器, 还会了解什么时候应该使用和如何使用 const_iterator 容器 11.3.1. 插入迭代器 521 第 11.2.2 节使用 back_insert 创建一个迭代器,用来给容器添加元素。 back_inserter 函数是一种插入器。插入器是一种迭代器适配器(第 9.7 节), 带有一个容器参数,并生成一个迭代器,用于在指定容器中插入元素。通过插入 迭代器赋值时,迭代器将会插入一个新的元素。C++ 语言提供了三种插入器,其 差别在于插入元素的位置不同。 • back_inserter,创建使用 push_back 实现插入的迭代器。 • front_inserter,使用 push_front 实现插入。 • inserter,使用 insert 实现插入操作。除了所关联的容器外,inserter 还带有第二实参:指向插入起始位置的迭代器。 front_inserter 需要使用 push_front front_inserter 的操作类似于 back_inserter:该函数将创建一个迭代器, 调用它所关联的基础容器的 push_front 成员函数代替赋值操作。 只有当容器提供 push_front 操作时,才能使用 front_inserter。在 vector 或其他没有 push_front 运算的 容器上使用 front_inserter,将产生错误。 inserter 将产生在指定位置实现插入的迭代器 inserter 适配器提供更普通的插入形式。这种适配器带有两个实参:所关 联的容器和指示起始插入位置的迭代器。 // position an iterator into ilst list::iterator it = find (ilst.begin(), ilst.end(), 42); // insert replaced copies of ivec at that point in ilst replace_copy (ivec.begin(), ivec.end(), inserter (ilst, it), 100, 0); 首先用 find 定位 ilst 中的某个元素。使用 inserter 作为实参调用 replace_copy,inserter 将会在 ilst 中由 find 返回的迭代器所指向的元素 前面插入新元素。而调用 replace_copy 的效果是从 ivec 中复制元素,并将其 中值为 100 的元素替换为 0 值。ilst 的新元素在 it 所标明的元素前面插入。 在创建 inserter 时,应指明新元素在何处插入。inserter 函数总是在它 的迭代器实参所标明的位置前面插入新元素。 也许我们会认为可使用 inserter 和容器的 begin 迭代器来模拟 front_inserter 的效果。然而,inserter 的行为与 front_inserter 的有很大 差别。在使用 front_inserter 时,元素始终在容器的第一个元素前面插入。而 使用 inserter 时,元素则在指定位置前面插入。即使此指定位置初始化为容器 522 中的第一个元素,但是,一旦在该位置前插入一个新元素后,插入位置就不再是 容器的首元素了: list ilst, ilst2, ilst3; // empty lists // after this loop ilst contains: 3 2 1 0 for (list::size_type i = 0; i != 4; ++i) ilst.push_front(i); // after copy ilst2 contains: 0 1 2 3 copy (ilst.begin(), ilst.end(), front_inserter(ilst2)); // after copy, ilst3 contains: 3 2 1 0 copy (ilst.begin(), ilst.end(), inserter (ilst3, ilst3.begin())); 在复制并创建 ilst2 的过程中,元素总是在这个 list 对象的所有元素之 前插入。而在复制创建 ilst3 的过程中,元素则在 ilst3 中的固定位置插入。 刚开始时,这个插入位置是此 list 对象的头部,但插入一个元素后,就不再是 首元素了。 回顾第 9.3.3 节的讨论,应该清楚理解 front_inserter 的使用将导致元素以相反的次序出 现在目标对象中,这点非常重要。 Exercises Section 11.3.1 Exercise 解释三种插入迭代器的区别。 11.13: Exercise 11.14: 编写程序使用 replace_copy 将一个容器中的序列复 制给另一个容器,并将前一个序列中给定的值替换为 指定的新值。分别使用 inserter、back_inserter 和 front_inserter 实现这个程序。讨论在不同情况下输 出序列如何变化。 Exercise 11.15: 算法标准库定义了一个名为 unique_copy 的函数,其 操作与 unique 类似,唯一的区别在于:前者接受第 三个迭代器实参,用于指定复制不重复元素的目标序 列。编写程序使用 unique_copy 将一个 list 对象中 不重复的元素复制到一个空的 vector 对象中。 11.3.2. iostream 迭代器 523 虽然 iostream 类型不是容器,但标准库同样提供了在 iostream 对象上使 用的迭代器:istream_iterator 用于读取输入流,而 ostream_iterator 则用 于写输出流(表 11.1)。这些迭代器将它们所对应的流视为特定类型的元素序 列。使用流迭代器时,可以用泛型算法从流对象中读数据(或将数据写到流对象 中)。 表 11.1 iostream 迭代器的构造函数 istream_iterator in(strm); 创建从输入流 strm 中读取 T 类型对象的 istream_iterator 对象 istream_iterator in; istream_iterator 对象的超出末端迭代器 ostream_iterator in(strm); 创建将 T 类型的对象写到输出流 strm 的 ostream_iterator 对象 ostream_iterator in(strm, delim); 创建将 T 类型的对象写到输出流 strm 的 ostream_iterator 对象,在写入过程中使用 delim 作为元素的分隔符。delim 是以空字符结束的字符数 组 流迭代器只定义了最基本的迭代器操作:自增、解引用和赋值。此外,可比 较两个 istream 迭代器是否相等(或不等)。而 ostream 迭代器则不提供比较 运算(表 11.2)。 表 11.2. istream_iterator 的操作 it1 == it2 it1 != it2 比较两上 istream_iterator 对象是否相等(不等)。迭代器读取 的必须是相同的类型。如果两个迭代器都是 end 值,则它们相等。 对于两个都不指向流结束位置的迭代器,如果它们使用同一个输入 流构造,则它们也相等 *it 返回从流中读取的值 it->mem 是 (*it).mem 的同义诩。返回从流中读取的对象的 mem 成员 ++it it++ 通过使用元素类型提供的 >> 操作从输入流中读取下一个元素值, 使迭代器向前移动。通常,前缀版本使用迭代器在流中向前移动, 并返回对加 1 后的迭代器的引用。而后缀版本使迭代器在流中向 前移动后,返回原值 流迭代器的定义 524 流迭代器都是类模板:任何已定义输入操作符(>> 操作符)的类型都可以 定义 istream_iterator。类似地,任何已定义输出操作符(<< 操作符)的类型 也可定义 ostream_iterator。 在创建流迭代器时,必须指定迭代器所读写的对象类型: istream_iterator cin_it(cin); // reads ints1 from cin istream_iterator end_of_stream; // end iterator value // writes Sales_items from the ofstream named outfile // each element is followed by a space ofstream outfile; ostream_iterator output(outfile, " "); ostream_iterator 对象必须与特定的流绑定在一起。在创建 istream_iterator 时,可直接将它绑定到一个流上。另一种方法是在创建时不 提供实参,则该迭代器指向超出末端位置。ostream_iterator 不提供超出末端 迭代器。 在创建 ostream_iterator 对象时,可提供第二个(可选的)实参,指定将 元素写入输出流时使用的分隔符。分隔符必须是 C 风格字符串。因为它是 C 风 格字符串,所以必须以空字符结束;否则,其行为将是未定义的。 istream_iterator 对象上的操作 构造与流绑定在一起的 istream_iterator 对象时将对迭代器定位,以便第 一次对该迭代器进行解引用时即可从流中读取第一个值。 考虑下面例子,可使用 istream_iterator 对象将标准输入读到 vector 对 象中。 istream_iterator in_iter(cin); // read ints from cin istream_iterator eof; // istream "end" iterator // read until end of file, storing what was read in vec while (in_iter != eof) // increment advances the stream to the next value // dereference reads next value from the istream vec.push_back(*in_iter++); 这个循环从 cin 中读取 int 型数据,并将读入的内容保存在 vec 中。每 次循环都检查 in_iter 是否为 eof。其中 eof 迭代器定义为空的 istream_iterator 对象,用作结束迭代器。绑在流上的迭代器在遇到文件结束 或某个错误时,将等于结束迭代器的值。 本程序最难理解的部分是传递给 push_back 的实参,该实参使用解引用和 后自增操作符。根据优先级规则(第 5.5 节),自增运算的结果将是解引用运 525 算的操作数。对 istream_iterator 对象做自增运算使该迭代器在流中向前移 动。然而,使用后自增运算的表达式,其结果是迭代器原来的值。自增的效果是 使迭代器的流中移动到下一个值,但返回指向前一个值的迭代器。对该迭代器进 行解引用获取该值。 更有趣的是可以这样重写程序: istream_iterator in_iter(cin); // read ints from cin istream_iterator eof; // istream "end" iterator vector vec(in_iter, eof); // construct vec from an iterator range 这里,用一对标记元素范围的迭代器构造 vec 对象。这些迭代器是 istream_iterator 对象,这就意味着这段范围的元素是通过读取所关联的流来 获得的。这个构造函数的效果是读 cin,直到到达文件结束或输入的不是 int 型 数值为止。读取的元素将用于构造 vec 对象。 ostream_iterator 对象和 ostream_iterator 对象的使用 可使用 ostream_iterator 对象将一个值序列写入流中,其操作的过程与使 用迭代器将一组值逐个赋给容器中的元素相同: // write one string per line to the standard output ostream_iterator out_iter(cout, "\n"); // read strings from standard input and the end iterator istream_iterator in_iter(cin), eof; // read until eof and write what was read to the standard output while (in_iter != eof) // write value of in_iter to standard output // and then increment the iterator to get the next value from cin *out_iter++ = *in_iter++; 这个程序读 cin,并将每个读入的值依次写到 cout 中不同的行中。 首先,定义一个 ostream_iterator 对象,用于将 string 类型的数据写到 cout 中,每个 string 对象后跟一个换行符。定义两个 istream_iterator 对 象,用于从 cin 中读取 string 对象。while 循环类似前一个例子。但是这一 次不是将读取的数据存储在 vector 对象中,而是将读取的数据赋给 out_iter, 从而输出到 cout 上。 这个赋值类似于第 6.7 节将一个数组复制给另一个数组的程序。对这两个 迭代器进行解引用,将右边的值赋给左边的元素,然后两个迭代器都自增 1。其 效果就是:将读取的数据输出到 cout 上,然后两个迭代器都加 1,再从 cin 中 读取下一个值。 526 在类类型上使用 istream_iterator 提供了输入操作符(>>)的任何类型都可以创建 istream_iterator 对象。 例如,可如下使用 istream_iterator 对象读取一系列的 Sales_iter 对象,并 求和: istream_iterator item_iter(cin), eof; Sales_item sum; // initially empty Sales_item sum = *item_iter++; // read first transaction into sum and get next record while (item_iter != eof) { if (item_iter->same_isbn(sum)) sum = sum + *item_iter; else { cout << sum << endl; sum = *item_iter; } ++item_iter; // read next transaction } cout << sum << endl; // remember to print last set of records 该程序将迭代器 item_iter 与 cin 绑在一起,意味着迭代器将读取 Sales_item 类型的对象。然后给迭代器加 1,使流从标准输入中读取下一记录。 sum = *item_iter++; // read first transaction into sum and get next record 这个语句使用解引用操作符获取标准输入的第一个记录,并将这个值赋给 sum。然后给迭代器加 1,使流从标准输入中读取下一记录。 while 循环反复执行直到到达 cin 的结束位置为止。在 while 循环中,将 刚读入记录的 isbn 与 sum 的 isbn 比较。while 中的第一个语句使用了箭头 操作符对 istream 迭代器进行解引用,获得最近读入的对象。然后在该对象和 sum 对象上调用 same_isbn 成员。 如果 isbn 值相同,则增加总和 sum。否则,输出 sum 的当前值,并将它 重设为最近读取对象的副本。循环的最后一步是给迭代器加 1,在本例中,将导 致从标准输入中读入下一个 Sales_item 对象。循环持续直到遇到错误或结束位 置为止。在结束程序之前,记住输出从输入中读入的最后一个 ISBN 所关联的值。 流迭代器的限制 流迭代器有下面几个重要的限制: 527 • 不可能从 ostream_iterator 对象读入,也不可能写到 istream_iterator 对象中。 • 一旦给 ostream_iterator 对象赋了一个值,写入就提交了。赋值后,没 有办法再改变这个值。此外,ostream_iterator 对象中每个不同的值都 只能正好输出一次。 • ostream_iterator 没有 -> 操作符。 与算法一起使用流迭代器 正如大家所知,算法是基于迭代器操作实现的。如同前面所述,流迭代器至 少定义了一些迭代器操作。由于流迭代器操作,因此,至少可在一些泛型算法上 使用这类迭代器。考虑下面的例子,从标准输入读取一些数,再将读取的不重复 的数写到标准输出: istream_iterator cin_it(cin); // reads ints from cin istream_iterator end_of_stream; // end iterator value // initialize vec from the standard input: vector vec(cin_it, end_of_stream); sort(vec.begin(), vec.end()); // writes ints to cout using " " as the delimiter ostream_iterator output(cout, " "); // write only the unique elements in vec to the standard output unique_copy(vec.begin(), vec.end(), output); 如果程序的输入是: 23 109 45 89 6 34 12 90 34 23 56 23 8 89 23 输出则是: 6 8 12 23 34 45 56 89 90 109 程序用一对迭代器 input 和 end_of_stream 创建了 vec 对象。这个初始 化的效果是读取 cin 直到文件结束或者出现错误为止。读取的值保存在 vec 里。 读取输入和初始化 vec 后,调用 sort 对输入的数排序。sort 调用完成后, 重复输入的数就会相邻存储。 程序再使用 unique_copy 算法,这是 unique 的“复制”版本。该算法将 输入范围中不重复的值复制到目标迭代器。该调用将输出迭代器用作目标。其效 果是将 vec 中不重复的值复制给 cout,每个复制的值后面输出一个空格。 528 Exercises Section 11.3.2 Exercise 重写(第 11.3.2 节第 3 小节)的程序,使用 copy 算 11.16: 法将一个文件的内容写到标准输出中。 Exercise 使用一对 istream_iterator 对象初始化一个 int 型 11.17: 的 vector 对象。 Exercise 11.18: 编写程序使用 istream_iterator 对象从标准输入读入 一系列整数。使用 ostream_iterator 对象将其中的奇 数写到一个文件中,并在每个写入的值后面加一个空格。 同样使用 ostream_iterator 对象将偶数写到第二个文 件,每个写入的值都存放在单独的行中。 11.3.3. 反向迭代器 反向迭代器是一种反向遍历容器的迭代器。也就是,从最后一个元素到第一 个元素遍历容器。反向迭代器将自增(和自减)的含义反过来了:对于反向迭代 器,++ 运算将访问前一个元素,而 -- 运算则访问下一个元素。 回想一下,所有容器都定义了 begin 和 end 成员,分别返回指向容器首元 素和尾元素下一位置的迭代器。容器还定义了 rbegin 和 rend 成员,分别返回 指向容器尾元素和首元素前一位置的反向迭代器。与普通迭代器一样,反向迭代 器也有常量(const)和非常量(nonconst)类型。图 11.1 使用一个假设名为 vec 的 vector 类型对象阐明了这四种迭代器之间的关系。 图 11.1 比较 begin/end 和 rbegin/rend 迭代器 假设有一个 vector 容器对象,存储 0-9 这 10 个以升序排列的数字: vector vec; for (vector::size_type i = 0; i != 10; ++i) vec.push_back(i); // elements are 0,1,2,...9 529 下面的 for 循环将以逆序输出这些元素: // reverse iterator of vector from back to front vector::reverse_iterator r_iter; for (r_iter = vec.rbegin(); // binds r_iter to last element r_iter != vec.rend(); // rend refers 1 before 1st element ++r_iter) // decrements iterator one element cout << *r_iter << endl; // prints 9,8,7,...0 虽然颠倒自增和自减这两个操作符的意义似乎容易使人迷惑,但是它让程序 员可以透明地向前或向后处理容器。例如,为了以降序排列 vector,只需向 sort 传递一对反向迭代器: // sorts vec in "normal" order sort(vec.begin(), vec.end()); // sorts in reverse: puts smallest element at the end of vec sort(vec.rbegin(), vec.rend()); 反向迭代器需要使用自减操作符 从一个既支持 -- 也支持 ++ 的迭代器就可以定义反向迭代器,这不用感到 吃惊。毕竟,反向迭代器的目的是移动迭代器反向遍历序列。标准容器上的迭代 器既支持自增运算,也支持自减运算。但是,流迭代器却不然,由于不能反向遍 历流,因此流迭代器不能创建反向迭代器。 反向迭代器与其他迭代器之间的关系 假设有一个名为 line 的 string 对象,存储以逗号分隔的单词列表。我们 希望输出 line 中的第一个单词。使用 find 可很简单地实现这个任务: // find first element in a comma-separated list string::iterator comma = find(line.begin(), line.end(), ','); cout << string(line.begin(), comma) << endl; 如果在 line 中有一个逗号,则 comma 指向这个逗号;否则,comma 的值 为 line.end()。在输出 string 对象中从 line.begin() 到 comma 的内容时, 从头开始输出字符直到遇到逗号为止。如果该 string 对象中没有逗号,则输出 整个 string 字符串。 如果要输出列表中最后一个单词,可使用反向迭代器: // find last element in a comma-separated list string::reverse_iterator rcomma = find(line.rbegin(), line.rend(), ','); 530 因为此时传递的是 rbegin() 和 rend(),这个函数调用从 line 的最后一 个字符开始往回搜索。当 find 完成时,如果列表中有逗号,那么 rcomma 指向 其最后一个逗号,即指向反向搜索找到的第一个逗号。如果没有逗号,则 rcomma 的值为 line.rend()。 在尝试输出所找到的单词时,有趣的事情发生了。直接尝试: // wrong: will generate the word in reverse order cout << string(line.rbegin(), rcomma) << endl; 会产生假的输出。例如,如果输入是: FIRST,MIDDLE,LAST 则将输出 TSAL! 图 11.2 阐明了这个问题:使用反向迭代器时,以逆序从后向前处理 string 对象。为了得到正确的输出,必须将反向迭代器 line.rbegin() 和 rcomma 转 换为从前向后移动的普通迭代器。其实没必要转换 line.rbegin(),因为我们知 道转换的结果必定是 line.end()。只需调用所有反向迭代器类型都提供的成员 函数 base 转换 rcomma 即可: 图 11.2. 反向迭代器与普通迭代器之间的区别 // ok: get a forward iterator and read to end of line cout << string(rcomma.base(), line.end()) << endl; 假设还是前面给出的输入,该语句将如愿输出 LAST。 图 11.2 显示的对象直观地解释了普通迭代器与反向迭代器之间的关系。例 如,正如 line_rbegin() 和 line.end() 一样,rcomma 和 rcomma.base() 也 指向不同的元素。为了确保正向和反向处理元素的范围相同,这些区别必要的。 从技术上来说,设计普通迭代器与反向迭代器之间的关系是为了适应左闭合范围 (第 9.2.1 节)这个性质的,所以,[line.rbegin(), rcomma) 和 [rcomma.base(), line.end()) 标记的是 line 中的相同元素。 531 反向迭代器用于表示范围,而所表示的范围是不对称的,这个 事实可推导出一个重要的结论:使用普通的迭代器对反向迭代 器进行初始化或赋值时,所得到的迭代器并不是指向原迭代器 所指向的元素。 Exercises Section 11.3.3 Exercise 编写程序使用 reverse_iterator 对象以逆序输出 11.19: vector 容器对象的内容。 Exercise 现在,使用普通的迭代器逆序输出上题中对象的元素。 11.20: Exercise 使用 find 在一个 int 型的 list 中寻找值为 0 的 11.21: 最后一个元素。 Exercise 假设有一个存储了 10 个元素的 vector 对象,将其 11.22: 中第 3 个至第 7 个位置上的元素以逆序复制给 list 对象。 11.3.4. const 迭代器 细心的读者可能已经注意到,在第 11.1 节使用 find 的程序中,我们将 result 定义为 const_iterator 类型。这样做是因为我们不希望使用这个迭代 器来修改容器中的元素。 另一方面,虽然第 11.2.1 节的程序也不打算改变容器内的任何元素,但是 它却使用了普通的非 const 迭代器来保存 find_first_of 的返回值。这两种处 理存在细微的差别,值得解释一下。 原因是,在第二个例子中,程序将迭代器用作 find_first_of 的实参: find_first_of(it, roster1.end(), roster2.begin(), roster2.end()) 该函数调用的输入范围由 it 和调用 roster1.end() 返回的迭代器指定。 算法要求用于指定范围的两个迭代器必须具有完全一样的类型。roster1.end() 返回的迭代器依赖于 roster1 的类型。如果该容器是 const 对象,则返回的迭 代器是 const_iterator 类型;否则,就是普通的 iterator 类型。在这个程序 中,roster1 不是 const 对象,因而 end 返回的只是一个普通的迭代器。 532 如果我们将 it 定义为 const_iterator,那么 find_first_of 的调用将无 法编译。用来指定范围的两个迭代器的类型不相同。it 是 const_iterator 类 型的对象,而 rotser1.end() 返回的则是一个 iterator 对象。 11.3.5. 五种迭代器 迭代器定义了常用的操作集,但有些迭代器具有比其他迭代器更强大的功 能。例如 ostream_iterator 只支持自增、解引用和赋值运算,而 vector 容器 提供的迭代器除了这些运算,还支持自减、关系和算术运算。因此,迭代器可根 据所提供的操作集进行分类。 类似地,还可根据算法要求它的迭代器提供什么类型的操作,对算法分类。 有一些算法,例如 find,只要求迭代器提供读取所指向内容和自增的功能。另 一些算法,,比如 sort,则要求其迭代器有读、写和随机访问元素的能力。算 法要求的迭代器操作分为五个类别,分别对应表 11.3 列出的五种迭代器。 表 11.3. 迭代器种类 Input iterator(输入迭代器) 读,不能写;只支持自增运算 Output iterator(输出迭代器) 写,不能读;只支持自增运算 Forward iterator(前向迭代器) 读和写;只支持自增运算 Bidirectional iterator(双向迭代器) 读和写;支持自增和自减运算 Random access iterator(随机访问迭代器) 读和写;支持完整的迭代器算术运 算 1. 输入迭代器可用于读取容器中的元素,但是不保证能支持容器的写入操 作。输入迭代器必须至少提供下列支持。 o 相等和不等操作符(==,!=),比较两个迭代器。 o 前置和后置的自增运算(++),使迭代器向前递进指向下一个元素。 o 用于读取元素的解引用操作符(*),此操作符只能出现在赋值运 算的右操作数上。 o 箭头操作符(->),这是 (*it).member 的同义语,也就是说,对 迭代器进行解引用来获取其所关联的对象的成员。 输入迭代器只能顺序使用;一旦输入迭代器自增了,就无法再用它检查之 前的元素。要求在这个层次上提供支持的泛型算法包括 find 和 accumulate。标准库 istream_iterator 类型输入迭代器。 2. 输出迭代器 可视为与输入迭代器功能互补的迭代器;输出迭代器可用于 向容器写入元素,但是不保证能支持读取容器内容。输出迭代器要求: o 前置和后置的自增运算(++),使迭代器向前递进指向下一个元素。 533 o 解引用操作符(*),引操作符只能出现在赋值运算的左操作数上。 给解引用的输出迭代器赋值,将对该迭代器所指向的元素做写入操 作。 输出迭代器可以要求每个迭代器的值必须正好写入一次。使用输出迭代器 时,对于指定的迭代器值应该使用一次 * 运算,而且只能用一次。输出 迭代器一般用作算法的第三个实参,标记起始写入的位置。例如,copy 算 法使用一个输出迭代器作为它的第三个实参,将输入范围内的元素复制到 输出迭代器指定的目标位置。标准库 ostream_iterator 类型输出迭代 器。 3. 前向迭代器 用于读写指定的容器。这类迭代器只会以一个方向遍历序列。 前向迭代器支持输入迭代器和输出迭代器提供的所有操作,除此之外,还 支持对同一个元素的多次读写。可复制前向迭代器来记录序列中的一个位 置,以便将来返回此处。需要前向迭代器的泛型算法包括 replace。 4. 双向迭代器 从两个方向读写容器。除了提供前向迭代器的全部操作之 外,双向迭代器还提供前置和后置的自减运算(--)。需要使用双向迭代 器的泛型算法包括 reverse。所有标准库容器提供的迭代器都至少达到双 向迭代器的要求。 5. 随机访问迭代器 提供在常量时间内访问容器任意位置的功能。这种迭代 器除了支持双向迭代器的所有功能之外,还支持下面的操作: o 关系操作符 <、<=、> 和 >=,比较两个迭代器的相对位置。 o 迭代器与整型数值 n 之间的加法和减法操作符 +、+=、- 和 -=, 结果是迭代器在容器中向前(或退回)n 个元素。 o 两个迭代器之间的减法操作符(--),得到两个迭代器间的距离。 o 下标操作符 iter[n],这是 *(iter + n) 的同义词。 需要随机访问迭代器的泛型算法包括 sort 算法。vector、deque 和 string 迭代器是随机访问迭代器,用作访问内置数组元素的指针也是随 机访问迭代器。 除了输出迭代器,其他类别的迭代器形成了一个层次结构:需要低级类别迭 代器的地方,可使用任意一种更高级的迭代器。对于需要输入迭代器的算法,可 传递前向、双向或随机访问迭代器调用该算法。调用需要随机访问迭代器的算法 时,必须传递随机访问迭代器。 map、set 和 list 类型提供双向迭代器,而 string、vector 和 deque 容 器上定义的迭代器都是随机访问迭代器都是随机访问迭代器,用作访问内置数组 元素的指针也是随机访问迭代器。istream_iterator 是输入迭代器,而 ostream_iterator 则是输出迭代器。 关键概念:关联容器与算法 534 尽管 map 和 set 类型提供双向迭代器,但关联容器只能使用算法的一 个子集。问题在于:关联容器的键是 const 对象。因此,关联容器不能 使用任何写序列元素的算法。只能使用与关联容器绑在一起的迭代器来 提供用于读操作的实参。 在处理算法时,最好将关联容器上的迭代器视为支 持自减运算的输入迭代器,而不是完整的双向迭代 器。 C++ 标准为所有泛型和算术算法的每一个迭代器形参指定了范围最小的迭 代器种类。例如,find(以只读方式单步遍历容器)至少需要一个输入迭代器。 replace 函数至少需要一对前向迭代器。replace_copy 函数的头两个迭代器必 须至少是前向迭代器,第三个参数代表输出目标,必须至少是输出迭代器。 对于每一个形参,迭代器必须保证最低功能。将支持更少功能的迭代器传递 给函数是错误的;而传递更强功能的迭代器则没问题。 向算法传递无效的迭代器类别所引起的错误,无法保证会在 编译时被捕获到。 Exercises Section 11.3.5 Exercise 列出五种迭代器类型及其各自支持的操作。 11.23: Exercise list 容器拥有什么类型的迭代器?而 vector 呢? 11.24: Exercise 你认为 copy 算法需要使用哪种迭代器?而 reverse 11.25: 和 unique 呢? Exercise 解释下列代码错误的原因,指出哪些错误可以在编译 11.26: 时捕获。 (a) string sa[10]; const vector file_names(sa, sa+6); vector::iterator it = 535 file_names.begin()+2; (b) const vector ivec; fill(ivec.begin(), ivec.end(), ival); (c) sort(ivec.begin(), ivec.rend()); (d) sort(ivec1.begin(), ivec2.end()); 11.4. 泛型算法的结构 正如所有的容器都建立在一致的设计模式上一样,算法也具有共同的设计基 础。理解标准算法库的设计基础有利于学习和使用算法。C++ 提供了超过一百个 算法,了解它们的结构显然要比死记所有的算法更好。 算法最基本的性质是需要使用的迭代器种类。所有算法都指定了它的每个迭 代器形参可使用的迭代器类型。如果形参必须为随机访问迭代器则可提供 vector 或 deque 类型的迭代器,或者提供指向数组的指针。而其他容器的迭代 器不能用在这类算法上。 另一种算法分类的方法,则如本章开头介绍的一样,根据对元素的操作将算 法分为下面几种: • 只读算法,不改变元素的值顺序。 • 给指定元素赋新值的算法。 • 将一个元素的值移给另一个元素的算法。 正如本节后续部分所介绍的,C++ 还提供了另外两种算法模式:一种模式由 算法所带的形参定义;另一种模式则通过两种函数命名和重载的规范定义。 11.4.1. 算法的形参模式 任何其他的算法分类都含有一组形参规范。理解这些形参规范有利于学习新 的算法——只要知道形参的含义,就可专注于了解算法实现的操作。大多数算法 采用下面四种形式之一: alg (beg, end, other parms); alg (beg, end, dest, other parms); alg (beg, end, beg2, other parms); alg (beg, end, beg2, end2, other parms); 536 其中,alg 是算法的名字,beg 和 end 指定算法操作的元素范围。我们通 常将该范围称为算法的“输入范围”。尽管几乎所有算法都有输入范围,但算法 是否使用其他形参取决于它所执行的操作。这里列出了比较常用的其他形参: dest、beg2 和 end2,它们都是迭代器。这些迭代器在使用时,充当类似的角色。 除了这些迭代器形参之外,有些算法还带有其他的菲迭代器形参,它们是这些算 法特有的。 带有单个目标迭代器的算法 dest 形参是一个迭代器,用于指定存储输出数据的目标对象。算法假定无 论需要写入多少个元素都是安全的。 调用这些算法时,必须确保输出容器有足够大的容量存储输出 数据,这正是通常要使用插入迭代器或者 ostream_iterator 来调用这些算法的原因。如果使用容器迭代器调用这些算法, 算法将假定容器里有足够多个需要的元素。 如果 dest 是容器上的迭代器,则算法将输出内容写到容器中已存在的元素 上。更普遍的用法是,将 dest 与某个插入迭代器(第 11.3.1 节)或者 ostream_iterator 绑定在一起。插入迭代器在容器中添加元素,以确保容器有 足够的空间存储输出。ostream_iterator 则实现写输出流的功能,无需要考虑 所写的元素个数。 带第二个输入序列的算法 有一些算法带有一个 beg2 迭代器形参,或者同时带有 beg2 和 end2 迭代 器形参,来指定它的第二个输入范围。这类算法通常将联合两个输入范围的元素 来完成计算功能。算法同时使用 beg2 和 end2 时,这些迭代器用于标记完整的 第二个范围。也就是说,此时,算法完整地指定了两个范围:beg 和 end 标记 第一个输入范围,而 beg2 和 end2 则标记第二个输入范围。 带有 beg2 而不带 end2 的算法将 beg2 视为第二个输入范围的首元素,但 没有指定该范围的最后一个元素。这些算法假定以 beg2 开始的范围至少与 beg 和 end 指定的范围一样大。 与写入 dest 的算法一样,只带有 beg2 的算法也假定以 beg2 开始的序列与 beg 和 end 标记的序列一样大。 11.4.2. 算法的命名规范 537 标准库使用一组相同的命名和重载规范,了解这些规范有助于更容易地学习 标准库。它们包括两种重要模式:第一种模式包括测试输入范围内元素的算法, 第二种模式则应用于对输入范围内元素重新排序的算法。 区别带有一个值或一个谓词函数参数的算法版本 很多算法通过检查其输入范围内的元素实现其功能。这些算法通常要用到标 准关系操作符:== 或 <。其中的大部分算法会提供第二个版本的函数,允许程 序员提供比较或测试函数取代操作符的使用。 重新对容器元素排序的算法要使用 < 操作符。这些算法的第二个重载版本 带有一个额外的形参,表示用于元素排序的不同运算: sort (beg, end); sort (beg, end, comp); elements // use < operator to sort the elements // use function named comp to sort the 检查指定值的算法默认使用 == 操作符。系统为这类算法提供另外命名的 (而非重载的)版本,带有谓词函数(第 11.2.3 节)形参。带有谓词函数形参 的算法,其名字带有后缀 _if: find(beg, end, val); range find_if(beg, end, pred); true // find first instance of val in the input // find first instance for which pred is 上述两个算法都在输入范围内寻找指定元素的第一个实例。其中,find 算 法查找一个指定的值,而 find_if 算法则用于查找一个使谓词函数 pred 返回 非零值的元素。 标准库为这些算法提供另外命名的版本,而非重载版本,其原因在于这个两 种版本的算法带有相同数目的形参。对于排序算法,只要根据参数的个数就很容 易消除函数调用的歧义。而对于查找指定元素的算法,不管检查的是一个值还是 谓词函数,函数调用都需要相同个数的参数。此时,如果使用重载版本,则可能 导致二义性(第 7.8.2 节),尽管这个可能出现的几率很低。因此,标准库为 这些算法提供两种不同名字的版本,而没有使用重载。 区别是否实现复制的算法版本 无论算法是否检查它的元素值,都可能重新排列输入范围内的元素。在默认 情况下,这些算法将重新排列的元素写回其输入范围。标准库也为这些算法提供 另外命名的版本,将元素写到指定的输出目标。此版本的算法在名字中添加了 _copy 后缀: reverse(beg, end); 538 reverse_copy(beg, end, dest); reverse 函数的功能就如它的名字所意味的:将输入序列中的元素反射重新 排列。其中,第一个函数版本将自己的输入序列中的元素反向重排。而第二个版 本,reverse_copy,则复制输入序列的元素,并将它们逆序存储到 dest 开始的 序列中。 Exercises Section 11.4.2 Exercise 11.27: 标准库定义了下面的算法: replace(beg, end, old_val, new_val); replace_if(beg, end, pred, new_val); replace_copy(beg, end, dest, old_val, new_val); replace_copy_if(beg, end, dest, pred, new_val); 只根据这些函数的名字和形参,描述这些算法的功能。 Exercise 假设 lst 是存储了 100 个元素的容器。请解释下面的 11.28: 程序段,并修正你认为的错误。 vector vec1; reverse_copy(lst.begin(), lst.end(), vec1.begin()); 11.5. 容器特有的算法 list 容器上的迭代器是双向的,而不是随机访问类型。由于 list 容器不 支持随机访问,因此,在此容器上不能使用需要随机访问迭代器的算法。这些算 法包括 sort 及其相关的算法。还有一些其他的泛型算法,如 merge、remove、 reverse 和 unique,虽然可以用在 list 上,但却付出了性能上的代价。如果 这些算法利用 list 容器实现的特点,则可以更高效地执行。 如果可以结合利用 list 容器的内部结构,则可能编写出更快的算法。与其 他顺序容器所支持的操作相比,标准库为 list 容器定义了更精细的操作集合, 使它不必只依赖于泛型操作。表 11.4 列出了 list 容器特有的操作,其中不包 括要求支持双向或更弱的迭代器类型的泛型算法,这类泛型算法无论是用在 list 容器上,还是用在其他容器上,都具有相同的效果。 539 表 11.4. list 容器特有的操作 lst.merge(lst2) lst.merge(lst2, comp) 将 lst2 的元素合并到 lst 中。这两个 list 容器对象都必 须排序。lst2 中的元素将被删除。合并后,lst2 为空。返 回 void 类型。第一个版本使用 < 操作符,而第二个版本则 使用 comp 指定的比较运算 lst.remove(val) lst.remove_if(unaryPred) 调用 lst.erase 删除所有等于指定值或使指定的谓词函数 返回非零值的元素。返回 void 类型 lst.reverse() 反向排列 lst 中的元素 lst.sort 对 lst 中的元素排序 lst.splice(iter, lst2) lst.splice(iter, lst2, iter2) lst.splice(iter, beg, end) 将 lst2 的元素移到 lst 中迭代器 iter 指向的元素前面。 在 lst2 中删除移出的元素。第一个版本将 lst2 的所有元 素移到 lst 中;合并后,lst2 为空。lst 和 lst2 不能是 同一个 list 对象。第二个版本只移动 iter2 所指向的元 素,这个元素必须是 lst2 中的元素。在这种情况中,lst 和 lst2 可以是同一个 list 对象。也就是说,可在一个 list 对象中使用 splice 运算移动一个元素。第三个版本移动迭 代器 beg 和 end 标记的范围内的元素。beg 和 end 照例必 须指定一个有效的范围。这两个迭代器可标记任意 list 对 象内的范围,包括 lst。当它们指定 lst 的一段范围时,如 果 iter 也指向这个范围的一个元素,则该运算未定义。 lst.unique() lst.unique(binaryPred) 调用 erase 删除同一个值的团结副本。第一个版本使用 == 操作符判断元素是否相等;第二个版本则使用指定的谓词函 数实现判断 对于 list 对象,应该优先使用 list 容器特有的成员 版本,而不是泛型算法。 540 大多数 list 容器特有的算法类似于其泛型形式中已经见过的相应的算法, 但并不相同: l.remove(val); // removes all instances of val from 1 l.remove_if(pred); // removes all instances for which pred is true from 1 l.reverse(); // reverses the order of elements in 1 l.sort(); // use element type < operator to compare elements l.sort(comp); // use comp to compare elements l.unique(); // uses element == to remove adjacent duplicates l.unique(comp); // uses comp to remove duplicate adjacent copies list 容器特有的算法与其泛型算法版本之间有两个至关重要的差别。其中 一个差别是 remove 和 unique 的 list 版本修改了其关联的基础容器:真正删 除了指定的元素。例如,list::unique 将 list 中第二个和后续重复的元素删 除出该容器。 与对应的泛型算法不同,list 容器特有的操作能添加和删除元 素。 另一个差别是 list 容器提供的 merge 和 splice 运算会破坏它们的实 参。使用 merge 的泛型算法版本时,合并的序列将写入目标迭代器指向的对象, 而它的两个输入序列保持不变。但是,使用 list 容器的 merge 成员函数时, 则会破坏它的实参 list 对象——当实参对象的元素合并到调用 merge 函数的 list 对象时,实参对象的元素被移出并删除。 Exercises Section 11.5 Exercise 用 list 容器取代 vector 重新实现 liminated 11.29: duplicate words that we wrote in 第 11.2.3 节编写 的排除重复单词的程序。 小结 C++ 标准化过程做出的更重要的贡献之一是:创建和扩展了标准库。容器和 算法库是标准库的基础。标准库定义了超过一百个算法。幸运的是,这些算法具 有相同的结构,使它们更易于学习和使用。 541 算法与类型无关:它们通常在一个元素序列上操作,这些元素可以存储在标 准库容器类型、内置数组甚至是生成的序列(例如读写流所生成的序列)上。算 法基于迭代器操作,从而实现类型无关性。大多数算法使用一对指定元素范围的 迭代器作为其头两个实参。其他的迭代器实参包括指定输出目标的输出迭代器, 或者用于指定第二个输入序列的另一个或一对迭代器。 迭代器可通过其所支持的操作来分类。标准库定义了五种迭代器类别:输入、 输出、前向、双向和随机访问迭代器。如果一个迭代器支持某种迭代器类别要求 的运算,则该迭代器属于这个迭代器类别。 正如迭代器根据操作来分类一样,算法的迭代器形参也通过其所要求的迭代 器操作来分类。只需要读取其序列的算法通常只要求输入迭代器的操作。而写目 标迭代器的算法则通常只要求输出迭代器的操作,依此类推。 查找某个值的算法通常提供第二个版本,用于查找使谓词函数返回非零值的 元素。对于这种算法,第二个版本的函数名字以 _if 后缀标识。类似地,很多 算法提供所谓的复制版本,将(修改过的)元素写到输出序列,而不是写回输入 范围。这种版本的名字以 _copy 结束。 第三种模式是考虑算法是是否对元素读、写或者重新排序。算法从不直接改 变它所操纵的序列的大小。(如果算法的实参是插入迭代器,则该迭代器会添加 新元素,但算法并不直接这么做。)算法可以从一个位置将元素复制到另一个位 置,但不直接添加或删除元素。 术语 back_inserter 形参为指向容器的引用的迭代器适配器,生成使用 push_back 为指定容 器添加元素的插入迭代器。 bidirectional iterator(双向迭代器) 除了提供前向迭代器相同的操作之外,还支持使用——操作符向后遍历序 列。 forward iterator(前向迭代器) 可读写元素的迭代器,但不支持——操作符。 front_inserter 一种迭代器适配器,生成使用 push_front 在指定容器的开始位置添加新 元素的插入迭代器。 542 generic algorithms(泛型算法) 与类型无关的算法。 input iterator(输入迭代器) 只能读不能写元素的迭代器。 insert iterator(插入迭代器) 使用容器操作插入元素而不是覆写元素的迭代器。给插入迭代器赋值,等 效于将具有所赋值的新元素插入到序列中。 inserter(插入器) 一种迭代器适配器,形参为一个迭代器和一个指向容器的引用,生成使用 insert 为容器添加元素的插入迭代器,新元素插入在该适配器的迭代器 形参所指向的元素前面。 istream_iterator 读输入流的流迭代器。 iterator categories(迭代器种类) 基于迭代器所支持的操作,在概念上对迭代器进行分类。迭代器种类形成 了一个层次结构,功能较强的迭代器种类提供比它弱的迭代器的所有操 作。算法使用迭代器种类来指定它的迭代器实参必须支持什么操作。只要 迭代器至少提供这个层次的操作,就可以用于该算法。例如,一些算法只 要求输入迭代器,则可以使用除了输出迭代器之外的任意迭代器调用这样 的算法。而要求使用随机访问迭代器的算法只能用在支持随机访问运算的 迭代器上。 off-the-end iterator(超出末端迭代器) 一种迭代器,用于标记序列中一个元素范围的结束位置。超出末端迭代器 用作结束遍历的“哨兵”,指向范围内最后一个元素的下一位置。超出末 端迭代器可能指向不存在的元素,因此永远不能做解引用运算。 ostream_iterator 写输出流的迭代器。 output iterator(输出迭代器) 只能写不能读元素的迭代器。 543 predicate(谓词) 其返回类型可转换为 bool 值的函数。通常被泛型算法用于检查元素。标 准库所使用的谓词函数不是一元(需要一个实参)的就是二元的(需要两 个实参)。 random-access iterator(随机访问迭代器) 除了支持双向迭代器相同的操作之外,还提供了使用关系运算比较迭代器 值的能力,以及在迭代器上做算术运算的能力。因此,这类迭代器支持随 机访问元素。 reverse iterator(反向迭代器) 向后遍历序列的迭代器。这些迭代器颠倒了 ++ 和 -- 的含义。 stream iterator(流迭代器) 可与流绑定在一起的迭代器。 544 第三部分:类和数据抽象 在大多数 C++ 程序中,类都是至关重要的:我们能够使用类来定义为要解 决的问题定制的数据类型,从而得到更加易于编写和理解的应用程序。设计良好 的类类型可以像内置类型一样容易使用。 类定义了数据成员和函数成员:数据成员用于存储与该类类型的对象相关联 的状态,而函数成员则负责执行赋予数据意义的操作。通过类我们能够将实现和 接口分离,用接口指定类所支持的操作,而实现的细节只需类的实现者了解或关 心。这种分离可以减少使编程冗长乏味和容易出错的那些繁琐工作。 类类型常被称为抽象数据类型(abstract data types)。抽象数据类型将 数据(即状态)和作用于状态的操作视为一个单元。我们可以抽象地考虑类该做 什么,而无须知道类如何去完成这些操作。抽象数据类型是面向对象编程和泛型 编程的基础。 第十二章开始详细地介绍如何定义类,包括类的使用中非常基本的主题:类 作用域、数据隐藏和构造函数。此外,还介绍了类的一些新特征:友元、使用隐 含的 this 指针,以及静态(static)和可变(mutable)成员的作用。 C++ 中的类能够控制在初始化、复制、赋值和销毁对象时发生的操作。在此 方面,C++ 不同于许多其他语言,它们大多没有赋予类设计者控制这些操作的能 力。第十三章讨论了这些主题。 第十四章考察了操作符重载,允许将类类型的操作数与内置操作符一起使用。利用操作 符重载,在 C++ 中创建新的类型,就像创建内置类型一样。此外,还介绍了另一种特殊的 类成员函数——转换函数,这种函数定义了类类型对象之间的隐式转换。编译器应用这些转 换就像它们是在内置类型之间发生的转换一样。 545 第十二章 类 在 C++ 中,用类来定义自己的抽象数据类型(abstract data types)。通 过定义类型来对应所要解决的问题中的各种概念,可以使我们更容易编写、调试 和修改程序。 本章进一步讨论类,并将更详细地阐述数据抽象的重要性。数据抽象能够隐 藏对象的内部表示,同时仍然允许执行对象的公有(public)操作。 我们也将进一步解释类作用域、构造函数以及 this 指针。此外,还要介绍 与类有关的三个新特征:友元(friend)、可变成员(mutable)、和静态成员 (static)。 类是 C++ 中最重要的特征。C++ 语言的早期版本被命名为“带类的 C(C with Classes)”,以强调类机制的中心作用。随着语言的演变,创建类的配套 支持也在不断增加。语言设计的主要目标也变成提供这样一些特性:允许程序定 义自己的类型,它们用起来与内置类型一样容易和直观。本章将介绍类的许多基 本特征。 12.1. 类的定义和声明 从第一章开始,程序中就已经使用了类。已经用过的标准库类型,比如 vector,istream 和 string,都是类类型。还定义了一些简单的类,如 Sales_item 和 TextQuery 类。为了扼要秣,再来看年 Sales_item 类: class Sales_item { public: // operations on Sales_item objects double avg_price() const; bool same_isbn(const Sales_item &rhs) const { return isbn == rhs.isbn; } // default constructor needed to initialize members of built-in type Sales_item(): units_sold(0), revenue(0.0) { } private: std::string isbn; unsigned units_sold; double revenue; }; double Sales_item::avg_price() const { if (units_sold) 546 return revenue/units_sold; else return 0; } 12.1.1. 类定义:扼要重述 在第 2.8 节和第 7.7 节中编写这个类时,已经学习了有关类的一些知识。 最简单地说,类就是定义了一个新的类型和一个新作用域。 类成员 每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型 别名。 一个类可以包含若干公有的、私有的和受保护的部分。我们已经使用过 public 和 private 访问标号:在 public 部分定义的成员可被使用该类型的所 有代码访问;在 private 部分定义的成员可被其他类成员访问。在第十五章讨 论继承时将进一步探讨 protected。 所有成员必须在类的内部声明,一旦类定义完成后,就没有任何方式可以增 加成员了。 构造函数 创建一个类类型的对象时,编译器会自动使用一个构造函数(第 2.3.3 节) 来初始化该对象。构造函数是一个特殊的、与类同名的成员函数,用于给每个数 据成员设置适当的初始值。 构造函数一般就使用一个构造函数初始化列表(第 7.7.3 节),来初始化 对象的数据成员: // default constructor needed to initialize members of built-in type Sales_item(): units_sold(0), revenue(0.0) { } 成员函数 547 在类内部,声明成员函数是必需的,而定义成员函数则是可选的。在类内部 定义的函数默认为 inline(第 7.6 节)。 在类外部定义的成员函数必须指明它们是在类的作用域中。 Sales_item::avg_price 的定义使用作用域操作符(第 1.2.2 节)来指明这是 Sales_item 类中 avg_price 函数的定义。 成员函数有一个附加的隐含实参,将函数绑定到调用函数的对象——当我们 编写下面的函数时: trans.avg_price() 就是在调用名 trans 的对象的 avg_price 函数。如果 trans 是一个 Sales_item 对象,则在 avg_price 函数内部对 Sales_item 类成员引用就是对 trans 成员的引用。 将关键字 const 加在形参表之后,就可以将成员函数声明为常量: double avg_price() const; const 成员不能改变其所操作的对象的数据成员。const 必须同时出现在声 明和定义中,若只出现在其中一处,就会出现一个编译时错误。 Exercises Section 12.1.1 Exercise 编写一个名为 Person 的类,表示人的名字和地址。使 12.1: 用 string 来保存每个元素。 Exercise 为 Person 提供一个接受两个 string 参数的构造函 12.2: 数。 Exercise 提供返回名字和地址的操作。这些函数应为 const 吗? 12.3: 解释你的选择。 Exercise 指明 Person 的哪个成员应声明为 public,哪个成员应 12.4: 声明为 private。解释你的选择。 12.1.2. 数据抽象和封装 类背后蕴涵的基本思想是数据抽象和封装。 548 数据抽象是一种依赖于接口和实现分离的编程(和设计)技术。类设计者必 须关心类是如何实现的,但使用该类的程序员不必了解这些细节。相反,使用一 个类型的程序员仅需了解类型的接口,他们可以抽象地考虑该类型做什么,而不 必具体地考虑该类型如何工作。 封装是一项低层次的元素组合起来的形成新的、高层次实体珠技术。函数是 封装的一种形式:函数所执行的细节行为被封装在函数本身这个更大的实体中。 被封装的元素隐藏了它们的实现细节——可以调用一个函数但不能访问它所执 行的语句。同样地,类也是一个封装的实体:它代表若干成员的聚焦,大多数(良 好设计的)类类型隐藏了实现该类型的成员。 标准库类型 vector 同时具备数据抽象和封装的特性。在使用方面它是抽象 的,只需考虑它的接口,即它能执行的操作。它又是封装的,因为我们既无法了 解该类型如何表示的细节,也无法访问其任意的实现制品。另一方面,数组在概 念上类似于 vector,但既不是抽象的,也不是封装的。可以通过访问存放数组 的内存来直接操纵数组。 访问标号实施抽象和封装 在 C++ 中,使用访问标号(第 2.8 节)来定义类的抽象接口和实施封装。 一个类可以没有访问标号,也可以包含多个访问标号: • 程序的所有部分都可以访问带有 public 标号的成员。类型的数据抽象视 图由其 public 成员定义。 • 使用类的代码不可以访问带有 private 标号的成员。private 封装了类 型的实现细节。 一个访问标号可以出现的次数通常是没有限制的。每个访问标号指定了随后 的成员定义的访问级别。这个指定的访问级别持续有效,直到遇到下一个访问标 号或看到类定义体的右花括号为止。 可以在任意的访问标号出现之前定义类成员。在类的左花括号之后、第一个 访问标号之前定义成员的访问级别,其值依赖于类是如何定义的。如果类是用 struct 关键字定义的,则在第一个访问标号之前的成员是公有的;如果类是用 class 关键字是定义的,则这些成员是私有的。 建议:具体类型和抽象类型 并非所有类型都必须是抽象的。标准库中的 pair 类就是一个实用的、 设计良好的具体类而不是抽象类。具体类会暴露而非隐藏其实现细节。 一些类,例如 pair,确实没有抽象接口。pair 类型只是将两个数据成 员捆绑成单个对象。在这种情况下,隐藏数据成员没有必要也没有明显 的好处。在像 pair 这样的类中隐藏数据成员只会造成类型使用的复杂 549 化。 尽管如此,这样的类型通常还是有成员函数。特别地,如果类具有内置 类型或复合类型数据成员,那么定义构造函数来初始化这些成员就是一 个好主意。类的使用都也可以初始化或赋值数据成员,但由类来做更不 易出错。 编程角色的不同类别 程序员经常会将运行应用程序的人看作“用户”。应用程序为最终“使用” 它的用户而设计,并响应用户的反馈而完善。类也类似:类的设计者为类的“用 户”设计并实现类。在这种情况下,“用户”是程序员,而不是应用程序的最终 用户。 成功的应用程序的创建者会很好地理解和实现用户的需求。同样地,良好设 计的、实用的类,其设计也要贴近类用户的需求。 另一方面,类的设计者与实现者之间的区别,也反映了应用程序的用户与设 计和实现者之间的区分。用户只关心应用程序能否以合理的费用满足他们的需 求。同样地,类的使用者只关心它的接口。好的类设计者会定义直观和易用的类 接口,而使用者只关心类中影响他们使用的部分实现。如果类的实现速度太慢或 给类的使用者加上负担,则必然引起使用者的关注。在良好设计的类中,只有类 的设计者会关心实现。 在简单的应用程序中,类的使用者和设计者也许是同一个人。即使在这种情 况下,保持角色区分也是有益的。设计类的接口时,设计者应该考虑的是如何方 便类的使用;使用类的时候,设计者就不应该考虑类如何工作。 注意,C++ 程序员经常会将应用程序的用户和类的使用者都称 为“用户”。 提到“用户”时,应该由上下文清楚地标明所指的是哪类用户。如果提到 “用户代码”或 Sales_item 类的”用户“,指的就是使用类编写应用程序的程 序员。如果提到书店应用程序的”用户“,那么指的是运行应用程序的书店管理 人员。 550 关键概念:数据抽象和封装的好处 数据抽象和封装提供了两个重要优点: • 避免类内部出现无意的、可能破坏对象状态的用户级错误。 • 随时间推移可以根据需求改变或缺陷(bug)报告来完美类实现, 而无须改变用户级代码。 仅在类的私有部分定义数据成员,类的设计者就可以自由地修改数据。 如果实现改变了,那么只需检查类代码来了解此变化可能造成的影响。 如果数据为仅有的,则任何直接访问原有数据成员的函数都可能遭到破 坏。在程序可重新使用之前,有必要定位和重写依赖原有表示的那部分 代码。 同样地,如果类的内部状态是私有的,则数据成员的改变只可能在有限 的地方发生。避免数据中出现用户可能引入的错误。如果有缺陷会破坏 对象的状态,就在局部位置搜寻缺陷:如果数据是私有的,那么只有成 员函数可能对该错误负责。对错误的搜寻是有限的,从而大大方便了程 序的维护和修正。 如果数据是私有的并且没有改变成员函数的接口,则操纵类对象的用户 函数无须改变。 改变头文件中的类定义可有效地改变包含该头文件的每 个源文件的程序文本,所以,当类发生改变时,使用该 类的代码必须重新编译。 Exercises Section 12.1.2 Exercise C++ 类支持哪些访问标号?在每个访问标号之后应定义 12.5: 哪种成员?如果有的话,在类的定义中,一个访问标号 可以出现在何处以及可出现多少次?约束条件是什么? Exercise 12.6: 有 class 关键字定义的类和用 struct 定义的类有什 么不同? 551 Exercise 什么是封装?为什么封装是有用的? 12.7: 12.1.3. 关于类定义的更多内容 迄今为止,所定义的类都是简单的,然而通过这些类我们已经了解到 C++ 语 言为类所提供的相当多的支持。本节的其余部分将阐述编写类的更多基础知识。 同一类型的多个数据成员 正如我们所见,类的数据成员的声明类似于普通变量的声明。如果一个类具 有多个同一类型的数据成员,则这些成员可以在一个成员声明中指定,这种情况 下,成员声明和普通变量声明是相同的。 例如,可以定义一个名为 Screen 的类型表示计算机上的窗口。每个 Screen 可以有一个保存窗口内容的 string 成员,以及三个 string::size_type 成员: 一个指定光标当前停留的字符,另外两个指定窗口的高度和宽度。可以用如下方 式这个类的成员: class Screen { public: // interface member functions private: std::string contents; std::string::size_type cursor; std::string::size_type height, width; }; 使用类型别名来简化类 除了定义数据和函数成员之外,类还可以定义自己的局部类型名字。如果为 std::string::size_type 提供一个类型别名,那么 Screen 类将是一个更好的 抽象: class Screen { public: // interface member functions typedef std::string::size_type index; private: std::string contents; index cursor; 552 index height, width; }; 类所定义的类型名遵循任何其他成员的标准访问控制。将 index 的定义放 在类的 public 部分,是因为希望用户使用这个名字。Screen 类的使用者不必 了解用 string 实现的底层细节。定义 index 来隐藏 Screen 的实现细节。将 这个类型设为 public,就允许用户使用这个名字。 成员函数可被重载 这些类之所以简单,另一个方面也是因为它们只定义了几个成员函数。特别 地,这些类都不需要定义其任意成员函数的重载版本。然而,像非成员函数一样, 成员函数也可以被重载(第 7.8 节)。 重载操作符(第 14.9.5 节)有特殊规则,是个例外,成员函数只能重载本 类的其他成员函数。类的成员函数与普通的非成员函数以及在其他类中声明的函 数不相关,也不能重载它们。重载的成员函数和普通函数应用相同的规则:两个 重载成员的形参数量和类型不能完全相同。调用非成员重载函数所用到的函数匹 配(第 7.8.2 节)过程也应用于重载成员函数的调用。 定义重载成员函数 为了举例说明重载,可以给出 Screen 类的两个重载成员,用于从窗口返回 一个特定字符。两个重载成员中,一个版本返回由当前光标指示的字符,另一个 返回指定行列处的字符: class Screen { public: typedef std::string::size_type index; // return character at the cursor or at a given position char get() const { return contents[cursor]; } char get(index ht, index wd) const; // remaining members private: std::string contents; index cursor; index height, width; }; 与任意的重载函数一样,给指定的函数调用提供适当数目和/或类型的实参 来选择运行哪个版本: Screen myscreen; 553 char ch = myscreen.get();// calls Screen::get() ch = myscreen.get(0,0); // calls Screen::get(index, index) 显式指定 inline 成员函数 在类内部定义的成员函数,例如不接受实参的 get 成员,将自动作为 inline 处理。也就是说,当它们被调用时,编译器将试图在同一行内扩展该函 数(第 7.6 节)。也可以显式地将成员函数声明为 inline: class Screen { public: typedef std::string::size_type index; // implicitly inline when defined inside the class declaration char get() const { return contents[cursor]; } // explicitly declared as inline; will be defined outside the class declaration inline char get(index ht, index wd) const; // inline not specified in class declaration, but can be defined inline later index get_cursor() const; // ... }; // inline declared in the class declaration; no need to repeat on the definition char Screen::get(index r, index c) const { index row = r * width; // compute the row location return contents[row + c]; // offset by c to fetch specified character } // not declared as inline in the class declaration, but ok to make inline in definition inline Screen::index Screen::get_cursor() const { return cursor; } 可以在类定义体内部指定一个成员为 inline,作为其声明的一部分。或者, 也可以在类定义外部的函数定义上指定 inline。在声明和定义处指定 inline 都是合法的。在类的外部定义 inline 的一个好处是可以使得类比较容易阅读。 554 像其他 inline 一样,inline 成员函数的定义必须在调用该函 数的每个源文件中是可见的。不在类定义体内定义的 inline 成员函数,其定义通常应放在有类定义的同一头文件中。 Exercises Section 12.1.3 Exercise 将 Sales_item::avg_price 定义为内联函数。 12.8: Exercise 修改本节中给出的 Screen 类,给出一个构造函数,根 12.9: 据屏幕的高度、宽度和内容的值来创建 Screen。 Exercise 12.10: 解释下述类中的每个成员: class Record { typedef std::size_t size; Record(): byte_count(0) { } Record(size s): byte_count(s) { } Record(std::string s): name(s), byte_count(0) { } size byte_count; std::string name; public: size get_count() const { return byte_count; } std::string get_name() const { return name; } }; 12.1.4. 类声明与类定义 一旦遇到右花括号,类的定义就结束了。并且一旦定义了类,那以我们就知 道了所有的类成员,以及存储该类的对象所需的存储空间。在一个给定的源文件 中,一个类只能被定义一次。如果在多个文件中定义一个类,那么每个文件中的 定义必须是完全相同的。 555 将类定义在头文件中,可以保证在每个使用类的文件中以同样的方式定义 类。使用头文件保护符(header guard)(第 2.9.2 节),来保证即使头文件 在同一文件中被包含多次,类定义也只出现一次。 可以声明一个类而不定义它: class Screen; // declaration of the Screen class 这个声明,有时称为前向声明(forward declaraton),在程序中引入了类 类型的 Screen。在声明之后、定义之前,类 Screen 是一个不完全类型 (incompete type),即已知 Screen 是一个类型,但不知道包含哪些成员。 不完全类型(incomplete type)只能以有限方式使用。不能定 义该类型的对象。不完全类型只能用于定义指向该类型的指针 及引用,或者用于声明(而不是定义)使用该类型作为形参类 型或返回类型的函数。 在创建类的对象之前,必须完整地定义该类。必须定义类,而不只是声明类, 这样,编译器就会给类的对象预定相应的存储空间。同样地,在使用引用或指针 访问类的成员之前,必须已经定义类。 为类的成员使用类声明 只有当类定义已经在前面出现过,数据成员才能被指定为该类类型。如果该 类型是不完全类型,那么数据成员只能是指向该类类型的指针或引用。 因为只有当类定义体完成后才能定义类,因此类不能具有自身类型的数据成 员。然而,只要类名一出现就可以认为该类已声明。因此,类的数据成员可以是 指向自身类型的指针或引用: class LinkScreen { Screen window; LinkScreen *next; LinkScreen *prev; }; 类的前身声明一般用来编写相互依赖的类。在第 13.4 节中, 我们将看到用法的一个例子。 556 Exercises Section 12.1.4 Exercise 定义两个类 X 和 Y,X 中有一个指向 Y 的指针,Y 中 12.11: 有一个 X 类型的对象。 Exercise 解释类声明与类定义之间的差异。何时使用类声明?何 12.12: 时使用类定义? 12.1.5. 类对象 定义一个类时,也就是定义了一个类型。一旦定义了类,就可以定义该类型 的对象。定义对象时,将为其分配存储空间,但(一般而言)定义类型时不进行 存储分配: class Sales_item { public: // operations on Sales_item objects private: std::string isbn; unsigned units_sold; double revenue; }; 定义了一个新的类型,但没有进行存储分配。当我们定义一个对象 Sales_item item; 时,编译器分配了足以容纳一个 Sales_item 对象的存储空间。item 指的 就是那个存储空间。每个对象具有自己的类数据成员的副本。修改 item 的数据 成员不会改变任何其他 Sales_item 对象的数据成员。 定义类类型的对象 定义了一个类类型之后,可以按以下两种方式使用。 • 将类的名字直接用作类型名。 • 指定关键字 class 或 struct,后面跟着类的名字: 557 Sales_item item1; // default initialized object of type Sales_item class Sales_item item1; // equivalent definition of item1 两种引用类类型方法是等价的。第二种方法是从 C 继承而来的,在 C++ 中 仍然有效。第一种更为简练,由 C++ 语言引入,使得类类型更容易使用。 为什么类的定义以分号结束 我们在第 2.8 节中指出,类的定义分号结束。分号是必需的,因为在类定 义之后可以接一个对象定义列表。定义必须以分号结束: class Sales_item { /* ... */ }; class Sales_item { /* ... */ } accum, trans; 通常,将对象定义成类定义的一部分是个坏主意。这样 做,会使所发生的操作难以理解。对读者而言,将两个 不同的实体(类和变量)组合在一个语句中,也会令人 迷惑不解。 12.2. 隐含的 this 指针 在第 7.7.1 节中已经提到,成员函数具有一个附加的隐含形参,即指向该 类对象的一个指针。这个隐含形参命名为 this,与调用成员函数的对象绑定在 一起。成员函数不能定义 this 形参,而是由编译器隐含地定义。成员函数的函 数体可以显式使用 this 指针,但不是必须这么做。如果对类成员的引用没有限 定,编译器会将这种引用处理成通过 this 指针的引用。 何时使用 this 指针 尽管在成员函数内部显式引用 this 通常是不必要的,但有一种情况下必须 这样做:当我们需要将一个对象作为整体引用而不是引用对象的一个成员时。最 常见的情况是在这样的函数中使用 this:该函数返回对调用该函数的对象的引 用。 某种类可能具有某些操作,这些操作应该返回引用,Screen 类就是这样的一 个类。迄今为止,我们的类只有一对 get 操作。逻辑上,我们可以添加下面的 操作。 • 一对 set 操作,将特定字符或光标指向的字符设置为给定值。 • 一个 move 操作,给定两个 index 值,将光标移至新位置。 558 理想情况下,希望用户能够将这些操作的序列连接成一个单独的表达式: // move cursor to given position, and set that character myScreen.move(4,0).set('#'); 这个语句等价于: myScreen.move(4,0); myScreen.set('#'); 返回 *this 在单个表达式中调用 move 和 set 操作时,每个操作必须返回一个引用, 该引用指向执行操作的那个对象: class Screen { public: // interface member functions Screen& move(index r, index c); Screen& set(char); Screen& set(index, index, char); // other members as before }; 注意,这些函数的返回类型是 Screen&,指明该成员函数返回对其自身类类 型的对象的引用。每个函数都返回调用自己的那个对象。使用 this 指针来访问 该对象。下面是对两个新成员的实现: Screen& Screen::set(char c) { contents[cursor] = c; return *this; } Screen& Screen::move(index r, index c) { index row = r * width; // row location cursor = row + c; return *this; } 559 函数中唯一需要关注的部分是 return 语句。在这两个操作中,每个函数都 返回 *this。在这些函数中,this 是一个指向非常量 Screen 的指针。如同任 意的指针一样,可以通过对 this 指针解引用来访问 this 指向的对象。 从 const 成员函数返回 *this 在普通的非 const 成员函数中,this 的类型是一个指向类类型的 const 指针(第 4.2.5 节)。可以改变 this 所指向的值,但不能改变 this 所保存 的地址。在 const 成员函数中,this 的类型是一个指向 const 类类型对象的 const 指针。既不能改变 this 所指向的对象,也不能改变 this 所保存的地址。 不能从 const 成员函数返回指向类对象的普通引用。const 成 员函数只能返回 *this 作为一个 const 引用。 例如,我们可以给 Screen 类增加一个 display 操作。这个函数应该在给 定的 ostream 上打印 contents。逻辑上,这个操作应该是一个 const 成员。 打印 contents 不会改变对象。如果将 display 作为 Screen 的 const 成员, 则 display 内部的 this 指针将是一个 const Screen* 型的 const。 然而,与 move 和 set 操作一样,我们希望能够在一个操作序列中使用 display: // move cursor to given position, set that character and display the screen myScreen.move(4,0).set('#').display(cout); 这个用法暗示了 display 应该返回一个 Screen 引用,并接受一个 ostream 引用。如果 display 是一个 const 成员,则它的返回类型必须是 const Screen&。 不幸的是,这个设计存在一个问题。如果将 display 定义为 const 成员, 就可以在非 const 对象上调用 display,但不能将对 display 的调用嵌入到一 个长表达式中。下面的代码将是非法的: Screen myScreen; // this code fails if display is a const member function // display return a const reference; we cannot call set on a const myScreen.display().set('*'); 560 问题在于这个表达式是在由 display 返回的对象上运行 set。该对象是 const,因为 display 将其对象作为 const 返回。我们不能在 const 对象上调 用 set。 基于 const 的重载 为了解决这个问题,我们必须定义两个 display 操作:一个是 const,另 一个不是 const。基于成员函数是否为 const,可以重载一个成员函数;同样地, 基于一个指针形参是否指向 const(第 7.8.4 节),可以重载一个函数。const 对象只能使用 const 成员。非 const 对象可以使用任一成员,但非 const 版 本是一个更好的匹配。 在此,我们将定义一个名为 do_display 的 private 成员来打印 Screen。 每个 display 操作都将调用此函数,然后返回调用自己的那个对象: class Screen { public: // interface member functions // display overloaded on whether the object is const or not Screen& display(std::ostream &os) { do_display(os); return *this; } const Screen& display(std::ostream &os) const { do_display(os); return *this; } private: // single function to do the work of displaying a Screen, // will be called by the display operations void do_display(std::ostream &os) const { os << contents; } // as before }; 现在,当我们将 display 嵌入到一个长表达式中时,将调用非 const 版本。 当我们 display 一个 const 对象时,就调用 const 版本: Screen myScreen(5,3); const Screen blank(5, 3); myScreen.set('#').display(cout); // calls nonconst version blank.display(cout); // calls const version 可变数据成员 有时(但不是很经常),我们希望类的数据成员(甚至在 const 成员函数 内)可以修改。这可以通过将它们声明为 mutable 来实现。 561 可变数据成员(mutable data member)永远都不能为 const,甚至当它是 const 对象的成员时也如此。因此,const 成员函数可以改变 mutable 成员。 要将数据成员声明为可变的,必须将关键字 mutable 放在成员声明之前: class Screen { public: // interface member functions private: mutable size_t access_ctr; // may change in a const members // other data members as before }; 我们给 Screen 添加了一个新的可变数据成员 access_ctr。使用 access_ctr 来跟踪调用 Screen 成员函数的频繁程度: void Screen::do_display(std::ostream& os) const { ++access_ctr; // keep count of calls to any member function os << contents; } 尽管 do_display 是 const,它也可以增加 access_ctr。该成员是可变成 员,所以,任意成员函数,包括 const 函数,都可以改变 access_ctr 的值。 建议:用于公共代码的私有实用函数 有些读者可能会奇怪为什么要费力地单独定义一个 do_display 内部所 做的操作更简单。为什么还要如此麻烦?我们这样做有下面几个原因。 1. 一般愿望是避免在多个地方编写同样的代码。 2. display 操作预期会随着类的演变而变得更复杂。当所涉及的动 作变得更复杂时,只在一处而不是两处编写这些动作有更显著的 意义。 3. 很可能我们会希望在开发时给 do_display 增加调试信息,这些 调试信息将会在代码的最终成品版本中去掉。如果只需要改变一 个 do_display 的定义来增加或删除调试代码,这样做将更容易。 4. 这个额外的函数调用不需要涉及任何开销。我们使 do_display 成为内联的,所以调用 do_display 与将代码直接放入 display 操作的运行时性能应该是相同的。 实际上,设计良好的 C++ 程序经常具有许多像 do_display 这样的小函 562 数,它们被调用来完成一些其他函数的“实际”工作。 Exercises Section 12.2 Exercise 扩展 Screen 类以包含 move、set 和 display 操作。 12.13: 通过执行如下表达式来测试类: [View full width] // move cursor to given position, set that character and display the screen myScreen.move(4,0).set('#').display(cout); Exercise 通过 this 指针引用成员虽然合法,但却是多余的。讨 12.14: 论显式使用 this 指针访问成员的优缺点。 12.3. 类作用域 每个类都定义了自己的新作用域和唯一的类型。在类的定义体内声明类成 员,将成员名引入类的作用域。两个不同的类具有两个的类作用域。 即使两个类具有完全相同的成员列表,它们也是不同的类型。 每个类的成员不同于任何其他类(或任何其他作用域)的成员。 例如 class First { public: int memi; double memd; }; class Second { 563 public: int memi; double memd; }; First obj1; Second obj2 = obj1; // error: obj1 and obj2 have different types 使用类的成员 在类作用域之外,成员只能通过对象或指针分别使用成员访问操作符 . 或 -> 来访问。这些操作符左边的操作数分别是一个类对象或指向类对象的指针。 跟在操作符后面的成员名字必须在相关联的类的作用域中声明: Class obj; // Class is some class type Class *ptr = &obj; // member is a data member of that class ptr->member; // fetches member from the object to which ptr points obj.member; // fetches member from the object named obj // memfcn is a function member of that class ptr->memfcn(); // runs memfcn on the object to which ptr points obj.memfcn(); // runs memfcn on the object named obj 一些成员使用成员访问操作符来访问,另一些直接通过类使用作用域操作符 (::)来访问。一般的数据或函数成员必须通过对象来访问。定义类型的成员, 如 Screen::index,使用作用域操作符来访问。 作用域与成员定义 尽管成员是在类的定义体之外定义的,但成员定义就好像它们是在类的作用 域中一样。回忆一下,出现在类的定义体之外的成员定义必须指明成员出现在哪 个类中: double Sales_item::avg_price() const { if (units_sold) return revenue/units_sold; else return 0; } 564 在这里,我们用完全限定名 Sales_item::avg_price 来指出这是类 Sales_item 作用域中的 avg_price 成员的定义。一旦看到成员的完全限定名, 就知道该定义是在类作用域中。因为该定义是在类作用域中,所以我们可以引用 revenue 或 units_sold,而不必写 this->revenue 或 this->units_sold。 形参表和函数体处于类作用域中 在定义于类外部的成员函数中,形参表和成员函数体都出现在成员名之后。 这些都是在类作用域中定义,所以可以不用限定而引用其他成员。例如,类 Screen 中 get 的二形参版本的定义: char Screen::get(index r, index c) const { index row = r * width; // compute the row location return contents[row + c]; character } // offset by c to fetch specified 该函数用 Screen 内定义的 index 类型来指定其形参类型。因为形参表是 在 Screen 类的作用域内,所以不必指明我们想要的是 Screen::index。我们想 要的是定义在当前类作用域中的,这是隐含的。同样,使用 index、width 和 contents 时指的都是 Screen 类中声明的名字。 函数返回类型不一定在类作用域中 与形参类型相比,返回类型出现在成员名字前面。如果函数在类定义体之外 定义,则用于返回类型的名字在类作用域之外。如果返回类型使用由类定义的类 型,则必须使用完全限定名。例如,考虑 get_cursor 函数: class Screen { public: typedef std::string::size_type index; index get_cursor() const; }; inline Screen::index Screen::get_cursor() const { return cursor; } 该函数的返回类型是 index,这是在 Screen 类内部定义的一个类型名。如 果在类定义体之外定义 get_cursor,则在函数名被处理之前,代码在不在类作 565 用域内。当看到返回类型时,其名字是在类作用域之外使用。必须用完全限定的 类型名 Screen::index 来指定所需要的 index 是在类 Screen 中定义的名字。 Exercises Section 12.3 Exercise 列出在类作用域中的程序文本部分。 12.15: Exercise 12.16: 如果如下定义 get_cursor,将会发生什么: index Screen::get_cursor() const { return cursor; } 12.3.1. 类作用域中的名字查找 迄今为止,在我们所编写的程序中,名字查找(寻找与给定的名字使用相匹 配的声明的过程)是相对直接的。 1. 首先,在使用该名字的块中查找名字的声明。只考虑在该项使用之前声明 的名字。 2. 如果找不到该名字,则在包围的作用域中查找。 如果找不到任何声明,则程序出错。在 C++ 程序中,所有名字必须在使用之 前声明。 类作用域也许表现得有点不同,但实际上遵循同一规则。可能引起混淆的是 函数中名字确定的方式,而该函数是在类定义体内定义的。 类定义实际上是在两个阶段中处理: 1. 首先,编译成员声明; 2. 只有在所有成员出现之后,才编译它们的定义本身。 566 当然,类作用域中使用的名字并非必须是类成员名。类作用域中的名字查找 也会发生在其他作用域中声明的名字。在名字查找期间,如果类作用域中使用的 名字不能确定为类成员名,则在包含该类或成员定义的作用域中查找,以便找到 该名字的声明。 类成员声明的名字查找 按以下方式确定在类成员的声明中用到的名字。 1. 检查出现在名字使用之前的类成员的声明。 2. 如果第 1 步查找不成功,则检查包含类定义的作用域中出现的声明以及 出现在类定义之前的声明。 例如: typedef double Money; class Account { public: Money balance() { return bal; } private: Money bal; // ... }; 在处理 balance 函数的声明时,编译器首先在类 Account 的作用域中查找 Money 的声明。编译器只考虑出现在 Money 使用之前的声明。因为找不到任何 成员声明,编译器随后在全局作用域中查找 Money 的声明。只考虑出现在类 Account 的定义之前的声明。找到全局的类型别名 Money 的声明,并将它用作 函数 balance 的返回类型和数据成员 bal 的类型。 必须在类中先定义类型名字,才能将它们用作数据成员的类型, 或者成员函数的返回类型或形参类型。 编译器按照成员声明在类中出现的次序来处理它们。通常,名字必须在使用 之前进行定义。而且,一旦一个名字被用作类型名,该名字就不能被重复定义: typedef double Money; class Account { public: 567 Money balance() { return bal; } // uses global definition of Money private: // error: cannot change meaning of Money typedef long double Money; Money bal; // ... }; 类成员定义中的名字查找 按以下方式确定在成员函数的函数体中用到的名字。 1. 首先检查成员函数局部作用域中的声明。 2. 如果在成员函数中找不到该名字的声明,则检查对所有类成员的声明。 3. 如果在类中找不到该名字的声明,则检查在此成员函数定义之前的作用域 中出现的声明。 类成员遵循常规的块作用域名字查找 例示名字查找的程序经常不得不依赖一些坏习惯。下面的几个 程序故意包含了坏的风格。 下面的函数使用了相同的名字来表示形参和成员,这是通常应该避免的。这 样做的目的是展示如何确定名字: // Note: This code is for illustration purposes only and reflects bad practice // It is a bad idea to use the same name for a parameter and a member int height; class Screen { public: void dummy_fcn(index height) { cursor = width * height; // which height? The parameter } private: index cursor; index height, width; 568 }; 查找 dummy_fcn 的定义中使用的名字 height 的声明时,编译器首先在该 函数的局部作用域中查找。函数的局部作用域中声明了一个函数形参。dummy_fcn 的函数体中使用的名字 height 指的就是这个形参声明。 在本例中,height 形参屏蔽名为 height 的成员。 尽管类的成员被屏蔽了,但仍然可以通过用类名来限定成员名 或显式使用 this 指针来使用它。 如果我们想覆盖常规的查找规则,应该这样做: // bad practice: Names local to member functions shouldn't hide member names void dummy_fcn(index height) { cursor = width * this->height; // member height // alternative way to indicate the member cursor = width * Screen::height; // member height } 函数作用域之后,在类作用域中查找 如果想要使用 height 成员,更好的方式也许是为形参取一个不同的名字: // good practice: Don't use member name for a parameter or other local variable void dummy_fcn(index ht) { cursor = width * height; // member height } 现在当编译器查找名字 height 时,它将不会在函数内查找该名字。编译器 接着会在 Screen 类中查找。因为 height 是在成员函数内部使用,所以编译器 在所有成员声明中查找。尽管 height 是先在 dummy_fcn 中使用,然后再声明, 编译器还是确定这里用的是名为 height 的数据成员。 类作用域之后,在外围作用域中查找 569 如果编译器不能在函数或类作用域中找到,就在外围作用域中查找。在本例 子中,出现在 Screen 定义之前的全局作用域中声明了一个名为 height 的全局 声明。然而,该对象被屏蔽了。 尽管全局对象被屏蔽了,但通过用全局作用域确定操作符来限 定名字,仍然可以使用它。 // bad practice: Don't hide names that are needed from surrounding scopes void dummy_fcn(index height) { cursor = width * ::height;// which height? The global one } 在文件中名字的出现处确定名字 当成员定义在类定义的外部时,名字查找的第 3 步不仅要考虑在 Screen 类定义之前的全局作用域中的声明,而且要考虑在成员函数定义之前出现的全局 作用域声明。例如: class Screen { public: // ... void setHeight(index); private: index height; }; Screen::index verify(Screen::index); void Screen::setHeight(index var) { // var: refers to the parameter // height: refers to the class member // verify: refers to the global function height = verify(var); } 注意,全局函数 verify 的声明在 Screen 类定义之前是不可见的。然而, 名字查找的第 3 步要考虑那些出现在成员定义之前的外围作用域声明,并找到 全局函数 verify 的声明。 570 Exercises Section 12.3.1 Exercise 如果将 Screen 类中的类型别名放到类中的最后一行, 12.17: 将会发生什么? Exercise 解释下述代码。指出每次使用 Type 或 initVal 时用到 12.18: 的是哪个名字定义。如果存在错误,说明如何改正。 typedef string Type; Type initVal(); class Exercise { public: // ... typedef double Type; Type setVal(Type); Type initVal(); private: int val; }; Type Exercise::setVal(Type parm) { val = parm + initVal(); } 成员函数 setVal 的定义有错。进行必要的修改以便类 Exercise 使用全局的类型别名 Type 和全局函数 initVal。 12.4. 构造函数 构造函数是特殊的成员函数,只要创建类类型的新对象,都要执行构造函数。 构造函数的工作是保证每个对象的数据成员具有合适的初始值。第 7.7.3 节展 示了如何定义构造函数: class Sales_item { public: // operations on Sales_itemobjects // default constructor needed to initialize members of built-in type 571 Sales_item(): units_sold(0), revenue(0.0) { } private: std::string isbn; unsigned units_sold; double revenue; }; 这个构造函数使用构造函数初始化列表来初始化 units_sold 和 revenue 成员。isbn 成员由 string 的默认构造函数隐式初始化为空串。 构造函数的名字与类的名字相同,并且不能指定返回类型。像其他任何函数 一样,它们可以没有形参,也可以定义多个形参。 构造函数可以被重载 可以为一个类声明的构造函数的数量没有限制,只要每个构造函数的形参表 是唯一的。我们如何才能知道应该定义哪个或多少个构造函数?一般而言,不同 的构造函数允许用户指定不同的方式来初始化数据成员。 例如,逻辑上可以通过提供两个额外的构造函数来扩展 Sales_item 类:一 个允许用户提供 isbn 的初始值,另一个允许用户通过读取 istream 对象来初 始化对象: class Sales_item; // other members as before public: // added constructors to initialize from a string or an istream Sales_item(const std::string&); Sales_item(std::istream&); Sales_item(); }; 实参决定使用哪个构造函数 我们的类现在定义了三个构造函数。在定义新对象时,可以使用这些构造函 数中的任意一个: // uses the default constructor: // isbn is the empty string; units_soldand revenue are 0 Sales_item empty; // specifies an explicit isbn; units_soldand revenue are 0 Sales_item Primer_3rd_Ed("0-201-82470-1"); 572 // reads values from the standard input into isbn, units_sold, and revenue Sales_item Primer_4th_ed(cin); 用于初始化一个对象的实参类型决定使用哪个构造函数。在 empty 的定义 中,没有初始化式,所以运行默认构造函数。接受一个 string 实参的构造函数 用于初始化 Primer_3rd_ed;接受一个 istream 引用的构造函数初始化 Primer_4th_ed。 构造函数自动执行 只要创建该类型的一个对象,编译器就运行一个构造函数: // constructor that takes a string used to create and initialize variable Sales_item Primer_2nd_ed("0-201-54848-8"); // default constructor used to initialize unnamed object on the heap Sales_item *p = new Sales_item(); 第一种情况下,运行接受一个 string 实参的构造函数,来初始化变量 Primer_2nd_ed。第二种情况下,动态分配一个新的 Sales_item 对象。假定分 配成功,则通过运行默认构造函数初始化该对象。 用于 const 对象的构造函数 构造函数不能声明为 const 第 7.7.1 节: class Sales_item { public: Sales_item() const; }; // error const 构造函数是不必要的。创建类类型的 const 对象时,运行一个普通 构造函数来初始化该 const 对象。构造函数的工作是初始化对象。不管对象是 否为 const,都用一个构造函数来初始化化该对象。 Exercises Section 12.4 Exercise 提供一个或多个构造函数,允许该类的用户不指定数据 12.19: 573 成员的初始值或指定所有数据成员的初始值: class NoName { public: // constructor(s) go here ... private: std::string *pstring; int ival; double dval; }; 解释如何确定需要多少个构造函数以及它们应接受什么 样的形参。 Exercise 从下述抽象中选择一个(或一个自己定义的抽象),确 12.20: 定类中需要什么数据,并提供适当的构造函数集。解释 你的决定: (a) Book (d) Vehicle (b) Date (e) Object (c) Employee (f) Tree 12.4.1. 构造函数初始化式 与任何其他函数一样,构造函数具有名字、形参表和函数体。与其他函数不 同的是,构造函数也可以包含一个构造函数初始化列表: // recommended way to write constructors using a constructor initializer Sales_item::Sales_item(const string &book): isbn(book), units_sold(0), revenue(0.0) { } 构造函数初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列 表,每个数据成员后面跟一个放在圆括号中的初始化式。这个构造函数将 isbn 成员初始化为 book 形参的值,将 units_sold 和 revenue 初始化为 0。与任 意的成员函数一样,构造函数可以定义在类的内部或外部。构造函数初始化只在 构造函数的定义中而不是声明中指定。 574 构造函数初始化列表是许多相当有经验的 C++ 程序员都没有 掌握的一个特性。 构造函数初始化列表难以理解的一个原因在于,省略初始化列表在构造函数 的函数体内对数据成员赋值是合法的。例如,可以将接受一个 string 的 Sales_item 构造函数编写为: // legal but sloppier way to write the constructor: // no constructor initializer Sales_item::Sales_item(const string &book) { isbn = book; units_sold = 0; revenue = 0.0; } 这个构造函数给类 Sales_item 的成员赋值,但没有进行显式初始化。不管 是否有显式的初始化式,在执行构造函数之前,要初始化 isbn 成员。这个构造 函数隐式使用默认的 string 构造函数来初始化 isbn。执行构造函数的函数体 时,isbn 成员已经有值了。该值被构造函数函数体中的赋值所覆盖。 从概念上讲,可以认为构造函数分两个阶段执行:(1)初始化阶段;(2) 普通的计算阶段。计算阶段由构造函数函数体中的所有语句组成。 不管成员是否在构造函数初始化列表中显式初始化,类类型的 数据成员总是在初始化阶段初始化。初始化发生在计算阶段开 始之前。 在构造函数初始化列表中没有显式提及的每个成员,使用与初始化变量相同 的规则来进行初始化。运行该类型的默认构造函数,来初始化类类型的数据成员。 内置或复合类型的成员的初始值依赖于对象的作用域:在局部作用域中这些成员 不被初始化,而在全局作用域中它们被初始化为 0。 在本节中编写的两个 Sales_item 构造函数版本具有同样的效果:无论是在 构造函数初始化列表中初始化成员,还是在构造函数函数体中对它们赋值,最终 结果是相同的。构造函数执行结束后,三个数据成员保存同样的值。不同之外在 575 于,使用构造函数初始化列表的版本初始化数据成员,没有定义初始化列表的构 造函数版本在构造函数函数体中对数据成员赋值。这个区别的重要性取决于数据 成员的类型。 有时需要构造函数初始化列表 如果没有为类成员提供初始化式,则编译器会隐式地使用成员类型的默认构 造函数。如果那个类没有默认构造函数,则编译器尝试使用默认构造函数将会失 败。在这种情况下,为了初始化数据成员,必须提供初始化式。 有些成员必须在构造函数初始化列表中进行初始化。对于这样 的成员,在构造函数函数体中对它们赋值不起作用。没有默认 构造函数的类类型的成员,以及 const 或引用类型的成员,不 管是哪种类型,都必须在构造函数初始化列表中进行初始化。 因为内置类型的成员不进行隐式初始化,所以对这些成员是进行初始化还是 赋值似乎都无关紧要。除了两个例外,对非类类型的数据成员进行赋值或使用初 始化式在结果和性能上都是等价的。 例如,下面的构造函数是错误的: class ConstRef { public: ConstRef(int ii); private: int i; const int ci; int &ri; }; // no explicit constructor initializer: error ri is uninitialized ConstRef::ConstRef(int ii) { // assignments: i = ii; // ok ci = ii; // error: cannot assign to a const ri = i; // assigns to ri which was not bound to an object } 记住,可以初始化 const 对象或引用类型的对象,但不能对它们赋值。在 开始执行构造函数的函数体之前,要完成初始化。初始化 const 或引用类型数 据成员的唯一机会是构造函数初始化列表中。编写该构造函数的正确方式为 // ok: explicitly initialize reference and const members 576 ConstRef::ConstRef(int ii): i(ii), ci(i), ri(ii) { } 建议:使用构造函数初始化列表 在许多类中,初始化和赋值严格来讲都是低效率的:数据成员可能已经 被直接初始化了,还要对它进行初始化和赋值。比较率问题更重要的是, 某些数据成员必须要初始化,这是一个事实。 必须对任何 const 或引用类型成员以及没有默认构造 函数的类类型的任何成员使用初始化式。 当类成员需要使用初始化列表时,通过常规地使用构造函数初始化列表, 就可以避免发生编译时错误。 成员初始化的次序 每个成员在构造函数初始化列表中只能指定一次,这不会令人惊讶。毕竟, 给一个成员两个初始值意味着什么?也许更令人惊讶的是,构造函数初始化列表 仅指定用于初始化成员的值,并不指定这些初始化执行的次序。成员被初始化的 次序就是定义成员的次序。第一个成员首先被初始化,然后是第二个,依次类推。 初始化的次序常常无关紧要。然而,如果一个成员是根据其他 成员而初始化,则成员初始化的次序是至关重要的。 考虑下面的类: class X { int i; int j; public: // run-time error: i is initialized before j X(int val): j(val), i(j) { } }; 577 在这种情况下,构造函数初始化列表看起来似乎是用 val 初始化 j,然后再 用 j 来初始化 i。然而,i 首先被初始化。这个初始化列表的效果是用尚未初 始化的 j 值来初始化 i! 如果数据成员在构造函数初始化列表中的列出次序与成员被声明的次序不 同,那么有的编译器非常友好,会给出一个警告。 按照与成员声明一致的次序编写构造函数初始化列表 是个好主意。此外,尽可能避免使用成员来初始化其他 成员。 一般情况下,通过(重复)使用构造函数的形参而不是使用对象的数据成员, 可以避免由初始化式的执行次序而引起的任何问题。例如,下面这样为 X 编写 构造函数可能更好: X(int val): i(val), j(val) { } 在这个版本中,i 和 j 初始化的次序就是无关紧要的。 初始化式可以是任意表达式 一个初始化式可以是任意复杂的表达式。例如,可以给 Sales_item 类一个 新的构造函数,该构造函数接受一个 string 表示 isbn,一个 usigned 表示售 出书的数目,一个 double 表示每本书的售出价格: Sales_item(const std::string &book, int cnt, double price): isbn(book), units_sold(cnt), revenue(cnt * price) { } revenue 的初始化式使用表示价格和售出数目的形参来计算对象的 revenue 成 员。 类类型的数据成员的初始化式 初始化类类型的成员时,要指定实参并传递给成员类型的一个构造函数。可 以使用该类型的任意构造函数。例如,Sales_item 类可以使用任意一个 string 构造函数来初始化 isbn(第 9.6.1 节)。也可以用 ISBN 取值的极限值来表示 isbn 的默认值,而不是用空字符串。可以将 isbn 初始化为由 10 个 9 构成的 串: 578 // alternative definition for Sales_item default constructor Sales_item(): isbn(10, '9'), units_sold(0), revenue(0.0) {} 这个初始化式使用 string 构造函数,接受一个计数值和一个字符,并生成 一个 string,来保存重复指定次数的字符。 Exercises Section 12.4.1 Exercise 12.21: 使用构造函数初始化列表编写类的默认构造函数,该类 包含如下成员:一个 const string,一个 int,一个 double* 和一个 ifstream&。初始化 string 来保存类 的名字。 Exercise 12.22: 下面的初始化式有错误。找出并改正错误。 struct X { X (int i, int j): base(i), rem(base % j) {} int rem, base; }; Exercise 12.23: 假定有个命名为 NoDefault 的类,该类有一个接受一个 int 的构造函数,但没有默认构造函数。定义有一个 NoDefault 类型成员的类 C。为类 C 定义默认构造函 数。 12.4.2. 默认实参与构造函数 再来看看默认构造函数和接受一个 string 的构造函数的定义: Sales_item(const std::string &book): isbn(book), units_sold(0), revenue(0.0) { } Sales_item(): units_sold(0), revenue(0.0) { } 这两个构造函数几乎是相同的:唯一的区别在于,接受一个 string 形参的 构造函数使用该形参来初始化 isbn。默认构造函数(隐式地)使用 string 的 默认构造函数来初始化 isbn。 579 可以通过为 string 初始化式提供一个默认实参将这些构造函数组合起来: class Sales_item { public: // default argument for book is the empty string Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) { } Sales_item(std::istream &is); // as before }; 在这里,我们只定义了两个构造函数,其中一个为其形参提供一个默认实参。 对于下面的任一定义,将执行为其 string 形参接受默认实参的那个构造函数: Sales_item empty; Sales_item Primer_3rd_Ed("0-201-82470-1"); 在 empty 的情况下,使用默认实参,而 Primer_3rd_ed 提供了一个显式实参。 类的两个版本提供同一接口:给定一个 string 或不给定初始化式,它们都 将一个 Sales_item 初始化为相同的值。 我们更喜欢使用默认实参,因为它减少代码重复。 Exercises Section 12.4.2 Exercise 12.24: 上面的 Sales_item 定义了两个构造函数,其中之一有 一个默认实参对应其单个 string 形参。使用该 Sales_item 版本,确定用哪个构造函数来初始化下述的 每个变量,并列出每个对象中数据成员的值: Sales_item first_item(cin); int main() { Sales_item next; Sales_item last("9-999-99999-9"); 580 } Exercise 逻辑上讲,我们可能希望将 cin 作为默认实参提供给接 12.25: 受一个 istream& 形参的构造函数。编写使用 cin 作为 默认实参的构造函数声明。 Exercise 对于分别接受一个 string 和接受一个 istream& 的构 12.26: 造函数,具有默认实参都是合法的吗?如果不是,为什 么? 12.4.3. 默认构造函数 只要定义一个对象时没有提供初始化式,就使用默认构造函数。为所有形参 提供默认实参的构造函数也定义了默认构造函数。 合成的默认构造函数 一个类哪怕只定义了一个构造函数,编译器也不会再生成默认构造函数。这 条规则的根据是,如果一个类在某种情况下需要控制对象初始化,则该类很可能 在所有情况下都需要控制。 只有当一个类没有定义构造函数时,编译器才会自动生成一个 默认构造函数。 合成的默认构造函数(synthesized default constructor)使用与变量初 始化相同的规则来初始化成员。具有类类型的成员通过运行各自的默认构造函数 来进行初始化。内置和复合类型的成员,如指针和数组,只对定义在全局作用域 中的对象才初始化。当对象定义在局部作用域中时,内置或复合类型的成员不进 行初始化。 如果类包含内置或复合类型的成员,则该类不应该依赖 于合成的默认构造函数。它应该定义自己的构造函数来 初始化这些成员。 581 此外,每个构造函数应该为每个内置或复合类型的成员提供初始化式。没有 初始化内置或复合类型成员的构造函数,将使那些成员处于未定义的状态。除了 作为赋值的目标之外,以任何方式使用一个未定义的成员都是错误的。如果每个 构造函数将每个成员设置为明确的已知状态,则成员函数可以区分空对象和具有 实际值的对象。 类通常应定义一个默认构造函数 在某些情况下,默认构造函数是由编译器隐式应用的。如果类没有默认构造 函数,则该类就不能用在这些环境中。为了例示需要默认构造函数的情况,假定 有一个 NoDefault 类,它没有定义自己的默认构造函数,却有一个接受一个 string 实参的构造函数。因为该类定义了一个构造函数,因此编译器将不合成 默认构造函数。NoDefault 没有默认构造函数,意味着: 1. 具有 NoDefault 成员的每个类的每个构造函数,必须通过传递一个初始 的 string 值给 NoDefault 构造函数来显式地初始化 NoDefault 成员。 2. 编译器将不会为具有 NoDefault 类型成员的类合成默认构造函数。如果 这样的类希望提供默认构造函数,就必须显式地定义,并且默认构造函数 必须显式地初始化其 NoDefault 成员。 3. NoDefault 类型不能用作动态分配数组的元素类型。 4. NoDefault 类型的静态分配数组必须为每个元素提供一个显式的初始化 式。 5. 如果有一个保存 NoDefault 对象的容器,例如 vector,就不能使用接受 容器大小而没有同时提供一个元素初始化式的构造函数。 实际上,如果定义了其他构造函数,则提供一个默认构 造函数几乎总是对的。通常,在默认构造函数中给成员 提供的初始值应该指出该对象是“空”的。 使用默认构造函数 初级 C++ 程序员常犯的一个错误是,采用以下方式声明一个用 默认构造函数初始化的对象: // oops! declares a function, not an object Sales_item myobj(); 582 编译 myobj 的声明没有问题。然而,当我们试图使用 myobj 时 Sales_item myobj(); // ok: but defines a function, not an object if (myobj.same_isbn(Primer_3rd_ed)) // error: myobj is a function 编译器会指出不能将成员访问符号用于一个函数!问题在于 myobj 的定义 被编译器解释为一个函数的声明,该函数不接受参数并返回一个 Sales_item 类 型的对象——与我们的意图大相径庭!使用默认构造函数定义一个对象的正确方 式是去掉最后的空括号: // ok: defines a class object ... Sales_item myobj; 另一方面,下面这段代码也是正确的: // ok: create an unnamed, empty Sales_itemand use to initialize myobj Sales_item myobj = Sales_item(); 在这里,我们创建并初始化一个 Sales_item 对象,然后用它来按值初始化 myobj。编译器通过运行 Sales_item 的默认构造函数来按值初始化一个 Sales_item。 Exercises Section 12.4.3 Exercise 12.27: 下面的陈述中哪个是不正确的(如果有的话)?为什么? a. 类必须提供至少一个构造函数。 b. 默认构造函数的形参列表中没有形参。 c. 如果一个类没有有意义的默认值,则该类不应该 提供默认构造函数。 d. 如果一个类没有定义默认构造函数,则编译器会 自动生成一个,同时将每个数据成员初始化为相 关类型的默认值。 12.4.4. 隐式类类型转换 583 在第 5.12 节介绍过,C++ 语言定义了内置类型之间的几个自动转换。也可 以定义如何将其他类型的对象隐式转换为我们的类类型,或将我们的类类型的对 象隐式转换为其他类型。在第 14.9 节将会看到如何定义从类类型到其他类型的 转换。为了定义到类类型的隐式转换,需要定义合适的构造函数。 可以用单个实参来调用的构造函数定义了从形参类型到该类类 型的一个隐式转换。 让我们再看看定义了两个构造函数的 Sales_item 版本: class Sales_item { public: // default argument for book is the empty string Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) { } Sales_item(std::istream &is); // as before }; 这里的每个构造函数都定义了一个隐式转换。因此,在期待一个 Sales_item 类型对象的地方,可以使用一个 string 或一个 istream: string null_book = "9-999-99999-9"; // ok: builds a Sales_itemwith 0 units_soldand revenue from // and isbn equal to null_book item.same_isbn(null_book); 这段程序使用一个 string 类型对象作为实参传给 Sales_item 的 same_isbn 函数。该函数期待一个 Sales_item 对象作为实参。编译器使用接受 一个 string 的 Sales_item 构造函数从 null_book 生成一个新的 Sales_item 对象。新生成的(临时的)Sales_item 被传递给 same_isbn。 这个行为是否我们想要的,依赖于我们认为用户将如何使用这个转换。在这 种情况下,它可能是一个好主意。book 中的 string 可能代表一个不存在的 ISBN,对 same_isbn 的调用可以检测 item 中的 Sales_item 是否表示一个空 的 Sales_item。另一方面,用户也许在 null_book 上错误地调用了 same_isbn。 更成问题的是从 istream 到 Sales_item 的转换: 584 // ok: uses the Sales_item istream constructor to build an object // to pass to same_isbn item.same_isbn(cin); 这段代码将 cin 隐式转换为 Sales_item。这个转换执行接受一个 istream 的 Sales_item 构造函数。该构造函数通过读标准输入来创建一个(临时的) Sales_item 对象。然后该对象被传递给 same_isbn。 这个 Sales_item 对象是一个临时对象(第 7.3.2 节)。一旦 same_isbn 结 束,就不能再访问它。实际上,我们构造了一个在测试完成后被丢弃的对象。这 个行为几乎肯定是一个错误。 抑制由构造函数定义的隐式转换 可以通过将构造函数声明为 explicit,来防止在需要隐式转换的上下文中 使用构造函数: class Sales_item { public: // default argument for book is the empty string explicit Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) { } explicit Sales_item(std::istream &is); // as before }; explicit 关键字只能用于类内部的构造函数声明上。在类的定义体外部所做的 定义上不再重复它: // error: explicit allowed only on constructor declaration in class header explicit Sales_item::Sales_item(istream& is) { is >> *this; // uses Sales_iteminput operator to read the members } 现在,两个构造函数都不能用于隐式地创建对象。前两个使用都不能编译: item.same_isbn(null_book); // error: string constructor is explicit item.same_isbn(cin); // error: istream constructor is explicit 585 当构造函数被声明 explicit 时,编译器将不使用它作为转换 操作符。 为转换而显式地使用构造函数 只要显式地按下面这样做,就可以用显式的构造函数来生成转换: string null_book = "9-999-99999-9"; // ok: builds a Sales_itemwith 0 units_soldand revenue from // and isbn equal to null_book item.same_isbn(Sales_item(null_book)); 在这段代码中,从 null_book 创建一个 Sales_item。尽管构造函数为显式 的,但这个用法是允许的。显式使用构造函数只是中止了隐式地使用构造函数。 任何构造函数都可以用来显式地创建临时对象。 通常,除非有明显的理由想要定义隐式转换,否则,单 形参构造函数应该为 explicit。将构造函数设置为 explicit 可以避免错误,并且当转换有用时,用户可 以显式地构造对象。 Exercises Section 12.4.4 Exercise 解释一下接受一个 string 的 Sales_item 构造函数是 12.28: 否应该为 explicit。将构造函数设置为 explicit 的好 处是什么?缺点是什么? Exercise 12.29: 解释在下面的定义中所发生的操作。 string null_isbn = "9-999-99999-9"; Sales_item null1(null_isbn); Sales_item null("9-999-99999-9"); Exercise 编译如下代码: 12.30: 586 f(const vector&); int main() { vector v2; f(v2); // should be ok f(42); // should be an error return 0; } 基于对 f 的第二个调用中出现的错误,我们可以对 vector 构造函数作出什么推断?如果该调用成功了,那 么你能得出什么结论? 12.4.5. 类成员的显式初始化 尽管大多数对象可以通过运行适当的构造函数进行初始化,但是直接初始化 简单的非抽象类的数据成员仍是可能的。对于没有定义构造函数并且其全体数据 成员均为 public 的类,可以采用与初始化数组元素相同的方式初始化其成员: struct Data { int ival; char *ptr; }; // val1.ival = 0; val1.ptr = 0 Data val1 = { 0, 0 }; // val2.ival = 1024; // val2.ptr = "Anna Livia Plurabelle" Data val2 = { 1024, "Anna Livia Plurabelle" }; 根据数据成员的声明次序来使用初始化式。例如,因为 ival 在 ptr 之前 声明,所以下面的用法是错误的: // error: can't use "Anna Livia Plurabelle" to initialize the int ival Data val2 = { "Anna Livia Plurabelle" , 1024 }; 这种形式的初始化从 C 继承而来,支持与 C 程序兼容。显式初始化类类型 对象的成员有三个重大的缺点。 587 1. 要求类的全体数据成员都是 public。 2. 将初始化每个对象的每个成员的负担放在程序员身上。这样的初始化是乏 味且易于出错的,因为容易遗忘初始化式或提供不适当的初始化式。 3. 如果增加或删除一个成员,必须找到所有的初始化并正确更新。 定义和使用构造函数几乎总是较好的。当我们为自己定 义的类型提供一个默认构造函数时,允许编译器自动运 行那个构造函数,以保证每个类对象在初次使用之前正 确地初始化。 Exercises Section 12.4.5 Exercise pair 的数据成员为 public,然而下面这段代码却不能 12.31: 编译,为什么? pair p2 = {0, 42}; // doesn't compile, why? 12.5. 友元 在某些情况下,允许特定的非成员函数访问一个类的私有成员,同时仍然阻 止一般的访问,这是很方便做到的。例如,被重载的操作符,如输入或输出操作 符,经常需要访问类的私有数据成员。这些操作符不可能为类的成员,具体原因 参见第十四章。然而,尽管不是类的成员,它们仍是类的“接口的组成部分”。 友元机制允许一个类将对其非公有成员的访问权授予指定的函数或类。友元 的声明以关键字 friend 开始。它只能出现在类定义的内部。友元声明可以出现 在类中的任何地方:友元不是授予友元关系的那个类的成员,所以它们不受声明 出现部分的访问控制影响。 通常,将友元声明成组地放在类定义的开始或结尾是个好主 意。 友元关系:一个例子 588 想像一下,除了 Screen 类之外,还有一个窗口管理器,管理给定显示器上 的一组 Screen。窗口管理类在逻辑上可能需要访问由其管理的 Screen 对象的 内部数据。假定 Window_Mgr 是该窗口管理类的名字,Screen 应该允许 Window_Mgr 像下面这样访问其成员: class Screen { // Window_Mgr members can access private parts of class Screen friend class Window_Mgr; // ...restofthe Screen class }; Window_Mgr 的成员可以直接引用 Screen 的私有成员。例如,Window_Mgr 可以有一个函数来重定位一个 Screen: Window_Mgr& Window_Mgr::relocate(Screen::index r, Screen::index c, Screen& s) { // ok to refer to height and width s.height += r; s.width += c; return *this; } 缺少友元声明时,这段代码将会出错:将不允许使用形参 s 的 height 和 width 成员。因为 Screen 将友元关系授予 Window_Mgr,所以,Window_Mgr 中 的函数都可以访问 Screen 的所有成员。 友元可以是普通的非成员函数,或前面定义的其他类的成员函数,或整个类。 将一个类设为友元,友元类的所有成员函数都可以访问授予友元关系的那个类的 非公有成员。 使其他类的成员函数成为友元 如果不是将整个 Window_Mgr 类设为友元,Screen 就可以指定只允许 relocate 成员访问: class Screen { // Window_Mgrmust be defined before class Screen friend Window_Mgr& Window_Mgr::relocate(Window_Mgr::index, Window_Mgr::index, 589 Screen&); // ...restofthe Screen class }; 当我们将成员函数声明为友元时,函数名必须用该函数所属的类名字加以限定。 友元声明与作用域 为了正确地构造类,需要注意友元声明与友元定义之间的互相依赖。在前面 的例子中,类 Window_Mgr 必须先定义。否则,Screen 类就不能将一个 Window_Mgr 函数指定为友元。然而,只有在定义类 Screen 之后,才能定义 relocate 函数——毕竟,它被设为友元是为了访问类 Screen 的成员。 更一般地讲,必须先定义包含成员函数的类,才能将成员函数设为友元。另 一方面,不必预先声明类和非成员函数来将它们设为友元。 友元声明将已命名的类或非成员函数引入到外围作用域中。此 外,友元函数可以在类的内部定义,该函数的作用域扩展到包 围该类定义的作用域。 用友元引入的类名和函数(定义或声明),可以像预先声明的一样使用: class X { friend class Y; friend void f() { /* ok to define friend function in the class body */ } }; class Z { Y *ymem; // ok: declaration for class Y introduced by friend in X void g() { return ::f(); } // ok: declaration of f introduced by X }; 重载函数与友元关系 类必须将重载函数集中每一个希望设为友元的函数都声明为友元: // overloaded storeOn functions extern std::ostream& storeOn(std::ostream &, Screen &); extern BitMap& storeOn(BitMap &, Screen &); 590 class Screen { // ostream version of storeOn may access private parts of Screen objects friend std::ostream& storeOn(std::ostream &, Screen &); // ... }; 类 Screen 将接受一个 ostream& 的 storeOn 版本设为自己的友元。接受 一个 BitMap& 的版本对 Screen 没有特殊访问权。 Exercises Section 12.5 Exercise 什么是友元函数?什么是友元类? 12.32: Exercise 什么时候友元是有用的?讨论使用友元的优缺点。 12.33: Exercise 定义一个增加两个 Sales_item 对象的非成员函数。 12.34: Exercise 定义一个非成员函数,读取一个 istream 并将读入的内 12.35: 容存储到一个 Sales_item 中。 12.6. static 类成员 对于特定类类型的全体对象而言,访问一个全局对象有时是必要的。也许, 在程序的任意点需要统计已创建的特定类类型对象的数量;或者,全局对象可能 是指向类的错误处理例程的一个指针;或者,它是指向类类型对象的内在自由存 储区的一个指针。 然而,全局对象会破坏封装:对象需要支持特定类抽象的实现。如果对象是 全局的,一般的用户代码就可以修改这个值。类可以定义类 静态成员,而不是 定义一个可普遍访问的全局对象。 通常,非 static 数据成员存在于类类型的每个对象中。不像普通的数据成 员,static 数据成员独立于该类的任意对象而存在;每个 static 数据成员是 与类关联的对象,并不与该类的对象相关联。 591 正如类可以定义共享的 static 数据成员一样,类也可以定义 static 成员 函数。static 成员函数没有 this 形参,它可以直接访问所属类的 static 成 员,但不能直接使用非 static 成员。 使用类的 static 成员的优点 使用 static 成员而不是全局对象有三个优点。 1. static 成员的名字是在类的作用域中,因此可以避免与其他类的成员或 全局对象名字冲突。 2. 可以实施封装。static 成员可以是私有成员,而全局对象不可以。 3. 通过阅读程序容易看出 static 成员是与特定类关联的。这种可见性可清 晰地显示程序员的意图。 定义 static 成员 在成员声明前加上关键字 static 将成员设为 static。static 成员遵循正 常的公有/私有访问规则。 例如,考虑一个简单的表示银行账户的类。每个账户具有余额和拥有者,并 且按月获得利息,但应用于每个账户的利率总是相同的。可以按下面的这样编写 这个类 class Account { public: // interface functions here void applyint() { amount += amount * interestRate; } static double rate() { return interestRate; } static void rate(double); // sets a new rate private: std::string owner; double amount; static double interestRate; static double initRate(); }; 这个类的每个对象具有两个数据成员:owner 和 amount。对象没有与 static 数据成员对应的数据成员,但是,存在一个单独的 interestRate 对象, 由 Account 类型的全体对象共享。 使用类的 static 成员 可以通过作用域操作符从类直接调用 static 成员,或者通过对象、引用或 指向该类类型对象的指针间接调用。 592 Account ac1; Account *ac2 = &ac1; // equivalent ways to call the static member rate function double rate; rate = ac1.rate(); // through an Account object or reference rate = ac2->rate(); // through a pointer to an Account object rate = Account::rate(); // directly from the class using the scope operator 像使用其他成员一样,类成员函数可以不用作用域操作符来引用类的 static 成员: class Account { public: // interface functions here void applyint() { amount += amount * interestRate; } }; Exercises Section 12.6 Exercise 什么是 static 类成员?static 成员的优点是什么? 12.36: 它们与普通有什么不同? Exercise 编写自己的 Account 类版本。 12.37: 12.6.1. static 成员函数 Account 类有两个名为 rate 的 static 成员函数,其中一个定义在类的内 部。当我们在类的外部定义 static 成员时,无须重复指定 static 保留字,该 保留字只出现在类定义体内部的声明处: void Account::rate(double newRate) { interestRate = newRate; } static 函数没有 this 指针 593 static 成员是类的组成部分但不是任何对象的组成部分,因此,static 成 员函数没有 this 指针。通过使用非 static 成员显式或隐式地引用 this 是一 个编译时错误。 因为 static 成员不是任何对象的组成部分,所以 static 成员函数不能被 声明为 const。毕竟,将成员函数声明为 const 就是承诺不会修改该函数所属 的对象。最后,static 成员函数也不能被声明为虚函数。我们将在第 15.2.4 节 学习虚函数。 Exercises Section 12.6.1 Exercise 12.38: 定义一个命名为 Foo 的类,具有单个 int 型数据成员。 为该类定义一个构造函数,接受一个 int 值并用该值初 始化数据成员。为该类定义一个函数,返回其数据成员 的值。 Exercise 给定上题中定义的 Foo 类定义另一个 Bar 类。Bar 类 12.39: 具有两个 static 数据成员:一个为 int 型,另一个为 Foo 类型。 Exercise 12.40: 使用上面两题中定义的类,给 Bar 类增加一对成员函 数:第一个成品命名为 FooVal,返回 Bar 类的 Foo 类 型 static 成员的值;第二个成员命名为 callsFooVal, 保存 xval 被调用的次数。 12.6.2. static 数据成员 static 数据成员可以声明为任意类型,可以是常量、引用、数组、类类型, 等等。 static 数据成员必须在类定义体的外部定义(正好一次)。不像普通数据成员, static 成员不是通过类构造函数进行初始化,而是应该在定义时进行初始化。 保证对象正好定义一次的最好办法,就是将 static 数据成 员的定义放在包含类非内联成员函数定义的文件中。 定义 static 数据成员的方式与定义其他类成员和变量的方式相同:先指定 类型名,接着是成员的完全限定名。 594 可以定义如下 interestRate: // define and initialize static class member double Account::interestRate = initRate(); 这个语句定义名为 interestRate 的 static 对象,它是类 Account 的成 员,为 double 型。像其他成员定义一样,一旦成员名出现,static 成员的就 是在类作用域中。因此,我们可以没有限定地直接使用名为 initRate 的 static 成员函数,作为 interestRate 初始化式。注意,尽管 initRate 是私有的,我 们仍然可以使用该函数来初始化 interestRate。像任意的其他成员定义一样, interestRate 的定义是在类的作用域中,因此可以访问该类的私有成员。 像使用任意的类成员一样,在类定义体外部引用类的 static 成员时,必须指定成员是在哪个类中定义的。然而,static 关 键字只能用于类定义体内部的声明中,定义不能标示为 static。 特殊的整型 const static 成员 一般而言,类的 static 成员,像普通数据成员一样,不能在类的定义体中 初始化。相反,static 数据成员通常在定义时才初始化。 这个规则的一个例外是,只要初始化式是一个常量表达式,整型 const static 数据成员就可以在类的定义体中进行初始化: class Account { public: static double rate() { return interestRate; } static void rate(double); // sets a new rate private: static const int period = 30; // interest posted every 30 days double daily_tbl[period]; // ok: period is constant expression }; 用常量值初始化的整型 const static 数据成员是一个常量表达式。同样地, 它可以用在任何需要常量表达式的地方,例如指定数组成员 daily_tbl 的维。 const static 数据成员在类的定义体中初始化时,该数据成员 仍必须在类的定义体之外进行定义。 595 在类内部提供初始化式时,成员的定义不必再指定初始值: // definition of static member with no initializer; // the initial value is specified inside the class definition const int Account::period; static 成员不是类对象的组成部分 普通成员都是给定类的每个对象的组成部分。static 成员独立于任何对象 而存在,不是类类型对象的组成部分。因为 static 数据成员不是任何对象的组 成部分,所以它们的使用方式对于非 static 数据成员而言是不合法的。 例如,static 数据成员的类型可以是该成员所属的类类型。非 static 成 员被限定声明为其自身类对象的指针或引用: class Bar { public: // ... private: static Bar mem1; // ok Bar *mem2; // ok Bar mem3; // error }; 类似地,static 数据成员可用作默认实参: class Screen { public: // bkground refers to the static member // declared later in the class definition Screen& clear(char = bkground); private: static const char bkground = '#'; }; 非 static 数据成员不能用作默认实参,因为它的值不能独立于所属的对象 而使用。使用非 static 数据成员作默认实参,将无法提供对象以获取该成员的 值,因而是错误的。 596 Exercises Section 12.6.2 Exercise 利用第 12.6.1 节的习题中编写的类 Foo 和 Bar,初始 12.41: 化 Foo 的 static 成员。将 int 成员初始化为 20,并 将 Foo 成员初始化为 0。 Exercise 下面的 static 数据成员声明和定义中哪些是错误的 12.42: (如果有的话)?解释为什么。 // example.h class Example { public: static double rate = 6.5; static const int vecSize = 20; static vector vec(vecSize); }; // example.C #include "example.h" double Example::rate; vector Example::vec; 小结 类是 C++ 中最基本的特征,允许定义新的类型以适应应用程序的需要,同 时使程序更短且更易于修改。 数据抽象是指定义数据和函数成员的能力,而封装是指从常规访问中保护类 成员的能力,它们都是类的基础。成员函数定义类的接口。通过将类的实现所用 到的数据和函数设置为 private 来封装类。 类可以定义构造函数,它们是特殊的成员函数,控制如何初始化类的对象。 可以重载构造函数。每个构造函数就初始化每个数据成员。初始化列表包含的是 名—值对,其中的名是一个成员,而值则是该成员的初始值。 类可以将对其非 public 成员的访问权授予其他类或函数,并通过将其他的 类或函数设为友元来授予其访问权。 597 类也可以定义 mutable 或 static 成员。mutable 成员永远都不能为 const;它的值可以在 const 成员函数中修改。static 成员可以是函数或数据, 独立于类类型的对象而存在。 术语 abstract data type(抽象数据类型) 使用封装来隐藏其实现的数据结构,允许使用类型的程序员抽象地考虑该 类型做什么,而不是具体地考虑类型如何表示。C++ 中的类可用来定义抽 象数据类型。 access label(访问标号) public 或 private 标号,指定后面的成员可以被类的使用者访问或者只 能被类的友元和成员访问。每个标号为在该标号到下一个标号之间声明的 成员设置访问保护。标号可以在类中出现多次。 class(类) 是 C++ 中定义抽象数据类型的一种机制,可以有数据、函数或类型成员。 一个类定义了新的类型和新的作用域。 class declaration(类声明) 类可以在定义之前声明。类声明用关键字 class(或 struct)表示,后 面加类名字和一个分号。已声明但没有定义的类是一个不完全的类型。 class keyword(class 关键字) 用在 class 关键字定义的类中,初始的隐式访问标号是 private。 class scope(类作用域) 每个类定义一个作用域。类作用域比其他作用域复杂得多——在类的定义 体内定义的成员函数可以使用出现在该定义之后的名字。 concrete class(具体类) 暴露其实现细节的类。 const member function(常量成员函数) 598 一种成员函数,不能改变对象的普通(即,既不是 static 也不是 mutable)数据成员。const 成员中的 this 指针指向 const 对象。成员 函数是否可以被重载取决于该函数是否为 const。 constructor initializer list(构造函数初始化列表